From 3d18d86845c6296e767a4cf01b28083319104b3b Mon Sep 17 00:00:00 2001 From: pallakatos Date: Fri, 11 Sep 2026 15:41:48 +0200 Subject: [PATCH 001/111] Import complete Kars Bridge as an optional monorepo application Keep independent application packages, images and Helm release. Qualify core and Bridge at one immutable public source revision. Preserve existing core builds and explicitly retain remaining source and native gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 196 + .github/workflows/bridge-native.yml | 180 + Cargo.toml | 2 + Makefile | 19 + README.md | 9 + bridge/.env.example | 10 + bridge/.gitignore | 23 + bridge/Makefile | 70 + bridge/README.md | 125 + bridge/bff/.dockerignore | 3 + bridge/bff/Cargo.toml | 43 + bridge/bff/Dockerfile | 24 + bridge/bff/src/auth.rs | 277 + bridge/bff/src/config.rs | 150 + bridge/bff/src/error.rs | 131 + bridge/bff/src/kars/approval.rs | 89 + bridge/bff/src/kars/cluster.rs | 4230 ++++++++ .../bff/src/kars/credential_binding_tests.rs | 280 + bridge/bff/src/kars/credential_contract.rs | 109 + .../src/kars/credential_entrypoint_tests.rs | 272 + .../bff/src/kars/credential_handler_tests.rs | 461 + bridge/bff/src/kars/credential_review.rs | 607 ++ .../bff/src/kars/credential_review_tests.rs | 470 + bridge/bff/src/kars/credential_targets.rs | 257 + bridge/bff/src/kars/credential_tests.rs | 362 + bridge/bff/src/kars/credential_transport.rs | 47 + bridge/bff/src/kars/credentials.rs | 867 ++ bridge/bff/src/kars/github_grants.rs | 67 + bridge/bff/src/kars/mod.rs | 19 + .../src/kars/observation_credential_tests.rs | 232 + bridge/bff/src/kars/operator_credentials.rs | 316 + bridge/bff/src/kars/receipt.rs | 104 + bridge/bff/src/kars/receipt_log.rs | 273 + bridge/bff/src/kars/receipt_log/tests.rs | 667 ++ bridge/bff/src/kars/sre_action.rs | 78 + bridge/bff/src/kars/task.rs | 416 + bridge/bff/src/kars/team.rs | 153 + .../bff/src/kars/workspace_credential_plan.rs | 225 + bridge/bff/src/lib.rs | 13 + bridge/bff/src/main.rs | 203 + bridge/bff/src/routes/approvals.rs | 412 + bridge/bff/src/routes/artifacts.rs | 395 + bridge/bff/src/routes/budgets.rs | 530 + bridge/bff/src/routes/channels.rs | 290 + bridge/bff/src/routes/compose.rs | 4175 ++++++++ bridge/bff/src/routes/credential_review.rs | 348 + bridge/bff/src/routes/digests.rs | 75 + bridge/bff/src/routes/efficiency.rs | 858 ++ bridge/bff/src/routes/engineering.rs | 3364 ++++++ bridge/bff/src/routes/foundry.rs | 674 ++ bridge/bff/src/routes/github.rs | 564 + bridge/bff/src/routes/health.rs | 266 + bridge/bff/src/routes/insights.rs | 358 + bridge/bff/src/routes/mod.rs | 441 + bridge/bff/src/routes/operator.rs | 4497 ++++++++ bridge/bff/src/routes/options.rs | 1599 +++ bridge/bff/src/routes/ownership.rs | 187 + bridge/bff/src/routes/receipts.rs | 973 ++ bridge/bff/src/routes/receipts/statement.rs | 214 + bridge/bff/src/routes/retention.rs | 114 + bridge/bff/src/routes/review.rs | 277 + bridge/bff/src/routes/run.rs | 369 + bridge/bff/src/routes/sre_actions.rs | 156 + bridge/bff/src/routes/system.rs | 382 + bridge/bff/src/routes/tasks.rs | 5173 +++++++++ bridge/bff/src/routes/teams.rs | 3548 +++++++ bridge/bff/src/routes/teams_internal.rs | 455 + bridge/bff/src/routes/telemetry.rs | 191 + bridge/bff/src/routes/validate.rs | 1273 +++ bridge/bff/src/state.rs | 174 + bridge/bff/tests/health.rs | 90 + bridge/bff/tests/jwt_backend.rs | 117 + bridge/deploy/helm/kars-bridge/Chart.yaml | 27 + bridge/deploy/helm/kars-bridge/README.md | 140 + .../helm/kars-bridge/templates/NOTES.txt | 35 + .../helm/kars-bridge/templates/_helpers.tpl | 21 + .../helm/kars-bridge/templates/bff.yaml | 122 + .../kars-bridge/templates/idp-secret.yaml | 28 + .../helm/kars-bridge/templates/idp.yaml | 144 + .../helm/kars-bridge/templates/ingress.yaml | 33 + .../helm/kars-bridge/templates/namespace.yaml | 41 + .../kars-bridge/templates/networkpolicy.yaml | 81 + .../templates/observation-egress.yaml | 38 + .../helm/kars-bridge/templates/rbac.yaml | 164 + .../kars-bridge/templates/teams-gateway.yaml | 297 + .../helm/kars-bridge/templates/web.yaml | 135 + .../deploy/helm/kars-bridge/values-kind.yaml | 16 + bridge/deploy/helm/kars-bridge/values.yaml | 234 + bridge/deploy/rbac.yaml | 183 + bridge/docs/README.md | 49 + bridge/docs/SUMMARY.md | 36 + bridge/docs/approvals-egress.md | 45 + bridge/docs/architecture.md | 245 + bridge/docs/compatibility.md | 110 + bridge/docs/connections.md | 85 + bridge/docs/contributing.md | 38 + bridge/docs/deployment.md | 212 + bridge/docs/evidence-compliance.md | 66 + bridge/docs/glossary.md | 18 + bridge/docs/governed-credentials.md | 418 + bridge/docs/identity.md | 88 + bridge/docs/inference-budgets.md | 68 + bridge/docs/local-inference.md | 51 + bridge/docs/mcp-servers.md | 75 + bridge/docs/missions-and-teams.md | 60 + bridge/docs/observability.md | 55 + bridge/docs/operations.md | 49 + bridge/docs/providers.md | 42 + bridge/docs/quickstart.md | 91 + bridge/docs/rbac.md | 86 + bridge/docs/skills.md | 50 + bridge/docs/team-workflows.md | 261 + bridge/docs/troubleshooting.md | 39 + bridge/start-bff.sh | 6 + bridge/teams-gateway/.dockerignore | 6 + bridge/teams-gateway/.gitignore | 2 + bridge/teams-gateway/Dockerfile | 19 + bridge/teams-gateway/package-lock.json | 3622 +++++++ bridge/teams-gateway/package.json | 35 + bridge/teams-gateway/src/bff-client.ts | 163 + bridge/teams-gateway/src/cards.ts | 291 + bridge/teams-gateway/src/config.ts | 128 + .../teams-gateway/src/conversation-store.ts | 516 + bridge/teams-gateway/src/hmac.ts | 26 + bridge/teams-gateway/src/identity.ts | 43 + bridge/teams-gateway/src/log.ts | 45 + bridge/teams-gateway/src/main.ts | 585 ++ bridge/teams-gateway/src/watcher.ts | 849 ++ .../tests/chart-lifecycle.test.ts | 176 + .../teams-gateway/tests/chart-upgrade.test.ts | 100 + bridge/teams-gateway/tests/chart.test.ts | 255 + .../legacy-namespace-chart/Chart.yaml | 4 + .../templates/namespace.yaml | 4 + .../tests/fixtures/values-10505214.yaml | 221 + bridge/teams-gateway/tests/gateway.test.ts | 673 ++ bridge/teams-gateway/tests/monorepo.test.ts | 77 + .../tests/native-qualification.test.ts | 354 + bridge/teams-gateway/tests/packaging.test.ts | 29 + bridge/teams-gateway/tsconfig.json | 24 + bridge/teams-gateway/vitest.config.ts | 7 + .../tests/native-credentials/Dockerfile.bff | 5 + .../tests/native-credentials/Dockerfile.probe | 4 + .../native-credentials/Dockerfile.runtime | 7 + .../native-credentials/admission_cases.py | 215 + .../tests/native-credentials/api-values.yaml | 16 + bridge/tests/native-credentials/api_gate.py | 126 + .../api_outcome_diagnostics.py | 152 + .../native-credentials/audit-policy.yaml | 9 + bridge/tests/native-credentials/boot.py | 160 + .../native-credentials/credential_cases.py | 272 + .../native-credentials/credential_review.py | 128 + .../tests/native-credentials/kind_config.py | 40 + .../native-credentials/lifecycle_cases.py | 369 + .../tests/native-credentials/loaded_images.py | 48 + bridge/tests/native-credentials/native_api.py | 261 + .../native-credentials/observation_cases.py | 303 + .../observation_diagnostics.py | 195 + .../observer_cilium_diagnostics.py | 494 + .../observer_network_diagnostics.py | 471 + .../tests/native-credentials/private_tls.py | 68 + bridge/tests/native-credentials/run.py | 197 + .../tests/native-credentials/runtime_probe.py | 55 + .../tests/native-credentials/runtime_state.py | 47 + .../native-credentials/source_revision.py | 24 + .../test_cilium_baseline_witness.py | 214 + .../test_cilium_status_schema.py | 158 + .../test_credential_review.py | 174 + .../test_credential_target_startup.py | 80 + .../test_observation_diagnostics.py | 417 + .../test_observer_cilium_diagnostics.py | 337 + .../test_observer_network_diagnostics.py | 631 ++ .../test_source_revision.py | 27 + bridge/web/.dockerignore | 4 + bridge/web/.gitignore | 41 + bridge/web/AGENTS.md | 5 + bridge/web/CLAUDE.md | 1 + bridge/web/Dockerfile | 32 + bridge/web/README.md | 42 + bridge/web/eslint.config.mjs | 18 + bridge/web/next.config.ts | 14 + bridge/web/package-lock.json | 9284 +++++++++++++++++ bridge/web/package.json | 39 + bridge/web/postcss.config.mjs | 7 + bridge/web/public/file.svg | 1 + bridge/web/public/globe.svg | 1 + bridge/web/public/next.svg | 1 + bridge/web/public/vercel.svg | 1 + bridge/web/public/window.svg | 1 + bridge/web/src/app/api/[...path]/route.ts | 114 + bridge/web/src/app/api/health/route.ts | 12 + bridge/web/src/app/audit/layout.tsx | 63 + bridge/web/src/app/audit/page.tsx | 31 + bridge/web/src/app/auth/callback/route.ts | 103 + bridge/web/src/app/auth/login/route.ts | 66 + bridge/web/src/app/auth/logout/route.ts | 39 + bridge/web/src/app/auth/no-roles/page.tsx | 25 + bridge/web/src/app/console/access/page.tsx | 204 + bridge/web/src/app/console/approvals/page.tsx | 211 + .../app/console/audit/audit-receipt-row.tsx | 390 + .../src/app/console/audit/audit-search.tsx | 167 + bridge/web/src/app/console/audit/page.tsx | 32 + .../web/src/app/console/author-resource.tsx | 132 + .../web/src/app/console/capabilities/page.tsx | 235 + .../additional-provider-actions.ts | 36 + .../configuration/copilot-login-actions.ts | 38 + .../configuration/credential-actions.ts | 15 + .../console/configuration/credential-form.tsx | 111 + .../configuration/github-app-actions.ts | 39 + .../configuration/local-inference-actions.ts | 121 + .../configuration/local-model-deploy.tsx | 237 + .../console/configuration/model-catalogue.tsx | 157 + .../src/app/console/configuration/page.tsx | 147 + .../console/configuration/provider-actions.ts | 21 + .../provider-discover-actions.ts | 25 + .../console/configuration/provider-wizard.tsx | 889 ++ .../set-default-model-actions.ts | 21 + .../set-default-provider-actions.ts | 19 + bridge/web/src/app/console/datapath/page.tsx | 243 + .../web/src/app/console/delete-resource.tsx | 70 + .../web/src/app/console/evals/eval-detail.tsx | 147 + .../src/app/console/evals/new-eval-form.tsx | 151 + bridge/web/src/app/console/evals/page.tsx | 170 + .../app/console/fleet/capacity-dashboard.tsx | 214 + .../web/src/app/console/fleet/fleet-list.tsx | 212 + .../src/app/console/fleet/mesh-topology.tsx | 368 + bridge/web/src/app/console/fleet/page.tsx | 86 + bridge/web/src/app/console/foundry-actions.ts | 78 + .../web/src/app/console/foundry-onboard.tsx | 215 + .../web/src/app/console/governance-actions.ts | 135 + .../app/console/inference-policy-editor.tsx | 349 + bridge/web/src/app/console/insights/page.tsx | 596 ++ bridge/web/src/app/console/layout.tsx | 105 + .../web/src/app/console/mcp-catalog-data.ts | 232 + bridge/web/src/app/console/mcp-catalog.tsx | 236 + .../src/app/console/mcp-profile-actions.ts | 42 + bridge/web/src/app/console/mcp-profiles.tsx | 103 + .../web/src/app/console/mcp-server-editor.tsx | 299 + .../app/console/operator-github-status.tsx | 111 + bridge/web/src/app/console/page.tsx | 224 + bridge/web/src/app/console/policies/page.tsx | 240 + .../src/app/console/policy-builder-data.ts | 178 + bridge/web/src/app/console/policy-builder.tsx | 218 + bridge/web/src/app/console/profile-editor.tsx | 292 + bridge/web/src/app/console/skill-approval.tsx | 98 + .../src/app/console/skill-submit-action.ts | 25 + .../src/app/console/sre-action-decision.tsx | 54 + .../web/src/app/console/sre-actions/page.tsx | 117 + .../src/app/console/troubleshooting/page.tsx | 193 + bridge/web/src/app/dex/[...path]/route.ts | 110 + bridge/web/src/app/favicon.ico | Bin 0 -> 25931 bytes bridge/web/src/app/globals.css | 286 + bridge/web/src/app/inbox/approval-actions.ts | 26 + bridge/web/src/app/layout.tsx | 59 + bridge/web/src/app/page.tsx | 8 + bridge/web/src/app/role-actions.ts | 38 + .../src/app/tasks/[name]/execution-panel.tsx | 303 + .../src/app/tasks/[name]/launch-actions.ts | 53 + .../app/tasks/[name]/task-approvals-panel.tsx | 87 + bridge/web/src/app/workspace/agents/page.tsx | 322 + .../src/app/workspace/connections/page.tsx | 41 + .../web/src/app/workspace/inbox/loading.tsx | 5 + bridge/web/src/app/workspace/inbox/page.tsx | 329 + bridge/web/src/app/workspace/layout.tsx | 97 + .../missions/[name]/budget-recovery.tsx | 150 + .../missions/[name]/delete-actions.ts | 34 + .../missions/[name]/delete-control.tsx | 62 + .../missions/[name]/deploy-timeline.tsx | 123 + .../missions/[name]/egress-actions.ts | 37 + .../missions/[name]/egress-request.tsx | 43 + .../workspace/missions/[name]/halt-button.tsx | 90 + .../missions/[name]/mission-autorun.tsx | 89 + .../missions/[name]/mission-blockers.tsx | 273 + .../workspace/missions/[name]/mission-map.tsx | 119 + .../missions/[name]/network-mode.tsx | 187 + .../workspace/missions/[name]/org-chart.tsx | 383 + .../app/workspace/missions/[name]/page.tsx | 1253 +++ .../missions/[name]/promote-mission.tsx | 42 + .../missions/[name]/readiness-panel.tsx | 140 + .../missions/[name]/reliability-runner.tsx | 59 + .../missions/[name]/review-actions.ts | 46 + .../missions/[name]/review-panel.tsx | 262 + .../workspace/missions/[name]/role-actions.ts | 93 + .../workspace/missions/[name]/run-actions.ts | 51 + .../src/app/workspace/missions/loading.tsx | 5 + .../app/workspace/missions/missions-list.tsx | 133 + .../web/src/app/workspace/missions/page.tsx | 57 + bridge/web/src/app/workspace/new/actions.ts | 180 + .../src/app/workspace/new/envelope-reveal.tsx | 124 + .../web/src/app/workspace/new/intake-flow.tsx | 1336 +++ bridge/web/src/app/workspace/new/page.tsx | 62 + bridge/web/src/app/workspace/page.tsx | 474 + .../web/src/app/workspace/skills/loading.tsx | 5 + bridge/web/src/app/workspace/skills/page.tsx | 110 + .../src/app/workspace/skills/skill-actions.ts | 23 + .../src/app/workspace/skills/skill-upload.tsx | 17 + .../workspace/teams/[name]/channel-actions.ts | 60 + .../workspace/teams/[name]/delete-actions.ts | 34 + .../workspace/teams/[name]/delete-control.tsx | 63 + .../teams/[name]/engineering-actions.ts | 94 + .../teams/[name]/engineering-intake.tsx | 617 ++ .../src/app/workspace/teams/[name]/page.tsx | 969 ++ .../workspace/teams/[name]/promote-actions.ts | 41 + .../teams/[name]/promote-control.tsx | 99 + .../app/workspace/teams/[name]/run-actions.ts | 37 + .../workspace/teams/[name]/run-control.tsx | 110 + .../teams/[name]/runs/[run]/halt-button.tsx | 94 + .../teams/[name]/runs/[run]/page.tsx | 785 ++ .../workspace/teams/[name]/task-actions.ts | 93 + .../workspace/teams/[name]/team-channels.tsx | 170 + .../app/workspace/teams/[name]/team-edit.tsx | 287 + .../workspace/teams/[name]/team-ledger.tsx | 73 + .../workspace/teams/[name]/team-outcomes.tsx | 300 + .../teams/[name]/team-roster-edit.tsx | 240 + .../app/workspace/teams/[name]/team-tabs.tsx | 104 + .../app/workspace/teams/[name]/team-tasks.tsx | 277 + .../teams/[name]/watching-status.tsx | 168 + .../web/src/app/workspace/teams/loading.tsx | 5 + .../src/app/workspace/teams/new/actions.ts | 241 + .../web/src/app/workspace/teams/new/page.tsx | 66 + .../app/workspace/teams/new/team-composer.tsx | 1011 ++ bridge/web/src/app/workspace/teams/page.tsx | 54 + .../src/app/workspace/teams/teams-list.tsx | 164 + bridge/web/src/components/activity-stream.tsx | 401 + bridge/web/src/components/agent-graph.tsx | 1531 +++ bridge/web/src/components/app-shell.tsx | 0 .../web/src/components/approval-decision.tsx | 114 + .../src/components/approval-phase-badge.tsx | 34 + bridge/web/src/components/audit-report.tsx | 116 + bridge/web/src/components/audit-view.tsx | 145 + bridge/web/src/components/bar-chart.tsx | 35 + .../src/components/clarification-answer.tsx | 93 + bridge/web/src/components/compliance-pack.tsx | 149 + .../web/src/components/connect-channels.tsx | 168 + bridge/web/src/components/connect-github.tsx | 151 + bridge/web/src/components/connect-teams.tsx | 196 + bridge/web/src/components/console-nav.tsx | 85 + bridge/web/src/components/copy-digest.tsx | 53 + .../web/src/components/deliverable-view.tsx | 398 + bridge/web/src/components/envelope-card.tsx | 145 + bridge/web/src/components/envelope-digest.tsx | 26 + .../web/src/components/execution-explorer.tsx | 158 + .../web/src/components/execution-lifetime.tsx | 174 + bridge/web/src/components/fleet-live.tsx | 174 + bridge/web/src/components/honest-state.tsx | 79 + bridge/web/src/components/how-it-works.tsx | 37 + bridge/web/src/components/icon.tsx | 318 + .../web/src/components/inference-budgets.tsx | 590 ++ bridge/web/src/components/intent-entry.tsx | 150 + bridge/web/src/components/journey-rail.tsx | 131 + bridge/web/src/components/list-skeleton.tsx | 35 + .../web/src/components/live-activity-view.tsx | 72 + bridge/web/src/components/live-refresh.tsx | 88 + bridge/web/src/components/loop-designer.tsx | 209 + bridge/web/src/components/mermaid-diagram.tsx | 111 + .../web/src/components/mission-scorecard.tsx | 85 + bridge/web/src/components/mission-status.tsx | 71 + .../web/src/components/orchestration-cube.tsx | 99 + bridge/web/src/components/org-tree.tsx | 194 + bridge/web/src/components/phase-badge.tsx | 22 + bridge/web/src/components/preflight-check.tsx | 111 + bridge/web/src/components/primary-nav.tsx | 0 .../web/src/components/provenance-overlay.tsx | 74 + .../web/src/components/provenance-story.tsx | 95 + bridge/web/src/components/receipt-panel.tsx | 276 + bridge/web/src/components/receipt-verify.tsx | 113 + bridge/web/src/components/repo-access.tsx | 132 + .../web/src/components/retention-policy.tsx | 140 + bridge/web/src/components/role-switcher.tsx | 148 + bridge/web/src/components/rubiks-cube.tsx | 56 + bridge/web/src/components/segmented-tier.tsx | 90 + bridge/web/src/components/skill-composer.tsx | 272 + bridge/web/src/components/stat-card.tsx | 55 + bridge/web/src/components/status-badge.tsx | 31 + .../web/src/components/surface-switcher.tsx | 50 + bridge/web/src/components/task-checkpoint.tsx | 51 + .../web/src/components/team-run-activity.tsx | 159 + bridge/web/src/components/team-run-flow.tsx | 372 + bridge/web/src/components/team-timing.tsx | 57 + bridge/web/src/components/theme-toggle.tsx | 83 + bridge/web/src/components/ui.tsx | 80 + bridge/web/src/components/use-live-trace.ts | 97 + bridge/web/src/components/viewport-portal.tsx | 27 + bridge/web/src/components/wiring-badge.tsx | 29 + bridge/web/src/components/workspace-nav.tsx | 80 + bridge/web/src/lib/auth-return.ts | 17 + bridge/web/src/lib/bff.ts | 894 ++ bridge/web/src/lib/classify-intent.ts | 79 + bridge/web/src/lib/config.ts | 144 + bridge/web/src/lib/credential-review.ts | 197 + bridge/web/src/lib/format.ts | 72 + bridge/web/src/lib/loop-patterns.ts | 164 + bridge/web/src/lib/member-archetypes.ts | 97 + bridge/web/src/lib/oidc-config.ts | 75 + bridge/web/src/lib/oidc.ts | 182 + bridge/web/src/lib/preflight-actions.ts | 20 + bridge/web/src/lib/run-mission-client.ts | 25 + bridge/web/src/lib/session-token.ts | 100 + bridge/web/src/lib/session.ts | 116 + bridge/web/src/lib/team-run-evidence.ts | 407 + bridge/web/src/lib/types.ts | 1611 +++ bridge/web/src/proxy.ts | 64 + bridge/web/tests/credential-review.test.mjs | 159 + bridge/web/tsconfig.json | 34 + ci/no-custom-crypto.sh | 3 + ci/no-null-provider-prod.sh | 1 + ci/no-stubs.sh | 3 + ci/security-audit-required.sh | 2 +- .../2026-09-11-bridge-application.md | 60 + 408 files changed, 109573 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/bridge-ci.yml create mode 100644 .github/workflows/bridge-native.yml create mode 100644 bridge/.env.example create mode 100644 bridge/.gitignore create mode 100644 bridge/Makefile create mode 100644 bridge/README.md create mode 100644 bridge/bff/.dockerignore create mode 100644 bridge/bff/Cargo.toml create mode 100644 bridge/bff/Dockerfile create mode 100644 bridge/bff/src/auth.rs create mode 100644 bridge/bff/src/config.rs create mode 100644 bridge/bff/src/error.rs create mode 100644 bridge/bff/src/kars/approval.rs create mode 100644 bridge/bff/src/kars/cluster.rs create mode 100644 bridge/bff/src/kars/credential_binding_tests.rs create mode 100644 bridge/bff/src/kars/credential_contract.rs create mode 100644 bridge/bff/src/kars/credential_entrypoint_tests.rs create mode 100644 bridge/bff/src/kars/credential_handler_tests.rs create mode 100644 bridge/bff/src/kars/credential_review.rs create mode 100644 bridge/bff/src/kars/credential_review_tests.rs create mode 100644 bridge/bff/src/kars/credential_targets.rs create mode 100644 bridge/bff/src/kars/credential_tests.rs create mode 100644 bridge/bff/src/kars/credential_transport.rs create mode 100644 bridge/bff/src/kars/credentials.rs create mode 100644 bridge/bff/src/kars/github_grants.rs create mode 100644 bridge/bff/src/kars/mod.rs create mode 100644 bridge/bff/src/kars/observation_credential_tests.rs create mode 100644 bridge/bff/src/kars/operator_credentials.rs create mode 100644 bridge/bff/src/kars/receipt.rs create mode 100644 bridge/bff/src/kars/receipt_log.rs create mode 100644 bridge/bff/src/kars/receipt_log/tests.rs create mode 100644 bridge/bff/src/kars/sre_action.rs create mode 100644 bridge/bff/src/kars/task.rs create mode 100644 bridge/bff/src/kars/team.rs create mode 100644 bridge/bff/src/kars/workspace_credential_plan.rs create mode 100644 bridge/bff/src/lib.rs create mode 100644 bridge/bff/src/main.rs create mode 100644 bridge/bff/src/routes/approvals.rs create mode 100644 bridge/bff/src/routes/artifacts.rs create mode 100644 bridge/bff/src/routes/budgets.rs create mode 100644 bridge/bff/src/routes/channels.rs create mode 100644 bridge/bff/src/routes/compose.rs create mode 100644 bridge/bff/src/routes/credential_review.rs create mode 100644 bridge/bff/src/routes/digests.rs create mode 100644 bridge/bff/src/routes/efficiency.rs create mode 100644 bridge/bff/src/routes/engineering.rs create mode 100644 bridge/bff/src/routes/foundry.rs create mode 100644 bridge/bff/src/routes/github.rs create mode 100644 bridge/bff/src/routes/health.rs create mode 100644 bridge/bff/src/routes/insights.rs create mode 100644 bridge/bff/src/routes/mod.rs create mode 100644 bridge/bff/src/routes/operator.rs create mode 100644 bridge/bff/src/routes/options.rs create mode 100644 bridge/bff/src/routes/ownership.rs create mode 100644 bridge/bff/src/routes/receipts.rs create mode 100644 bridge/bff/src/routes/receipts/statement.rs create mode 100644 bridge/bff/src/routes/retention.rs create mode 100644 bridge/bff/src/routes/review.rs create mode 100644 bridge/bff/src/routes/run.rs create mode 100644 bridge/bff/src/routes/sre_actions.rs create mode 100644 bridge/bff/src/routes/system.rs create mode 100644 bridge/bff/src/routes/tasks.rs create mode 100644 bridge/bff/src/routes/teams.rs create mode 100644 bridge/bff/src/routes/teams_internal.rs create mode 100644 bridge/bff/src/routes/telemetry.rs create mode 100644 bridge/bff/src/routes/validate.rs create mode 100644 bridge/bff/src/state.rs create mode 100644 bridge/bff/tests/health.rs create mode 100644 bridge/bff/tests/jwt_backend.rs create mode 100644 bridge/deploy/helm/kars-bridge/Chart.yaml create mode 100644 bridge/deploy/helm/kars-bridge/README.md create mode 100644 bridge/deploy/helm/kars-bridge/templates/NOTES.txt create mode 100644 bridge/deploy/helm/kars-bridge/templates/_helpers.tpl create mode 100644 bridge/deploy/helm/kars-bridge/templates/bff.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/idp.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/ingress.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/namespace.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/rbac.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml create mode 100644 bridge/deploy/helm/kars-bridge/templates/web.yaml create mode 100644 bridge/deploy/helm/kars-bridge/values-kind.yaml create mode 100644 bridge/deploy/helm/kars-bridge/values.yaml create mode 100644 bridge/deploy/rbac.yaml create mode 100644 bridge/docs/README.md create mode 100644 bridge/docs/SUMMARY.md create mode 100644 bridge/docs/approvals-egress.md create mode 100644 bridge/docs/architecture.md create mode 100644 bridge/docs/compatibility.md create mode 100644 bridge/docs/connections.md create mode 100644 bridge/docs/contributing.md create mode 100644 bridge/docs/deployment.md create mode 100644 bridge/docs/evidence-compliance.md create mode 100644 bridge/docs/glossary.md create mode 100644 bridge/docs/governed-credentials.md create mode 100644 bridge/docs/identity.md create mode 100644 bridge/docs/inference-budgets.md create mode 100644 bridge/docs/local-inference.md create mode 100644 bridge/docs/mcp-servers.md create mode 100644 bridge/docs/missions-and-teams.md create mode 100644 bridge/docs/observability.md create mode 100644 bridge/docs/operations.md create mode 100644 bridge/docs/providers.md create mode 100644 bridge/docs/quickstart.md create mode 100644 bridge/docs/rbac.md create mode 100644 bridge/docs/skills.md create mode 100644 bridge/docs/team-workflows.md create mode 100644 bridge/docs/troubleshooting.md create mode 100755 bridge/start-bff.sh create mode 100644 bridge/teams-gateway/.dockerignore create mode 100644 bridge/teams-gateway/.gitignore create mode 100644 bridge/teams-gateway/Dockerfile create mode 100644 bridge/teams-gateway/package-lock.json create mode 100644 bridge/teams-gateway/package.json create mode 100644 bridge/teams-gateway/src/bff-client.ts create mode 100644 bridge/teams-gateway/src/cards.ts create mode 100644 bridge/teams-gateway/src/config.ts create mode 100644 bridge/teams-gateway/src/conversation-store.ts create mode 100644 bridge/teams-gateway/src/hmac.ts create mode 100644 bridge/teams-gateway/src/identity.ts create mode 100644 bridge/teams-gateway/src/log.ts create mode 100644 bridge/teams-gateway/src/main.ts create mode 100644 bridge/teams-gateway/src/watcher.ts create mode 100644 bridge/teams-gateway/tests/chart-lifecycle.test.ts create mode 100644 bridge/teams-gateway/tests/chart-upgrade.test.ts create mode 100644 bridge/teams-gateway/tests/chart.test.ts create mode 100644 bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml create mode 100644 bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml create mode 100644 bridge/teams-gateway/tests/fixtures/values-10505214.yaml create mode 100644 bridge/teams-gateway/tests/gateway.test.ts create mode 100644 bridge/teams-gateway/tests/monorepo.test.ts create mode 100644 bridge/teams-gateway/tests/native-qualification.test.ts create mode 100644 bridge/teams-gateway/tests/packaging.test.ts create mode 100644 bridge/teams-gateway/tsconfig.json create mode 100644 bridge/teams-gateway/vitest.config.ts create mode 100644 bridge/tests/native-credentials/Dockerfile.bff create mode 100644 bridge/tests/native-credentials/Dockerfile.probe create mode 100644 bridge/tests/native-credentials/Dockerfile.runtime create mode 100644 bridge/tests/native-credentials/admission_cases.py create mode 100644 bridge/tests/native-credentials/api-values.yaml create mode 100644 bridge/tests/native-credentials/api_gate.py create mode 100644 bridge/tests/native-credentials/api_outcome_diagnostics.py create mode 100644 bridge/tests/native-credentials/audit-policy.yaml create mode 100644 bridge/tests/native-credentials/boot.py create mode 100644 bridge/tests/native-credentials/credential_cases.py create mode 100644 bridge/tests/native-credentials/credential_review.py create mode 100644 bridge/tests/native-credentials/kind_config.py create mode 100644 bridge/tests/native-credentials/lifecycle_cases.py create mode 100644 bridge/tests/native-credentials/loaded_images.py create mode 100644 bridge/tests/native-credentials/native_api.py create mode 100644 bridge/tests/native-credentials/observation_cases.py create mode 100644 bridge/tests/native-credentials/observation_diagnostics.py create mode 100644 bridge/tests/native-credentials/observer_cilium_diagnostics.py create mode 100644 bridge/tests/native-credentials/observer_network_diagnostics.py create mode 100644 bridge/tests/native-credentials/private_tls.py create mode 100644 bridge/tests/native-credentials/run.py create mode 100644 bridge/tests/native-credentials/runtime_probe.py create mode 100644 bridge/tests/native-credentials/runtime_state.py create mode 100644 bridge/tests/native-credentials/source_revision.py create mode 100644 bridge/tests/native-credentials/test_cilium_baseline_witness.py create mode 100644 bridge/tests/native-credentials/test_cilium_status_schema.py create mode 100644 bridge/tests/native-credentials/test_credential_review.py create mode 100644 bridge/tests/native-credentials/test_credential_target_startup.py create mode 100644 bridge/tests/native-credentials/test_observation_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_observer_cilium_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_observer_network_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_source_revision.py create mode 100644 bridge/web/.dockerignore create mode 100644 bridge/web/.gitignore create mode 100644 bridge/web/AGENTS.md create mode 100644 bridge/web/CLAUDE.md create mode 100644 bridge/web/Dockerfile create mode 100644 bridge/web/README.md create mode 100644 bridge/web/eslint.config.mjs create mode 100644 bridge/web/next.config.ts create mode 100644 bridge/web/package-lock.json create mode 100644 bridge/web/package.json create mode 100644 bridge/web/postcss.config.mjs create mode 100644 bridge/web/public/file.svg create mode 100644 bridge/web/public/globe.svg create mode 100644 bridge/web/public/next.svg create mode 100644 bridge/web/public/vercel.svg create mode 100644 bridge/web/public/window.svg create mode 100644 bridge/web/src/app/api/[...path]/route.ts create mode 100644 bridge/web/src/app/api/health/route.ts create mode 100644 bridge/web/src/app/audit/layout.tsx create mode 100644 bridge/web/src/app/audit/page.tsx create mode 100644 bridge/web/src/app/auth/callback/route.ts create mode 100644 bridge/web/src/app/auth/login/route.ts create mode 100644 bridge/web/src/app/auth/logout/route.ts create mode 100644 bridge/web/src/app/auth/no-roles/page.tsx create mode 100644 bridge/web/src/app/console/access/page.tsx create mode 100644 bridge/web/src/app/console/approvals/page.tsx create mode 100644 bridge/web/src/app/console/audit/audit-receipt-row.tsx create mode 100644 bridge/web/src/app/console/audit/audit-search.tsx create mode 100644 bridge/web/src/app/console/audit/page.tsx create mode 100644 bridge/web/src/app/console/author-resource.tsx create mode 100644 bridge/web/src/app/console/capabilities/page.tsx create mode 100644 bridge/web/src/app/console/configuration/additional-provider-actions.ts create mode 100644 bridge/web/src/app/console/configuration/copilot-login-actions.ts create mode 100644 bridge/web/src/app/console/configuration/credential-actions.ts create mode 100644 bridge/web/src/app/console/configuration/credential-form.tsx create mode 100644 bridge/web/src/app/console/configuration/github-app-actions.ts create mode 100644 bridge/web/src/app/console/configuration/local-inference-actions.ts create mode 100644 bridge/web/src/app/console/configuration/local-model-deploy.tsx create mode 100644 bridge/web/src/app/console/configuration/model-catalogue.tsx create mode 100644 bridge/web/src/app/console/configuration/page.tsx create mode 100644 bridge/web/src/app/console/configuration/provider-actions.ts create mode 100644 bridge/web/src/app/console/configuration/provider-discover-actions.ts create mode 100644 bridge/web/src/app/console/configuration/provider-wizard.tsx create mode 100644 bridge/web/src/app/console/configuration/set-default-model-actions.ts create mode 100644 bridge/web/src/app/console/configuration/set-default-provider-actions.ts create mode 100644 bridge/web/src/app/console/datapath/page.tsx create mode 100644 bridge/web/src/app/console/delete-resource.tsx create mode 100644 bridge/web/src/app/console/evals/eval-detail.tsx create mode 100644 bridge/web/src/app/console/evals/new-eval-form.tsx create mode 100644 bridge/web/src/app/console/evals/page.tsx create mode 100644 bridge/web/src/app/console/fleet/capacity-dashboard.tsx create mode 100644 bridge/web/src/app/console/fleet/fleet-list.tsx create mode 100644 bridge/web/src/app/console/fleet/mesh-topology.tsx create mode 100644 bridge/web/src/app/console/fleet/page.tsx create mode 100644 bridge/web/src/app/console/foundry-actions.ts create mode 100644 bridge/web/src/app/console/foundry-onboard.tsx create mode 100644 bridge/web/src/app/console/governance-actions.ts create mode 100644 bridge/web/src/app/console/inference-policy-editor.tsx create mode 100644 bridge/web/src/app/console/insights/page.tsx create mode 100644 bridge/web/src/app/console/layout.tsx create mode 100644 bridge/web/src/app/console/mcp-catalog-data.ts create mode 100644 bridge/web/src/app/console/mcp-catalog.tsx create mode 100644 bridge/web/src/app/console/mcp-profile-actions.ts create mode 100644 bridge/web/src/app/console/mcp-profiles.tsx create mode 100644 bridge/web/src/app/console/mcp-server-editor.tsx create mode 100644 bridge/web/src/app/console/operator-github-status.tsx create mode 100644 bridge/web/src/app/console/page.tsx create mode 100644 bridge/web/src/app/console/policies/page.tsx create mode 100644 bridge/web/src/app/console/policy-builder-data.ts create mode 100644 bridge/web/src/app/console/policy-builder.tsx create mode 100644 bridge/web/src/app/console/profile-editor.tsx create mode 100644 bridge/web/src/app/console/skill-approval.tsx create mode 100644 bridge/web/src/app/console/skill-submit-action.ts create mode 100644 bridge/web/src/app/console/sre-action-decision.tsx create mode 100644 bridge/web/src/app/console/sre-actions/page.tsx create mode 100644 bridge/web/src/app/console/troubleshooting/page.tsx create mode 100644 bridge/web/src/app/dex/[...path]/route.ts create mode 100644 bridge/web/src/app/favicon.ico create mode 100644 bridge/web/src/app/globals.css create mode 100644 bridge/web/src/app/inbox/approval-actions.ts create mode 100644 bridge/web/src/app/layout.tsx create mode 100644 bridge/web/src/app/page.tsx create mode 100644 bridge/web/src/app/role-actions.ts create mode 100644 bridge/web/src/app/tasks/[name]/execution-panel.tsx create mode 100644 bridge/web/src/app/tasks/[name]/launch-actions.ts create mode 100644 bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx create mode 100644 bridge/web/src/app/workspace/agents/page.tsx create mode 100644 bridge/web/src/app/workspace/connections/page.tsx create mode 100644 bridge/web/src/app/workspace/inbox/loading.tsx create mode 100644 bridge/web/src/app/workspace/inbox/page.tsx create mode 100644 bridge/web/src/app/workspace/layout.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/delete-actions.ts create mode 100644 bridge/web/src/app/workspace/missions/[name]/delete-control.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/egress-actions.ts create mode 100644 bridge/web/src/app/workspace/missions/[name]/egress-request.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/halt-button.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/mission-map.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/network-mode.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/org-chart.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/page.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/review-actions.ts create mode 100644 bridge/web/src/app/workspace/missions/[name]/review-panel.tsx create mode 100644 bridge/web/src/app/workspace/missions/[name]/role-actions.ts create mode 100644 bridge/web/src/app/workspace/missions/[name]/run-actions.ts create mode 100644 bridge/web/src/app/workspace/missions/loading.tsx create mode 100644 bridge/web/src/app/workspace/missions/missions-list.tsx create mode 100644 bridge/web/src/app/workspace/missions/page.tsx create mode 100644 bridge/web/src/app/workspace/new/actions.ts create mode 100644 bridge/web/src/app/workspace/new/envelope-reveal.tsx create mode 100644 bridge/web/src/app/workspace/new/intake-flow.tsx create mode 100644 bridge/web/src/app/workspace/new/page.tsx create mode 100644 bridge/web/src/app/workspace/page.tsx create mode 100644 bridge/web/src/app/workspace/skills/loading.tsx create mode 100644 bridge/web/src/app/workspace/skills/page.tsx create mode 100644 bridge/web/src/app/workspace/skills/skill-actions.ts create mode 100644 bridge/web/src/app/workspace/skills/skill-upload.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/channel-actions.ts create mode 100644 bridge/web/src/app/workspace/teams/[name]/delete-actions.ts create mode 100644 bridge/web/src/app/workspace/teams/[name]/delete-control.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts create mode 100644 bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/page.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/promote-actions.ts create mode 100644 bridge/web/src/app/workspace/teams/[name]/promote-control.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/run-actions.ts create mode 100644 bridge/web/src/app/workspace/teams/[name]/run-control.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/task-actions.ts create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-channels.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-edit.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx create mode 100644 bridge/web/src/app/workspace/teams/[name]/watching-status.tsx create mode 100644 bridge/web/src/app/workspace/teams/loading.tsx create mode 100644 bridge/web/src/app/workspace/teams/new/actions.ts create mode 100644 bridge/web/src/app/workspace/teams/new/page.tsx create mode 100644 bridge/web/src/app/workspace/teams/new/team-composer.tsx create mode 100644 bridge/web/src/app/workspace/teams/page.tsx create mode 100644 bridge/web/src/app/workspace/teams/teams-list.tsx create mode 100644 bridge/web/src/components/activity-stream.tsx create mode 100644 bridge/web/src/components/agent-graph.tsx create mode 100644 bridge/web/src/components/app-shell.tsx create mode 100644 bridge/web/src/components/approval-decision.tsx create mode 100644 bridge/web/src/components/approval-phase-badge.tsx create mode 100644 bridge/web/src/components/audit-report.tsx create mode 100644 bridge/web/src/components/audit-view.tsx create mode 100644 bridge/web/src/components/bar-chart.tsx create mode 100644 bridge/web/src/components/clarification-answer.tsx create mode 100644 bridge/web/src/components/compliance-pack.tsx create mode 100644 bridge/web/src/components/connect-channels.tsx create mode 100644 bridge/web/src/components/connect-github.tsx create mode 100644 bridge/web/src/components/connect-teams.tsx create mode 100644 bridge/web/src/components/console-nav.tsx create mode 100644 bridge/web/src/components/copy-digest.tsx create mode 100644 bridge/web/src/components/deliverable-view.tsx create mode 100644 bridge/web/src/components/envelope-card.tsx create mode 100644 bridge/web/src/components/envelope-digest.tsx create mode 100644 bridge/web/src/components/execution-explorer.tsx create mode 100644 bridge/web/src/components/execution-lifetime.tsx create mode 100644 bridge/web/src/components/fleet-live.tsx create mode 100644 bridge/web/src/components/honest-state.tsx create mode 100644 bridge/web/src/components/how-it-works.tsx create mode 100644 bridge/web/src/components/icon.tsx create mode 100644 bridge/web/src/components/inference-budgets.tsx create mode 100644 bridge/web/src/components/intent-entry.tsx create mode 100644 bridge/web/src/components/journey-rail.tsx create mode 100644 bridge/web/src/components/list-skeleton.tsx create mode 100644 bridge/web/src/components/live-activity-view.tsx create mode 100644 bridge/web/src/components/live-refresh.tsx create mode 100644 bridge/web/src/components/loop-designer.tsx create mode 100644 bridge/web/src/components/mermaid-diagram.tsx create mode 100644 bridge/web/src/components/mission-scorecard.tsx create mode 100644 bridge/web/src/components/mission-status.tsx create mode 100644 bridge/web/src/components/orchestration-cube.tsx create mode 100644 bridge/web/src/components/org-tree.tsx create mode 100644 bridge/web/src/components/phase-badge.tsx create mode 100644 bridge/web/src/components/preflight-check.tsx create mode 100644 bridge/web/src/components/primary-nav.tsx create mode 100644 bridge/web/src/components/provenance-overlay.tsx create mode 100644 bridge/web/src/components/provenance-story.tsx create mode 100644 bridge/web/src/components/receipt-panel.tsx create mode 100644 bridge/web/src/components/receipt-verify.tsx create mode 100644 bridge/web/src/components/repo-access.tsx create mode 100644 bridge/web/src/components/retention-policy.tsx create mode 100644 bridge/web/src/components/role-switcher.tsx create mode 100644 bridge/web/src/components/rubiks-cube.tsx create mode 100644 bridge/web/src/components/segmented-tier.tsx create mode 100644 bridge/web/src/components/skill-composer.tsx create mode 100644 bridge/web/src/components/stat-card.tsx create mode 100644 bridge/web/src/components/status-badge.tsx create mode 100644 bridge/web/src/components/surface-switcher.tsx create mode 100644 bridge/web/src/components/task-checkpoint.tsx create mode 100644 bridge/web/src/components/team-run-activity.tsx create mode 100644 bridge/web/src/components/team-run-flow.tsx create mode 100644 bridge/web/src/components/team-timing.tsx create mode 100644 bridge/web/src/components/theme-toggle.tsx create mode 100644 bridge/web/src/components/ui.tsx create mode 100644 bridge/web/src/components/use-live-trace.ts create mode 100644 bridge/web/src/components/viewport-portal.tsx create mode 100644 bridge/web/src/components/wiring-badge.tsx create mode 100644 bridge/web/src/components/workspace-nav.tsx create mode 100644 bridge/web/src/lib/auth-return.ts create mode 100644 bridge/web/src/lib/bff.ts create mode 100644 bridge/web/src/lib/classify-intent.ts create mode 100644 bridge/web/src/lib/config.ts create mode 100644 bridge/web/src/lib/credential-review.ts create mode 100644 bridge/web/src/lib/format.ts create mode 100644 bridge/web/src/lib/loop-patterns.ts create mode 100644 bridge/web/src/lib/member-archetypes.ts create mode 100644 bridge/web/src/lib/oidc-config.ts create mode 100644 bridge/web/src/lib/oidc.ts create mode 100644 bridge/web/src/lib/preflight-actions.ts create mode 100644 bridge/web/src/lib/run-mission-client.ts create mode 100644 bridge/web/src/lib/session-token.ts create mode 100644 bridge/web/src/lib/session.ts create mode 100644 bridge/web/src/lib/team-run-evidence.ts create mode 100644 bridge/web/src/lib/types.ts create mode 100644 bridge/web/src/proxy.ts create mode 100644 bridge/web/tests/credential-review.test.mjs create mode 100644 bridge/web/tsconfig.json create mode 100644 docs/security-audits/2026-09-11-bridge-application.md diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml new file mode 100644 index 000000000..211a58418 --- /dev/null +++ b/.github/workflows/bridge-ci.yml @@ -0,0 +1,196 @@ +name: Bridge CI + +on: + pull_request: + branches: [main, dev, kars-bridge] + paths: ['bridge/**', '.github/workflows/bridge-ci.yml', '.github/workflows/bridge-native.yml', 'Cargo.toml', 'ci/npm-audit-bulk.mjs'] + push: + branches: [main, kars-bridge] + paths: ['bridge/**', '.github/workflows/bridge-ci.yml', '.github/workflows/bridge-native.yml', 'Cargo.toml', 'ci/npm-audit-bulk.mjs'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bridge-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + bff: + name: BFF build and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: bridge/bff + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: bridge/bff + - run: cargo fmt --all -- --check + - run: cargo clippy --locked --all-targets -- -D warnings + - run: cargo test --locked + - name: Check explicit credential review orchestration + run: PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=../tests/native-credentials python3 -m unittest discover -s ../tests/native-credentials -p test_credential_review.py + + web: + name: Web build and lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: bridge/web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: bridge/web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npx --no-install tsc --noEmit + - name: Check credential form review and resubmission + run: node --experimental-strip-types --test tests/credential-review.test.mjs + - name: Build the production web image without publishing + run: docker build --tag kars-bridge-web-qualification:latest . + - name: Start web with an immutable root filesystem + run: | + container=$(docker run --detach --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + --tmpfs /tmp:rw,noexec,nosuid,size=134217728,uid=10001,gid=10001 \ + --tmpfs /app/.next/cache:rw,noexec,nosuid,size=268435456,uid=10001,gid=10001 \ + --publish 127.0.0.1:3000:3000 --env HOSTNAME=0.0.0.0 \ + kars-bridge-web-qualification:latest) + echo "WEB_CONTAINER_ID=$container" >> "$GITHUB_ENV" + curl --fail --retry 20 --retry-all-errors --retry-delay 1 \ + --max-time 10 http://127.0.0.1:3000/api/health + docker exec "$container" node -e ' + const fs = require("node:fs"); + const assert = require("node:assert/strict"); + fs.writeFileSync("/app/.next/cache/qualification", "cache works"); + fs.writeFileSync("/tmp/qualification", "temporary writes work"); + assert.throws(() => fs.writeFileSync("/app/qualification", "denied"), + error => error.code === "EROFS" || error.code === "EACCES"); + ' + docker exec "$container" node --input-type=module -e ' + import assert from "node:assert/strict"; + import sharp from "sharp"; + const image = await sharp(Buffer.from([255, 0, 0, 255]), + { raw: { width: 1, height: 1, channels: 4 } }).resize(2, 2).png().toBuffer(); + const metadata = await sharp(image).metadata(); + assert.equal(metadata.width, 2); + assert.equal(metadata.height, 2); + assert.equal(metadata.format, "png"); + ' + - name: Remove the qualification container + if: always() + run: | + if [ -n "${WEB_CONTAINER_ID:-}" ]; then + docker logs "$WEB_CONTAINER_ID" + docker rm --force "$WEB_CONTAINER_ID" + fi + + addon: + name: Add-on install and uninstall + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: bridge/teams-gateway/package-lock.json + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + - run: npm ci + working-directory: bridge/teams-gateway + - run: npm run lint && npm run typecheck && npm run build && npm test + working-directory: bridge/teams-gateway + - run: helm lint bridge/deploy/helm/kars-bridge + - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + cluster_name: bridge-addon-lifecycle + kubeconfig: ${{ runner.temp }}/bridge-addon-kubeconfig + - name: Exercise real Helm removal in a disposable cluster + working-directory: bridge/teams-gateway + env: + BRIDGE_TEST_KIND_LIFECYCLE: '1' + BRIDGE_TEST_KUBECONFIG: ${{ runner.temp }}/bridge-addon-kubeconfig + run: npm test -- tests/chart-lifecycle.test.ts + + dependencies: + name: Dependency audit (${{ matrix.project }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + project: [web, teams-gateway] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - run: node ci/npm-audit-bulk.mjs bridge/${{ matrix.project }}/package-lock.json + + rust-dependencies: + name: Rust dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: taiki-e/install-action@ba47c86ac325773530516bb756137ac718732518 # v2.86.5 + with: + tool: cargo-audit + - run: cargo audit --file bridge/bff/Cargo.lock + + lockfiles: + name: Lockfile consistency (${{ matrix.project }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + project: [web, teams-gateway] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - name: Resolve without installing packages or running dependency scripts + working-directory: bridge/${{ matrix.project }} + run: npm install --package-lock-only --ignore-scripts --no-audit + - name: Require the resolved lockfile to be committed + run: git diff --exit-code -- bridge/${{ matrix.project }}/package-lock.json + - name: Retain the generated lockfile for review + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: proposed-lockfile-${{ matrix.project }} + path: bridge/${{ matrix.project }}/package-lock.json + if-no-files-found: error + retention-days: 7 + + security: + name: Source and configuration security + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: bridge + scanners: misconfig,secret + severity: HIGH,CRITICAL + exit-code: '1' + + secrets: + name: Secret scanning + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: trufflesecurity/trufflehog@30d5bb91af1a771378349dbbb0c82129392acf70 # v3.95.6 + with: + extra_args: --only-verified diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml new file mode 100644 index 000000000..395288da9 --- /dev/null +++ b/.github/workflows/bridge-native.yml @@ -0,0 +1,180 @@ +name: Bridge native qualification + +on: + pull_request: + branches: [main, dev, kars-bridge] + paths: + - '.github/workflows/bridge-native.yml' + - 'bridge/**' + - 'controller/**' + - 'inference-router/**' + - 'shared/**' + - 'deploy/helm/kars/**' + - 'Cargo.toml' + - 'Cargo.lock' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: bridge-native-${{ github.ref }} + cancel-in-progress: true + +env: + CORE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} + KUBECONFIG: ${{ github.workspace }}/bridge/.native/kubeconfig + TMPDIR: ${{ github.workspace }}/bridge/.native/scratch + +defaults: + run: + working-directory: bridge + +jobs: + api-admission: + name: Native API and admission (no active SRE) + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Create isolated disposable runner paths + run: install -d -m 700 .native/scratch .native/evidence + - name: Check out the exact public core candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: bridge/.native/core + persist-credentials: false + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: v3.17.3 + - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + version: v0.24.0 + kubectl_version: v1.31.0 + node_image: kindest/node:v1.31.0@sha256:53df588e04085fd41ae12de0c3fe4c72f7013bba32a20e7325357a1ac94ba865 + cluster_name: bridge-native-api + kubeconfig: ${{ github.workspace }}/bridge/.native/kubeconfig + - name: Validate real CRD schemas and every admission expression + run: python3 tests/native-credentials/api_gate.py + - name: Retain only secret-free acceptance markers + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: native-api-evidence + path: bridge/.native/evidence/*.json + if-no-files-found: warn + retention-days: 7 + - name: Remove the disposable API cluster + if: always() + run: kind delete cluster --name bridge-native-api + + native-runtime: + name: Native BFF grants, lifecycle, TLS and CNI (no active SRE) + # Collect actual controller/BFF evidence independently of schema diagnostics. + # Both jobs remain mandatory in the final gate; core readiness is unchanged. + runs-on: ubuntu-22.04 + timeout-minutes: 90 + env: + CARGO_INCREMENTAL: '0' + CARGO_BUILD_JOBS: '2' + CARGO_PROFILE_DEV_DEBUG: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - run: install -d -m 700 .native/scratch .native/evidence .native/bin + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + path: bridge/.native/core + persist-credentials: false + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: | + bridge/.native/core + bridge/bff + key: native-locked-debug-no-symbols + cache-on-failure: true + - name: Build core and Bridge from the same exact source on the disposable host + run: | + cargo build --manifest-path .native/core/Cargo.toml --locked \ + -p kars-controller -p kars-inference-router \ + --bin kars-controller --bin kars-inference-router + cargo build --manifest-path bff/Cargo.toml --locked --bin kars-bridge-bff + install -d .native/core/bin/amd64 + install .native/core/target/debug/kars-controller .native/core/bin/amd64/ + install .native/core/target/debug/kars-inference-router .native/core/bin/amd64/ + install bff/target/debug/kars-bridge-bff .native/bin/ + - name: Package only local qualification images, never publish + run: | + docker build --build-arg TARGETARCH=amd64 \ + --file .native/core/controller/Dockerfile --tag kars-native-controller:latest .native/core + docker build --build-arg TARGETARCH=amd64 \ + --file .native/core/inference-router/Dockerfile --tag kars-native-router:latest .native/core + docker build --file .native/core/tests/e2e/Dockerfile.sandbox-stub \ + --tag kars-native-runtime-base:latest .native/core/tests/e2e + docker build --file tests/native-credentials/Dockerfile.runtime \ + --tag kars-native-runtime:latest tests/native-credentials + docker build --file tests/native-credentials/Dockerfile.bff \ + --tag kars-native-bff:latest .native/bin + docker build --file tests/native-credentials/Dockerfile.probe \ + --tag kars-native-probe:latest tests/native-credentials + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: v3.17.3 + - name: Prepare a real CNI topology with metadata-only API auditing + run: python3 tests/native-credentials/kind_config.py + - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 + with: + version: v0.24.0 + kubectl_version: v1.31.0 + node_image: kindest/node:v1.31.0@sha256:53df588e04085fd41ae12de0c3fe4c72f7013bba32a20e7325357a1ac94ba865 + cluster_name: bridge-native + config: bridge/.native/kind.json + kubeconfig: ${{ github.workspace }}/bridge/.native/kubeconfig + - name: Install pinned Cilium, retaining normal kube-proxy + run: | + helm install cilium cilium --repo https://helm.cilium.io --version 1.18.5 \ + --namespace kube-system --set ipam.mode=kubernetes \ + --set kubeProxyReplacement=false --set operator.replicas=1 \ + --set image.pullPolicy=IfNotPresent --wait --timeout 5m + kubectl rollout status daemonset/cilium -n kube-system --timeout=180s + kubectl wait --for=condition=Ready nodes --all --timeout=180s + kind load docker-image --name bridge-native kars-native-controller:latest \ + kars-native-router:latest kars-native-runtime:latest kars-native-bff:latest kars-native-probe:latest + - name: Exercise normal BFF entrypoints and native controller authority + timeout-minutes: 35 + run: python3 tests/native-credentials/run.py + - name: Retain only bounded secret-free native outcomes + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: native-runtime-evidence + path: bridge/.native/evidence/*.json + if-no-files-found: warn + retention-days: 7 + - name: Destroy the entire disposable native environment + if: always() + run: kind delete cluster --name bridge-native + + native-required-gates: + name: Require both native API and runtime acceptance + needs: [api-admission, native-runtime] + if: always() + runs-on: ubuntu-22.04 + timeout-minutes: 2 + env: + API_RESULT: ${{ needs.api-admission.result }} + RUNTIME_RESULT: ${{ needs.native-runtime.result }} + steps: + - name: Fail if either independent prerequisite failed or was skipped + working-directory: ${{ github.workspace }} + run: | + test "$API_RESULT" = success + test "$RUNTIME_RESULT" = success diff --git a/Cargo.toml b/Cargo.toml index 9240812e1..5ee5128a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "tests/cncf-conformance", ] exclude = [ + # Optional application: Bridge keeps its own dependency graph and lockfile. + "bridge/bff", # cargo-fuzz targets build with libfuzzer-sys (nightly-only). Excluded # from the workspace so stable `cargo build --workspace` is unaffected. # Run targets with: cargo +nightly fuzz run (see fuzz/README.md). diff --git a/Makefile b/Makefile index 6e8402547..97fd06bd0 100644 --- a/Makefile +++ b/Makefile @@ -235,6 +235,25 @@ fuzz-quick: ## Smoke-run each fuzz target for 10s (CI-fast) cargo +nightly fuzz run $$t -- -max_total_time=10 -runs=100000 || exit 1; \ done +# ─── Optional Bridge ────────────────────────────────────────────────────────── + +.PHONY: bridge-check bridge-bff bridge-web bridge-images bridge-helm-lint + +bridge-check: ## Check the optional Bridge application without changing core build targets + $(MAKE) -C bridge check + +bridge-bff: ## Run the optional Bridge BFF in the foreground + $(MAKE) -C bridge bff + +bridge-web: ## Run the optional Bridge web application + $(MAKE) -C bridge web + +bridge-images: ## Build optional Bridge images; does not publish or deploy + $(MAKE) -C bridge images + +bridge-helm-lint: ## Lint the separate additive Bridge chart + $(MAKE) -C bridge helm-lint + # ─── Clean ──────────────────────────────────────────────────────────────────── clean: ## Remove build artifacts diff --git a/README.md b/README.md index 9e42512b0..bbf927284 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,15 @@ kars connect dev-agent --- +## Optional Kars Bridge + +[Kars Bridge](bridge/README.md) provides the Workspace, Operator Console and Audit +application for missions, standing teams and governed evidence. Its complete +source is under `bridge/`, with separate packages, images and an additive Helm +release. **Kars core builds, installs and runs without Bridge.** Removing Bridge +must preserve core resources and customer data. The `kars-bridge` branch is an +integration preview, not a production release or image publication. + ## The problem Giving an AI agent real tools means giving it real credentials and a real network. In production that is too much blast radius: a single prompt-injected agent can reach your Azure subscription, your GitHub org, and your customer data. diff --git a/bridge/.env.example b/bridge/.env.example new file mode 100644 index 000000000..4ae3e96e8 --- /dev/null +++ b/bridge/.env.example @@ -0,0 +1,10 @@ +# kars Bridge local configuration. Copy to .env and adjust as needed. +# Never commit real secrets. + +# --- BFF --- +BRIDGE_BFF_PORT=8081 +BRIDGE_WEB_ORIGIN=http://localhost:3000 +BRIDGE_LOG_JSON=false + +# --- Web (server-side) --- +BRIDGE_BFF_URL=http://localhost:8081 diff --git a/bridge/.gitignore b/bridge/.gitignore new file mode 100644 index 000000000..ae947306f --- /dev/null +++ b/bridge/.gitignore @@ -0,0 +1,23 @@ +# Rust (BFF) +/bff/target/ +**/*.rs.bk + +# Node / Next.js (web) +node_modules +/web/.next/ +/web/out/ +/web/next-env.d.ts + +# Env & secrets — never commit +.env +.env.* +!.env.example + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.log + +# Disposable native qualification credentials, builds, and evidence +/.native/ diff --git a/bridge/Makefile b/bridge/Makefile new file mode 100644 index 000000000..f9140fb11 --- /dev/null +++ b/bridge/Makefile @@ -0,0 +1,70 @@ +.PHONY: dev bff web check check-bff check-web check-gateway install images image-gateway images-push helm-lint helm-test helm-install helm-install-kind + +# Run BFF + web together (Ctrl-C stops both). +dev: + @echo "starting kars-bridge-bff (:8081) and web (:3000)" + @( cd bff && cargo run ) & \ + ( cd web && npm run dev ) ; \ + wait + +bff: + cd bff && cargo run + +web: + cd web && npm run dev + +install: + cd web && npm ci + cd teams-gateway && npm ci + +# Component and chart quality gates. +check: check-bff check-web check-gateway helm-lint + +check-bff: + cd bff && cargo build --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked + +check-web: + cd web && npm run lint && npm run build + +check-gateway: + cd teams-gateway && npm run lint && npm run typecheck && npm run build && npm test + +# ── Container images + Helm (deploy on any cluster: AKS/EKS/GKE/kind) ───────── +# Local image builds are safe by default; select your registry explicitly to publish. +REGISTRY ?= localhost +TAG ?= latest +KIND_CLUSTER ?= kars-dev + +# Build both Bridge images (BFF + web). Override REGISTRY/TAG for your registry. +images: + docker build -f bff/Dockerfile -t $(REGISTRY)/kars-bridge-bff:$(TAG) bff + docker build -f web/Dockerfile -t $(REGISTRY)/kars-bridge-web:$(TAG) web + +image-gateway: + docker build -f teams-gateway/Dockerfile -t $(REGISTRY)/kars-bridge-teams-gateway:$(TAG) teams-gateway + +images-push: images + docker push $(REGISTRY)/kars-bridge-bff:$(TAG) + docker push $(REGISTRY)/kars-bridge-web:$(TAG) + +helm-lint: + helm lint deploy/helm/kars-bridge + +# Offline add-on lifecycle regressions, using the existing Vitest runner. +helm-test: + cd teams-gateway && npm test -- tests/chart.test.ts + +# Install (or upgrade) the Bridge additively on an existing kars cluster. +helm-install: + helm upgrade --install kars-bridge deploy/helm/kars-bridge -n kars-system \ + --set bff.image.repository=$(REGISTRY)/kars-bridge-bff \ + --set web.image.repository=$(REGISTRY)/kars-bridge-web \ + --set bff.image.tag=$(TAG) --set web.image.tag=$(TAG) + +# Local kind: build dev images, load them, install with the kind overlay. +helm-install-kind: + docker build -f bff/Dockerfile -t kars-bridge-bff:dev bff + docker build -f web/Dockerfile -t kars-bridge-web:dev web + kind load docker-image kars-bridge-bff:dev kars-bridge-web:dev --name $(KIND_CLUSTER) + helm upgrade --install kars-bridge deploy/helm/kars-bridge -n kars-system \ + -f deploy/helm/kars-bridge/values-kind.yaml diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 000000000..09371b46a --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,125 @@ +# Kars Bridge + +**Mission control for governed agent work.** + +Kars Bridge is a human-facing experience layer on top of +[Kars](https://github.com/Azure/kars). Employees launch missions and standing +teams; operators govern providers, MCP servers, skills, egress, budgets, and +approvals; auditors inspect receipts and evidence. + +> **Status: integration preview.** The complete application lives in `bridge/` +> in the Kars repository, initially on the `kars-bridge` branch. Source publication +> is not a release, an image publication or a production support commitment. +> Build images in your own registry and configure the chart explicitly. + +## Product boundary + +**Bridge depends on Kars; Kars never depends on Bridge.** + +Bridge owns composition, workflows, visualization, and personas. Kars owns the +CRDs, controller, sandbox isolation, inference router, policies, encrypted mesh, +and durable evidence. Every Kars primitive remains usable without Bridge. +The BFF keeps an independent Cargo manifest and lockfile and is excluded from +the core workspace. Web and Teams gateway retain their own npm packages. +Bridge uses its own additive Helm release; removing it must preserve Kars +resources and customer data. Repository co-location does not change this boundary. + +## Start here + +| Goal | Documentation | +|---|---| +| Understand the product | [Documentation home](docs/README.md) | +| Install the integration preview | [Quickstart](docs/quickstart.md) | +| Review Kars compatibility | [Compatibility](docs/compatibility.md) | +| Configure identity and roles | [Identity](docs/identity.md) and [RBAC](docs/rbac.md) | +| Run missions and teams | [Missions and teams](docs/missions-and-teams.md) | +| Understand the full team/run/evidence flow | [Team workflows](docs/team-workflows.md) | +| Add Playwright or another MCP | [MCP servers](docs/mcp-servers.md) | +| Govern skills and approvals | [Skills](docs/skills.md) and [approvals/egress](docs/approvals-egress.md) | +| Deploy local GPU models | [Local inference](docs/local-inference.md) | +| Diagnose a failure | [Troubleshooting](docs/troubleshooting.md) | + +## Surfaces + +| Surface | Persona | Purpose | +|---|---|---| +| Workspace | User, operator, admin | Compose and review missions, teams, connections, and deliverables | +| Operator Console | Operator, admin | Providers, models, policies, MCP, skills, approvals, fleet health, budgets | +| Audit | Auditor, admin | Read-only receipts, evidence, and verification | + +These are separate persona boundaries. Workspace links do not depend on Console +routes, and Audit is self-contained. + +## Architecture + +```mermaid +flowchart LR + Browser --> Web["Next.js web"] + Web --> BFF["Rust BFF"] + BFF --> K8s["Kubernetes API"] + K8s --> Kars["Kars controller and CRDs"] + Kars --> Sandboxes["Isolated agent sandboxes"] +``` + +- The browser never receives Kubernetes credentials. +- The BFF verifies the signed Bridge principal and applies persona checks. +- The BFF ServiceAccount is the aggregate Kubernetes permission ceiling. +- Kars controllers and routers remain the runtime enforcement layer. + +## Qualification + +The Bridge CI workflow checks the BFF, web, Teams gateway, dependency locks, +security configuration and additive install/removal behavior. The native +workflow checks the application and core from the **same immutable monorepo +commit**. Component checks, older demonstrations and API-only admission are not +substitutes for full native acceptance. + +This integration candidate is not yet a qualified release. Credential lifecycle, +observer access and complete governed Team execution must satisfy their +applicable acceptance gates before a release claim. See +[Compatibility](docs/compatibility.md) for the scope of the evidence. + +## Develop + +Prerequisites: + +- Rust 1.88+ +- Node.js 22+ +- access to a compatible Kars cluster for integration testing + +```bash +cd bridge +make dev +make bff +make web +``` + +The application also defines `make check`; the root repository exposes +`make bridge-check` as an explicit opt-in. Core build targets do not run or +install Bridge. Successful component checks are not full-stack qualification. + +`make helm-test` runs offline add-on boundary, namespace retention, readiness +wiring, and optional Teams regressions using Helm and the existing +`teams-gateway` Vitest development dependencies. It is included in `make check` +and needs neither a cluster nor tenant credentials. + +The Bridge PR workflow runs BFF, web and gateway quality gates, dependency and +secret audits, configuration scanning, and a disposable-Kind Helm removal test. +It reuses the repository's audit client without a second external checkout. + +Local development defaults are intentionally convenient and are not a +production authentication model. See [Identity](docs/identity.md). + +## Repository + +| Directory | Purpose | +|---|---| +| `bff/` | Rust backend-for-frontend and Kubernetes integration | +| `web/` | Next.js Workspace, Console, and Audit surfaces | +| `teams-gateway/` | Optional Microsoft Teams transport and add-on tests | +| `deploy/helm/kars-bridge/` | Additive Bridge Helm chart | +| `docs/` | Product, operator, deployment, and contributor documentation | + +## License + +MIT. diff --git a/bridge/bff/.dockerignore b/bridge/bff/.dockerignore new file mode 100644 index 000000000..8432daef9 --- /dev/null +++ b/bridge/bff/.dockerignore @@ -0,0 +1,3 @@ +target/ +.git/ +*.md diff --git a/bridge/bff/Cargo.toml b/bridge/bff/Cargo.toml new file mode 100644 index 000000000..cf4211749 --- /dev/null +++ b/bridge/bff/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "kars-bridge-bff" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "kars Bridge backend-for-frontend — the only server-side path between the web app and the kars cluster." +license = "MIT" +publish = false + +[dependencies] +axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "process"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "request-id", "util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Used to build atomic (test-precondition + add/remove) PATCH bodies for +# optimistic-concurrency read-modify-write against Secrets/ConfigMaps — this +# stays on the `patch` RBAC verb (the BFF's ClusterRole never grants `update`, +# which a PUT-based replace()/CAS would require). See cluster.rs +# mutate_secret_keys / update_configmap_data. +json-patch = "4" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +thiserror = "2" +anyhow = "1" +http = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +kube = { version = "0.99", default-features = false, features = ["client", "runtime", "derive", "rustls-tls", "jsonpatch"] } +k8s-openapi = { version = "0.24", features = ["latest"] } +schemars = "0.8" +rustls = { version = "0.23", features = ["aws-lc-rs"] } +base64 = "0.22" +sha2 = "0.10" +ed25519-dalek = "2" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +tokio-stream = "0.1.18" +async-stream = "0.3.6" +hex = "0.4" + +[lints.clippy] +all = "warn" diff --git a/bridge/bff/Dockerfile b/bridge/bff/Dockerfile new file mode 100644 index 000000000..df51c2568 --- /dev/null +++ b/bridge/bff/Dockerfile @@ -0,0 +1,24 @@ +# kars Bridge BFF — container image. Multi-stage: build the Rust binary against a +# glibc base, then ship it on a slim Debian runtime. Cloud-agnostic: the image +# runs identically on AKS, EKS, GKE, and local kind. +# +# Build from the bff/ directory as context: +# docker build -f bff/Dockerfile -t /kars-bridge-bff: bff +FROM rust:1-bookworm AS build +WORKDIR /src +# Compile only the real sources against the reviewed lockfile. +COPY . . +RUN cargo build --release --locked && strip target/release/kars-bridge-bff + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ + useradd --uid 10001 --user-group --home-dir /home/bridge --create-home bridge +COPY --from=build /src/target/release/kars-bridge-bff /usr/local/bin/kars-bridge-bff +USER 10001 +EXPOSE 8081 +# The BFF binds BRIDGE_BFF_HOST:BRIDGE_BFF_PORT; in-cluster it must listen on all +# interfaces. The ServiceAccount token is mounted by Kubernetes and picked up by +# the in-cluster kube config automatically. +ENV BRIDGE_BFF_HOST=0.0.0.0 BRIDGE_BFF_PORT=8081 +ENTRYPOINT ["/usr/local/bin/kars-bridge-bff"] diff --git a/bridge/bff/src/auth.rs b/bridge/bff/src/auth.rs new file mode 100644 index 000000000..a8b9fc20d --- /dev/null +++ b/bridge/bff/src/auth.rs @@ -0,0 +1,277 @@ +// kars Bridge BFF — authenticated principal and persona authorization boundary. +// +// The Next.js web tier verifies the user's OIDC-derived `bridge-session` cookie +// and forwards that same HS256 token in X-Kars-Principal-Token. The BFF verifies +// it independently before serving any API route, derives the actor from signed +// claims, and enforces persona routes server-side. Browser-supplied actor headers +// or body fields never become authority. + +use axum::body::Body; +use axum::extract::State; +use axum::http::{Method, Request, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; + +pub const PRINCIPAL_HEADER: &str = "x-kars-principal-token"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Principal { + pub sub: String, + pub name: String, + pub roles: Vec, +} + +#[derive(Debug, Deserialize)] +struct PrincipalClaims { + sub: String, + name: String, + #[serde(default)] + roles: Vec, + exp: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RequiredPersona { + SignedIn, + User, + Operator, + Auditor, +} + +fn is_mutating(method: &Method) -> bool { + matches!( + *method, + Method::POST | Method::PUT | Method::PATCH | Method::DELETE + ) +} + +fn required_persona(path: &str, method: &Method) -> RequiredPersona { + if path == "/api/operator/audit" && *method == Method::GET { + return RequiredPersona::Auditor; + } + if path.starts_with("/api/operator/") { + return RequiredPersona::Operator; + } + if path.starts_with("/api/namespaces/") + && (path.contains("/receipt") || path.ends_with("/compliance")) + { + return RequiredPersona::SignedIn; + } + if path.starts_with("/api/") { + return RequiredPersona::User; + } + RequiredPersona::SignedIn +} + +fn has_role(principal: &Principal, required: RequiredPersona) -> bool { + let has = |role: &str| principal.roles.iter().any(|r| r == role); + if has("admin") { + return true; + } + match required { + RequiredPersona::SignedIn => !principal.roles.is_empty(), + RequiredPersona::User => has("user") || has("operator"), + RequiredPersona::Operator => has("operator"), + RequiredPersona::Auditor => has("auditor"), + } +} + +fn json_error(status: StatusCode, code: &str, message: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"error":{"code":code,"message":message}}).to_string(), + )) + .expect("static error response") +} + +fn verify_principal(token: &str, secret: &str) -> Option { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + let claims = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .ok()? + .claims; + let _ = claims.exp; + if claims.sub.is_empty() || claims.roles.is_empty() { + return None; + } + + Some(Principal { + sub: claims.sub, + name: claims.name, + roles: claims.roles, + }) +} + +fn local_dev_principal() -> Principal { + let roles = std::env::var("BRIDGE_ROLES") + .unwrap_or_else(|_| "admin,operator,auditor,user".into()) + .split(',') + .map(str::trim) + .filter(|r| matches!(*r, "admin" | "operator" | "auditor" | "user")) + .map(str::to_string) + .collect(); + Principal { + sub: "local-dev".into(), + name: std::env::var("BRIDGE_OPERATOR").unwrap_or_else(|_| "bridge-operator@local".into()), + roles, + } +} + +pub async fn require_token( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let path = req.uri().path(); + if matches!(path, "/healthz" | "/readyz") { + return next.run(req).await; + } + + // Internal Teams decision endpoint uses its own secret-header auth — + // bypass the principal token validation entirely for this path. + if path.starts_with("/api/internal/teams/") { + req.extensions_mut().insert(local_dev_principal()); + return next.run(req).await; + } + + if let Some(secret) = state.principal_secret() { + let Some(token) = req + .headers() + .get(PRINCIPAL_HEADER) + .and_then(|v| v.to_str().ok()) + else { + return json_error( + StatusCode::UNAUTHORIZED, + "principal_required", + "a signed Bridge user session is required", + ); + }; + let Some(principal) = verify_principal(token, secret) else { + return json_error( + StatusCode::UNAUTHORIZED, + "invalid_principal", + "the Bridge user session is invalid or expired", + ); + }; + let required = required_persona(path, req.method()); + if !has_role(&principal, required) { + return json_error( + StatusCode::FORBIDDEN, + "forbidden", + "the signed-in persona is not authorized for this API route", + ); + } + // Route handlers consume this typed extension for immutable actor + // attribution. Remove the raw token before downstream logging. + req.headers_mut().remove(PRINCIPAL_HEADER); + req.extensions_mut().insert(principal); + return next.run(req).await; + } + + // Local-dev compatibility: when SSO/principal auth is not configured, retain + // the existing optional shared bearer guard for writes. + let Some(expected) = state.api_token() else { + req.extensions_mut().insert(local_dev_principal()); + return next.run(req).await; + }; + if !is_mutating(req.method()) { + req.extensions_mut().insert(local_dev_principal()); + return next.run(req).await; + } + let ok = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|t| t == expected) + .unwrap_or(false); + if ok { + req.extensions_mut().insert(local_dev_principal()); + next.run(req).await + } else { + json_error( + StatusCode::UNAUTHORIZED, + "unauthorized", + "bearer token required for mutating requests", + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn principal(roles: &[&str]) -> Principal { + Principal { + sub: "subject-1".into(), + name: "alice".into(), + roles: roles.iter().map(|r| (*r).to_string()).collect(), + } + } + + #[test] + fn route_personas_are_server_enforced() { + assert_eq!( + required_persona("/api/operator/mcpservers", &Method::GET), + RequiredPersona::Operator + ); + assert_eq!( + required_persona("/api/operator/audit", &Method::GET), + RequiredPersona::Auditor + ); + assert_eq!( + required_persona( + "/api/namespaces/kars-system/approvals/a/decision", + &Method::POST + ), + RequiredPersona::User + ); + assert_eq!( + required_persona("/api/namespaces/kars-system/tasks", &Method::POST), + RequiredPersona::User + ); + assert_eq!( + required_persona( + "/api/namespaces/kars-system/tasks/task/receipt", + &Method::GET + ), + RequiredPersona::SignedIn + ); + assert_eq!( + required_persona( + "/api/namespaces/kars-system/tasks/task/receipt/verify", + &Method::POST + ), + RequiredPersona::SignedIn + ); + } + + #[test] + fn persona_role_matrix_matches_shared_tenant_contract() { + assert!(has_role( + &principal(&["operator"]), + RequiredPersona::Operator + )); + assert!(!has_role( + &principal(&["operator"]), + RequiredPersona::Auditor + )); + assert!(!has_role( + &principal(&["auditor"]), + RequiredPersona::Operator + )); + assert!(has_role(&principal(&["auditor"]), RequiredPersona::Auditor)); + assert!(has_role(&principal(&["user"]), RequiredPersona::User)); + assert!(!has_role(&principal(&["auditor"]), RequiredPersona::User)); + } +} diff --git a/bridge/bff/src/config.rs b/bridge/bff/src/config.rs new file mode 100644 index 000000000..d6f48d6c7 --- /dev/null +++ b/bridge/bff/src/config.rs @@ -0,0 +1,150 @@ +// Copyright (c) Pal Lakatos-Toth. +// kars Bridge BFF — runtime configuration loaded from the environment. +// +// The BFF is the only process that holds privileged cluster access and +// (later) the Entra confidential-client secret. Every value here is +// deployment configuration, never a hardcoded credential. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +/// Fully-resolved BFF configuration. +#[derive(Debug, Clone)] +pub struct Config { + /// Address the HTTP server binds to. + pub bind_addr: SocketAddr, + /// Allowed browser origin for the Next.js web app (CORS). + pub web_origin: String, + /// Log filter directive (`RUST_LOG`-style), e.g. `info,kars_bridge_bff=debug`. + pub log_filter: String, + /// Emit logs as JSON (production) vs. pretty (local dev). + pub log_json: bool, + /// Default namespace the BFF scopes readiness + listings to. + pub default_namespace: String, + /// Optional bearer token guarding MUTATING endpoints. When set, every state- + /// changing request must present `Authorization: Bearer `. When unset, + /// mutations are allowed (dev) but a startup warning is logged. + pub api_token: Option, + /// HS256 key shared only with the Bridge web server. When configured, every + /// API request (except health/readiness) must carry the user's signed Bridge + /// session in `X-Kars-Principal-Token`; route authorization is enforced from + /// its immutable role claims. + pub principal_secret: Option, + /// Shared secret for the Teams gateway internal decision endpoint. + pub teams_internal_secret: Option, + /// Entra→Bridge role map JSON for server-side principal resolution. + /// Parsed from BRIDGE_TEAMS_ENTRA_ROLE_MAP. + /// Format: [{"entra_subject":"","bridge_subject":"","roles":["operator"],"name":"Alice"}] + pub teams_entra_role_map: Vec<(String, String, Vec, String)>, + /// How often the background engineering-intake worker scans for due sources. + /// Individual teams retain their own durable poll interval. + pub engineering_poller_interval_seconds: u64, +} + +impl Config { + /// Load configuration from the environment, applying safe defaults. + /// + /// Returns an error only when a provided value is malformed — absence + /// of an optional variable falls back to a documented default. + pub fn from_env() -> anyhow::Result { + let port: u16 = parse_env("BRIDGE_BFF_PORT", 8081)?; + let host: IpAddr = match std::env::var("BRIDGE_BFF_HOST") { + Ok(v) => v + .parse() + .map_err(|e| anyhow::anyhow!("invalid BRIDGE_BFF_HOST `{v}`: {e}"))?, + Err(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + }; + + let web_origin = std::env::var("BRIDGE_WEB_ORIGIN") + .unwrap_or_else(|_| "http://localhost:3000".to_string()); + let log_filter = + std::env::var("RUST_LOG").unwrap_or_else(|_| "info,kars_bridge_bff=debug".to_string()); + let log_json = parse_env("BRIDGE_LOG_JSON", false)?; + let default_namespace = + std::env::var("BRIDGE_DEFAULT_NAMESPACE").unwrap_or_else(|_| "kars-system".to_string()); + let api_token = std::env::var("BRIDGE_API_TOKEN") + .ok() + .filter(|t| !t.is_empty()); + let principal_secret = std::env::var("BRIDGE_PRINCIPAL_SECRET") + .ok() + .filter(|t| !t.is_empty()); + let teams_internal_secret = std::env::var("BRIDGE_TEAMS_INTERNAL_SECRET") + .ok() + .filter(|t| !t.is_empty()); + let teams_entra_role_map = std::env::var("BRIDGE_TEAMS_ENTRA_ROLE_MAP") + .ok() + .filter(|t| !t.is_empty()) + .map(|raw| parse_entra_role_map(&raw)) + .unwrap_or_default(); + let engineering_poller_interval_seconds = + parse_env("BRIDGE_ENGINEERING_POLLER_SECONDS", 60_u64)?; + if engineering_poller_interval_seconds < 15 { + anyhow::bail!("BRIDGE_ENGINEERING_POLLER_SECONDS must be at least 15"); + } + + Ok(Self { + bind_addr: SocketAddr::new(host, port), + web_origin, + log_filter, + log_json, + default_namespace, + api_token, + principal_secret, + teams_internal_secret, + teams_entra_role_map, + engineering_poller_interval_seconds, + }) + } +} + +/// Parse an environment variable into `T`, returning `default` when unset. +fn parse_env(key: &str, default: T) -> anyhow::Result +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + match std::env::var(key) { + Ok(v) => v + .parse() + .map_err(|e| anyhow::anyhow!("invalid {key} `{v}`: {e}")), + Err(_) => Ok(default), + } +} + +/// Parse the Entra→Bridge role map from JSON. +/// Returns (entra_subject, bridge_subject, bridge_roles, display_name) tuples. +fn parse_entra_role_map(raw: &str) -> Vec<(String, String, Vec, String)> { + let parsed: serde_json::Value = match serde_json::from_str(raw) { + Ok(v) => v, + Err(e) => { + tracing::warn!("BRIDGE_TEAMS_ENTRA_ROLE_MAP is not valid JSON: {e}"); + return Vec::new(); + } + }; + let Some(entries) = parsed.as_array() else { + tracing::warn!("BRIDGE_TEAMS_ENTRA_ROLE_MAP is not a JSON array"); + return Vec::new(); + }; + entries + .iter() + .filter_map(|entry| { + let entra_subject = entry.get("entra_subject")?.as_str()?.trim().to_string(); + let bridge_subject = entry.get("bridge_subject")?.as_str()?.trim().to_string(); + let name = entry.get("name")?.as_str()?.trim().to_string(); + let roles: Vec = entry + .get("roles")? + .as_array()? + .iter() + .filter_map(|r| r.as_str().map(|s| s.trim().to_string())) + .filter(|r| !r.is_empty()) + .collect(); + if entra_subject.is_empty() + || bridge_subject.is_empty() + || name.is_empty() + || roles.is_empty() + { + return None; + } + Some((entra_subject, bridge_subject, roles, name)) + }) + .collect() +} diff --git a/bridge/bff/src/error.rs b/bridge/bff/src/error.rs new file mode 100644 index 000000000..68b4210c8 --- /dev/null +++ b/bridge/bff/src/error.rs @@ -0,0 +1,131 @@ +// Copyright (c) Pal Lakatos-Toth. +// kars Bridge BFF — typed error handling. +// +// Errors map to HTTP responses with a stable JSON shape so the web app can +// render them consistently. Internal detail is logged, never leaked to the +// browser. + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +/// Application error type returned by route handlers. +#[derive(Debug, thiserror::Error)] +pub enum AppError { + /// The requested resource does not exist. + #[error("not found")] + NotFound, + /// No cluster connection is configured — the BFF is running web-only. + #[error("cluster unavailable")] + ClusterUnavailable, + /// An upstream dependency (the cluster API) failed. The detail is logged + /// and a sanitized message is returned to the browser. + #[error("upstream error: {0}")] + Upstream(String), + /// The request was rejected by the cluster's admission/validation rules + /// (e.g. a CEL rule on the CRD). The message is safe to show the user — + /// it is the API server's own validation message, not internal detail. + #[error("rejected: {0}")] + Rejected(String), + /// The request was malformed (missing/invalid input). The message is safe + /// to show the user. + #[error("bad request: {0}")] + BadRequest(String), + /// Optimistic-concurrency or already-decided conflict. + #[error("conflict: {0}")] + Conflict(String), + /// A confirmed, UID-bound source write may be reviewed for binding-only continuation. + #[error("conflict: explicitly refresh and review credential metadata before resubmitting")] + CredentialConflict(Box), + /// Authenticated principal lacks the required persona/authority. + #[error("forbidden: {0}")] + Forbidden(String), + /// An unexpected internal error. + #[error("internal error")] + Internal(#[from] anyhow::Error), +} + +impl AppError { + fn status(&self) -> StatusCode { + match self { + AppError::NotFound => StatusCode::NOT_FOUND, + AppError::ClusterUnavailable => StatusCode::SERVICE_UNAVAILABLE, + AppError::Upstream(_) => StatusCode::BAD_GATEWAY, + AppError::Rejected(_) => StatusCode::UNPROCESSABLE_ENTITY, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Conflict(_) => StatusCode::CONFLICT, + AppError::CredentialConflict(_) => StatusCode::CONFLICT, + AppError::Forbidden(_) => StatusCode::FORBIDDEN, + AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + /// Stable, client-safe error code. + fn code(&self) -> &'static str { + match self { + AppError::NotFound => "not_found", + AppError::ClusterUnavailable => "cluster_unavailable", + AppError::Upstream(_) => "upstream_error", + AppError::Rejected(_) => "rejected", + AppError::BadRequest(_) => "bad_request", + AppError::Conflict(_) => "conflict", + AppError::CredentialConflict(_) => "conflict", + AppError::Forbidden(_) => "forbidden", + AppError::Internal(_) => "internal_error", + } + } + + /// Client-safe message. Upstream/internal detail is logged, not returned; + /// a `Rejected` message is the API server's own validation text and is + /// intentionally surfaced so the user can correct their input. + fn client_message(&self) -> String { + match self { + AppError::Internal(_) => "internal error".to_string(), + AppError::Upstream(_) => "upstream dependency failed".to_string(), + AppError::Rejected(msg) => msg.clone(), + other => other.to_string(), + } + } +} + +#[derive(Serialize)] +struct ErrorBody { + error: ErrorDetail, +} + +#[derive(Serialize)] +struct ErrorDetail { + code: &'static str, + message: String, + #[serde( + rename = "credentialContinuation", + skip_serializing_if = "Option::is_none" + )] + credential_continuation: Option, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status(); + if status.is_server_error() { + tracing::error!(error = %self, "request failed"); + } else { + tracing::debug!(error = %self, "request rejected"); + } + let body = ErrorBody { + error: ErrorDetail { + code: self.code(), + message: self.client_message(), + credential_continuation: match &self { + AppError::CredentialConflict(continuation) => Some((**continuation).clone()), + _ => None, + }, + }, + }; + (status, Json(body)).into_response() + } +} + +/// Convenience result alias for handlers. +pub type AppResult = Result; diff --git a/bridge/bff/src/kars/approval.rs b/bridge/bff/src/kars/approval.rs new file mode 100644 index 000000000..dab5bc094 --- /dev/null +++ b/bridge/bff/src/kars/approval.rs @@ -0,0 +1,89 @@ +// kars Bridge BFF — typed view of the `KarsApproval` CRD. +// +// CONTRACT OWNERSHIP: the `KarsApproval` schema is owned by core kars +// (`Azure/kars`, controller/src/kars_approval.rs). This is a *consumer* +// projection mirroring only what the steering inbox needs, with matching +// group/version/kind and camelCase serde. + +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::kars::task::LocalObjectRef; + +/// `KarsApproval.spec` — a human decision a task is waiting on. +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsApproval", + namespaced, + status = "KarsApprovalStatus" +)] +#[serde(rename_all = "camelCase")] +pub struct KarsApprovalSpec { + /// The task this approval gates (same namespace). + pub task_ref: LocalObjectRef, + /// What needs a human decision. + pub action: ApprovalAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_by: Option, + /// ISO-8601 TTL (`PT15M`, `PT4H`, …). Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, + /// The human decision, once made. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision: Option, +} + +/// The action a `KarsApproval` gates. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalAction { + pub kind: String, + pub summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_tier: Option, +} + +/// A human's decision on an approval. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalDecision { + /// `approve` or `deny`. + pub verdict: String, + pub decider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decider_subject: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub decider_roles: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalActor { + pub subject: String, + pub name: String, +} + +/// `KarsApproval.status` — the controller is the sole writer. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsApprovalStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decided_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_envelope_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decider: Option, +} diff --git a/bridge/bff/src/kars/cluster.rs b/bridge/bff/src/kars/cluster.rs new file mode 100644 index 000000000..fbf8acf4b --- /dev/null +++ b/bridge/bff/src/kars/cluster.rs @@ -0,0 +1,4230 @@ +// kars Bridge BFF — cluster access. +// +// The BFF is the only process that holds a cluster client. The browser never +// receives kube credentials. For local dev the client is built from the +// ambient kubeconfig; in-cluster it uses the mounted ServiceAccount. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use k8s_openapi::api::core::v1::{ConfigMap, Node, Pod}; +use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams}; +use kube::core::ApiResource; +use kube::{Client, ResourceExt}; +use sha2::{Digest, Sha256}; + +const ORCHESTRATOR_POLICY_READY_ATTEMPTS: usize = 180; +const ORCHESTRATOR_POLICY_POLL_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(500); + +/// True when a sandbox name denotes an ephemeral standing-run sandbox +/// (`-run-`), which is short-lived and often mid-execution — +/// not a stable target for routing the Bridge's orchestrator inference through. +fn is_ephemeral_run(sandbox: &str) -> bool { + if let Some(idx) = sandbox.rfind("-run-") { + let suffix = &sandbox[idx + 5..]; + return !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()); + } + false +} + +fn normalize_registry_host(value: &str) -> String { + value + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} + +fn image_registry_host(image: &str) -> String { + let first = image.trim().split('/').next().unwrap_or_default(); + if first.contains('.') || first.contains(':') || first == "localhost" { + first.to_ascii_lowercase() + } else { + "docker.io".to_string() + } +} + +fn public_registry(registry: &str) -> bool { + matches!( + registry, + "docker.io" | "registry-1.docker.io" | "mcr.microsoft.com" | "public.ecr.aws" + ) +} + +fn descendant_sandbox_objects(sandboxes: &[DynamicObject], root: &str) -> Vec { + let mut descendants = Vec::new(); + let mut frontier = vec![root.to_string()]; + let mut seen = std::collections::HashSet::from([root.to_string()]); + while let Some(parent) = frontier.pop() { + for sandbox in sandboxes { + let Some(name) = sandbox.metadata.name.as_ref() else { + continue; + }; + let is_child = sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("kars.azure.com/parent")) + == Some(&parent); + if is_child && seen.insert(name.clone()) { + descendants.push(sandbox.clone()); + frontier.push(name.clone()); + } + } + } + descendants +} + +fn mission_evidence_key(cm: &ConfigMap, legacy_label: &str) -> Option { + cm.metadata + .annotations + .as_ref() + .and_then(|annotations| { + annotations + .get("kars.azure.com/mission-evidence-key") + .filter(|value| !value.trim().is_empty()) + .cloned() + }) + .or_else(|| { + cm.metadata + .labels + .as_ref() + .and_then(|labels| labels.get(legacy_label).cloned()) + }) +} + +fn mission_evidence_role(cm: &ConfigMap) -> Option { + cm.metadata.annotations.as_ref().and_then(|annotations| { + annotations + .get("kars.azure.com/mission-evidence-role") + .filter(|value| !value.trim().is_empty()) + .cloned() + }) +} + +fn mission_principal_name(cm: &ConfigMap) -> Option { + cm.metadata + .annotations + .as_ref() + .and_then(|annotations| { + annotations + .get("kars.azure.com/mission-principal-name") + .filter(|value| !value.trim().is_empty()) + .cloned() + }) + .or_else(|| { + cm.metadata + .labels + .as_ref() + .and_then(|labels| labels.get("kars.azure.com/mission-principal").cloned()) + }) +} + +fn mission_output_candidate( + cm: ConfigMap, +) -> Option<( + String, + Option, + std::collections::BTreeMap, +)> { + let evidence_key = mission_evidence_key(&cm, "kars.azure.com/mission-output")?; + let role = mission_evidence_role(&cm); + let principal_name = mission_principal_name(&cm); + let mut data = cm.data.unwrap_or_default(); + if !data.contains_key("taskName") { + if let Some(principal_name) = principal_name { + data.insert("taskName".to_string(), principal_name); + } else if data + .get("assignmentNonce") + .is_some_and(|nonce| nonce != &evidence_key) + { + data.insert("taskName".to_string(), evidence_key.clone()); + } + } + Some((evidence_key, role, data)) +} + +fn select_mission_output_records( + records: Vec<( + String, + Option, + std::collections::BTreeMap, + )>, +) -> Vec<(String, std::collections::BTreeMap)> { + let mut grouped = std::collections::BTreeMap::< + String, + Vec<( + String, + Option, + std::collections::BTreeMap, + )>, + >::new(); + for (key, role, data) in records { + let task_name = data.get("taskName").cloned().unwrap_or_else(|| key.clone()); + grouped + .entry(task_name) + .or_default() + .push((key, role, data)); + } + grouped + .into_values() + .filter_map(|group| { + group + .into_iter() + .max_by_key(|(key, role, data)| match role.as_deref() { + Some("current") => 4, + Some("canonical") => 3, + Some("archive") => 1, + Some(_) => 0, + None if data.get("assignmentNonce").is_none() => 3, + None if data.get("assignmentNonce") != Some(key) => 2, + None => 1, + }) + .map(|(key, _, data)| (key, data)) + }) + .collect() +} + +fn select_mission_evidence_records( + records: Vec<( + String, + Option, + std::collections::BTreeMap, + )>, +) -> Vec<(String, std::collections::BTreeMap)> { + let mut grouped = std::collections::BTreeMap::< + String, + Vec<( + String, + Option, + std::collections::BTreeMap, + )>, + >::new(); + for (key, role, data) in records { + let identity = data + .get("assignmentNonce") + .cloned() + .unwrap_or_else(|| key.clone()); + grouped.entry(identity).or_default().push((key, role, data)); + } + grouped + .into_values() + .filter_map(|group| { + group + .into_iter() + .max_by_key(|(key, role, data)| match role.as_deref() { + Some("archive" | "canonical") => 3, + Some("current") => 1, + Some(_) => 0, + None if data.get("assignmentNonce") == Some(key) => 2, + None => 1, + }) + .map(|(key, _, data)| (key, data)) + }) + .collect() +} + +fn project_mission_output_record( + evidence_key: String, + data: std::collections::BTreeMap, +) -> MissionOutputRecord { + let task_name = data + .get("taskName") + .cloned() + .unwrap_or_else(|| evidence_key.clone()); + MissionOutputRecord { + task_name, + evidence_key, + data, + } +} + +fn trace_record_identity(cm: &ConfigMap) -> Option { + if !cm + .metadata + .name + .as_deref() + .is_some_and(|name| name.starts_with("kars-mission-trace-")) + { + return None; + } + if mission_evidence_role(cm).as_deref() == Some("current") { + return None; + } + let data = cm.data.as_ref()?; + let trace = data.get("trace.json").filter(|trace| trace.len() > 2)?; + if let Some(nonce) = data.get("assignmentNonce") { + return Some(format!("nonce:{nonce}")); + } + let captured_at = data.get("capturedAt").map(String::as_str).unwrap_or(""); + Some(format!( + "legacy:{captured_at}:{:x}", + Sha256::digest(trace.as_bytes()) + )) +} +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; + +use crate::kars::task::KarsTask; + +#[derive(Debug, Clone)] +pub struct MissionOutputRecord { + pub task_name: String, + pub evidence_key: String, + pub data: std::collections::BTreeMap, +} + +/// Classify the inherited inference provider from an optional `KARS_PROVIDER` +/// override plus the configured endpoint hosts. Mirrors the router's detection +/// (`inference-router/src/config.rs`): the three providers kars supports are +/// GitHub Copilot, GitHub Models, and Azure AI Foundry. Returns `(id, label, +/// note)`, or `None` when nothing identifiable is configured. +fn classify_provider( + override_val: Option<&str>, + endpoints: &[String], + token_hint: Option<&str>, +) -> Option<(String, String, String)> { + let host_has = |needle: &str| endpoints.iter().any(|e| e.contains(needle)); + let copilot = ( + "github-copilot", + "GitHub Copilot", + "Models served through your GitHub Copilot subscription (GitHub-hosted inference).", + ); + let gh_models = ( + "github-models", + "GitHub Models", + "Models served through GitHub Models (OpenAI-compatible, GitHub-hosted).", + ); + let foundry = ( + "azure-foundry", + "Azure AI Foundry", + "Models served through your Azure AI Foundry project.", + ); + // A local in-cluster model deployed via the "Local model" wizard — + // its endpoint is always a Service DNS name inside the Bridge-owned + // kars-local-inference namespace (see docs/local-inference.md). Checked + // before the generic Foundry fallback so promoting one to the cluster + // default doesn't display as a misleading "Azure AI Foundry" label. + let local = ( + "local-inference", + "Local model (in-cluster)", + "Models served by an in-cluster deployment — no external API, no per-token billing.", + ); + let is_local_host = host_has(".kars-local-inference.svc.cluster.local"); + // A GitHub OAuth/user token (`gho_`/`ghu_`) indicates a Copilot login; a + // classic PAT (`ghp_`) indicates free GitHub Models. + let is_oauth_token = + matches!(token_hint, Some(t) if t.starts_with("gho_") || t.starts_with("ghu_")); + let on_github = host_has("models.github.ai") || host_has("models.inference.ai.azure.com"); + let pick = match override_val { + // Explicit operator declaration is authoritative. + Some("github-copilot") | Some("copilot") => copilot, + Some("github-models") => gh_models, + Some("foundry") | Some("azure-openai") | Some("azure-foundry") => foundry, + // Otherwise infer from endpoint + token kind. + _ if host_has("api.githubcopilot.com") => copilot, + _ if on_github && is_oauth_token => copilot, + _ if on_github => gh_models, + _ if is_local_host => local, + _ if !endpoints.is_empty() => foundry, + _ => return None, + }; + Some((pick.0.to_string(), pick.1.to_string(), pick.2.to_string())) +} + +/// A handle to the kars cluster, scoped per request to a namespace. +#[derive(Clone)] +pub struct Cluster { + pub(super) client: Client, +} + +/// Outcome of awaiting a mesh-driven run (`Cluster::await_mesh_run`). +pub enum MeshRunOutcome { + /// The controller stamped `run-completed` and the deliverable is written. + Completed(std::collections::BTreeMap), + /// The mesh peer acknowledged (`run-ack`) and is actively delivering, but + /// hasn't finished within the wait — the caller must NOT single-turn (that + /// would race the controller's deliverable write); the result lands async. + InProgress, + /// No `run-ack` appeared — the mesh peer never picked this up (no lease + /// holder / relay down), so a single-turn fallback is safe. + NeverProcessed, +} + +/// A running agent's mesh identity, discovered from the AGT registry. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AgentIdentity { + /// The agent's decentralized mesh identifier, e.g. `did:mesh:`. + pub did: String, + /// Capabilities the agent advertises (includes the sandbox name + e.g. + /// `task-execution`, `kars-agent`). + pub capabilities: Vec, + /// Last time the registry saw the agent (RFC3339), when reported. + pub last_seen: Option, + /// The agent's mesh reputation score, when reported. + pub reputation_score: Option, +} + +/// Honest, status-derived health of an agent's running pod (no metrics-server / +/// CPU-mem dependency). Answers "is this agent healthy right now". +#[derive(Debug, Clone, serde::Serialize)] +pub struct PodHealth { + /// Ready containers vs total (e.g. 2/2). Fewer-than-total means degraded. + pub ready_containers: i32, + pub total_containers: i32, + /// Cumulative container restarts — a crash-loop signal. + pub restarts: i32, + /// Seconds since the pod started (uptime). + pub uptime_seconds: Option, + /// The node the pod is scheduled on. + pub node: Option, + /// A container's waiting reason (e.g. CrashLoopBackOff, ImagePullBackOff) + /// when one isn't running — the honest unhealthy signal. + pub waiting_reason: Option, +} + +/// Per-container state for the run-failure troubleshooter. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ContainerState { + pub name: String, + pub ready: bool, + pub restarts: i32, + /// running / waiting / terminated / unknown. + pub state: String, + /// The waiting or terminated reason (ImagePullBackOff, OOMKilled, …). + pub reason: Option, +} + +impl Cluster { + #[cfg(test)] + pub(crate) fn for_test_client(client: Client) -> Self { + Self { client } + } + + /// Build a cluster handle from the ambient configuration (kubeconfig + /// locally, in-cluster ServiceAccount in production). Returns `None`-style + /// errors as `anyhow` so the readiness probe can report honest status. + pub async fn connect() -> anyhow::Result { + let client = Client::try_default().await?; + Ok(Self { client }) + } + + /// `KarsTask` API scoped to a namespace. + pub fn tasks(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Task metadata for usage attribution: `name -> (namespace, created_by)`, + /// listed across ALL namespaces so per-workspace (namespace) and per-user + /// (the `kars.azure.com/created-by` annotation the Bridge stamps) budgets can + /// attribute a run's tokens to the tenant that owns it. Team runs + /// (`-run-`) are attributed to the parent team's creator. + pub async fn list_task_meta(&self) -> std::collections::HashMap { + use kube::api::ListParams; + let api: Api = Api::all(self.client.clone()); + let mut out = std::collections::HashMap::new(); + let Ok(list) = api.list(&ListParams::default()).await else { + return out; + }; + for t in list.items { + let name = t.metadata.name.clone().unwrap_or_default(); + let ns = t + .metadata + .namespace + .clone() + .unwrap_or_else(|| "kars-system".into()); + let created_by = t + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/created-by").cloned()) + .unwrap_or_else(|| "unattributed".into()); + out.insert(name, (ns, created_by)); + } + out + } + + /// `KarsTeam` API scoped to a namespace. + pub fn teams(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Read the operator-curated MCP profiles (named vetted server bundles), + /// stored as `profiles.json` in the `kars-mcp-profiles` ConfigMap. Returns + /// `[]` when unset. A profile is `{name, summary, servers:[mcpserver names]}`. + pub async fn read_mcp_profiles(&self) -> String { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt("kars-mcp-profiles") + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| d.get("profiles.json").cloned()) + .unwrap_or_else(|| "[]".to_string()) + } + + /// Persist the operator-curated MCP profiles (server-side apply). + pub async fn write_mcp_profiles(&self, profiles_json: &str) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { "name": "kars-mcp-profiles", "labels": { "app.kubernetes.io/managed-by": "kars-bridge" } }, + "data": { "profiles.json": profiles_json }, + }); + cms.patch( + "kars-mcp-profiles", + &PatchParams::apply("kars-bridge/mcp-profiles").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Persist a skill PACKAGE's files as the `karsskill-` ConfigMap in + /// kars-system. Each entry is ` -> ` (SKILL.md + + /// scripts). The controller mirrors this ConfigMap into a granting sandbox's + /// namespace and mounts it into the agent's skills dir. + pub async fn write_skill_package( + &self, + skill_name: &str, + files: &std::collections::BTreeMap, + package_digest: &str, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let cm_name = format!("karsskill-{skill_name}"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": cm_name, + "labels": { + "app.kubernetes.io/managed-by": "kars-bridge", + "kars.azure.com/skill": skill_name, + }, + "annotations": { + "kars.azure.com/package-digest": package_digest, + }, + }, + "data": files, + }); + cms.patch( + &cm_name, + &PatchParams::apply("kars-bridge/skill-package").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + // ── Keyless git write: shared App + per-principal connections (§14) ────── + + /// The cluster-shared kars GitHub App credentials (App id + PEM private key) + /// from `Secret kars-github-app` in kars-system. `None` when the operator + /// hasn't configured the App — git write is simply off (fail-closed). + pub async fn github_app_creds(&self) -> Result, kube::Error> { + let (_, s) = self + .integration_store(&self.core_namespace(), "kars-github-app") + .await?; + let Some(data) = s.data else { return Ok(None) }; + let read = |k: &str| -> Option { + data.get(k) + .and_then(|v| String::from_utf8(v.0.clone()).ok()) + }; + let Some(id) = read("GITHUB_APP_ID") else { + return Ok(None); + }; + let Some(key) = read("GITHUB_APP_PRIVATE_KEY") else { + return Ok(None); + }; + if id.trim().is_empty() || key.trim().is_empty() { + return Ok(None); + } + Ok(Some((id.trim().to_string(), key))) + } + + /// Read a principal's GitHub connection ConfigMap in the namespace. + pub async fn read_github_connection( + &self, + ns: &str, + connection_name: &str, + ) -> Option<(String, String, Vec)> { + self.read_github_connection_result(ns, connection_name) + .await + .ok() + .flatten() + } + + /// Read a principal GitHub connection while preserving Kubernetes API + /// failures for background jobs that must report an honest source status. + pub async fn read_github_connection_result( + &self, + ns: &str, + connection_name: &str, + ) -> Result)>, kube::Error> { + let api: Api = Api::namespaced(self.client.clone(), ns); + let Some(data) = api.get_opt(connection_name).await?.and_then(|cm| cm.data) else { + return Ok(None); + }; + let read = |key: &str| data.get(key).cloned(); + let Some(installation_id) = read("installation_id") else { + return Ok(None); + }; + let account = read("account").unwrap_or_default(); + let repos = read("repos") + .and_then(|r| serde_json::from_str::>(&r).ok()) + .unwrap_or_default(); + Ok(Some((installation_id, account, repos))) + } + + /// Store a principal's GitHub connection. No token or credential is stored. + pub async fn write_github_connection( + &self, + ns: &str, + connection_name: &str, + installation_id: &str, + account: &str, + repos: &[String], + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams, PostParams}; + let api: Api = Api::namespaced(self.client.clone(), ns); + let data = std::collections::BTreeMap::from([ + ("installation_id".to_string(), installation_id.to_string()), + ("account".to_string(), account.to_string()), + ("repos".to_string(), serde_json::to_string(repos)?), + ]); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": connection_name, + "namespace": ns, + "labels": { "app.kubernetes.io/managed-by": "kars-bridge", "kars.azure.com/github-connection": "true" }, + }, + "data": data, + }); + if let Some(current) = api.get_opt(connection_name).await? { + let grant = self.credential_grant(ns).await?; + if current.metadata.deletion_timestamp.is_some() + || current.uid().is_none() + || !grant.document.data["spec"]["githubConnections"] + .as_array() + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry["connection"]["name"] == connection_name + && entry["connection"]["uid"] + == serde_json::json!(current.metadata.uid) + }) + }) + { + anyhow::bail!( + "Existing GitHub connection requires exact operator UID enrollment before mutation; no adoption" + ); + } + api.patch(connection_name,&PatchParams::default(),&Patch::Merge(serde_json::json!({ + "metadata":{"uid":current.metadata.uid,"resourceVersion":current.metadata.resource_version},"data":data + }))).await?; + } else { + let created: ConfigMap = serde_json::from_value(patch)?; + api.create(&PostParams::default(), &created).await?; + } + Ok(()) + } + + /// Remove only the named principal GitHub connection. + pub async fn delete_github_connection( + &self, + ns: &str, + connection_name: &str, + ) -> anyhow::Result<()> { + use kube::api::{DeleteParams, Preconditions}; + let api: Api = Api::namespaced(self.client.clone(), ns); + if let Some(current) = api.get_opt(connection_name).await? { + if current.uid().is_none() || current.resource_version().is_none() { + anyhow::bail!("GitHub connection identity is unavailable; no deletion"); + } + api.delete( + connection_name, + &DeleteParams { + preconditions: Some(Preconditions { + uid: current.metadata.uid, + resource_version: current.metadata.resource_version, + }), + ..Default::default() + }, + ) + .await?; + } + Ok(()) + } + + // ── Bridge engineering intake sources ─────────────────────────────────── + + pub async fn read_engineering_source( + &self, + name: &str, + ) -> Result, kube::Error> { + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + api.get_opt(name).await + } + + pub async fn list_engineering_sources( + &self, + limit: u32, + ) -> Result, kube::Error> { + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + let params = ListParams::default() + .labels("bridge.kars.azure.com/engineering-source=true") + .limit(limit); + Ok(api.list(¶ms).await?.items) + } + + pub async fn create_engineering_source( + &self, + name: &str, + annotations: &std::collections::BTreeMap, + data: &std::collections::BTreeMap, + ) -> Result<(), kube::Error> { + use kube::api::PostParams; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + let config_map: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": name, + "namespace": "kars-system", + "labels": { + "app.kubernetes.io/managed-by": "kars-bridge", + "bridge.kars.azure.com/engineering-source": "true", + }, + "annotations": annotations, + }, + "data": data, + })) + .expect("engineering source ConfigMap is valid"); + api.create(&PostParams::default(), &config_map) + .await + .map(|_| ()) + } + + pub async fn patch_engineering_source_data( + &self, + name: &str, + data: &std::collections::BTreeMap, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + api.patch( + name, + &PatchParams::default(), + &Patch::Merge(serde_json::json!({ "data": data })), + ) + .await?; + Ok(()) + } + + pub async fn claim_engineering_source( + &self, + name: &str, + expected_config: &str, + expected_status: &str, + claimed_status: &str, + ) -> Result { + use kube::api::{Patch, PatchParams}; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + let patch = json_patch::Patch(vec![ + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "config.json"]), + value: serde_json::Value::String(expected_config.to_string()), + }), + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(expected_status.to_string()), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(claimed_status.to_string()), + }), + ]); + match api + .patch( + name, + &PatchParams::default(), + &Patch::Json::(patch), + ) + .await + { + Ok(_) => Ok(true), + Err(kube::Error::Api(error)) + if error.code == 404 || error.code == 409 || error.code == 422 => + { + Ok(false) + } + Err(error) => Err(error), + } + } + + pub async fn complete_engineering_source_claim( + &self, + name: &str, + expected_claimed_status: &str, + cursor: &str, + completed_status: &str, + ) -> Result { + use kube::api::{Patch, PatchParams}; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + let patch = json_patch::Patch(vec![ + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(expected_claimed_status.to_string()), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "cursor.json"]), + value: serde_json::Value::String(cursor.to_string()), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(completed_status.to_string()), + }), + ]); + match api + .patch( + name, + &PatchParams::default(), + &Patch::Json::(patch), + ) + .await + { + Ok(_) => Ok(true), + Err(kube::Error::Api(error)) + if error.code == 404 || error.code == 409 || error.code == 422 => + { + Ok(false) + } + Err(error) => Err(error), + } + } + + pub async fn delete_engineering_source(&self, name: &str) -> anyhow::Result<()> { + use kube::api::DeleteParams; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + if api.get_opt(name).await?.is_some() { + api.delete(name, &DeleteParams::default()).await?; + } + Ok(()) + } + + pub async fn replace_engineering_source( + &self, + mut current: ConfigMap, + annotations: &std::collections::BTreeMap, + data: &std::collections::BTreeMap, + ) -> Result<(), kube::Error> { + use kube::api::PostParams; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + let name = current.metadata.name.clone().unwrap_or_default(); + current.metadata.annotations = Some(annotations.clone()); + current.data = Some(data.clone()); + api.replace(&name, &PostParams::default(), ¤t) + .await + .map(|_| ()) + } + + pub async fn delete_engineering_source_if_version( + &self, + name: &str, + resource_version: String, + ) -> Result<(), kube::Error> { + use kube::api::{DeleteParams, Preconditions}; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + api.delete( + name, + &DeleteParams { + preconditions: Some(Preconditions { + resource_version: Some(resource_version), + uid: None, + }), + ..DeleteParams::default() + }, + ) + .await + .map(|_| ()) + } + + /// Read a team's knowledge-commons ConfigMap (`kars-commons-`) from + /// the controller namespace. Returns the parsed index + raw entry content. + pub async fn read_commons( + &self, + commons: &str, + ) -> Option> { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let cm = cms + .get_opt(&format!("kars-commons-{commons}")) + .await + .ok()??; + cm.data + } + + /// Read a team's task backlog (raw `tasks.json`, or `[]` when unset). Shared + /// with the controller: the ConfigMap `kars-team-tasks-` is the durable + /// queue the Bridge appends to and the controller drains. + pub async fn read_team_tasks(&self, team: &str) -> String { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt(&format!("kars-team-tasks-{team}")) + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| d.get("tasks.json").cloned()) + .unwrap_or_else(|| "[]".to_string()) + } + + /// Read the hierarchical inference-budget config (`kars-inference-budgets` + /// ConfigMap, key `budgets.json`). Returns the raw JSON string, or `"{}"` + /// when unset — the budgets route parses it into the typed hierarchy. + pub async fn read_inference_budgets(&self) -> String { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt("kars-inference-budgets") + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| d.get("budgets.json").cloned()) + .unwrap_or_else(|| "{}".to_string()) + } + + /// Persist the hierarchical inference-budget config (server-side apply). + pub async fn write_inference_budgets(&self, budgets_json: &str) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "kars-inference-budgets", + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + }, + "data": { "budgets.json": budgets_json }, + }); + cms.patch( + "kars-inference-budgets", + &PatchParams::apply("kars-bridge/inference-budgets").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Read the cluster-wide retention-policy default (`kars-retention-policy` + /// ConfigMap, key `defaultTtlSeconds`) the controller's KarsTask retention + /// reconciler reads. `0`/absent means "never auto-delete" (the safe + /// default). Returns `0` on any read failure. + pub async fn read_retention_policy(&self) -> i64 { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt("kars-retention-policy") + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| { + d.get("defaultTtlSeconds") + .and_then(|v| v.parse::().ok()) + }) + .unwrap_or(0) + } + + /// Persist the cluster-wide retention-policy default (server-side apply). + pub async fn write_retention_policy(&self, ttl_seconds: i64) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "kars-retention-policy", + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + }, + "data": { "defaultTtlSeconds": ttl_seconds.to_string() }, + }); + cms.patch( + "kars-retention-policy", + &PatchParams::apply("kars-bridge/retention-policy").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Which communication-channel env keys a team has configured. SECURITY: + /// returns only the *key names* (e.g. `TELEGRAM_BOT_TOKEN`), never the token + /// values — the Bridge must never echo a secret back to a browser. + pub async fn team_channel_keys( + &self, + namespace: &str, + team: &str, + ) -> Result, kube::Error> { + let target = self + .credential_target(namespace, "KarsTeam", team) + .await? + .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; + self.configured_channel_keys(namespace, Some(&target)).await + } + + /// Merge channel credentials into a team's channel Secret (create if absent). + /// SECURITY: token values are written straight into a K8s Secret and are + /// never logged or returned. Existing keys not in `data` are preserved. + pub async fn merge_team_channel( + &self, + namespace: &str, + team: &str, + data: std::collections::BTreeMap, + ) -> anyhow::Result<()> { + let target = self + .credential_target(namespace, "KarsTeam", team) + .await? + .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; + self.write_agent_credentials( + namespace, + "KarsTeam", + team, + Some(&target.uid), + data, + Vec::new(), + ) + .await?; + Ok(()) + } + + /// Remove specific channel env keys from a team's channel Secret; delete the + /// Secret entirely when no keys remain (so "disable all channels" is clean). + pub async fn remove_team_channel_keys( + &self, + namespace: &str, + team: &str, + keys: &[String], + ) -> anyhow::Result<()> { + let target = self + .credential_target(namespace, "KarsTeam", team) + .await? + .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; + self.write_agent_credentials( + namespace, + "KarsTeam", + team, + Some(&target.uid), + std::collections::BTreeMap::new(), + keys.to_vec(), + ) + .await?; + Ok(()) + } + + // ─── Workspace-level (agent-agnostic) channels ─────────────────────────── + // The same channel model as a team's, but scoped to the WORKSPACE (secret + // `kars-workspace-channels` in kars-system), configured on the Connections + // tab. The controller propagates it into EVERY run sandbox — mission or team — + // so any agent can report over Telegram/Slack/Discord/WhatsApp. + + /// The env-key names present in the workspace channel Secret (no values). + pub async fn workspace_channel_keys( + &self, + namespace: &str, + ) -> Result, kube::Error> { + let mut keys = self.configured_channel_keys(namespace, None).await?; + if self.teams_configured().await? { + keys.push("TEAMS_ENABLED".into()); + } + Ok(keys) + } + + /// Merge channel credentials into the workspace channel Secret (create if + /// absent). Token values are written straight into a K8s Secret, never logged + /// or returned. Existing keys not in `data` are preserved. + pub async fn merge_workspace_channel( + &self, + namespace: &str, + data: std::collections::BTreeMap, + ) -> anyhow::Result<()> { + self.write_agent_credentials(namespace, "Workspace", namespace, None, data, Vec::new()) + .await?; + Ok(()) + } + + /// Remove specific channel env keys from the workspace channel Secret; delete + /// the Secret entirely when no keys remain. + pub async fn remove_workspace_channel_keys( + &self, + namespace: &str, + keys: &[String], + ) -> anyhow::Result<()> { + self.write_agent_credentials( + namespace, + "Workspace", + namespace, + None, + std::collections::BTreeMap::new(), + keys.to_vec(), + ) + .await?; + Ok(()) + } + + /// `KarsReceipt` API scoped to a namespace. + pub fn receipts(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Write Teams gateway credentials into the dedicated `kars-bridge-teams` Secret. + /// This Secret is mounted ONLY by the Teams gateway pod — never propagated to + /// sandbox pods. Uses Server-Side Apply so the BFF can create-or-update idempotently. + pub async fn write_dedicated_teams_secret( + &self, + namespace: &str, + name: &str, + data: std::collections::BTreeMap, + ) -> anyhow::Result<()> { + self.mutate_integration(namespace, name, |keys| keys.extend(data.clone())) + .await?; + Ok(()) + } + + /// Revoke Teams bot credentials while retaining the BFF-only internal + /// secret and role map required for a healthy BFF rollout. + pub async fn disable_dedicated_teams_secret( + &self, + namespace: &str, + name: &str, + ) -> anyhow::Result<()> { + self.mutate_integration(namespace, name, |keys| { + for key in ["client-id", "tenant-id", "client-secret"] { + keys.remove(key); + } + }) + .await?; + Ok(()) + } + + /// Restart BFF and enable/disable the Teams gateway so Secret and role-map + /// changes become effective immediately. + pub async fn reconcile_teams_deployments( + &self, + namespace: &str, + gateway_name: &str, + bff_name: &str, + _enabled: bool, + ) -> anyhow::Result<()> { + self.request_teams_reconcile(namespace, gateway_name, bff_name) + .await?; + Ok(()) + } + + /// `KarsApproval` API scoped to a namespace. + pub fn approvals(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// `KarsSREAction` API, cluster-wide. This is an operator-persona, + /// platform-level surface (the kars-sre agent's proposals), not scoped to + /// a workspace namespace — mirrors the `KarsTask` `Api::all` pattern used + /// for cross-namespace operator views. + pub fn sre_actions_all(&self) -> Api { + Api::all(self.client.clone()) + } + + /// `KarsSREAction` API scoped to a namespace (for approve/reject patches, + /// which must target the CR's own namespace). + pub fn sre_actions(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Find the name of the **Running** pod for a sandbox in its namespace. + /// A task-materialized sandbox runs in namespace `kars-`; its pod + /// carries `kars.azure.com/sandbox=`. Returns `None` if no Running + /// pod is found. + /// Whether a Deployment matching `name` exists in `namespace` (best-effort; + /// false on any API error). Used to detect optional integrations like the + /// Headlamp dashboard (`headlamp` deployment in the `headlamp` namespace). + pub async fn deployment_exists(&self, namespace: &str, name: &str) -> bool { + use k8s_openapi::api::apps::v1::Deployment; + let api: Api = Api::namespaced(self.client.clone(), namespace); + matches!(api.get_opt(name).await, Ok(Some(_))) + } + + /// Every pod in the kars-relevant namespaces (all `kars*` namespaces plus + /// `agentmesh`), for the operator diagnostics scan. Uses a cluster-wide list + /// then filters, so it's one API call regardless of sandbox count. + pub async fn all_pods(&self) -> Vec { + let pods: Api = Api::all(self.client.clone()); + pods.list(&ListParams::default()) + .await + .map(|l| { + l.items + .into_iter() + .filter(|p| { + let ns = p.metadata.namespace.as_deref().unwrap_or(""); + ns.starts_with("kars") || ns == "agentmesh" + }) + .collect() + }) + .unwrap_or_default() + } + + pub async fn running_pod_for_sandbox(&self, sandbox: &str) -> Option { + let ns = format!("kars-{sandbox}"); + let pods: Api = Api::namespaced(self.client.clone(), &ns); + let list = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + .ok()?; + list.items.into_iter().find_map(|p| { + let phase = p.status.as_ref().and_then(|s| s.phase.as_deref()); + if phase == Some("Running") { + p.metadata.name + } else { + None + } + }) + } + + /// Honest health of a sandbox's running pod: container readiness, restart + /// count, uptime, and node. No metrics-server dependency (no CPU/mem) — these + /// are status-derived signals that answer "is this agent healthy right now". + /// `None` when no pod is running for the sandbox. + pub async fn sandbox_pod_health(&self, sandbox: &str) -> Option { + let ns = format!("kars-{sandbox}"); + let pods: Api = Api::namespaced(self.client.clone(), &ns); + let list = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + .ok()?; + let pod = list + .items + .into_iter() + .find(|p| p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running"))?; + let status = pod.status.as_ref(); + let cs = status.and_then(|s| s.container_statuses.as_ref()); + let total = cs.map(|c| c.len()).unwrap_or(0) as i32; + let ready = cs + .map(|c| c.iter().filter(|s| s.ready).count()) + .unwrap_or(0) as i32; + let restarts = cs + .map(|c| c.iter().map(|s| s.restart_count).sum()) + .unwrap_or(0); + // Uptime from the pod start time. + let uptime_seconds = status + .and_then(|s| s.start_time.as_ref()) + .map(|t| (chrono::Utc::now() - t.0).num_seconds().max(0)); + // A container stuck waiting (e.g. CrashLoopBackOff) is the honest + // unhealthy signal — surface the reason. + let waiting_reason = cs.and_then(|c| { + c.iter().find_map(|s| { + s.state + .as_ref() + .and_then(|st| st.waiting.as_ref()) + .and_then(|w| w.reason.clone()) + }) + }); + Some(PodHealth { + ready_containers: ready, + total_containers: total, + restarts, + uptime_seconds, + node: pod.spec.as_ref().and_then(|s| s.node_name.clone()), + waiting_reason, + }) + } + + /// Read recent logs from a sandbox pod container (best-effort). Powers the + /// live run-failure troubleshooter, which surfaces the REAL agent output as + /// evidence rather than pattern-matching a status string. + pub async fn read_sandbox_logs( + &self, + sandbox: &str, + container: &str, + tail: i64, + ) -> Option { + let ns = format!("kars-{sandbox}"); + let pods: Api = Api::namespaced(self.client.clone(), &ns); + let list = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + .ok()?; + let pod_name = list.items.into_iter().find_map(|p| p.metadata.name)?; + let lp = kube::api::LogParams { + container: Some(container.to_string()), + tail_lines: Some(tail), + timestamps: false, + ..Default::default() + }; + pods.logs(&pod_name, &lp).await.ok() + } + + /// Per-container status for a sandbox pod (name, ready, restarts, and the + /// current state reason — Running / a waiting reason like ImagePullBackOff / + /// a terminated reason like OOMKilled). Used by the troubleshooter. + pub async fn sandbox_container_states(&self, sandbox: &str) -> Vec { + let ns = format!("kars-{sandbox}"); + let pods: Api = Api::namespaced(self.client.clone(), &ns); + let Ok(list) = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + else { + return Vec::new(); + }; + let Some(pod) = list.items.into_iter().next() else { + return Vec::new(); + }; + let cs = pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()); + cs.map(|list| { + list.iter() + .map(|c| { + let (state, reason) = if let Some(st) = c.state.as_ref() { + if st.running.is_some() { + ("running".to_string(), None) + } else if let Some(w) = st.waiting.as_ref() { + ("waiting".to_string(), w.reason.clone()) + } else if let Some(t) = st.terminated.as_ref() { + ("terminated".to_string(), t.reason.clone()) + } else { + ("unknown".to_string(), None) + } + } else { + ("unknown".to_string(), None) + }; + ContainerState { + name: c.name.clone(), + ready: c.ready, + restarts: c.restart_count, + state, + reason, + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// Find ANY running sandbox's namespace + pod, so the Bridge can route an + /// orchestrator/composer model call through an existing secure inference + /// router (via `router_chat`). This is how the envelope composer reaches the + /// model on workload-identity clusters — without a static token, reusing the + /// same governed path agents use. Prefers a persistent sandbox; falls back + /// to any Running sandbox pod. Returns `(namespace, pod)`. + /// Ranked list of stable sandbox `(namespace, pod)` candidates whose + /// inference router the Bridge can route an orchestrator/composer model call + /// through. Excludes ephemeral standing-run sandboxes (short-lived / busy), + /// requires the router container ready, and orders freshest-first (a + /// recently (re)started pod runs the current router image with valid + /// provider auth). The caller tries them in order so a single sandbox with + /// stale auth or a warming router is skipped gracefully. + pub async fn running_sandbox_candidates(&self) -> Vec<(String, String)> { + let pods: Api = Api::all(self.client.clone()); + let Ok(list) = pods + .list(&ListParams::default().labels("kars.azure.com/sandbox")) + .await + else { + return Vec::new(); + }; + let mut candidates: Vec<&Pod> = list + .items + .iter() + .filter(|p| { + let phase = p.status.as_ref().and_then(|s| s.phase.as_deref()); + if phase != Some("Running") { + return false; + } + let name = p.metadata.name.as_deref().unwrap_or_default(); + let sandbox = p + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/sandbox")) + .map(String::as_str) + .unwrap_or(name); + // Prefer stable sandboxes, but do NOT exclude ephemeral team-run + // sandboxes — on a teams-only cluster they are the ONLY inference + // path the orchestrator has. We sort them last (below) so a stable + // sandbox always wins when one exists. + let _ = sandbox; + p.status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .map(|cs| cs.iter().any(|c| c.name == "inference-router" && c.ready)) + .unwrap_or(false) + }) + .collect(); + candidates.sort_by(|a, b| { + // Stable sandboxes before ephemeral run sandboxes, then freshest first. + let eph = |p: &&Pod| -> bool { + let n = p.metadata.name.as_deref().unwrap_or_default(); + let sb = p + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/sandbox")) + .map(String::as_str) + .unwrap_or(n); + is_ephemeral_run(sb) + }; + let ta = a.metadata.creation_timestamp.as_ref().map(|t| t.0); + let tb = b.metadata.creation_timestamp.as_ref().map(|t| t.0); + eph(a).cmp(&eph(b)).then(tb.cmp(&ta)) // stable first, then freshest + }); + candidates + .into_iter() + .filter_map(|p| Some((p.metadata.namespace.clone()?, p.metadata.name.clone()?))) + .collect() + } + + /// Drive a real model call through a sandbox's secure inference router, + /// using the Kubernetes **pods/proxy subresource** — hard-scoped to one + /// pod, port 8443, and the exact `/v1/chat/completions` path. This is the + /// only proxy the BFF performs and it is NOT a generic tunnel: it cannot + /// reach any other port or path. The router still enforces content-safety, + /// token budgets, and governance on the call — the agent never sees a key. + /// + /// `ns` is the sandbox namespace (`kars-`), `pod` the Running pod. + /// Returns the raw response JSON text from the router. + pub async fn router_chat( + &self, + ns: &str, + pod: &str, + body: &serde_json::Value, + ) -> anyhow::Result { + let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/v1/chat/completions"); + let req = http::Request::builder() + .method(http::Method::POST) + .uri(path) + .header("content-type", "application/json") + .body(serde_json::to_vec(body)?)?; + let text = self.client.request_text(req).await?; + Ok(text) + } + + /// Drive a model call through a sandbox's inference router using the NATIVE + /// Anthropic Messages endpoint (`/v1/messages`) via pods/proxy. Claude on + /// the OpenAI-compatible `/chat/completions` path returns empty content for + /// the Bridge's composer; the native path returns proper text/`tool_use` + /// content (the same reason agents use `/v1/messages`). Body is Anthropic- + /// shaped (`{model, system, messages, max_tokens}`). Returns raw response. + pub async fn router_messages( + &self, + ns: &str, + pod: &str, + body: &serde_json::Value, + ) -> anyhow::Result { + let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/v1/messages"); + let req = http::Request::builder() + .method(http::Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(serde_json::to_vec(body)?)?; + let text = self.client.request_text(req).await?; + Ok(text) + } + + /// The live egress enforcement mode of a sandbox — read from the + /// `KarsSandbox.spec.networkPolicy.egressMode` the controller materialized. + /// `"Learn"` (default) observes + records every domain the agent reaches + /// without denying; `"Strict"` denies anything outside the allowlist. This + /// is the real, cluster-truth mode — not derived from the blueprint. + pub async fn sandbox_egress_mode(&self, sandbox: &str) -> Option { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", "KarsSandbox"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), "kars-system", &ar); + let sb = api.get_opt(sandbox).await.ok().flatten()?; + Some( + sb.data + .get("spec") + .and_then(|s| s.get("networkPolicy")) + .and_then(|n| n.get("egressMode")) + .and_then(|m| m.as_str()) + .unwrap_or("Learn") + .to_string(), + ) + } + + /// Read only the declared private observation capability. The legacy + /// agent-shared admin token and apiserver header tricks are never fallbacks. + pub async fn sandbox_learned_domains(&self, sandbox: &str) -> anyhow::Result> { + self.private_learned_domains(sandbox) + .await + .map_err(Into::into) + } + + /// The resolved egress allowlist the sandbox actually enforces — read from + /// the `karssandbox--egress-allowlist` ConfigMap the controller + /// compiles into the sandbox namespace. Each entry is the exact host(:port) + /// the agent is permitted to reach. Empty in Learn mode (nothing pinned). + pub async fn sandbox_allowlist(&self, sandbox: &str) -> Vec { + let ns = format!("kars-{sandbox}"); + let name = format!("karssandbox-{sandbox}-egress-allowlist"); + let cms: Api = Api::namespaced(self.client.clone(), &ns); + let Some(cm) = cms.get_opt(&name).await.ok().flatten() else { + return Vec::new(); + }; + let Some(raw) = cm.data.and_then(|d| d.get("allowlist.json").cloned()) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + parsed + .get("endpoints") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host").and_then(|h| h.as_str())?; + match e.get("port").and_then(|p| p.as_u64()) { + Some(p) => Some(format!("{host}:{p}")), + None => Some(host.to_string()), + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// Persist a mission's run output into a namespaced ConfigMap + /// `kars-mission-output-` in `kars-system`, so the deliverable is a + /// durable, readable cluster object (the §16 artifact record, minimal form). + /// Server-side apply, idempotent per task. + pub async fn write_mission_output( + &self, + task: &str, + data: std::collections::BTreeMap, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let name = format!("kars-mission-output-{task}"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { "name": name, "labels": { "kars.azure.com/mission-output": task } }, + "data": data, + }); + cms.patch( + &name, + &PatchParams::apply("kars-bridge/mission-output").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Read a mission's persisted run output ConfigMap, if present. + pub async fn read_mission_output( + &self, + task: &str, + ) -> Option> { + self.configmap_data(&format!("kars-mission-output-{task}")) + .await + } + + /// Read a mission's persisted artifact set — the complete file set the + /// agent produced over the mesh, written by the controller to + /// `kars-mission-artifacts-`. Text artifacts come back as `data` + /// (filename → content); binary artifacts are reported by name + size via + /// the output ConfigMap's manifest (their bytes live in the ConfigMap's + /// `binaryData` and aren't inlined here). Returns `None` when the mission + /// produced no artifacts (honest empty, never fabricated). + pub async fn read_mission_artifacts( + &self, + task: &str, + ) -> Option> { + self.configmap_data(&format!("kars-mission-artifacts-{task}")) + .await + } + + /// Read a single artifact file's raw bytes for download — text artifacts + /// from the ConfigMap's `data`, binary ones from `binaryData` (base64). The + /// filename is matched against the same sanitized key the manifest exposes. + /// Returns `(bytes, is_binary)` or `None` when the file isn't found. This is + /// the Bridge-native fetch path so operators never need `kubectl`. + pub async fn read_mission_artifact_bytes( + &self, + task: &str, + key: &str, + ) -> Option<(Vec, bool)> { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let cm = cms + .get_opt(&format!("kars-mission-artifacts-{task}")) + .await + .ok() + .flatten()?; + if let Some(text) = cm.data.as_ref().and_then(|d| d.get(key)) { + return Some((text.clone().into_bytes(), false)); + } + // `binaryData` values are `ByteString`, already base64-decoded by the API + // client into raw bytes — serve them directly. + if let Some(bytes) = cm.binary_data.as_ref().and_then(|d| d.get(key)) { + return Some((bytes.0.clone(), true)); + } + None + } + + /// Read a mission's persisted execution trace — the clean per-tool audit + /// record the controller wrote to `kars-mission-trace-`. Returns the + /// raw `trace.json` string (a JSON array of round/tool events) when present. + pub async fn read_mission_trace(&self, task: &str) -> Option { + self.configmap_data(&format!("kars-mission-trace-{task}")) + .await + .and_then(|d| d.get("trace.json").cloned()) + } + + pub async fn read_mission_progress(&self, task: &str) -> Option { + self.configmap_data(&format!("kars-mission-progress-{task}")) + .await + .and_then(|data| data.get("checkpoint.json").cloned()) + .and_then(|raw| serde_json::from_str(&raw).ok()) + } + + /// LIVE per-agent execution trace, straight from a running sandbox's router + /// (`GET /telemetry/trace` — a PUBLIC in-pod endpoint, reached via the + /// apiserver pod-proxy; no admin token required). Unlike the persisted + /// `kars-mission-trace-` ConfigMap (written once, at delivery), this + /// ticks WHILE the agent works, so the activity stream is genuinely live. + /// Returns the router's `events` array (round/tool shape); empty on any + /// error or when the sandbox has no running pod yet. + pub async fn sandbox_live_trace(&self, sandbox: &str) -> Vec { + let ns = format!("kars-{sandbox}"); + let pods: Api = Api::namespaced(self.client.clone(), &ns); + let pod_list = pods.list(&ListParams::default()).await; + if let Err(e) = &pod_list { + tracing::warn!(target: "kars_bridge::live_trace", %ns, error = %e, "pod list failed"); + } + let Some(pod) = pod_list + .ok() + .and_then(|l| { + l.items.into_iter().find(|p| { + p.status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .map(|ph| ph == "Running") + .unwrap_or(false) + }) + }) + .and_then(|p| p.metadata.name) + else { + tracing::warn!(target: "kars_bridge::live_trace", %ns, "no running pod found"); + return Vec::new(); + }; + let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/telemetry/trace"); + let Ok(req) = http::Request::builder() + .method(http::Method::GET) + .uri(&path) + .body(Vec::new()) + else { + tracing::warn!(target: "kars_bridge::live_trace", %path, "request build failed"); + return Vec::new(); + }; + match self.client.request_text(req).await { + Ok(text) => { + let n = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("events").and_then(|e| e.as_array()).cloned()) + .unwrap_or_default(); + tracing::debug!(target: "kars_bridge::live_trace", %pod, events = n.len(), body_len = text.len(), "live trace ok"); + n + } + Err(e) => { + tracing::warn!(target: "kars_bridge::live_trace", %path, error = %e, "proxy request failed"); + Vec::new() + } + } + } + + /// Names of the sub-agent sandboxes a principal spawned at run time — the + /// complete transitive `kars.azure.com/parent` tree. Used to aggregate the + /// WHOLE agent tree's live activity, not just direct children. + pub async fn sub_agent_sandboxes( + &self, + namespace: &str, + parent_sandbox: &str, + ) -> Vec { + self.list_kind(namespace, "KarsSandbox") + .await + .map(|items| descendant_sandbox_objects(&items, parent_sandbox)) + .unwrap_or_default() + } + + pub async fn sub_agent_sandbox_names( + &self, + namespace: &str, + parent_sandbox: &str, + ) -> Vec { + self.sub_agent_sandboxes(namespace, parent_sandbox) + .await + .iter() + .filter_map(|sandbox| sandbox.metadata.name.clone()) + .collect() + } + + /// Count missions with a real per-tool execution-trace record — the live + /// telemetry substrate. Counts `kars-mission-trace-*` ConfigMaps carrying a + /// non-empty trace, not deliverable count (the two can differ). + pub async fn count_trace_records(&self) -> usize { + use kube::api::ListParams; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + cms.list(&ListParams::default()) + .await + .map(|l| { + l.items + .iter() + .filter_map(trace_record_identity) + .collect::>() + .len() + }) + .unwrap_or(0) + } + + /// List every mission that has produced a captured deliverable — one entry + /// per `kars-mission-output-*` ConfigMap. New records retain the full + /// evidence key in an annotation because nonce-scoped label values can + /// exceed Kubernetes' 63-byte limit; legacy records fall back to the label. + /// Returns `(task, data)` pairs so the caller can build the cross-mission + /// Artifacts index from real, durable records (never fabricated). Sorted by + /// `finishedAt` descending so the most recent deliverables surface first. + pub async fn list_mission_outputs(&self) -> Vec { + use kube::api::ListParams; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let list = match cms + .list(&ListParams::default().labels("kars.azure.com/mission-output")) + .await + { + Ok(l) => l, + Err(_) => return Vec::new(), + }; + let records: Vec<( + String, + Option, + std::collections::BTreeMap, + )> = list + .items + .into_iter() + .filter_map(mission_output_candidate) + .collect(); + let mut out = select_mission_output_records(records) + .into_iter() + .map(|(evidence_key, data)| project_mission_output_record(evidence_key, data)) + .collect::>(); + out.sort_by(|a, b| { + b.data + .get("finishedAt") + .cloned() + .unwrap_or_default() + .cmp(&a.data.get("finishedAt").cloned().unwrap_or_default()) + }); + out + } + + /// List each nonce-scoped execution exactly once for accounting, efficiency, + /// and historical evidence. Immutable archives/canonical records are kept; + /// task-keyed current-pointer mirrors are excluded. + pub async fn list_mission_output_evidence(&self) -> Vec { + use kube::api::ListParams; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let list = match cms + .list(&ListParams::default().labels("kars.azure.com/mission-output")) + .await + { + Ok(list) => list, + Err(_) => return Vec::new(), + }; + let records = list + .items + .into_iter() + .filter_map(mission_output_candidate) + .collect(); + let mut out = select_mission_evidence_records(records) + .into_iter() + .map(|(evidence_key, data)| project_mission_output_record(evidence_key, data)) + .collect::>(); + out.sort_by(|left, right| { + right + .data + .get("finishedAt") + .cloned() + .unwrap_or_default() + .cmp(&left.data.get("finishedAt").cloned().unwrap_or_default()) + }); + out + } + + /// Request a **mesh-driven agent run** of a task by stamping the + /// `kars.azure.com/run-requested` annotation with a fresh nonce. The core + /// controller (a live mesh peer) watches this annotation, discovers the + /// agent over the mesh, delivers the objective straight into the agent's + /// native loop (gated by the AGT `task:execute` policy), captures the + /// reply, writes it to `kars-mission-output-`, and stamps + /// `kars.azure.com/run-completed` with the same nonce. This is the Bridge + /// *consuming* a neutral core capability — the Bridge never reaches into + /// the agent itself. Returns the nonce to correlate completion. + pub async fn request_mesh_run(&self, ns: &str, name: &str) -> anyhow::Result { + use kube::api::{Patch, PatchParams}; + // In-flight guard: if a run is already pending (run-requested set to a + // nonce the controller hasn't completed yet), REUSE that nonce instead of + // stamping a fresh one. Two concurrent triggers (double-click, cadence + + // run-now) would otherwise each mint a distinct nonce; the controller acks + // only the last, the first caller's await never matches → it single-turns + // while the mesh also delivers → the task executes twice and the outputs + // clobber. Reusing the pending nonce makes both callers await the same run. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { + let ann = task.metadata.annotations.unwrap_or_default(); + let requested = ann.get("kars.azure.com/run-requested").cloned(); + let completed = ann.get("kars.azure.com/run-completed").cloned(); + if let Some(req) = requested.filter(|r| !r.is_empty()) + && completed.as_deref() != Some(req.as_str()) + { + return Ok(req); + } + } + let nonce = format!( + "run-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/run-requested": nonce } } + }); + self.tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + // Re-read and adopt whatever nonce actually won the annotation, so two + // truly-simultaneous triggers converge on the SAME run instead of each + // awaiting its own (last-write-wins) nonce. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await + && let Some(actual) = task + .metadata + .annotations + .and_then(|a| a.get("kars.azure.com/run-requested").cloned()) + .filter(|r| !r.is_empty()) + { + return Ok(actual); + } + Ok(nonce) + } + + /// Poll the task's `kars.azure.com/run-completed` annotation until it + /// equals `nonce` (the controller stamps it once the mesh round-trip is + /// done) or `timeout` elapses. Returns the freshly-written mission output + /// on completion, or `None` on timeout. + /// Outcome of awaiting a mesh run. Distinguishes "the mesh peer never picked + /// this up" (safe to fall back to a single turn) from "it acknowledged and is + /// actively delivering" (must NOT single-turn — that would race the + /// controller's deliverable write). + pub async fn await_mesh_run( + &self, + ns: &str, + name: &str, + nonce: &str, + timeout: std::time::Duration, + ) -> MeshRunOutcome { + let deadline = std::time::Instant::now() + timeout; + let mut saw_ack = false; + let mut saw_activity = false; + loop { + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { + let ann = task.metadata.annotations.clone().unwrap_or_default(); + if ann.get("kars.azure.com/run-ack").map(String::as_str) == Some(nonce) { + saw_ack = true; + } + if ann.get("kars.azure.com/run-completed").map(String::as_str) == Some(nonce) { + return match self.read_mission_output(name).await { + Some(out) => MeshRunOutcome::Completed(out), + None => MeshRunOutcome::InProgress, + }; + } + } + // LIVE-ACTIVITY signal — the robust "a real agent loop is running" + // proof that works even against an OLD controller that never stamps + // run-ack. If the sandbox's router is emitting rounds/tool calls, a + // genuine run is in flight and we must NEVER single-turn over it + // (that produced a garbage one-shot deliverable that clobbered the + // real streaming run). Latch it once seen. + if !saw_activity && !self.sandbox_live_trace(name).await.is_empty() { + saw_activity = true; + } + if std::time::Instant::now() >= deadline { + return if saw_ack || saw_activity { + MeshRunOutcome::InProgress + } else { + MeshRunOutcome::NeverProcessed + }; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + + /// Clear the pending `run-requested` annotation — used when the BFF gives up + /// on the mesh path and single-turns, so a late mesh-peer recovery doesn't + /// ALSO deliver + write the output (double-write). + pub async fn clear_run_request(&self, ns: &str, name: &str) { + use kube::api::{Patch, PatchParams}; + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/run-requested": serde_json::Value::Null } } + }); + let _ = self + .tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await; + } + + /// Discover a running agent's **mesh identity** from the AGT registry — the + /// harness-neutral discovery layer. Every runtime adapter registers its + /// agent under capabilities that include the sandbox name; we query + /// `/v1/discover?capability=` through the Kubernetes services/proxy + /// subresource (the registry is a ClusterIP service the BFF reaches via the + /// API server) and return the most-recently-seen DID + its capabilities and + /// last-seen time. This proves the agent is a real, live mesh participant + /// and is the discovery prerequisite for mesh-driven task delivery. Returns + /// `None` when the registry is unreachable or the agent isn't registered + /// (honest empty, never fabricated). + pub async fn discover_agent_identity(&self, sandbox: &str) -> Option { + let path = format!( + "/api/v1/namespaces/agentmesh/services/agentmesh-registry:8080/proxy/v1/discover?capability={sandbox}&limit=10" + ); + let req = http::Request::builder() + .method(http::Method::GET) + .uri(path) + .body(Vec::new()) + .ok()?; + let text = self.client.request_text(req).await.ok()?; + let body: serde_json::Value = serde_json::from_str(&text).ok()?; + let results = body.get("results")?.as_array()?; + // Pick the most-recently-seen registration for this sandbox. + let best = results + .iter() + .filter(|r| { + r.get("capabilities") + .and_then(|c| c.as_array()) + .map(|caps| caps.iter().any(|c| c.as_str() == Some(sandbox))) + .unwrap_or(false) + }) + .max_by_key(|r| { + r.get("last_seen") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + })?; + Some(AgentIdentity { + did: best.get("did")?.as_str()?.to_string(), + capabilities: best + .get("capabilities") + .and_then(|c| c.as_array()) + .map(|caps| { + caps.iter() + .filter_map(|c| c.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + last_seen: best + .get("last_seen") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + reputation_score: best.get("reputation_score").and_then(|v| v.as_f64()), + }) + } + + /// Read a ConfigMap's `data` map from `kars-system` (e.g. the receipt + /// inclusion-log signed checkpoint). Returns `None` when absent. + pub async fn configmap_data( + &self, + name: &str, + ) -> Option> { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt(name).await.ok().flatten().and_then(|c| c.data) + } + + /// Like `configmap_data` but distinguishes a genuine API error from an absent + /// ConfigMap: `Ok(None)` means "not found", `Err` means the read actually + /// failed. Use on critical read-modify-write paths so a transient cluster + /// error can't be mistaken for "no prior data" and silently clobber it. + pub async fn configmap_data_result( + &self, + name: &str, + ) -> Result>, kube::Error> { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + Ok(cms.get_opt(name).await?.and_then(|c| c.data)) + } + + /// List `kars-system` ConfigMaps matching a label selector, retaining each + /// object name so callers can verify ordered segmented stores. + pub async fn configmaps_data_by_label( + &self, + selector: &str, + ) -> Result)>, kube::Error> { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + Ok(cms + .list(&ListParams::default().labels(selector)) + .await? + .items + .into_iter() + .filter_map(|cm| Some((cm.metadata.name?, cm.data.unwrap_or_default()))) + .collect()) + } + + /// Read-modify-write a `kars-system` ConfigMap's `data` under OPTIMISTIC + /// CONCURRENCY: a single atomic JSON Patch (RFC 6902) — a `test` op + /// asserting `resourceVersion` hasn't moved, followed by `add`/`remove` + /// ops for the actual data changes — retried on failure. This makes + /// concurrent writers serialize instead of silently clobbering each other + /// (an SSA `force` apply of the whole `data` drops the other writer's + /// fields; a `replace()`/PUT needs the `update` RBAC verb, which the + /// BFF's ClusterRole never grants — confirmed live against the real + /// ServiceAccount: a PUT-based CAS here 403s in an RBAC-enforced + /// deployment. See `mutate_secret_keys` for the full rationale, including + /// why a plain JSON *merge* patch alone can't do this: K8s doesn't honor + /// `resourceVersion` as a precondition for merge patches, only for + /// `test`-op JSON Patches, SSA, and PUT). Use for any read-append-write + /// on a shared ConfigMap (e.g. review history). + pub async fn update_configmap_data( + &self, + name: &str, + labels: &[(&str, &str)], + mut modify: F, + ) -> Result<(), kube::Error> + where + F: FnMut(&mut std::collections::BTreeMap), + { + use k8s_openapi::api::core::v1::ConfigMap; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use kube::api::{Patch, PostParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let label_map: std::collections::BTreeMap = labels + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + for _attempt in 0..6 { + let existing = cms.get_opt(name).await?; + let Some(current) = existing else { + // Doesn't exist yet — a JSON Patch has nothing to patch onto; + // create it fresh (a create-race surfaces as a 409 below). + let mut data = std::collections::BTreeMap::new(); + modify(&mut data); + let cm = ConfigMap { + metadata: ObjectMeta { + name: Some(name.to_string()), + labels: (!label_map.is_empty()).then(|| label_map.clone()), + ..Default::default() + }, + data: Some(data), + ..Default::default() + }; + match cms.create(&PostParams::default(), &cm).await { + Ok(_) => return Ok(()), + Err(kube::Error::Api(ae)) if ae.code == 409 => continue, + Err(e) => return Err(e), + } + }; + let Some(rv) = current.metadata.resource_version.clone() else { + continue; // no resourceVersion to pin to — re-read and retry. + }; + let before = current.data.clone().unwrap_or_default(); + let mut after = before.clone(); + modify(&mut after); + + let mut ops: Vec = vec![json_patch::PatchOperation::Test( + json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "metadata", + "resourceVersion", + ]), + value: serde_json::Value::String(rv), + }, + )]; + if current.data.is_none() { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data"]), + value: serde_json::json!({}), + })); + } + for key in before.keys() { + if !after.contains_key(key) { + ops.push(json_patch::PatchOperation::Remove( + json_patch::RemoveOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "data", + key.as_str(), + ]), + }, + )); + } + } + for (key, value) in &after { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", key.as_str()]), + value: serde_json::Value::String(value.clone()), + })); + } + if !label_map.is_empty() { + if current.metadata.labels.is_none() { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["metadata", "labels"]), + value: serde_json::json!({}), + })); + } + for (k, v) in &label_map { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "metadata", + "labels", + k.as_str(), + ]), + value: serde_json::Value::String(v.clone()), + })); + } + } + + match cms + .patch( + name, + &kube::api::PatchParams::default(), + &Patch::Json::(json_patch::Patch(ops)), + ) + .await + { + Ok(_) => return Ok(()), + // 422 = the `test` op failed (resourceVersion moved under us, + // i.e. a real concurrent writer) — re-read and retry. 409 + // covers any other conflict (e.g. a create race). + Err(kube::Error::Api(ae)) if ae.code == 422 || ae.code == 409 => continue, + Err(e) => return Err(e), + } + } + Err(kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: format!("exhausted optimistic-concurrency retries writing {name}"), + reason: "Conflict".into(), + code: 409, + })) + } + + /// Read all team digest logs (`kars-team-digest-*`) across teams, flattened + /// and newest-first. Best-effort. + pub async fn list_team_digests(&self) -> Vec { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let lp = ListParams::default().labels("kars.azure.com/team-digest"); + let mut out: Vec = Vec::new(); + if let Ok(list) = cms.list(&lp).await { + for cm in list.items { + if let Some(log) = cm.data.as_ref().and_then(|d| d.get("log.json")) + && let Ok(entries) = serde_json::from_str::>(log) + { + out.extend(entries); + } + } + } + out.sort_by(|a, b| { + b.get("at") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(a.get("at").and_then(|v| v.as_str()).unwrap_or("")) + }); + out + } + + /// Read a task's review record (`kars-mission-review-`), if any. + pub async fn read_review( + &self, + task: &str, + ) -> Option> { + self.configmap_data(&format!("kars-mission-review-{task}")) + .await + } + + /// Write a task's review record (`kars-mission-review-`), SSA-merged. + pub async fn write_review( + &self, + task: &str, + data: std::collections::BTreeMap, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let name = format!("kars-mission-review-{task}"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { "name": name, "labels": { "kars.azure.com/mission-review": task } }, + "data": data, + }); + cms.patch( + &name, + &PatchParams::apply("kars-bridge-bff").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Re-drive a task on reviewer feedback without mutating its immutable spec. + /// The revision objective is nonce-bound and digest-protected in annotations; + /// the controller verifies it before constructing the signed task contract. + pub async fn redrive_with_revision( + &self, + ns: &str, + name: &str, + revised_objective: &str, + ) -> anyhow::Result { + use kube::api::{Patch, PatchParams}; + // In-flight guard (same rationale as request_mesh_run): if a run is + // already pending, don't stamp a second concurrent redrive — reuse the + // pending nonce so two concurrent request_changes reviews can't double- + // execute the producing agent. The revision remains nonce-scoped. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { + let ann = task.metadata.annotations.clone().unwrap_or_default(); + let requested = ann.get("kars.azure.com/run-requested").cloned(); + let completed = ann.get("kars.azure.com/run-completed").cloned(); + if let Some(req) = requested.filter(|r| !r.is_empty()) + && completed.as_deref() != Some(req.as_str()) + { + let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); + let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); + let patch = serde_json::json!({ + "metadata": { "annotations": { + "kars.azure.com/run-objective-nonce": req.clone(), + "kars.azure.com/run-objective-b64": encoded, + "kars.azure.com/run-objective-digest": digest + }} + }); + self.tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + return Ok(req); + } + } + let nonce = format!( + "rev-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); + let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); + let patch = serde_json::json!({ + "metadata": { "annotations": { + "kars.azure.com/run-requested": nonce.clone(), + "kars.azure.com/run-objective-nonce": nonce.clone(), + "kars.azure.com/run-objective-b64": encoded, + "kars.azure.com/run-objective-digest": digest + }} + }); + self.tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + // Adopt whichever nonce won, so concurrent redrives converge on one run. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await + && let Some(actual) = task + .metadata + .annotations + .and_then(|a| a.get("kars.azure.com/run-requested").cloned()) + .filter(|r| !r.is_empty()) + { + return Ok(actual); + } + Ok(nonce) + } + + /// The model deployments this cluster is configured to serve, read from the + /// controller Deployment's environment (`KARS_TASK_DEFAULT_MODEL`, + /// `AZURE_OPENAI_DEPLOYMENT`, and the comma-separated `FOUNDRY_DEPLOYMENTS`). + /// This is the authoritative "what can actually run here" fact — the same + /// values the controller stamps onto a task's InferencePolicy. Best-effort: + /// an unreadable Deployment yields an empty list (honest, not an error), so + /// the launch package degrades to the controller default rather than lying. + pub async fn controller_models(&self) -> (Option, Vec) { + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api = Api::namespaced(self.client.clone(), &self.core_namespace()); + let Ok(Some(d)) = deploys.get_opt("kars-controller").await else { + return (None, Vec::new()); + }; + let mut default: Option = None; + let mut catalog: Vec = Vec::new(); + let envs = d + .spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()); + for e in envs { + let Some(val) = e.value else { continue }; + match e.name.as_str() { + "KARS_TASK_DEFAULT_MODEL" | "AZURE_OPENAI_DEPLOYMENT" if default.is_none() => { + default = Some(val); + } + "FOUNDRY_DEPLOYMENTS" | "KARS_MODEL_CATALOG" => { + catalog.extend( + val.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + ); + } + _ => {} + } + } + (default, catalog) + } + + /// The GitHub token wired for GitHub Copilot — checked in BOTH places + /// Copilot can be configured: the shared providers secret (an additional + /// provider, or one signed-in via the wizard's device login) FIRST, then + /// the controller's `COPILOT_GITHUB_TOKEN` env (the cluster default). Used + /// to fetch the seat's LIVE model catalog so the Model catalogue + + /// orchestrator reflect what Copilot actually serves. `None` when unset. + pub async fn controller_copilot_token(&self) -> Option { + // Wizard sign-in / additional-provider path stores it here. + if let Ok(keys) = self + .read_secret_all("kars-system", "kars-inference-providers") + .await + && let Some(t) = keys + .get("COPILOT_GITHUB_TOKEN") + .filter(|v| !v.trim().is_empty()) + { + return Some(t.clone()); + } + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api = Api::namespaced(self.client.clone(), &self.core_namespace()); + let d = deploys.get_opt("kars-controller").await.ok().flatten()?; + d.spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()) + .find(|e| e.name == "COPILOT_GITHUB_TOKEN") + .and_then(|e| e.value) + .filter(|v| !v.trim().is_empty()) + } + /// fact chosen at cluster setup, NOT something the Bridge picks. kars + /// supports exactly three: GitHub Copilot, GitHub Models, and Azure AI + /// Foundry. The classification mirrors the inference-router's own endpoint + /// detection (`inference-router/src/config.rs`): a `KARS_PROVIDER` override + /// wins, otherwise the configured endpoint host decides. Returns + /// `(id, label, note)` or `None` when the controller is unreadable. + pub async fn controller_provider(&self) -> Option<(String, String, String)> { + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api = Api::namespaced(self.client.clone(), &self.core_namespace()); + let d = deploys.get_opt("kars-controller").await.ok().flatten()?; + let mut provider_override: Option = None; + let mut endpoints: Vec = Vec::new(); + let mut token_hint: Option = None; + let envs = d + .spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()); + for e in envs { + let Some(val) = e.value else { continue }; + match e.name.as_str() { + // Explicit operator declaration — the authoritative brand signal. + "KARS_PROVIDER" | "KARS_INFERENCE_PROVIDER" if !val.is_empty() => { + provider_override = Some(val) + } + "FOUNDRY_ENDPOINT" | "FOUNDRY_PROJECT_ENDPOINT" | "AZURE_OPENAI_ENDPOINT" => { + endpoints.push(val) + } + // Auth token KIND disambiguates the GitHub endpoint: a GitHub + // OAuth/user token (`gho_`/`ghu_`) is a Copilot login; a classic + // PAT (`ghp_`) is free GitHub Models. We only inspect the prefix, + // never the secret, and only when provided inline (dev profile). + "AZURE_OPENAI_API_KEY" | "GITHUB_TOKEN" | "COPILOT_GITHUB_TOKEN" + if token_hint.is_none() && !val.is_empty() => + { + token_hint = Some(val.chars().take(4).collect()); + } + _ => {} + } + } + classify_provider( + provider_override.as_deref(), + &endpoints, + token_hint.as_deref(), + ) + } + + /// Read the receipt-signing public-key anchor published by the controller + /// to the `kars-receipt-pubkey` ConfigMap in `kars-system`. This is the + /// out-of-band trust root a verifier checks against — never a key embedded + /// in a receipt. Returns `(key_id, public_key_b64, scheme)`. + pub async fn receipt_pubkey_anchor(&self) -> Option<(String, String, String)> { + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + let cm = cms.get_opt("kars-receipt-pubkey").await.ok().flatten()?; + let data = cm.data?; + Some(( + data.get("keyId")?.clone(), + data.get("publicKey")?.clone(), + data.get("scheme").cloned().unwrap_or_default(), + )) + } + + /// The orchestrator inference config this cluster already provides — read + /// from the controller Deployment env the SAME way the runtime does, so the + /// Bridge's intent→package orchestrator inherits the cluster's provider + /// instead of needing its own credentials. Returns `(endpoint, token, + /// model)` when an endpoint, a usable token, and a default model are all + /// present. `None` when the cluster authenticates via workload identity + /// (no static token the BFF can reuse) — the UI then falls back to manual + /// composition honestly. + pub async fn orchestrator_inference(&self) -> Option<(String, String, String)> { + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api = Api::namespaced(self.client.clone(), &self.core_namespace()); + let d = deploys.get_opt("kars-controller").await.ok().flatten()?; + let mut endpoint: Option = None; + let mut token: Option = None; + let mut model: Option = None; + let envs = d + .spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()); + for e in envs { + let Some(val) = e.value else { continue }; + if val.is_empty() { + continue; + } + match e.name.as_str() { + "FOUNDRY_ENDPOINT" if endpoint.is_none() => endpoint = Some(val), + "AZURE_OPENAI_ENDPOINT" if endpoint.is_none() => endpoint = Some(val), + "AZURE_OPENAI_API_KEY" | "GITHUB_TOKEN" | "COPILOT_GITHUB_TOKEN" + if token.is_none() => + { + token = Some(val) + } + "KARS_TASK_DEFAULT_MODEL" | "AZURE_OPENAI_DEPLOYMENT" if model.is_none() => { + model = Some(val) + } + _ => {} + } + } + // Normalize a bare Foundry/AOAI endpoint to its OpenAI-compatible base so + // `{endpoint}/chat/completions` resolves. GitHub Models already exposes + // `/inference` as the base; leave it intact. + let endpoint = endpoint?; + Some((endpoint, token?, model?)) + } + + /// Which agent harnesses are actually runnable on this cluster. A configured + /// image is insufficient: private images also need a controller pull secret + /// whose Docker auth covers that image registry. This keeps the composer and + /// preflight from advertising a runtime that will immediately ImagePullBackOff. + pub async fn runnable_runtimes(&self) -> std::collections::BTreeSet { + use k8s_openapi::api::apps::v1::Deployment; + let mut runnable: std::collections::BTreeSet = std::collections::BTreeSet::new(); + // BYO remains selectable because its image is supplied by the BYO contract. + runnable.insert("BYO".into()); + let Ok(Some(d)) = (Api::::namespaced(self.client.clone(), "kars-system")) + .get_opt("kars-controller") + .await + else { + return runnable; + }; + let Some(pod_spec) = d.spec.and_then(|s| s.template.spec) else { + return runnable; + }; + let configured: std::collections::BTreeMap = pod_spec + .containers + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()) + .filter_map(|e| { + e.value + .filter(|value| !value.trim().is_empty()) + .map(|value| (e.name, value)) + }) + .collect(); + // The BFF deliberately has no Secret RBAC. The controller exposes only + // the non-sensitive registry hostnames covered by its pull credentials. + let authenticated_registries = configured + .get("IMAGE_PULL_REGISTRIES") + .into_iter() + .flat_map(|value| value.split(',')) + .map(normalize_registry_host) + .filter(|registry| !registry.is_empty()) + .collect::>(); + let image_is_pullable = |image: &str| { + let registry = image_registry_host(image); + public_registry(®istry) || authenticated_registries.contains(®istry) + }; + if configured + .get("SANDBOX_IMAGE") + .is_some_and(|image| image_is_pullable(image)) + { + runnable.insert("OpenClaw".into()); + } + let mapping = [ + ("OPENAI_AGENTS_RUNTIME_IMAGE", "OpenAIAgents"), + ("MAF_RUNTIME_IMAGE", "MicrosoftAgentFramework"), + ("ANTHROPIC_RUNTIME_IMAGE", "Anthropic"), + ("LANGGRAPH_RUNTIME_IMAGE", "LangGraph"), + ("LANGGRAPH_TS_RUNTIME_IMAGE", "LangGraph"), + ("PYDANTIC_AI_RUNTIME_IMAGE", "PydanticAi"), + ("HERMES_RUNTIME_IMAGE", "Hermes"), + ]; + for (env, kind) in mapping { + if configured + .get(env) + .is_some_and(|image| image_is_pullable(image)) + { + runnable.insert(kind.to_string()); + } + } + runnable + } + + /// Read-only readiness check of the required APIs, bounded across all requests. + pub async fn ping(&self, namespace: &str) -> anyhow::Result<()> { + const REQUIRED_KARS_APIS: &[(&str, &str)] = &[ + ("KarsSandbox", "karssandboxes"), + ("KarsTask", "karstasks"), + ("KarsTeam", "karsteams"), + ("KarsProfile", "karsprofiles"), + ("KarsSkill", "karsskills"), + ("KarsApproval", "karsapprovals"), + ("EgressApproval", "egressapprovals"), + ("KarsReceipt", "karsreceipts"), + ("McpServer", "mcpservers"), + ("InferencePolicy", "inferencepolicies"), + ("ToolPolicy", "toolpolicies"), + ("KarsMemory", "karsmemories"), + ("KarsEval", "karsevals"), + ("KarsSREAction", "karssreactions"), + ("KarsCredentialGrant", "karscredentialgrants"), + ]; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + for (kind, plural) in REQUIRED_KARS_APIS { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let mut ar = ApiResource::from_gvk(&gvk); + ar.plural = (*plural).to_string(); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + tokio::time::timeout_at(deadline, api.list(&ListParams::default().limit(1))) + .await + .map_err(|_| { + anyhow::anyhow!( + "required Kars API kars.azure.com/v1alpha1/{kind} readiness check timed out" + ) + })? + .map_err(|error| { + anyhow::anyhow!( + "required Kars API kars.azure.com/v1alpha1/{kind} is unavailable: {error}" + ) + })?; + } + Ok(()) + } + + /// True iff a CRD with the given plural.group name is installed (e.g. + /// `karssandboxes.kars.azure.com`). Used by the System view to report + /// honest wiring status read from the cluster, not asserted. + pub async fn crd_installed(&self, name: &str) -> bool { + let crds: Api = Api::all(self.client.clone()); + crds.get_opt(name).await.ok().flatten().is_some() + } + + /// Count resources of an arbitrary kars CRD kind in a namespace, via the + /// dynamic API so the BFF need not model every CRD it merely *counts*. + /// Returns `None` when the CRD is not installed. + pub async fn count_kind(&self, namespace: &str, kind: &str) -> Option { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + match api.list(&ListParams::default()).await { + Ok(list) => Some(list.items.len()), + Err(_) => None, + } + } + + /// Create a kars CRD object from a JSON spec in `namespace`. Used to file a + /// request resource (e.g. a temporary `EgressApproval`) the controller then + /// reconciles through human approval — the BFF never widens posture itself. + /// Ensure the standing **orchestrator sandbox** exists — a persistent, + /// non-ephemeral sandbox whose inference router the Bridge orchestrator + /// (compose) always routes through. Without it, compose can only borrow a + /// running agent's router, so on a teams-only cluster (all ephemeral runs) + /// it has no cold-start inference path. Idempotent SSA; safe to call on every + /// startup. + pub async fn ensure_orchestrator_sandbox(&self) -> Result<(), kube::Error> { + const NAME: &str = "bridge-orchestrator"; + const NS: &str = "kars-system"; + let inference = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "InferencePolicy", + "metadata": { "name": format!("{NAME}-inference"), "namespace": NS, + "labels": { "kars.azure.com/managed-by": "kars-bridge" } }, + "spec": { + "appliesTo": { "sandboxName": NAME }, + "modelPreference": { "primary": { "provider": "github-copilot", "deployment": "claude-opus-4.8" } }, + }, + }); + self.apply_kind(NS, "InferencePolicy", inference, true) + .await?; + let sandbox = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { "name": NAME, "namespace": NS, + "labels": { "kars.azure.com/managed-by": "kars-bridge", "kars.azure.com/orchestrator": "true" } }, + "spec": { + "runtime": { "kind": "OpenClaw", "openclaw": {} }, + "inferenceRef": { "name": format!("{NAME}-inference") }, + "sandbox": { "isolation": "standard" }, + "networkPolicy": { "defaultDeny": true }, + "governance": { "enabled": true, "toolPolicyRef": { "name": "kars-default" }, "trustThreshold": 0 }, + "agent": { "instructions": "Standing orchestrator inference host for the kars Bridge composer. Stay idle; your router serves compose requests." }, + }, + }); + self.apply_kind(NS, "KarsSandbox", sandbox, true).await?; + Ok(()) + } + + pub async fn create_kind( + &self, + namespace: &str, + kind: &str, + body: serde_json::Value, + ) -> Result { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + let obj: DynamicObject = serde_json::from_value(body).map_err(|e| { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: e.to_string(), + reason: "BadRequest".into(), + code: 400, + }) + })?; + api.create(&kube::api::PostParams::default(), &obj).await + } + + /// Server-Side Apply a `kars.azure.com` CRD — the Kubernetes-native + /// declarative upsert (the same operation `kubectl apply` performs): creates + /// the object on first apply, edits it on re-apply. The Bridge owns its + /// fields under the stable `kars-bridge` field manager, so the controller, + /// other tools, and a human's `kubectl edit` can co-own different fields + /// without clobbering each other (tracked in `metadata.managedFields`). + /// + /// `force = false` (default) surfaces a 409 field-ownership conflict when + /// another manager owns a field this apply sets — the caller decides whether + /// to override. `force = true` takes ownership of the applied fields. The + /// real authorization boundary is RBAC on the Bridge ServiceAccount + the + /// CRD's admission/CEL validation — not this method. + pub async fn apply_kind( + &self, + namespace: &str, + kind: &str, + body: serde_json::Value, + force: bool, + ) -> Result { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + let obj: DynamicObject = serde_json::from_value(body).map_err(|e| { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: e.to_string(), + reason: "BadRequest".into(), + code: 400, + }) + })?; + let name = obj.metadata.name.clone().unwrap_or_default(); + let mut pp = kube::api::PatchParams::apply("kars-bridge"); + if force { + pp = pp.force(); + } + api.patch(&name, &pp, &kube::api::Patch::Apply(&obj)).await + } + + /// Delete a namespaced kars CRD by kind + name. Foreground propagation so + /// the controller's finalizers run (revoking any downstream state) before + /// the object disappears. The RBAC boundary is the Bridge ServiceAccount's + /// `delete` verb on the resource; a 403/404 surfaces to the caller. + pub async fn delete_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + ) -> Result<(), kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + let dp = kube::api::DeleteParams::foreground(); + api.delete(name, &dp).await?; + Ok(()) + } + + /// Delete a standing team and its owned substrate. Deleting the `KarsTeam` + /// CRD cascade-removes its runs + sandboxes (owner references); this then + /// best-effort sweeps the team's auxiliary records the controller writes + /// alongside the CRD — shared memory, task backlog, engineering source, and + /// write-only channel secret — so a deleted team leaves nothing behind. + /// Aux cleanup is best-effort: a missing aux object is not an error. + /// Delete a mission (KarsTask) and sweep the ConfigMaps the controller keyed + /// on its name — the deliverable, artifacts, live trace, and review record. + /// Without the sweep, a deleted mission's outputs keep surfacing on the + /// Artifacts page and its direct URL keeps resolving from output-only + /// history (same class of orphan the team delete sweep fixes). + pub async fn delete_task(&self, namespace: &str, name: &str) -> Result<(), kube::Error> { + // The CRD itself (foreground cascade → sandbox + child resources). + self.delete_kind(namespace, "KarsTask", name).await?; + self.sweep_mission_artifacts(name).await; + Ok(()) + } + + /// Best-effort deletion of the ConfigMaps the controller keys on a mission's + /// name (deliverable, files, trace, review). Used by mission delete and the + /// output-only cleanup path. + pub async fn sweep_mission_artifacts(&self, name: &str) { + use k8s_openapi::api::core::v1::ConfigMap; + use kube::api::DeleteParams; + let cms: Api = Api::namespaced(self.client.clone(), "kars-system"); + for cm in [ + format!("kars-mission-output-{name}"), + format!("kars-mission-artifacts-{name}"), + format!("kars-mission-trace-{name}"), + format!("kars-mission-review-{name}"), + ] { + let _ = cms.delete(&cm, &DeleteParams::default()).await; + } + } + + pub async fn delete_team( + &self, + namespace: &str, + name: &str, + uid: &str, + version: &str, + ) -> Result<(), kube::Error> { + self.teams(namespace) + .delete( + name, + &kube::api::DeleteParams { + propagation_policy: Some(kube::api::PropagationPolicy::Foreground), + preconditions: Some(kube::api::Preconditions { + uid: Some(uid.into()), + resource_version: Some(version.into()), + }), + ..Default::default() + }, + ) + .await?; + // Core owns Team/source/commons cleanup. Historical or ambiguous + // name-keyed records are retained rather than deleting another UID's data. + Ok(()) + } + + /// Patch the controller deployment env to set the model catalog (and + /// optionally an endpoint) so an onboarded provider's models surface in the + /// launch palette. Triggers a rolling restart. Operator-gated write. + /// + /// When `key_secret` is `Some((secret_name, secret_key))`, the provider's + /// API key is wired via a `secretKeyRef` on `AZURE_OPENAI_API_KEY` — the env + /// var the controller reads and then propagates to every sandbox pod it + /// creates (see controller reconciler). This is what makes an `auth=api` + /// provider actually usable end-to-end, not merely stored. + pub async fn set_controller_catalog( + &self, + catalog: &str, + endpoint: Option<&str>, + key_secret: Option<(&str, &str)>, + ) -> Result<(), kube::Error> { + // Strategic merge on `env` (merge-key `name`) upserts these entries and + // preserves every other existing env var on the container. + let mut env = vec![serde_json::json!({"name": "KARS_MODEL_CATALOG", "value": catalog})]; + // The FIRST catalog entry is the default model — pin it as + // KARS_TASK_DEFAULT_MODEL + AZURE_OPENAI_DEPLOYMENT so switching the + // default provider (or a specific default model) actually changes what + // missions inherit, not just the offered catalog. Without this, the + // controller kept serving a STALE default model after every switch. + if let Some(default_model) = catalog.split(',').map(str::trim).find(|s| !s.is_empty()) { + env.push( + serde_json::json!({"name": "KARS_TASK_DEFAULT_MODEL", "value": default_model}), + ); + env.push( + serde_json::json!({"name": "AZURE_OPENAI_DEPLOYMENT", "value": default_model}), + ); + } + // `controller_provider()` (the "what's the current default provider" + // read used by the Configuration page's status card) checks THREE + // things it treats as stale-able: an explicit `KARS_PROVIDER` / + // `KARS_INFERENCE_PROVIDER` override (checked FIRST, absolute + // priority over everything else — typically set once at cluster + // bootstrap, e.g. `KARS_PROVIDER=github-copilot`), then + // FOUNDRY_ENDPOINT / FOUNDRY_PROJECT_ENDPOINT / AZURE_OPENAI_ENDPOINT + // as interchangeable endpoint aliases (picks whichever it finds + // FIRST). Every caller of this function is switching the cluster + // default to a NEW provider, so ALL of these must be cleared here — + // confirmed live this was a real, pre-existing bug affecting the + // ORIGINAL "Add or switch a provider" flow too, not just the new + // local-inference promote action: switching the default endpoint + // correctly patched FOUNDRY_ENDPOINT, but the Configuration page + // kept showing "GitHub Copilot" forever after, because the + // bootstrap-time `KARS_PROVIDER=github-copilot` override (checked + // before any endpoint) was never cleared by anything. Neither + // caller of this function ever wants to declare copilot/models as + // default (both explicitly reject that combination before calling + // in), so unconditionally clearing the override is correct here. + // `$patch: delete` is the standard strategic-merge-patch mechanism + // for removing one named entry from a mergeKey'd list without + // touching the rest — a no-op if the name was never present. + for stale in [ + "KARS_PROVIDER", + "KARS_INFERENCE_PROVIDER", + "AZURE_OPENAI_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", + ] { + env.push(serde_json::json!({"name": stale, "$patch": "delete"})); + } + if let Some(e) = endpoint { + env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "value": e})); + } else { + // No explicit endpoint (e.g. switching to GitHub Copilot/Models + // default, which reach their well-known host without one) — clear + // any previously-set FOUNDRY_ENDPOINT too, for the same reason. + env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "$patch": "delete"})); + } + if let Some((secret, key)) = key_secret { + // valueFrom.secretKeyRef replaces any prior static `value` for this + // name under strategic merge, so the key is sourced from the Secret. + env.push(serde_json::json!({ + "name": "AZURE_OPENAI_API_KEY", + "valueFrom": { "secretKeyRef": { "name": secret, "key": key } }, + })); + } else { + // The new default has no key (e.g. an unauthenticated in-cluster + // local model, or Workload Identity) — clear any key wired for a + // PRIOR default so the router doesn't keep sending a stale + // credential to an endpoint that never asked for one. + env.push(serde_json::json!({"name": "AZURE_OPENAI_API_KEY", "$patch": "delete"})); + } + self.write_controller_environment(env).await + } + + /// Make GitHub Copilot the cluster's DEFAULT provider. Copilot doesn't use + /// the endpoint+key shape `set_controller_catalog` wires — it authenticates + /// via a GitHub token exchanged for a short-lived Copilot JWT by the router + /// (`copilot_auth`). This wires exactly what Copilot-as-default needs on the + /// controller (which propagates it to every sandbox): `KARS_PROVIDER= + /// github-copilot`, `COPILOT_GITHUB_TOKEN` (the token signed in via the + /// wizard, read from the shared providers secret), the Copilot API host as + /// the `AZURE_OPENAI_ENDPOINT` sentinel (the controller refuses to + /// provision a sandbox without SOME inference endpoint), the model catalog, + /// and the default model — clearing any stale Azure/Foundry endpoint+key + /// from a prior default. Returns an error if no Copilot token is stored yet + /// (the operator must sign in first). + pub async fn set_copilot_as_default(&self, models: &str) -> Result<(), kube::Error> { + let token = self + .read_secret_all("kars-system", "kars-inference-providers") + .await? + .get("COPILOT_GITHUB_TOKEN") + .filter(|v| !v.trim().is_empty()) + .cloned(); + let Some(_token) = token else { + return Err(kube::Error::Api(kube::error::ErrorResponse { + status: "Failure".into(), + message: "no Copilot token is stored — sign in to GitHub Copilot first".into(), + reason: "BadRequest".into(), + code: 400, + })); + }; + let default_model = models + .split(',') + .next() + .map(str::trim) + .unwrap_or("") + .to_string(); + let mut env = vec![ + serde_json::json!({"name": "KARS_PROVIDER", "value": "github-copilot"}), + serde_json::json!({"name": "COPILOT_GITHUB_TOKEN", "valueFrom":{"secretKeyRef":{ + "name":"kars-inference-providers","key":"COPILOT_GITHUB_TOKEN"}}}), + serde_json::json!({"name": "AZURE_OPENAI_ENDPOINT", "value": "https://api.githubcopilot.com"}), + serde_json::json!({"name": "KARS_MODEL_CATALOG", "value": models}), + ]; + if !default_model.is_empty() { + env.push(serde_json::json!({"name": "KARS_TASK_DEFAULT_MODEL", "value": default_model.clone()})); + env.push( + serde_json::json!({"name": "AZURE_OPENAI_DEPLOYMENT", "value": default_model}), + ); + } + // Clear anything a prior (Azure/Foundry) default left behind so the + // router doesn't keep a stale endpoint/key alongside Copilot. + for stale in [ + "FOUNDRY_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", + "AZURE_OPENAI_API_KEY", + "KARS_INFERENCE_PROVIDER", + ] { + env.push(serde_json::json!({"name": stale, "$patch": "delete"})); + } + self.write_controller_environment(env).await + } + /// Upsert a Secret via server-side apply, merging keys without clobbering + /// existing ones. Used to store agent credentials (write-only); the value is + /// never read back through any endpoint. + pub async fn upsert_secret( + &self, + namespace: &str, + name: &str, + body: serde_json::Value, + ) -> Result<(), kube::Error> { + let data = body + .get("stringData") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| super::credentials::failure("Integration update requires stringData"))?; + let values = data + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| { + super::credentials::failure("Integration value must be a string") + }) + }) + .collect::, _>>()?; + self.mutate_integration(namespace, name, |keys| keys.extend(values.clone())) + .await + } + + /// Delete a write-only credential Secret this Bridge authored (e.g. the + /// shared `kars-github-app` Secret on disconnect). A 404 is not an error — + /// the secret is already absent, which is the caller's desired end state. + pub async fn delete_secret(&self, namespace: &str, name: &str) -> Result<(), kube::Error> { + self.mutate_integration(namespace, name, |keys| keys.clear()) + .await + } + + /// List all objects of a kars CRD `kind` across **all** namespaces, as + /// dynamic objects the caller projects into a DTO. This is the generic + /// read the operator surfaces use so the BFF need not type every CRD it + /// merely lists. Returns `Err` only on a real API failure; an absent CRD + /// surfaces as `Ok(vec![])` so the caller can render an honest empty state. + pub async fn list_kind_all(&self, kind: &str) -> Result, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::all_with(self.client.clone(), &ar); + match api.list(&ListParams::default()).await { + Ok(list) => Ok(list.items), + // A 404 means the CRD isn't installed — honest empty, not an error. + Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + pub async fn list_metrics_all( + &self, + kind: &str, + plural: &str, + ) -> Result, kube::Error> { + let ar = ApiResource { + group: "metrics.k8s.io".into(), + version: "v1beta1".into(), + api_version: "metrics.k8s.io/v1beta1".into(), + kind: kind.into(), + plural: plural.into(), + }; + let api: Api = Api::all_with(self.client.clone(), &ar); + Ok(api.list(&ListParams::default()).await?.items) + } + + pub async fn list_nodes(&self) -> Result, kube::Error> { + let api: Api = Api::all(self.client.clone()); + Ok(api.list(&ListParams::default()).await?.items) + } + + pub async fn controller_env_value(&self, name: &str) -> Option { + use k8s_openapi::api::apps::v1::Deployment; + let deployment = Api::::namespaced(self.client.clone(), "kars-system") + .get_opt("kars-controller") + .await + .ok() + .flatten()?; + deployment + .spec? + .template + .spec? + .containers + .first()? + .env + .as_ref()? + .iter() + .find(|entry| entry.name == name) + .and_then(|entry| entry.value.clone()) + } + + /// List objects of a kars CRD `kind` across all namespaces filtered by a + /// label selector — used to find an agent's spawned sub-agents, which the + /// inference router labels `kars.azure.com/parent=`. Absent CRD → + /// `Ok(vec![])`. + pub async fn list_kind_labeled( + &self, + kind: &str, + selector: &str, + ) -> Result, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::all_with(self.client.clone(), &ar); + match api.list(&ListParams::default().labels(selector)).await { + Ok(list) => Ok(list.items), + Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + /// List objects of a kars CRD `kind` within a namespace, as dynamic + /// objects. Absent CRD → `Ok(vec![])`. + pub async fn list_kind( + &self, + namespace: &str, + kind: &str, + ) -> Result, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + match api.list(&ListParams::default()).await { + Ok(list) => Ok(list.items), + Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + /// Fetch a single kars CRD object by kind + namespace + name. + /// Merge-patch annotations onto a namespaced kars CRD's metadata. Used by + /// the operator skill-admission gate to record the review verdict, the + /// approver, and the version digest the approval is locked to — a real, + /// auditable admission record on the object itself (RBAC: the Bridge SA's + /// `patch` verb). A `None` value removes the annotation. + pub async fn annotate_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + annotations: &[(&str, Option)], + ) -> Result { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + let mut ann = serde_json::Map::new(); + for (k, v) in annotations { + ann.insert((*k).to_string(), serde_json::json!(v)); + } + let patch = serde_json::json!({ "metadata": { "annotations": ann } }); + api.patch( + name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&patch), + ) + .await + } + + /// Apply a strategic **merge patch** to a kars CRD object — used for small, + /// in-place edits (e.g. an operator changing an InferencePolicy's token + /// budget). Unlike SSA this doesn't take field-manager ownership of the whole + /// spec, so it co-exists with the controller's own management. + pub async fn merge_patch_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + patch: serde_json::Value, + ) -> Result { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + api.patch( + name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&patch), + ) + .await + } + + /// The current Foundry connection, read live from the `kars-controller` + /// Deployment env: `(project_endpoint, inference_endpoint, memory_store_id, + /// has_api_key)`. All `None`/false when Foundry has not been onboarded. The + /// API key is NEVER returned — only whether one is wired. + pub async fn get_foundry_connection( + &self, + ) -> (Option, Option, Option, bool) { + use k8s_openapi::api::apps::v1::Deployment; + let api: Api = Api::namespaced(self.client.clone(), &self.core_namespace()); + let Some(dep) = api.get_opt("kars-controller").await.ok().flatten() else { + return (None, None, None, false); + }; + let mut project = None; + let mut inference = None; + let mut store = None; + let mut has_key = false; + if let Some(spec) = dep.spec.and_then(|s| s.template.spec) { + for c in spec.containers { + for env in c.env.unwrap_or_default() { + match env.name.as_str() { + "FOUNDRY_PROJECT_ENDPOINT" => project = env.value.filter(|v| !v.is_empty()), + "FOUNDRY_ENDPOINT" => inference = env.value.filter(|v| !v.is_empty()), + "FOUNDRY_MEMORY_STORE_ID" => store = env.value.filter(|v| !v.is_empty()), + "FOUNDRY_API_KEY" => { + has_key = env.value_from.is_some() + || env.value.as_ref().is_some_and(|v| !v.is_empty()); + } + _ => {} + } + } + } + } + (project, inference, store, has_key) + } + + /// Onboard a Foundry connection by patching the `kars-controller` Deployment + /// env (strategic merge on `env` by name, preserving all other vars). Sets + /// `FOUNDRY_PROJECT_ENDPOINT` (+ optional inference endpoint / memory store), + /// and for API-key auth wires `FOUNDRY_API_KEY` from a Secret via + /// `secretKeyRef`. The controller then propagates these to sandbox routers. + /// Managed-identity auth stores no key — the router uses the cluster's + /// workload identity (audience `https://ai.azure.com`). + pub async fn set_foundry_connection( + &self, + project_endpoint: &str, + inference_endpoint: Option<&str>, + memory_store_id: Option<&str>, + key_secret: Option<(&str, &str)>, + ) -> Result<(), kube::Error> { + let mut env = vec![ + serde_json::json!({"name": "FOUNDRY_PROJECT_ENDPOINT", "value": project_endpoint}), + ]; + if let Some(e) = inference_endpoint.filter(|e| !e.is_empty()) { + env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "value": e})); + } + if let Some(s) = memory_store_id.filter(|s| !s.is_empty()) { + env.push(serde_json::json!({"name": "FOUNDRY_MEMORY_STORE_ID", "value": s})); + } + if let Some((secret, key)) = key_secret { + env.push(serde_json::json!({ + "name": "FOUNDRY_API_KEY", + "valueFrom": { "secretKeyRef": { "name": secret, "key": key } }, + })); + } + self.write_controller_environment(env).await + } + + /// Read a single key's value from a Secret (base64-decoded UTF-8). `None` + /// when the secret/key is absent. Used by the Foundry preflight to make a + /// real authenticated call with the onboarded key — the key never leaves the + /// BFF process. + pub async fn read_secret_value( + &self, + namespace: &str, + secret: &str, + key: &str, + ) -> Result, kube::Error> { + let (_, s) = self.integration_store(namespace, secret).await?; + if let Some(v) = s.data.as_ref().and_then(|d| d.get(key)) { + return String::from_utf8(v.0.clone()) + .map(Some) + .map_err(|_| super::credentials::failure("Credential value is not UTF-8")); + } + Ok(None) + } + + /// Read every key of a Secret as UTF-8 strings (base64-decoded). Empty map + /// when the secret doesn't exist. Used for the multi-provider inference + /// Secret, whose keys ARE the literal env var names the router reads + /// (`KARS_PROVIDER__ENDPOINT`, `COPILOT_GITHUB_TOKEN`, ...) — listing + /// requires reading the whole key set, not one key at a time. + pub async fn read_secret_all( + &self, + namespace: &str, + secret: &str, + ) -> Result, kube::Error> { + let (_, s) = self.integration_store(namespace, secret).await?; + Ok(Self::decode_secret_data(&s)) + } + + /// Read-modify-write a Secret's full key set under real optimistic + /// concurrency (CAS): a single atomic JSON Patch (RFC 6902) — a `test` op + /// asserting `resourceVersion` hasn't moved, followed by `add`/`remove` + /// ops for the actual key changes — retried on failure. + /// + /// Why JSON Patch specifically, not `replace()`/PUT or a plain JSON merge + /// patch: + /// - `replace()` (PUT) is the "update" RBAC verb, which the BFF's + /// ClusterRole deliberately never grants (write access here is + /// `create`/`patch` only) — using it would 403 in any real + /// RBAC-enforced deployment. Confirmed live against the actual + /// ServiceAccount (not a developer's cluster-admin kubeconfig). + /// - A plain JSON *merge* patch (RFC 7396, what this function used + /// before) uses the `patch` verb correctly, but the K8s API does NOT + /// honor `resourceVersion` as a precondition for merge patches — + /// confirmed live: a merge patch carrying a stale resourceVersion + /// still applies. So a merge patch alone has no way to detect a + /// concurrent writer. + /// - JSON Patch's `test` op DOES enforce the precondition atomically + /// alongside the real mutation (confirmed live: a stale + /// resourceVersion in a `test` op → the whole patch is rejected, + /// HTTP 422, and none of the following ops apply) — and it's still + /// the `patch` verb, so no RBAC widening is needed. + /// - Field removal still works here (unlike Server-Side-Apply, whose + /// merge semantics never remove an absent key) via an explicit + /// `remove` op per dropped key. + pub async fn mutate_secret_keys( + &self, + namespace: &str, + secret: &str, + mutate: impl Fn(&mut std::collections::BTreeMap), + ) -> Result<(), kube::Error> { + self.mutate_integration(namespace, secret, mutate).await + } + + /// Decode a Secret's `data` (+ any pending `stringData`) into a flat map, + /// the shared helper behind both `read_secret_all` and the CAS loop above. + fn decode_secret_data( + s: &k8s_openapi::api::core::v1::Secret, + ) -> std::collections::BTreeMap { + let mut out = std::collections::BTreeMap::new(); + if let Some(d) = s.data.as_ref() { + for (k, v) in d { + if let Ok(s) = String::from_utf8(v.0.clone()) { + out.insert(k.clone(), s); + } + } + } + if let Some(d) = s.string_data.as_ref() { + for (k, v) in d { + out.insert(k.clone(), v.clone()); + } + } + out + } + + /// The workload-identity client-id wired onto the sandbox/controller service + /// account, if any — evidence the cluster can obtain managed-identity tokens + /// (the same path Foundry data-plane access uses). `None` when not wired. + pub async fn workload_identity_client_id(&self) -> Option { + use k8s_openapi::api::core::v1::ServiceAccount; + let api: Api = Api::namespaced(self.client.clone(), "kars-system"); + for sa in ["kars-controller", "default"] { + if let Some(obj) = api.get_opt(sa).await.ok().flatten() + && let Some(cid) = obj + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("azure.workload.identity/client-id")) + .filter(|v| !v.is_empty()) + { + return Some(cid.clone()); + } + } + None + } + + pub async fn get_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + ) -> Result, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::namespaced_with(self.client.clone(), namespace, &ar); + api.get_opt(name).await + } + + /// Model pinned to the persistent Bridge composer sandbox. Composition calls + /// must use this route rather than an unrelated cluster-default deployment. + pub async fn bridge_orchestrator_model(&self) -> Option { + self.get_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + ) + .await + .ok() + .flatten()? + .data + .pointer("/spec/modelPreference/primary/deployment") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .filter(|model| !model.trim().is_empty()) + } + + pub async fn configure_bridge_orchestrator_model( + &self, + provider: &str, + deployment: &str, + ) -> Result<(), String> { + let policy_ready = |policy: &DynamicObject| { + let generation_matches = policy + .data + .pointer("/status/observedGeneration") + .and_then(serde_json::Value::as_i64) + == policy.metadata.generation; + generation_matches + && policy + .data + .pointer("/spec/modelPreference/primary/provider") + .and_then(serde_json::Value::as_str) + == Some(provider) + && policy + .data + .pointer("/spec/modelPreference/primary/deployment") + .and_then(serde_json::Value::as_str) + == Some(deployment) + && policy + .data + .pointer("/status/conditions") + .and_then(serde_json::Value::as_array) + .is_some_and(|conditions| { + conditions.iter().any(|condition| { + condition.get("type").and_then(serde_json::Value::as_str) + == Some("Ready") + && condition.get("status").and_then(serde_json::Value::as_str) + == Some("True") + }) + }) + }; + if self + .get_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + ) + .await + .map_err(|error| error.to_string())? + .as_ref() + .is_some_and(&policy_ready) + { + return Ok(()); + } + self.merge_patch_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + serde_json::json!({ + "spec": { + "modelPreference": { + "primary": { + "provider": provider, + "deployment": deployment + }, + "fallback": [] + } + } + }), + ) + .await + .map_err(|error| error.to_string())?; + let revision = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().to_string()) + .unwrap_or_else(|_| format!("{provider}:{deployment}")); + self.merge_patch_kind( + "kars-system", + "KarsSandbox", + "bridge-orchestrator", + serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/orchestrator-model-revision": revision + } + } + }), + ) + .await + .map_err(|error| error.to_string())?; + // Changing the mounted policy deliberately rolls the orchestrator pod. + // Wait for the controller's exact generation + router-echo confirmation, + // not merely the policy write or a fixed short pod-start assumption. + for _ in 0..ORCHESTRATOR_POLICY_READY_ATTEMPTS { + let ready = self + .get_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + ) + .await + .map_err(|error| error.to_string())? + .as_ref() + .is_some_and(&policy_ready); + if ready { + return Ok(()); + } + tokio::time::sleep(ORCHESTRATOR_POLICY_POLL_INTERVAL).await; + } + Err(format!( + "timed out waiting for the bridge orchestrator router to enforce {provider}/{deployment}" + )) + } + + /// The model every team run inherits when its blueprint pins none — the + /// controller's `KARS_TASK_DEFAULT_MODEL` env (see + /// `controller/src/kars_task_execution.rs::default_model`). Read live from + /// the `kars-controller` Deployment so the Bridge shows the *effective* + /// model, not a hardcoded guess. `None` when the controller isn't found or + /// the env is unset (the caller then labels it generically). + pub async fn controller_default_model(&self) -> Option { + use k8s_openapi::api::apps::v1::Deployment; + let api: Api = Api::namespaced(self.client.clone(), &self.core_namespace()); + let dep = api.get_opt("kars-controller").await.ok().flatten()?; + let containers = dep.spec?.template.spec?.containers; + for c in containers { + for env in c.env.unwrap_or_default() { + if env.name == "KARS_TASK_DEFAULT_MODEL" + && let Some(v) = env.value.filter(|v| !v.is_empty()) + { + return Some(v); + } + } + } + None + } + + // ─── Local (in-cluster) inference — AI Runway ModelDeployment ─────────── + // See docs/local-inference.md. kars does NOT install AI Runway/KAITO — + // an operator does that once via their own helm/kubectl, exactly like the + // GitHub App or Azure AI Foundry connection. kars-bridge only detects + // presence and manages `ModelDeployment` objects on top, in a namespace it + // owns (LOCAL_INFERENCE_NAMESPACE), never anyone else's. + + /// Whether AI Runway's `ModelDeployment` CRD is installed in this + /// cluster. A cheap, read-only check (list with a 1-item limit) — the + /// Bridge already holds `customresourcedefinitions: get/list` (used for + /// CRD-schema introspection elsewhere), so this needs no new RBAC beyond + /// the narrow `modeldeployments.airunway.ai` grant added alongside it. + pub async fn local_inference_available(&self) -> bool { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + api.list(&ListParams::default().limit(1)).await.is_ok() + } + + /// Server-Side Apply a `ModelDeployment` (create-or-update), namespaced to + /// `LOCAL_INFERENCE_NAMESPACE`. Mirrors `apply_kind`'s shape but targets + /// AI Runway's own API group instead of `kars.azure.com`. Ensures the + /// namespace exists first — the Bridge's own namespace, never created by + /// AI Runway/KAITO's install, so this is the one place it needs to. + pub async fn apply_model_deployment( + &self, + name: &str, + spec: serde_json::Value, + ) -> Result { + let namespaces: Api = Api::all(self.client.clone()); + if let Some(namespace) = namespaces.get_opt(LOCAL_INFERENCE_NAMESPACE).await? { + if namespace.metadata.deletion_timestamp.is_some() { + return Err(super::credentials::failure( + "Local inference namespace is terminating", + )); + } + } else { + let namespace = serde_json::from_value(serde_json::json!({ + "apiVersion":"v1","kind":"Namespace","metadata":{"name":LOCAL_INFERENCE_NAMESPACE, + "labels":{"app.kubernetes.io/managed-by":"kars-bridge"}} + })) + .map_err(|_| { + super::credentials::failure("Local inference namespace metadata invalid") + })?; + namespaces + .create(&kube::api::PostParams::default(), &namespace) + .await?; + } + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + let obj: DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "airunway.ai/v1alpha1", + "kind": "ModelDeployment", + "metadata": { + "name": name, + "namespace": LOCAL_INFERENCE_NAMESPACE, + "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}, + }, + "spec": spec, + })) + .map_err(|e| { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: e.to_string(), + reason: "BadRequest".into(), + code: 400, + }) + })?; + api.patch( + name, + &kube::api::PatchParams::apply("kars-bridge").force(), + &kube::api::Patch::Apply(&obj), + ) + .await + } + + /// List every `ModelDeployment` in the cluster. Discovery is read-only + /// across namespaces so an existing operator-managed AI Runway deployment + /// is visible without being recreated under `kars-local-inference`. + pub async fn list_model_deployments(&self) -> Result, kube::Error> { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = Api::all_with(self.client.clone(), &ar); + Ok(api.list(&ListParams::default()).await?.items) + } + + /// Read one `ModelDeployment`'s current state (status included). + pub async fn get_model_deployment( + &self, + name: &str, + ) -> Result, kube::Error> { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + api.get_opt(name).await + } + + /// Delete a `ModelDeployment` (foreground — the provider controller's + /// owner-referenced `Workspace`/pods/Service cascade with it). + pub async fn delete_model_deployment(&self, name: &str) -> Result<(), kube::Error> { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + api.delete(name, &kube::api::DeleteParams::foreground()) + .await?; + Ok(()) + } + + /// Real-capacity GPU node scan (read-only `nodes: get/list`) so the + /// wizard can offer GPU-tier models only when the cluster can actually + /// schedule them — never a hardcoded guess. Returns the count of + /// schedulable nodes advertising `nvidia.com/gpu` capacity and the + /// distinct GPU product names found (from the `nvidia.com/gpu.product` + /// NFD/GPU-feature-discovery label, when present). + pub async fn gpu_node_summary(&self) -> Result { + use k8s_openapi::api::core::v1::Node; + let api: Api = Api::all(self.client.clone()); + let nodes = api.list(&ListParams::default()).await?; + let mut gpu_node_count = 0u32; + let mut products = std::collections::BTreeSet::new(); + for n in &nodes.items { + let has_gpu = n + .status + .as_ref() + .and_then(|s| s.capacity.as_ref()) + .map(|c| c.contains_key("nvidia.com/gpu")) + .unwrap_or(false); + if has_gpu { + gpu_node_count += 1; + if let Some(product) = n + .metadata + .labels + .as_ref() + .and_then(|l| l.get("nvidia.com/gpu.product")) + { + products.insert(product.clone()); + } + } + } + Ok(GpuNodeSummary { + gpu_node_count, + gpu_products: products.into_iter().collect(), + }) + } + + /// Rich, LIVE status for one in-flight (or settled) local model deploy — + /// what powers the deploy progress tracker's percentage + activity feed. + /// Sourced entirely from real cluster signals (no synthetic spinner): + /// • the `ModelDeployment` CR's ordered `status.conditions` + phase, + /// • the KAITO pod(s) selected by `airunway.ai/model-deployment=` + /// (container waiting reason / running / ready), and + /// • the namespace's Kubernetes Events for those pods (Pulling, Pulled, + /// Failed, BackOff, Started, …) — the actual activity feed. + /// The percentage is milestone-derived (validated → workspace → scheduled + /// → image pulled → running), so it only advances on real progress. + pub async fn local_deployment_live_status( + &self, + name: &str, + ) -> Result { + use k8s_openapi::api::core::v1::{Event, Pod}; + + let cr = self.get_model_deployment(name).await?; + let mut status = LocalDeployLiveStatus { + name: name.to_string(), + found: cr.is_some(), + ..Default::default() + }; + if let Some(cr) = &cr { + let st = cr.data.get("status"); + status.phase = st + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(str::to_string); + status.message = st + .and_then(|s| s.get("message")) + .and_then(|m| m.as_str()) + .map(str::to_string); + if let Some(reps) = st.and_then(|s| s.get("replicas")) { + status.replicas_desired = + reps.get("desired").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + status.replicas_ready = + reps.get("ready").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + } + if let Some(conds) = st + .and_then(|s| s.get("conditions")) + .and_then(|c| c.as_array()) + { + for c in conds { + status.conditions.push(DeployCondition { + cond_type: c + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + status: c + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + reason: c + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + message: c + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }); + } + } + } + + // Pod(s) for this deployment (KAITO stamps airunway.ai/model-deployment). + let pods_api: Api = Api::namespaced(self.client.clone(), LOCAL_INFERENCE_NAMESPACE); + let lp = ListParams::default().labels(&format!("airunway.ai/model-deployment={name}")); + let mut pod_names: Vec = Vec::new(); + if let Ok(pods) = pods_api.list(&lp).await { + for p in &pods.items { + let pod_name = p.metadata.name.clone().unwrap_or_default(); + pod_names.push(pod_name.clone()); + let phase = p + .status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_default(); + let mut ready = false; + let mut waiting_reason: Option = None; + let mut waiting_message: Option = None; + let mut running = false; + if let Some(cs) = p + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + { + for c in cs { + ready = ready || c.ready; + if let Some(state) = &c.state { + if let Some(w) = &state.waiting { + waiting_reason = w.reason.clone(); + waiting_message = w.message.clone(); + } + if state.running.is_some() { + running = true; + } + } + } + } + status.pods.push(DeployPodState { + name: pod_name, + phase, + ready, + running, + waiting_reason, + waiting_message, + }); + } + } + + // Real Kubernetes events for the CR + its pods — the live activity feed. + let events_api: Api = + Api::namespaced(self.client.clone(), LOCAL_INFERENCE_NAMESPACE); + if let Ok(events) = events_api.list(&ListParams::default()).await { + for e in &events.items { + let obj = e.involved_object.name.clone().unwrap_or_default(); + if obj != name && !pod_names.contains(&obj) { + continue; + } + let time = e + .last_timestamp + .as_ref() + .map(|t| t.0.to_rfc3339()) + .or_else(|| e.event_time.as_ref().map(|t| t.0.to_rfc3339())); + status.activities.push(DeployActivity { + time, + reason: e.reason.clone().unwrap_or_default(), + message: e.message.clone().unwrap_or_default(), + event_type: e.type_.clone().unwrap_or_default(), + count: e.count.unwrap_or(1), + }); + } + // Oldest → newest so the feed reads like a log. + status.activities.sort_by(|a, b| a.time.cmp(&b.time)); + } + + // Terminal failure detection from real pod container state. + for p in &status.pods { + if let Some(reason) = &p.waiting_reason + && matches!( + reason.as_str(), + "ImagePullBackOff" + | "ErrImagePull" + | "CrashLoopBackOff" + | "CreateContainerError" + | "InvalidImageName" + ) + { + status.failed = true; + status.failure_reason = Some(reason.clone()); + status.failure_message = p.waiting_message.clone(); + } + } + status.ready = status.phase.as_deref() == Some("Running") + || (status.replicas_desired > 0 && status.replicas_ready >= status.replicas_desired); + + // Milestone-derived percentage — advances only on real progress. + status.percent = compute_deploy_percent(&status); + Ok(status) + } +} + +/// One `ModelDeployment.status.conditions[]` entry, browser-facing. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct DeployCondition { + #[serde(rename = "type")] + pub cond_type: String, + pub status: String, + pub reason: String, + pub message: String, +} + +/// Live state of one KAITO pod backing a local model deployment. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct DeployPodState { + pub name: String, + pub phase: String, + pub ready: bool, + pub running: bool, + pub waiting_reason: Option, + pub waiting_message: Option, +} + +/// One real Kubernetes Event — an entry in the live activity feed. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct DeployActivity { + pub time: Option, + pub reason: String, + pub message: String, + #[serde(rename = "type")] + pub event_type: String, + pub count: i32, +} + +/// The full live status the deploy tracker renders. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct LocalDeployLiveStatus { + pub name: String, + pub found: bool, + pub phase: Option, + pub message: Option, + pub percent: u8, + pub ready: bool, + pub failed: bool, + pub failure_reason: Option, + pub failure_message: Option, + pub replicas_desired: u32, + pub replicas_ready: u32, + pub conditions: Vec, + pub pods: Vec, + pub activities: Vec, +} + +/// Milestone-derived deploy percentage from real signals. Each milestone the +/// deployment has genuinely reached sets a floor; nothing here advances on a +/// timer alone (the client adds a small time-based ease WITHIN the current +/// band for visible motion, but never past the next real milestone). +fn compute_deploy_percent(s: &LocalDeployLiveStatus) -> u8 { + if s.ready { + return 100; + } + let cond_true = |t: &str| { + s.conditions + .iter() + .any(|c| c.cond_type == t && c.status == "True") + }; + let mut pct: u8 = if s.found { 8 } else { 3 }; + if cond_true("Validated") { + pct = pct.max(15); + } + if cond_true("ProviderSelected") || cond_true("ProviderCompatible") { + pct = pct.max(25); + } + if cond_true("ResourceCreated") { + pct = pct.max(38); + } + // Pod exists & scheduled (has a phase beyond nothing). + if s.pods + .iter() + .any(|p| !p.phase.is_empty() && p.phase != "Unknown") + { + pct = pct.max(52); + } + // Container running (image pulled, process started) but not yet Ready. + if s.pods.iter().any(|p| p.running) { + pct = pct.max(88); + } + pct +} + +/// Namespace the Bridge creates its own `ModelDeployment` objects in — never +/// the operator's `default` or the AI Runway/KAITO system namespaces, so +/// listing is naturally scoped to what the Bridge itself manages. +pub const LOCAL_INFERENCE_NAMESPACE: &str = "kars-local-inference"; + +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct GpuNodeSummary { + pub gpu_node_count: u32, + pub gpu_products: Vec, +} + +#[cfg(test)] +mod provider_tests { + use super::{ + classify_provider, descendant_sandbox_objects, image_registry_host, mission_evidence_key, + mission_output_candidate, normalize_registry_host, project_mission_output_record, + public_registry, select_mission_evidence_records, select_mission_output_records, + trace_record_identity, + }; + use k8s_openapi::api::core::v1::ConfigMap; + use kube::api::DynamicObject; + use serde_json::json; + use std::collections::BTreeMap; + + fn id(r: Option<(String, String, String)>) -> Option { + r.map(|(i, _, _)| i) + } + + #[test] + fn descendant_sandboxes_include_nested_agents_once() { + let sandbox = |name: &str, parent: Option<&str>| -> DynamicObject { + serde_json::from_value(json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { + "name": name, + "labels": parent.map(|parent| json!({"kars.azure.com/parent": parent})) + } + })) + .expect("sandbox") + }; + let items = vec![ + sandbox("child", Some("root")), + sandbox("grandchild", Some("child")), + sandbox("unrelated", Some("other")), + ]; + + let names = descendant_sandbox_objects(&items, "root") + .into_iter() + .filter_map(|sandbox| sandbox.metadata.name) + .collect::>(); + assert_eq!( + names, + std::collections::HashSet::from(["child".to_string(), "grandchild".to_string(),]) + ); + } + + #[test] + fn registry_matching_covers_private_runtime_images() { + assert_eq!( + image_registry_host("example.azurecr.io/kars-runtime-hermes:latest"), + "example.azurecr.io" + ); + assert_eq!( + normalize_registry_host("https://example.azurecr.io/v1/"), + "example.azurecr.io" + ); + assert!(!public_registry("example.azurecr.io")); + assert!(public_registry("mcr.microsoft.com")); + } + + #[test] + fn mission_evidence_annotation_restores_long_nonce_identity() { + let full = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let mut config_map = ConfigMap::default(); + config_map.metadata.annotations = Some(BTreeMap::from([( + "kars.azure.com/mission-evidence-key".to_string(), + full.to_string(), + )])); + config_map.metadata.labels = Some(BTreeMap::from([( + "kars.azure.com/mission-output".to_string(), + "stock-monitor-persistent-qual-principal-assig-0123456789ab".to_string(), + )])); + + assert_eq!( + mission_evidence_key(&config_map, "kars.azure.com/mission-output").as_deref(), + Some(full) + ); + } + + #[test] + fn mission_evidence_label_remains_legacy_fallback() { + let mut config_map = ConfigMap::default(); + config_map.metadata.labels = Some(BTreeMap::from([( + "kars.azure.com/mission-output".to_string(), + "team-run-100".to_string(), + )])); + + assert_eq!( + mission_evidence_key(&config_map, "kars.azure.com/mission-output").as_deref(), + Some("team-run-100") + ); + } + + #[test] + fn legacy_principal_label_restores_stable_task_name() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let mut config_map = ConfigMap::default(); + config_map.metadata.labels = Some(BTreeMap::from([ + ( + "kars.azure.com/mission-output".to_string(), + nonce.to_string(), + ), + ( + "kars.azure.com/mission-principal".to_string(), + "stock-monitor-persistent-qual-principal".to_string(), + ), + ])); + config_map.data = Some(BTreeMap::from([( + "assignmentNonce".to_string(), + nonce.to_string(), + )])); + + let (_, _, data) = mission_output_candidate(config_map).expect("candidate"); + assert_eq!( + data.get("taskName").map(String::as_str), + Some("stock-monitor-persistent-qual-principal") + ); + } + + #[test] + fn ordinary_mission_enumeration_keeps_the_task_pointer() { + let first_nonce = "run-1784912062312097896"; + let latest_nonce = "run-1784915840189332732"; + let first = BTreeMap::from([ + ("assignmentNonce".to_string(), first_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let latest = BTreeMap::from([ + ("assignmentNonce".to_string(), latest_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let selected = select_mission_output_records(vec![ + (first_nonce.to_string(), Some("archive".to_string()), first), + ( + latest_nonce.to_string(), + Some("archive".to_string()), + latest.clone(), + ), + ( + "kompli-research".to_string(), + Some("current".to_string()), + latest, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "kompli-research"); + } + + #[test] + fn explicit_current_pointer_beats_legacy_archive_for_same_task() { + let old_nonce = "rev-1"; + let latest_nonce = "rev-2"; + let legacy = BTreeMap::from([ + ("assignmentNonce".to_string(), old_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let current = BTreeMap::from([ + ("assignmentNonce".to_string(), latest_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let selected = select_mission_output_records(vec![ + (old_nonce.to_string(), None, legacy), + ( + "kompli-research".to_string(), + Some("current".to_string()), + current, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "kompli-research"); + } + + #[test] + fn legacy_ordinary_run_archives_do_not_become_phantom_tasks() { + let old_nonce = "run-1784912062312097896"; + let latest_nonce = "run-1784915840189332732"; + let mut archive = ConfigMap::default(); + archive.metadata.labels = Some(BTreeMap::from([ + ( + "kars.azure.com/mission-output".to_string(), + old_nonce.to_string(), + ), + ( + "kars.azure.com/mission-principal".to_string(), + "kompli-research".to_string(), + ), + ])); + archive.data = Some(BTreeMap::from([( + "assignmentNonce".to_string(), + old_nonce.to_string(), + )])); + let mut current = ConfigMap::default(); + current.metadata.labels = Some(BTreeMap::from([( + "kars.azure.com/mission-output".to_string(), + "kompli-research".to_string(), + )])); + current.data = Some(BTreeMap::from([( + "assignmentNonce".to_string(), + latest_nonce.to_string(), + )])); + let selected = select_mission_output_records(vec![ + mission_output_candidate(archive).expect("archive"), + mission_output_candidate(current).expect("current"), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "kompli-research"); + } + + #[test] + fn persistent_team_latest_enumeration_keeps_the_current_pointer() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ( + "taskName".to_string(), + "stock-monitor-persistent-qual-principal".to_string(), + ), + ( + "team".to_string(), + "stock-monitor-persistent-qual".to_string(), + ), + ]); + let selected = select_mission_output_records(vec![ + (nonce.to_string(), Some("archive".to_string()), data.clone()), + ( + "stock-monitor-persistent-qual-principal".to_string(), + Some("current".to_string()), + data, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "stock-monitor-persistent-qual-principal"); + } + + #[test] + fn accounting_enumeration_keeps_archives_and_drops_current_pointers() { + let nonce = "run-1784915840189332732"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let selected = select_mission_evidence_records(vec![ + (nonce.to_string(), Some("archive".to_string()), data.clone()), + ( + "kompli-research".to_string(), + Some("current".to_string()), + data, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, nonce); + } + + #[test] + fn accounting_enumeration_keeps_each_rerun_archive() { + let first_nonce = "rev-1"; + let latest_nonce = "rev-2"; + let selected = select_mission_evidence_records(vec![ + ( + first_nonce.to_string(), + Some("archive".to_string()), + BTreeMap::from([("assignmentNonce".to_string(), first_nonce.to_string())]), + ), + ( + latest_nonce.to_string(), + Some("archive".to_string()), + BTreeMap::from([("assignmentNonce".to_string(), latest_nonce.to_string())]), + ), + ( + "kompli-research".to_string(), + Some("current".to_string()), + BTreeMap::from([("assignmentNonce".to_string(), latest_nonce.to_string())]), + ), + ]); + + assert_eq!(selected.len(), 2); + assert!(selected.iter().any(|(key, _)| key == first_nonce)); + assert!(selected.iter().any(|(key, _)| key == latest_nonce)); + } + + #[test] + fn persistent_archive_projects_stable_task_and_separate_evidence_key() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ( + "taskName".to_string(), + "stock-monitor-persistent-qual-principal".to_string(), + ), + ( + "team".to_string(), + "stock-monitor-persistent-qual".to_string(), + ), + ]); + let projected = project_mission_output_record(nonce.to_string(), data); + + assert_eq!( + projected.task_name, + "stock-monitor-persistent-qual-principal" + ); + assert_eq!(projected.evidence_key, nonce); + } + + #[test] + fn mirrored_trace_records_share_one_counting_identity() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ( + "trace.json".to_string(), + r#"[{"kind":"round"}]"#.to_string(), + ), + ("capturedAt".to_string(), "2026-07-24T19:00:00Z".to_string()), + ]); + let mut archive = ConfigMap::default(); + archive.metadata.name = Some(format!("kars-mission-trace-{nonce}")); + archive.metadata.annotations = Some(BTreeMap::from([( + "kars.azure.com/mission-evidence-role".to_string(), + "archive".to_string(), + )])); + archive.data = Some(data.clone()); + let mut current = ConfigMap::default(); + current.metadata.name = + Some("kars-mission-trace-stock-monitor-persistent-qual-principal".to_string()); + current.metadata.annotations = Some(BTreeMap::from([( + "kars.azure.com/mission-evidence-role".to_string(), + "current".to_string(), + )])); + current.data = Some(data); + + assert!(trace_record_identity(&archive).is_some()); + assert!(trace_record_identity(¤t).is_none()); + } + + #[test] + fn explicit_override_wins() { + let eps = vec!["https://models.github.ai/inference".to_string()]; + assert_eq!( + id(classify_provider(Some("github-copilot"), &eps, None)).as_deref(), + Some("github-copilot") + ); + assert_eq!( + id(classify_provider( + Some("github-models"), + &eps, + Some("gho_x") + )) + .as_deref(), + Some("github-models") + ); + assert_eq!( + id(classify_provider(Some("foundry"), &[], None)).as_deref(), + Some("azure-foundry") + ); + } + + #[test] + fn github_endpoint_with_oauth_token_is_copilot() { + // The real localkarstest shape: models.github.ai + a gho_ OAuth token. + let eps = vec!["https://models.github.ai/inference".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, Some("gho_"))).as_deref(), + Some("github-copilot") + ); + assert_eq!( + id(classify_provider(None, &eps, Some("ghu_"))).as_deref(), + Some("github-copilot") + ); + } + + #[test] + fn github_endpoint_with_pat_is_models() { + let eps = vec!["https://models.github.ai/inference".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, Some("ghp_"))).as_deref(), + Some("github-models") + ); + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("github-models") + ); + } + + #[test] + fn copilot_endpoint_is_copilot() { + let eps = vec!["https://api.githubcopilot.com".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("github-copilot") + ); + } + + #[test] + fn foundry_endpoint_and_empty() { + let eps = vec!["https://my-proj.openai.azure.com".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("azure-foundry") + ); + assert_eq!(id(classify_provider(None, &[], None)), None); + } + + #[test] + fn local_inference_endpoint_is_not_mislabeled_as_foundry() { + // A promoted local model's endpoint is always a Service DNS name in + // the Bridge-owned kars-local-inference namespace — must be labeled + // distinctly, not fall into the generic Foundry bucket every other + // unrecognized endpoint gets. + let eps = vec!["http://my-model.kars-local-inference.svc.cluster.local:80".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("local-inference") + ); + } +} diff --git a/bridge/bff/src/kars/credential_binding_tests.rs b/bridge/bff/src/kars/credential_binding_tests.rs new file mode 100644 index 000000000..ae2cdf930 --- /dev/null +++ b/bridge/bff/src/kars/credential_binding_tests.rs @@ -0,0 +1,280 @@ +use super::*; +use std::collections::BTreeMap; + +#[path = "credential_entrypoint_tests.rs"] +mod entrypoint; + +const GRANT: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace"; +const SOURCE: &str = "/api/v1/namespaces/work/secrets/kars-credential-input-workspace"; +const REMOVED: &str = "kars.azure.com/credential-removed-keys"; + +pub(super) fn merge(value: &mut Value, patch: &Value) { + if let Some(fields) = patch.as_object() { + if !value.is_object() { + *value = json!({}); + } + for (key, item) in fields { + if item.is_null() { + value.as_object_mut().unwrap().remove(key); + } else { + merge(&mut value[key], item); + } + } + } else { + *value = patch.clone(); + } +} + +pub(super) fn acknowledge(state: &mut TestApi, source: &Value) { + if let Some(grant) = state.objects.get_mut(GRANT) { + let version = grant["metadata"]["resourceVersion"] + .as_str() + .unwrap() + .parse::() + .unwrap() + + 1; + grant["metadata"]["resourceVersion"] = version.to_string().into(); + grant["status"]["sources"] = json!([{"name":source["metadata"]["name"],"uid":source["metadata"]["uid"], + "resourceVersion":source["metadata"]["resourceVersion"],"phase":"Unbound","reason":"AwaitingImport", + "keys":source["data"].as_object().map(|values|values.keys().cloned().collect::>()).unwrap_or_default()}]); + } +} + +fn grant(legacy: bool) -> Value { + let review = json!({"sourceName":"kars-credential-input-workspace","namespace":"work","namespaceUid":"namespace", + "secret":{"name":"kars-workspace-channels","uid":"legacy"},"resourceVersion":"1","keys":["TELEGRAM_BOT_TOKEN"]}); + json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsCredentialGrant", + "metadata":{"name":"workspace","namespace":"work","uid":"grant","resourceVersion":"1","generation":1}, + "spec":{"enabled":true,"workspaceUid":"namespace","agentKeys":[],"integrationStores":[], + "legacyImports":if legacy{vec![review.clone()]}else{vec![]}}, + "status":{"phase":"Ready","observedGeneration":1,"sources":[], + "legacySources":if legacy{vec![review]}else{vec![]}}}) +} + +#[tokio::test] +async fn credential_removal_before_first_import_persists_source_tombstone_and_is_idempotent() { + let (cluster, state, server) = fixture().await; + state + .lock() + .unwrap() + .objects + .insert(GRANT.into(), grant(true)); + cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::new(), + vec!["TELEGRAM_BOT_TOKEN".into()], + ) + .await + .unwrap(); + let uid = state.lock().unwrap().objects[SOURCE]["metadata"]["uid"].clone(); + for _ in 0..2 { + let source = state.lock().unwrap().objects[SOURCE].clone(); + assert_eq!( + source["metadata"]["annotations"][REMOVED], + "[\"TELEGRAM_BOT_TOKEN\"]" + ); + assert!(source["data"].get("TELEGRAM_BOT_TOKEN").is_none()); + cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::new(), + vec!["TELEGRAM_BOT_TOKEN".into()], + ) + .await + .unwrap(); + } + assert_eq!( + state.lock().unwrap().objects[SOURCE]["metadata"]["uid"], + uid + ); + cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::from([("TELEGRAM_BOT_TOKEN".into(), "explicit-new".into())]), + Vec::new(), + ) + .await + .unwrap(); + let s = state.lock().unwrap(); + assert_eq!(s.objects[SOURCE]["metadata"]["annotations"][REMOVED], "[]"); + assert!( + s.objects[SOURCE]["data"] + .get("TELEGRAM_BOT_TOKEN") + .is_some() + ); + assert!(s.calls.iter().all(|(method, _, _)| method != "DELETE")); + server.abort(); +} + +#[tokio::test] +async fn credential_pending_import_update_keeps_unrelated_values_and_uid_fenced_removal_intent() { + let (cluster, state, server) = fixture().await; + let source = json!({"apiVersion":"v1","kind":"Secret","type":"Opaque", + "metadata":{"name":"kars-credential-input-workspace","namespace":"work","uid":"pending","resourceVersion":"1", + "annotations":{"customer":"keep"}},"data":{"SLACK_BOT_TOKEN":k8s_openapi::ByteString(b"keep".to_vec())}}); + { + let mut s = state.lock().unwrap(); + s.objects.insert(GRANT.into(), grant(true)); + s.objects.insert(SOURCE.into(), source.clone()); + acknowledge(&mut s, &source); + } + cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::new(), + vec!["TELEGRAM_BOT_TOKEN".into()], + ) + .await + .unwrap(); + let s = state.lock().unwrap(); + assert_eq!(s.objects[SOURCE]["metadata"]["uid"], "pending"); + assert_eq!( + s.objects[SOURCE]["metadata"]["annotations"]["customer"], + "keep" + ); + assert_eq!( + s.objects[SOURCE]["data"]["SLACK_BOT_TOKEN"], + source["data"]["SLACK_BOT_TOKEN"] + ); + assert_eq!( + s.objects[SOURCE]["metadata"]["annotations"][REMOVED], + "[\"TELEGRAM_BOT_TOKEN\"]" + ); + let patch = &s + .calls + .iter() + .find(|(method, path, _)| method == "PATCH" && path == SOURCE) + .unwrap() + .2; + assert_eq!(patch[0]["path"], "/metadata/uid"); + assert_eq!(patch[1]["path"], "/metadata/resourceVersion"); + server.abort(); +} + +fn sandbox(name: &str, spec: Value, ready: bool) -> Value { + let mut value = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":name,"namespace":"work","uid":format!("uid-{name}"),"resourceVersion":"1"},"spec":spec}); + if ready { + value["status"] = json!({"phase":"Running"}); + } + value +} +fn bindings(grant: &str) -> Value { + json!({"grant":{"name":"workspace","uid":grant},"sources":[{"scope":"workspace", + "source":{"name":"kars-credential-input-workspace","uid":"source"},"keys":[]}]}) +} + +#[tokio::test] +async fn workspace_rebinding_preserves_v1_and_unbounded_consumers_and_updates_only_fresh_or_opted_in() + { + let (cluster, state, server) = fixture().await; + let path = + |name: &str| format!("/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/{name}"); + let legacy = sandbox( + "v1", + json!({"credentialsRef":{"name":"kars-credential-source-v1","uid":"v1-source"}}), + true, + ); + let unbounded = sandbox("unbounded", json!({}), true); + { + let mut s = state.lock().unwrap(); + s.objects.insert(path("v1"), legacy.clone()); + s.objects.insert(path("unbounded"), unbounded.clone()); + s.objects + .insert(path("fresh"), sandbox("fresh", json!({}), false)); + s.objects.insert( + path("v2"), + sandbox("v2", json!({"credentialBindings":bindings("grant")}), true), + ); + } + cluster + .bind_workspace_credentials( + "work", + &Identity { + name: "workspace".into(), + uid: "grant".into(), + }, + &Identity { + name: "kars-credential-input-workspace".into(), + uid: "source".into(), + }, + vec!["SLACK_BOT_TOKEN".into()], + ) + .await + .unwrap(); + let s = state.lock().unwrap(); + assert_eq!(s.objects[&path("v1")], legacy); + assert_eq!(s.objects[&path("unbounded")], unbounded); + for name in ["fresh", "v2"] { + assert_eq!( + s.objects[&path(name)]["spec"]["credentialBindings"]["sources"][0]["keys"], + json!(["SLACK_BOT_TOKEN"]) + ); + } + assert_eq!( + s.calls + .iter() + .filter(|(method, _, _)| method == "PATCH") + .count(), + 2 + ); + server.abort(); +} + +#[tokio::test] +async fn workspace_consumer_plan_rejects_late_conflicts_before_converting_any_target() { + let (cluster, state, server) = fixture().await; + { + let mut s = state.lock().unwrap(); + s.objects.insert("/apis/kars.azure.com/v1alpha1/namespaces/work/karsteams/first".into(),json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTeam", + "metadata":{"name":"first","namespace":"work","uid":"first","resourceVersion":"1"},"spec":{"blueprint":{}}})); + s.objects.insert( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/late".into(), + sandbox( + "late", + json!({"credentialBindings":bindings("foreign")}), + true, + ), + ); + } + assert!( + cluster + .bind_workspace_credentials( + "work", + &Identity { + name: "workspace".into(), + uid: "grant".into() + }, + &Identity { + name: "kars-credential-input-workspace".into(), + uid: "source".into() + }, + vec!["SLACK_BOT_TOKEN".into()] + ) + .await + .is_err() + ); + assert!( + state + .lock() + .unwrap() + .calls + .iter() + .all(|(method, _, _)| method == "GET") + ); + server.abort(); +} diff --git a/bridge/bff/src/kars/credential_contract.rs b/bridge/bff/src/kars/credential_contract.rs new file mode 100644 index 000000000..8655c98a9 --- /dev/null +++ b/bridge/bff/src/kars/credential_contract.rs @@ -0,0 +1,109 @@ +use kube::api::DynamicObject; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Identity { + pub name: String, + pub uid: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Target { + pub kind: String, + pub namespace: String, + pub name: String, + pub uid: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Selection { + pub scope: String, + pub source: Identity, + pub keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CredentialBindings { + pub grant: Identity, + pub sources: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GitHubBinding { + pub grant: Identity, + pub connection: Identity, + pub repositories: Vec, + pub write: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceState { + pub name: String, + pub uid: String, + pub resource_version: String, + #[serde(default)] + pub ownership_from_resource_version: Option, + pub keys: Vec, + pub phase: String, + pub reason: String, + #[serde(default)] + pub target: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Store { + pub secret: Identity, + pub purpose: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Legacy { + pub source_name: String, + pub namespace: String, + pub namespace_uid: String, + pub secret: Identity, + pub resource_version: String, + pub keys: Vec, + #[serde(default)] + pub target: Option, +} + +#[derive(Clone, Debug)] +pub struct Grant { + pub identity: Identity, + pub agent_keys: Vec, + pub stores: Vec, + pub sources: Vec, + pub legacy: Vec, + pub reviewed_legacy: Vec, + pub document: DynamicObject, +} + +impl Grant { + pub(super) fn approves_agent_key(&self, key: &str) -> bool { + [ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_ALLOW_FROM", + "SLACK_BOT_TOKEN", + "DISCORD_BOT_TOKEN", + "WHATSAPP_ENABLED", + "BRAVE_API_KEY", + "TAVILY_API_KEY", + "EXA_API_KEY", + "FIRECRAWL_API_KEY", + "PERPLEXITY_API_KEY", + ] + .contains(&key) + || self.agent_keys.iter().any(|allowed| allowed == key) + } +} diff --git a/bridge/bff/src/kars/credential_entrypoint_tests.rs b/bridge/bff/src/kars/credential_entrypoint_tests.rs new file mode 100644 index 000000000..c293f1c99 --- /dev/null +++ b/bridge/bff/src/kars/credential_entrypoint_tests.rs @@ -0,0 +1,272 @@ +use super::*; + +const FIRST: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karsteams/first"; +const GOOD: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/good"; +const LATE: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/late"; + +fn source() -> Value { + json!({"apiVersion":"v1","kind":"Secret","type":"Opaque", + "metadata":{"name":"kars-credential-input-workspace","namespace":"work","uid":"source","resourceVersion":"1", + "annotations":{"customer":"preserved"}}, + "data":{"SLACK_BOT_TOKEN":k8s_openapi::ByteString(b"original-value".to_vec())}}) +} + +fn prepare(state: &mut TestApi, existing: bool, conflict: bool) { + state.objects.insert(GRANT.into(), grant(false)); + state.objects.insert(FIRST.into(),json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTeam", + "metadata":{"name":"first","namespace":"work","uid":"first","resourceVersion":"1"},"spec":{"blueprint":{}}})); + if existing { + let source = source(); + state.objects.insert(SOURCE.into(), source.clone()); + acknowledge(state, &source); + let mut good = sandbox( + "good", + json!({"credentialBindings":bindings("grant")}), + true, + ); + good["spec"]["credentialBindings"]["sources"][0]["keys"] = json!(["SLACK_BOT_TOKEN"]); + state.objects.insert(GOOD.into(), good); + } else { + state + .objects + .insert(GOOD.into(), sandbox("good", json!({}), false)); + } + if conflict { + state.objects.insert( + LATE.into(), + sandbox( + "late", + json!({"credentialBindings":bindings("foreign")}), + true, + ), + ); + } +} + +#[tokio::test] +async fn credential_public_entrypoint_preflights_all_consumers_before_new_or_existing_source_mutation() + { + for existing in [false, true] { + let (cluster, state, server) = fixture().await; + let before = { + let mut s = state.lock().unwrap(); + prepare(&mut s, existing, true); + s.objects.clone() + }; + let result = cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::from([("SLACK_BOT_TOKEN".into(), "replacement-value".into())]), + Vec::new(), + ) + .await; + assert!(result.is_err(), "existing source: {existing}"); + { + let s = state.lock().unwrap(); + assert_eq!( + s.objects, before, + "source values, UIDs and all consumers must remain unchanged" + ); + assert!( + s.calls.iter().all(|(method, _, _)| method == "GET"), + "no mutating request may precede full preflight" + ); + assert!( + s.calls + .iter() + .any(|(_, path, _)| path.ends_with("/karssandboxes")) + ); + if existing { + assert_eq!(s.objects[SOURCE]["metadata"]["uid"], "source"); + assert_eq!( + s.objects[SOURCE]["data"]["SLACK_BOT_TOKEN"], + json!(k8s_openapi::ByteString(b"original-value".to_vec())) + ); + } else { + assert!(!s.objects.contains_key(SOURCE)); + } + } + server.abort(); + } +} + +#[tokio::test] +async fn credential_public_entrypoint_binds_only_real_source_uid_after_full_preflight_and_acknowledgement() + { + for existing in [false, true] { + let (cluster, state, server) = fixture().await; + { + let mut s = state.lock().unwrap(); + prepare(&mut s, existing, false); + s.objects.insert("/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/v1".into(), + sandbox("v1",json!({"credentialsRef":{"name":"kars-credential-source-v1","uid":"v1-source"}}),true)); + } + if !existing { + // Native permissions deliberately do not reveal whether an + // uninventoried source exists. CREATE must bootstrap without GET. + let denied = kube::Api::::namespaced( + cluster.client.clone(), + "work", + ) + .get_metadata("kars-credential-input-workspace") + .await + .unwrap_err(); + assert!(matches!(denied,kube::Error::Api(error) if error.code==403)); + state.lock().unwrap().calls.clear(); + } + let result = cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::from([("SLACK_BOT_TOKEN".into(), "requested-value".into())]), + Vec::new(), + ) + .await + .unwrap(); + assert_eq!(result["stored"], true); + { + let s = state.lock().unwrap(); + let uid = if existing { "source" } else { "created-source" }; + assert_eq!(s.objects[SOURCE]["metadata"]["uid"], uid); + assert_eq!( + s.objects[FIRST]["spec"]["blueprint"]["credentialBindings"]["sources"][0]["source"] + ["uid"], + uid + ); + assert_eq!( + s.objects[GOOD]["spec"]["credentialBindings"]["sources"][0]["source"]["uid"], + uid + ); + let first_write = s + .calls + .iter() + .position(|(method, _, _)| method != "GET") + .unwrap(); + assert_eq!( + s.calls[first_write].1, + if existing { + SOURCE + } else { + "/api/v1/namespaces/work/secrets" + } + ); + for resource in ["karsteams", "karstasks", "karssandboxes"] { + assert!( + s.calls[..first_write] + .iter() + .any(|(_, path, _)| path.ends_with(&format!("/{resource}"))) + ); + } + let first_bind = s + .calls + .iter() + .position(|(method, path, _)| method == "PATCH" && path == FIRST) + .unwrap(); + assert!( + s.calls[first_write + 1..first_bind] + .iter() + .any(|(method, path, _)| method == "GET" && path == SOURCE) + ); + if !existing { + assert!( + !s.calls[..first_write] + .iter() + .any(|(method, path, _)| method == "GET" && path == SOURCE) + ); + let first_source_get = s + .calls + .iter() + .position(|(method, path, _)| method == "GET" && path == SOURCE) + .unwrap(); + assert!( + s.calls[first_write + 1..first_source_get] + .iter() + .any(|(method, path, _)| method == "GET" && path == GRANT) + ); + } + let legacy = + &s.objects["/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/v1"]; + assert_eq!(legacy["spec"]["credentialsRef"]["uid"], "v1-source"); + assert!(legacy["spec"].get("credentialBindings").is_none()); + } + server.abort(); + } +} + +#[tokio::test] +async fn credential_public_entrypoint_rejects_missing_referenced_source_and_unobserved_existing_source_without_adoption() + { + for unobserved in [false, true] { + let (cluster, state, server) = fixture().await; + let before = { + let mut s = state.lock().unwrap(); + prepare(&mut s, false, false); + if unobserved { + s.objects.insert(SOURCE.into(), source()); + } else { + s.objects.insert( + LATE.into(), + sandbox( + "late", + json!({"credentialBindings":bindings("grant")}), + true, + ), + ); + } + s.objects.clone() + }; + if unobserved { + let denied = kube::Api::::namespaced( + cluster.client.clone(), + "work", + ) + .get_metadata("kars-credential-input-workspace") + .await + .unwrap_err(); + assert!(matches!(denied,kube::Error::Api(error) if error.code==403)); + state.lock().unwrap().calls.clear(); + } + let result = cluster + .write_agent_credentials( + "work", + "Workspace", + "work", + None, + BTreeMap::from([("SLACK_BOT_TOKEN".into(), "new-value".into())]), + Vec::new(), + ) + .await; + assert!(result.is_err()); + if unobserved { + assert!(matches!(result,Err(kube::Error::Api(error)) if error.code==409)); + } + assert_eq!(state.lock().unwrap().objects, before); + { + let s = state.lock().unwrap(); + assert!( + !s.calls + .iter() + .any(|(method, path, _)| method == "GET" && path == SOURCE) + ); + if unobserved { + assert_eq!( + s.calls + .iter() + .filter(|(method, _, _)| method == "POST") + .count(), + 1 + ); + assert!(s.calls.iter().all(|(method, path, _)| method == "GET" + || (method == "POST" && path == "/api/v1/namespaces/work/secrets"))); + } else { + assert!(s.calls.iter().all(|(method, _, _)| method == "GET")); + } + } + server.abort(); + } +} diff --git a/bridge/bff/src/kars/credential_handler_tests.rs b/bridge/bff/src/kars/credential_handler_tests.rs new file mode 100644 index 000000000..b25a8d429 --- /dev/null +++ b/bridge/bff/src/kars/credential_handler_tests.rs @@ -0,0 +1,461 @@ +use super::*; +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + routing::post, +}; +use tower::ServiceExt; + +#[path = "credential_review_tests.rs"] +mod reviewed_flow; + +const GRANT: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace"; +const TARGET: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/target"; +const SOURCE: &str = "/api/v1/namespaces/work/secrets/kars-credential-input-sandbox-target"; +const PRIVATE: &str = "PRIVATE_CREDENTIAL_VALUE_NEVER_LOGGED_OR_RETURNED"; + +#[derive(Clone, Copy)] +pub(super) enum Fault { + Status, + TargetUid, + Spec, + Policy, + SourceUid, + GrantUid, + Api(u16), + CreateAck, + BindAck, +} + +pub(super) fn api_failure(code: u16) -> Response { + let reason = match code { + 403 => "Forbidden", + 409 => "Conflict", + 422 => "Invalid", + _ => "ServiceUnavailable", + }; + ( + StatusCode::from_u16(code).unwrap(), + axum::Json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","code":code,"reason":reason, + "message":PRIVATE,"details":{"causes":[{"message":PRIVATE}]} + })), + ) + .into_response() +} + +pub(super) fn before_request(state: &mut TestApi, method: &Method, path: &str) -> Option { + if method != Method::PATCH || path != TARGET { + return None; + } + let fault = state.fault?; + if matches!(fault, Fault::CreateAck | Fault::BindAck) { + return None; + } + state.fault = None; + if let Fault::Api(code) = fault { + return Some(api_failure(code)); + } + // Interleave after the handler's third target GET and before the real + // UID/RV comparison in the controlled API's PATCH implementation. + let target = state.objects.get_mut(TARGET).unwrap(); + target["metadata"]["resourceVersion"] = "2".into(); + match fault { + Fault::Status => target["status"] = json!({"phase":"Prepared","observedGeneration":1}), + Fault::TargetUid => target["metadata"]["uid"] = "replacement-target".into(), + Fault::Spec => { + target["metadata"]["generation"] = 2.into(); + target["spec"]["suspended"] = false.into(); + } + Fault::Policy => { + target["metadata"]["generation"] = 2.into(); + target["spec"]["inferenceRef"]["name"] = "changed-policy".into(); + } + Fault::SourceUid => { + state.objects.get_mut(SOURCE).unwrap()["metadata"]["uid"] = "replacement-source".into() + } + Fault::GrantUid => { + state.objects.get_mut(GRANT).unwrap()["metadata"]["uid"] = "replacement-grant".into() + } + _ => unreachable!(), + } + None +} + +pub(super) fn after_write(state: &mut TestApi, method: &Method, path: &str) -> Option { + let lost = matches!(state.fault, Some(Fault::CreateAck)) + && method == Method::POST + && path == "/api/v1/namespaces/work/secrets" + || matches!(state.fault, Some(Fault::BindAck)) && method == Method::PATCH && path == TARGET; + if !lost { + return None; + } + state.fault = None; + let code = if method == Method::POST { + StatusCode::CREATED + } else { + StatusCode::OK + }; + let stream = tokio_stream::iter([Err::(std::io::Error::from( + std::io::ErrorKind::ConnectionReset, + ))]); + Some((code, Body::from_stream(stream)).into_response()) +} + +fn prepare(state: &mut TestApi) { + state.objects.insert(GRANT.into(), json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsCredentialGrant", + "metadata":{"name":"workspace","namespace":"work","uid":"grant","resourceVersion":"1","generation":1}, + "spec":{"enabled":true,"workspaceUid":"namespace","agentKeys":[], + "integrationStores":[],"legacyImports":[]}, + "status":{"phase":"Ready","observedGeneration":1,"sources":[],"legacySources":[]} + })); + state.objects.insert(TARGET.into(), json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":"target","namespace":"work","uid":"uid-target","resourceVersion":"1","generation":1}, + "spec":{"suspended":true,"inferenceRef":{"name":"reviewed-policy"}}, + "status":{"phase":"Pending"} + })); +} + +async fn write(cluster: &Cluster, target_uid: &str) -> (StatusCode, Value) { + let app = Router::new() + .route( + "/api/operator/credentials", + post(crate::routes::operator::put_credential), + ) + .with_state(crate::state::AppState::for_test_client( + cluster.client.clone(), + "work", + )); + let request = Request::builder() + .method("POST") + .uri("/api/operator/credentials") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "namespace":"work","kind":"KarsSandbox","target":"target","targetUid":target_uid, + "key":"SLACK_BOT_TOKEN","value":PRIVATE, + }) + .to_string(), + )) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let code = response.status(); + let bytes = to_bytes(response.into_body(), 16384).await.unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains(PRIVATE)); + (code, serde_json::from_slice(&bytes).unwrap()) +} + +fn mutations(state: &TestApi) -> Vec<(&str, &str)> { + state + .calls + .iter() + .filter(|(method, _, _)| method != "GET") + .map(|(method, path, _)| (method.as_str(), path.as_str())) + .collect() +} + +#[tokio::test] +async fn credential_handler_status_rv_race_returns_typed_409_without_retry_or_partial_write_adoption() + { + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + state.fault = Some(Fault::Status); + } + let (code, body) = write(&cluster, "uid-target").await; + assert_eq!(code, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("review before resubmitting") + ); + { + let state = state.lock().unwrap(); + assert_eq!( + mutations(&state), + [ + ("POST", "/api/v1/namespaces/work/secrets"), + ("PATCH", TARGET) + ] + ); + assert_eq!( + state + .calls + .iter() + .filter(|(_, path, _)| path == GRANT) + .count(), + 1 + ); + assert_eq!( + state + .calls + .iter() + .filter(|(method, path, _)| method == "GET" && path == TARGET) + .count(), + 3 + ); + assert!( + !state + .calls + .iter() + .any(|(method, path, _)| method == "GET" && path == SOURCE) + ); + assert_eq!(state.objects[TARGET]["metadata"]["resourceVersion"], "2"); + assert_eq!(state.objects[TARGET]["metadata"]["generation"], 1); + assert!( + state.objects[TARGET]["spec"] + .get("credentialBindings") + .is_none() + ); + assert_eq!(state.objects[SOURCE]["metadata"]["uid"], "created-source"); + assert!( + state.objects[SOURCE]["data"]["SLACK_BOT_TOKEN"] + == json!(k8s_openapi::ByteString(PRIVATE.as_bytes().to_vec())) + ); + } + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_handler_authority_changes_cannot_trigger_a_rebase_retry_or_owned_rollback() { + for fault in [ + Fault::TargetUid, + Fault::Spec, + Fault::Policy, + Fault::SourceUid, + Fault::GrantUid, + ] { + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + state.fault = Some(fault); + } + let (code, body) = write(&cluster, "uid-target").await; + assert_eq!(code, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + { + let state = state.lock().unwrap(); + assert_eq!( + mutations(&state), + [ + ("POST", "/api/v1/namespaces/work/secrets"), + ("PATCH", TARGET) + ] + ); + assert!( + state.objects[TARGET]["spec"] + .get("credentialBindings") + .is_none() + ); + assert!( + state.objects.contains_key(SOURCE), + "No multi-object rollback or replacement deletion is authorized" + ); + if matches!(fault, Fault::SourceUid) { + assert_eq!( + state.objects[SOURCE]["metadata"]["uid"], + "replacement-source" + ); + } + if matches!(fault, Fault::GrantUid) { + assert_eq!(state.objects[GRANT]["metadata"]["uid"], "replacement-grant"); + } + } + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn credential_handler_only_typed_api_409_is_a_conflict_and_denials_remain_fatal() { + for upstream in [403, 409, 422, 503] { + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + state.fault = Some(Fault::Api(upstream)); + } + let (code, body) = write(&cluster, "uid-target").await; + assert_eq!( + code, + if upstream == 409 { + StatusCode::CONFLICT + } else { + StatusCode::BAD_GATEWAY + } + ); + assert_eq!( + body["error"]["code"], + if upstream == 409 { + "conflict" + } else { + "upstream_error" + } + ); + assert_eq!( + mutations(&state.lock().unwrap()), + [ + ("POST", "/api/v1/namespaces/work/secrets"), + ("PATCH", TARGET), + ] + ); + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn credential_handler_lost_create_or_bind_ack_never_replays_or_deletes_uncertain_side_effects() + { + for fault in [Fault::CreateAck, Fault::BindAck] { + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + state.fault = Some(fault); + } + let (code, body) = write(&cluster, "uid-target").await; + assert_eq!(code, StatusCode::BAD_GATEWAY); + assert_eq!(body["error"]["code"], "upstream_error"); + { + let state = state.lock().unwrap(); + let writes = mutations(&state); + assert_eq!( + writes + .iter() + .filter(|(method, _)| *method == "POST") + .count(), + 1 + ); + assert_eq!( + writes + .iter() + .filter(|(method, _)| *method == "PATCH") + .count(), + usize::from(matches!(fault, Fault::BindAck)) + ); + assert!(!writes.iter().any(|(method, _)| *method == "DELETE")); + assert_eq!(state.objects[SOURCE]["metadata"]["uid"], "created-source"); + assert_eq!( + state.objects[TARGET]["spec"] + .get("credentialBindings") + .is_some(), + matches!(fault, Fault::BindAck) + ); + } + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn credential_handler_rejects_stale_review_and_source_collision_without_adoption() { + for collision in [false, true] { + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + if collision { + state.objects.insert(SOURCE.into(), json!({ + "apiVersion":"v1","kind":"Secret","type":"Opaque", + "metadata":{"name":"kars-credential-input-sandbox-target","namespace":"work", + "uid":"foreign-source","resourceVersion":"7"}, + "data":{"SLACK_BOT_TOKEN":k8s_openapi::ByteString(PRIVATE.as_bytes().to_vec())} + })); + } + } + let (code, body) = write( + &cluster, + if collision { + "uid-target" + } else { + "old-target" + }, + ) + .await; + assert_eq!(code, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + { + let state = state.lock().unwrap(); + assert!(state.calls.iter().all(|(method, path, _)| method == "GET" + || (collision && method == "POST" && path == "/api/v1/namespaces/work/secrets"))); + assert!( + !state + .calls + .iter() + .any(|(method, path, _)| method == "GET" && path == SOURCE) + ); + assert!( + state.objects[TARGET]["spec"] + .get("credentialBindings") + .is_none() + ); + if collision { + assert_eq!(state.objects[SOURCE]["metadata"]["uid"], "foreign-source"); + } else { + assert!(!state.objects.contains_key(SOURCE)); + } + } + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn credential_handler_success_still_binds_the_created_source_once_after_metadata_ack() { + let (cluster, state, server) = fixture().await; + prepare(&mut state.lock().unwrap()); + let (code, body) = write(&cluster, "uid-target").await; + assert_eq!(code, StatusCode::OK); + assert_eq!(body["stored"], true); + assert_eq!(body["source"]["uid"], "created-source"); + assert_eq!( + mutations(&state.lock().unwrap()), + [ + ("POST", "/api/v1/namespaces/work/secrets"), + ("PATCH", TARGET), + ] + ); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_handler_unready_or_changed_workspace_authority_conflicts_before_any_write() { + for change in ["disabled", "workspace", "generation"] { + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + let grant = state.objects.get_mut(GRANT).unwrap(); + match change { + "disabled" => grant["spec"]["enabled"] = false.into(), + "workspace" => grant["spec"]["workspaceUid"] = "replacement".into(), + "generation" => grant["metadata"]["generation"] = 2.into(), + _ => unreachable!(), + } + } + let (code, body) = write(&cluster, "uid-target").await; + assert_eq!(code, StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + { + let state = state.lock().unwrap(); + assert!(mutations(&state).is_empty()); + assert!(!state.objects.contains_key(SOURCE)); + assert!( + state.objects[TARGET]["spec"] + .get("credentialBindings") + .is_none() + ); + } + server.abort(); + let _ = server.await; + } +} diff --git a/bridge/bff/src/kars/credential_review.rs b/bridge/bff/src/kars/credential_review.rs new file mode 100644 index 000000000..1bc4a7203 --- /dev/null +++ b/bridge/bff/src/kars/credential_review.rs @@ -0,0 +1,607 @@ +use super::{ + cluster::Cluster, + credential_contract::{Grant, Identity, Selection, Target}, + credential_targets::{planned_selection, reviewed_bindings}, + credential_transport::{failure, object_api, safe}, + credentials::input_name, +}; +use k8s_openapi::{ + api::core::v1::{Namespace, Secret}, + apimachinery::pkg::apis::meta::v1::ObjectMeta, +}; +use kube::{Api, ResourceExt, api::DynamicObject}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceReview { + pub name: String, + pub uid: Option, + pub version: Option, + pub metadata_digest: Option, + pub keys: Vec, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TargetReview { + pub kind: String, + pub namespace: String, + pub name: String, + pub uid: Option, + pub generation: Option, + pub version: Option, + pub intent: Option, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrantReview { + pub uid: String, + pub generation: i64, + pub version: String, + pub intent: String, + pub workspace_uid: String, + pub legacy_inventory: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CredentialReview { + pub target: TargetReview, + pub grant: GrantReview, + pub source: SourceReview, + pub key: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StoredSource { + pub name: String, + pub uid: String, + pub version: String, + pub metadata_digest: String, +} + +pub struct ReviewedWrite { + pub review: CredentialReview, + pub stored: Option, +} + +pub struct CredentialWriteFailure { + pub error: kube::Error, + pub stored: Option, + pub write_attempted: bool, +} + +fn digest(value: &impl Serialize) -> Result { + let mut value = + serde_json::to_value(value).map_err(|_| failure("Credential review encoding failed"))?; + value.sort_all_objects(); + let bytes = + serde_json::to_vec(&value).map_err(|_| failure("Credential review encoding failed"))?; + Ok(format!("sha256:{:x}", Sha256::digest(bytes))) +} + +fn intent(object: &DynamicObject) -> Result { + let mut value = + serde_json::to_value(object).map_err(|_| failure("Credential intent encoding failed"))?; + value + .as_object_mut() + .ok_or_else(|| failure("Credential target is malformed"))? + .remove("status"); + let metadata = value["metadata"] + .as_object_mut() + .ok_or_else(|| failure("Credential metadata is malformed"))?; + metadata.remove("resourceVersion"); + if let Some(Value::Array(fields)) = metadata.get_mut("managedFields") { + fields.retain(|field| field["subresource"] != "status"); + if fields.is_empty() { + metadata.remove("managedFields"); + } + } + digest(&value) +} + +impl StoredSource { + pub(super) fn from_metadata(metadata: &ObjectMeta) -> Result { + Ok(Self { + name: metadata + .name + .clone() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("Stored source name missing"))?, + uid: metadata + .uid + .clone() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("Stored source UID missing"))?, + version: metadata + .resource_version + .clone() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("Stored source version missing"))?, + metadata_digest: digest(metadata)?, + }) + } + + pub fn matches(&self, source: &SourceReview) -> bool { + source.name == self.name + && source.uid.as_ref() == Some(&self.uid) + && source.version.as_ref() == Some(&self.version) + && source.metadata_digest.as_ref() == Some(&self.metadata_digest) + } +} + +impl CredentialReview { + fn same_authority(&self, current: &Self) -> bool { + let mut target = current.target.clone(); + target.version.clone_from(&self.target.version); + let mut grant = current.grant.clone(); + grant.version.clone_from(&self.grant.version); + self.key == current.key && target == self.target && grant == self.grant + } + + pub fn permits_refresh(&self, current: &Self, stored: &StoredSource) -> bool { + let mut keys = self.source.keys.clone(); + keys.push(self.key.clone()); + keys.sort(); + keys.dedup(); + self.same_authority(current) + && stored.matches(¤t.source) + && current.source.keys == keys + } + + pub fn permits_unwritten_refresh(&self, current: &Self) -> bool { + self.same_authority(current) && self.source == current.source + } +} + +fn grant_review(grant: &Grant) -> Result { + Ok(GrantReview { + uid: grant.identity.uid.clone(), + generation: grant + .document + .metadata + .generation + .ok_or_else(|| failure("Grant generation missing"))?, + version: grant + .document + .resource_version() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("Grant version missing"))?, + intent: intent(&grant.document)?, + workspace_uid: grant.document.data["spec"]["workspaceUid"] + .as_str() + .ok_or_else(|| failure("Workspace UID missing"))? + .into(), + legacy_inventory: digest(&grant.legacy)?, + }) +} + +fn target_review( + kind: &str, + namespace: &str, + name: &str, + object: Option<&DynamicObject>, +) -> Result { + let mut result = TargetReview { + kind: kind.into(), + namespace: namespace.into(), + name: name.into(), + uid: None, + generation: None, + version: None, + intent: None, + }; + if let Some(object) = object { + if object.metadata.deletion_timestamp.is_some() + || object.namespace().as_deref() != Some(namespace) + || object.name_any() != name + || object.types.as_ref().is_none_or(|types| { + types.api_version != "kars.azure.com/v1alpha1" || types.kind != kind + }) + || !object.data["spec"].is_object() + { + return Err(failure("Credential target identity changed")); + } + result.uid = Some( + object + .uid() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("Target UID missing"))?, + ); + result.generation = Some( + object + .metadata + .generation + .ok_or_else(|| failure("Target generation missing"))?, + ); + result.version = Some( + object + .resource_version() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("Target version missing"))?, + ); + result.intent = Some(intent(object)?); + } + Ok(result) +} + +impl Cluster { + pub async fn review_credentials( + &self, + namespace: &str, + kind: &str, + name: &str, + key: &str, + ) -> Result { + let source_name = input_name(kind, name)?; + let grant = self.credential_grant(namespace).await?; + if !grant.approves_agent_key(key) { + return Err(failure("Credential key is outside the operator grant")); + } + let object = object_api(self, namespace, kind) + .get_opt(name) + .await + .map_err(|error| safe("Review credential target", error))?; + let target = target_review(kind, namespace, name, object.as_ref())?; + if target.uid.is_none() + && Api::::all(self.client.clone()) + .get_opt(&format!("kars-{name}")) + .await + .map_err(|error| safe("Review staging namespace", error))? + .is_some() + { + return Err(failure( + "An existing runtime namespace prevents unbound staging review", + )); + } + let owner = target.uid.as_ref().map(|uid| Target { + kind: kind.into(), + namespace: namespace.into(), + name: name.into(), + uid: uid.clone(), + }); + let prior = grant + .sources + .iter() + .find(|source| source.name == source_name); + if prior.is_some_and(|source| { + source.phase == "Blocked" + || source.phase.is_empty() + || source + .target + .as_ref() + .is_some_and(|target| Some(target) != owner.as_ref()) + }) { + return Err(failure( + "Credential source is blocked or belongs to different authority", + )); + } + let mut source = SourceReview { + name: source_name, + uid: None, + version: None, + metadata_digest: None, + keys: prior.map(|source| source.keys.clone()).unwrap_or_default(), + }; + for legacy in grant + .legacy + .iter() + .filter(|legacy| legacy.source_name == source.name && legacy.target == owner) + { + if !grant.reviewed_legacy.contains(legacy) { + return Err(failure("Legacy source requires operator review")); + } + source.keys.extend( + legacy + .keys + .iter() + .filter(|key| key.as_str() != "TEAMS_ENABLED") + .cloned(), + ); + } + source.keys.sort(); + source.keys.dedup(); + if let Some(prior) = prior { + let metadata = Api::::namespaced(self.client.clone(), namespace) + .get_metadata(&source.name) + .await + .map_err(|error| safe("Review acknowledged source metadata", error))?; + let stored = StoredSource::from_metadata(&metadata.metadata)?; + if metadata.metadata.deletion_timestamp.is_some() + || stored.name != source.name + || metadata.metadata.namespace.as_deref() != Some(namespace) + || stored.uid != prior.uid + || stored.version != prior.resource_version + { + return Err(failure( + "Source metadata is not acknowledged at this UID/version", + )); + } + source.uid = Some(stored.uid); + source.version = Some(stored.version); + source.metadata_digest = Some(stored.metadata_digest); + } + if let (Some(object), Some(owner)) = (object.as_ref(), owner.as_ref()) { + let bindings = reviewed_bindings(object, owner, &grant.identity)?; + let scope = if kind == "KarsTeam" { "team" } else { "target" }; + if bindings.sources.iter().any(|selected| { + selected.scope == scope + && (selected.source.name != source.name + || Some(&selected.source.uid) != source.uid.as_ref() + || selected.owner.as_ref() != Some(owner)) + }) { + return Err(failure( + "Credential source replacement requires separate operator review", + )); + } + if let Some(uid) = &source.uid { + let mut keys = source.keys.clone(); + keys.push(key.into()); + planned_selection( + object, + owner, + &grant.identity, + Selection { + scope: scope.into(), + source: Identity { + name: source.name.clone(), + uid: uid.clone(), + }, + keys, + owner: Some(owner.clone()), + }, + )?; + } else if kind == "KarsTask" && object.data["spec"]["execution"]["launch"] == true { + return Err(failure( + "An active standalone Task requires explicit governed rebinding", + )); + } + } + Ok(CredentialReview { + target, + grant: grant_review(&grant)?, + source, + key: key.into(), + }) + } + + pub(super) async fn check_credential_review( + &self, + reviewed: &CredentialReview, + ) -> Result<(), kube::Error> { + let current = self + .review_credentials( + &reviewed.target.namespace, + &reviewed.target.kind, + &reviewed.target.name, + &reviewed.key, + ) + .await?; + if ¤t != reviewed { + return Err(failure("Credential metadata changed since explicit review")); + } + Ok(()) + } + + pub(super) fn check_reviewed_grant( + &self, + reviewed: &CredentialReview, + grant: &Grant, + ) -> Result<(), kube::Error> { + if grant_review(grant)? != reviewed.grant { + return Err(failure("Reviewed credential grant changed")); + } + Ok(()) + } + + pub(super) fn check_reviewed_target( + &self, + reviewed: &CredentialReview, + object: &DynamicObject, + ) -> Result<(), kube::Error> { + if target_review( + &reviewed.target.kind, + &reviewed.target.namespace, + &reviewed.target.name, + Some(object), + )? != reviewed.target + { + return Err(failure("Reviewed credential target changed")); + } + Ok(()) + } + + pub(super) async fn check_written_credential_review( + &self, + reviewed: &CredentialReview, + stored: &StoredSource, + ) -> Result { + let current = self + .review_credentials( + &reviewed.target.namespace, + &reviewed.target.kind, + &reviewed.target.name, + &reviewed.key, + ) + .await?; + if !reviewed.same_authority(¤t) { + return Err(failure( + "Reviewed target or acknowledged write changed before binding", + )); + } + // The target RV is still enforced by check_reviewed_target immediately + // before binding. First retain any attested ownership-only source change. + if reviewed.permits_refresh(¤t, stored) { + return Ok(stored.clone()); + } + let grant = self.credential_grant(&reviewed.target.namespace).await?; + let mut receipt_review = current.clone(); + receipt_review.grant = grant_review(&grant)?; + let attested = grant.sources.iter().any(|source| { + source.name == stored.name + && source.uid == stored.uid + && source.phase == "Ready" + && source.ownership_from_resource_version.as_deref() + == Some(stored.version.as_str()) + && current.source.version.as_deref() == Some(source.resource_version.as_str()) + && source.target.as_ref().is_some_and(|target| { + target.kind == reviewed.target.kind + && target.namespace == reviewed.target.namespace + && target.name == reviewed.target.name + && Some(target.uid.as_str()) == reviewed.target.uid.as_deref() + }) + }); + if !attested || !reviewed.same_authority(&receipt_review) { + return Err(failure( + "Source version changed without a matching controller ownership receipt", + )); + } + let metadata = Api::::namespaced(self.client.clone(), &reviewed.target.namespace) + .get_metadata(&stored.name) + .await + .map_err(|error| safe("Verify controller ownership transition", error))?; + let updated = StoredSource::from_metadata(&metadata.metadata)?; + if metadata.metadata.namespace.as_deref() != Some(reviewed.target.namespace.as_str()) + || metadata.metadata.deletion_timestamp.is_some() + || updated.uid != stored.uid + || !reviewed.permits_refresh(¤t, &updated) + { + return Err(failure( + "Source changed after controller ownership acknowledgement", + )); + } + Ok(updated) + } + + pub(super) fn check_bound_credential_target( + &self, + before: &DynamicObject, + patch: &Value, + after: &DynamicObject, + ) -> Result<(), kube::Error> { + let mut expected = before.data["spec"].clone(); + json_patch::merge(&mut expected, patch); + let mut old_metadata = before.metadata.clone(); + let mut new_metadata = after.metadata.clone(); + for metadata in [&mut old_metadata, &mut new_metadata] { + metadata.resource_version = None; + metadata.generation = None; + metadata.managed_fields = None; + } + if expected != after.data["spec"] + || old_metadata != new_metadata + || before + .metadata + .generation + .and_then(|generation| generation.checked_add(1)) + != after.metadata.generation + || after.metadata.resource_version == before.metadata.resource_version + { + return Err(failure( + "Credential binding response changed the approved intent", + )); + } + Ok(()) + } + + pub(super) async fn check_completed_credential_review( + &self, + review: &CredentialReview, + stored: &StoredSource, + bound: Option<&DynamicObject>, + ) -> Result<(), kube::Error> { + let current = self + .review_credentials( + &review.target.namespace, + &review.target.kind, + &review.target.name, + &review.key, + ) + .await?; + let expected_target = target_review( + &review.target.kind, + &review.target.namespace, + &review.target.name, + bound, + )?; + let mut target = current.target.clone(); + target.version.clone_from(&expected_target.version); + let mut grant = current.grant.clone(); + grant.version.clone_from(&review.grant.version); + let mut keys = review.source.keys.clone(); + keys.push(review.key.clone()); + keys.sort(); + keys.dedup(); + if target != expected_target + || grant != review.grant + || !stored.matches(¤t.source) + || current.source.keys != keys + { + return Err(failure( + "Credential write changed before its completion could be verified", + )); + } + Ok(()) + } + + pub async fn review_stored_credentials( + &self, + original: &CredentialReview, + stored: &StoredSource, + ) -> Result { + if stored.name != original.source.name { + return Err(failure("Continuation source name changed")); + } + tokio::time::timeout( + std::time::Duration::from_secs(30), + self.await_source_metadata( + &original.target.namespace, + &Identity { + name: stored.name.clone(), + uid: stored.uid.clone(), + }, + ), + ) + .await + .map_err(|_| failure("Source acknowledgement review deadline"))??; + let current = self + .review_credentials( + &original.target.namespace, + &original.target.kind, + &original.target.name, + &original.key, + ) + .await?; + if !original.permits_refresh(¤t, stored) { + return Err(failure( + "Continuation authority, intent or acknowledged source changed", + )); + } + Ok(current) + } + + pub async fn review_unwritten_credentials( + &self, + original: &CredentialReview, + ) -> Result { + let current = self + .review_credentials( + &original.target.namespace, + &original.target.kind, + &original.target.name, + &original.key, + ) + .await?; + if !original.permits_unwritten_refresh(¤t) { + return Err(failure( + "Unwritten credential review authority or source changed", + )); + } + Ok(current) + } +} diff --git a/bridge/bff/src/kars/credential_review_tests.rs b/bridge/bff/src/kars/credential_review_tests.rs new file mode 100644 index 000000000..4daf121a6 --- /dev/null +++ b/bridge/bff/src/kars/credential_review_tests.rs @@ -0,0 +1,470 @@ +use super::*; +use axum::Extension; +use base64::Engine; + +const REVIEW: &str = "/api/operator/credentials/review"; +const WRITE: &str = "/api/operator/credentials"; +const SIGNING: &str = "private-review-fixture-signing-key"; + +#[tokio::test] +async fn credential_review_accepts_only_attested_ownership_metadata_transition_after_its_write() { + for (receipt_present, wrong_from) in [(false, false), (true, true), (true, false)] { + let accepted = receipt_present && !wrong_from; + let (cluster, state, server) = fixture().await; + { + let mut state = state.lock().unwrap(); + prepare(&mut state); + state.bind_source_owner = true; + state.publish_ownership_receipt = receipt_present; + state.ownership_from_override = wrong_from.then(|| "another-write".into()); + } + let (status, reviewed) = preview(&cluster, None).await; + assert_eq!(status, StatusCode::OK); + let (status, result) = submit(&cluster, &reviewed, PRIVATE).await; + assert_eq!( + status, + if accepted { + StatusCode::OK + } else { + StatusCode::CONFLICT + } + ); + { + let state = state.lock().unwrap(); + assert_eq!(state.source_value_reads, 0); + assert_eq!(state.objects[SOURCE]["metadata"]["resourceVersion"], "2"); + assert_eq!( + state.objects[SOURCE]["data"]["SLACK_BOT_TOKEN"], + json!(k8s_openapi::ByteString(PRIVATE.as_bytes().to_vec())) + ); + let writes = mutations(&state); + assert_eq!( + writes + .iter() + .filter(|(method, _)| *method == "POST") + .count(), + 1 + ); + assert_eq!( + writes.iter().filter(|(_, path)| *path == TARGET).count(), + usize::from(accepted) + ); + if accepted { + assert_eq!(result["stored"], true); + assert_eq!(result["resourceVersion"], "2"); + } else { + assert_eq!( + result["error"]["credentialContinuation"]["source"]["version"], + "1" + ); + } + } + server.abort(); + let _ = server.await; + } +} + +async fn call( + cluster: &Cluster, + path: &str, + body: Value, + actor: &str, + role: &str, +) -> (StatusCode, Value) { + let principal = crate::auth::Principal { + sub: actor.into(), + name: actor.into(), + roles: vec![role.into()], + }; + let app = Router::new() + .route(REVIEW, post(crate::routes::credential_review::review)) + .route(WRITE, post(crate::routes::operator::put_credential)) + .layer(Extension(principal)) + .with_state( + crate::state::AppState::for_test_client(cluster.client.clone(), "work") + .with_principal_secret(Some(SIGNING.into())), + ); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 32768).await.unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains(PRIVATE)); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +fn input() -> Value { + json!({"namespace":"work","kind":"KarsSandbox","target":"target","targetUid":"uid-target","key":"SLACK_BOT_TOKEN"}) +} + +async fn preview(cluster: &Cluster, continuation: Option<&str>) -> (StatusCode, Value) { + let mut body = input(); + if let Some(token) = continuation { + body["continuation"] = token.into(); + } + call(cluster, REVIEW, body, "operator", "operator").await +} + +async fn submit(cluster: &Cluster, review: &Value, value: &str) -> (StatusCode, Value) { + let mut body = input(); + body["review"] = review["token"].clone(); + body["value"] = value.into(); + call(cluster, WRITE, body, "operator", "operator").await +} + +async fn stored_conflict() -> ( + Cluster, + Arc>, + tokio::task::JoinHandle<()>, + Value, + Value, +) { + let (cluster, state, server) = fixture().await; + prepare(&mut state.lock().unwrap()); + let (status, first) = preview(&cluster, None).await; + assert_eq!(status, StatusCode::OK); + state.lock().unwrap().fault = Some(Fault::Status); + let (status, failure) = submit(&cluster, &first, PRIVATE).await; + assert_eq!(status, StatusCode::CONFLICT); + let continuation = failure["error"]["credentialContinuation"].clone(); + assert_eq!(continuation["outcome"], "source-stored"); + assert_eq!(continuation["source"]["uid"], "created-source"); + (cluster, state, server, first, continuation) +} + +#[tokio::test] +async fn credential_review_explicit_acknowledged_resume_binds_once_without_rewriting_or_reading_values() + { + let (cluster, state, server, first, continuation) = stored_conflict().await; + let (status, refreshed) = preview(&cluster, continuation["token"].as_str()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(refreshed["bindingOnly"], true); + assert_eq!(refreshed["submission"], 2); + assert_eq!(refreshed["expiresAt"], first["expiresAt"]); + assert_eq!( + refreshed["metadata"]["target"]["intent"], + first["metadata"]["target"]["intent"] + ); + assert_eq!( + refreshed["metadata"]["target"]["generation"], + first["metadata"]["target"]["generation"] + ); + assert_eq!(refreshed["metadata"]["source"]["uid"], "created-source"); + let (status, result) = submit(&cluster, &refreshed, PRIVATE).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["stored"], true); + { + let state = state.lock().unwrap(); + assert_eq!( + mutations(&state), + [ + ("POST", "/api/v1/namespaces/work/secrets"), + ("PATCH", TARGET), + ("PATCH", TARGET), + ] + ); + assert_eq!(state.source_value_reads, 0); + assert!(state.source_metadata_reads > 0); + assert_eq!( + state.objects[TARGET]["spec"]["credentialBindings"]["sources"][0]["source"]["uid"], + "created-source" + ); + assert!( + state.objects[SOURCE]["data"]["SLACK_BOT_TOKEN"] + == json!(k8s_openapi::ByteString(PRIVATE.as_bytes().to_vec())) + ); + } + let token = refreshed["token"].as_str().unwrap(); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(token.split('.').nth(1).unwrap()) + .unwrap(); + assert!(!String::from_utf8_lossy(&payload).contains(PRIVATE)); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_review_zero_write_conflict_requires_explicit_refresh_of_unchanged_authority() { + let (cluster, state, server) = fixture().await; + prepare(&mut state.lock().unwrap()); + let (_, first) = preview(&cluster, None).await; + state.lock().unwrap().objects.get_mut(TARGET).unwrap()["metadata"]["resourceVersion"] = + "2".into(); + let (status, failure) = submit(&cluster, &first, PRIVATE).await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(mutations(&state.lock().unwrap()).is_empty()); + let receipt = &failure["error"]["credentialContinuation"]; + assert_eq!(receipt["outcome"], "no-write-attempted"); + assert!(receipt["source"].is_null()); + let (status, fresh) = preview(&cluster, receipt["token"].as_str()).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fresh["bindingOnly"], false); + let (status, _) = submit(&cluster, &fresh, PRIVATE).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + mutations(&state.lock().unwrap()), + [ + ("POST", "/api/v1/namespaces/work/secrets"), + ("PATCH", TARGET), + ] + ); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_review_actor_role_value_and_signature_changes_never_mutate_after_partial_write() +{ + let (cluster, state, server, _, receipt) = stored_conflict().await; + let (_, fresh) = preview(&cluster, receipt["token"].as_str()).await; + let writes = mutations(&state.lock().unwrap()).len(); + for (actor, role, value, tamper) in [ + ("other", "operator", PRIVATE, false), + ("operator", "user", PRIVATE, false), + ("operator", "operator", "different-credential", false), + ("operator", "operator", PRIVATE, true), + ] { + let mut body = input(); + body["value"] = value.into(); + body["review"] = if tamper { + format!("{}x", fresh["token"].as_str().unwrap()).into() + } else { + fresh["token"].clone() + }; + let (status, _) = call(&cluster, WRITE, body, actor, role).await; + assert!(status == StatusCode::CONFLICT || status == StatusCode::FORBIDDEN); + assert_eq!(mutations(&state.lock().unwrap()).len(), writes); + } + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_review_changed_intent_grant_source_and_namespace_cannot_be_accepted_by_refresh() +{ + for change in [ + "uid", + "generation", + "spec", + "metadata", + "grant", + "grant-policy", + "source", + "source-version", + "workspace", + ] { + let (cluster, state, server, _, receipt) = stored_conflict().await; + let writes = mutations(&state.lock().unwrap()).len(); + { + let mut state = state.lock().unwrap(); + match change { + "uid" => { + state.objects.get_mut(TARGET).unwrap()["metadata"]["uid"] = "replacement".into() + } + "generation" => { + state.objects.get_mut(TARGET).unwrap()["metadata"]["generation"] = 2.into() + } + "spec" => { + state.objects.get_mut(TARGET).unwrap()["spec"]["suspended"] = false.into() + } + "metadata" => { + state.objects.get_mut(TARGET).unwrap()["metadata"]["annotations"] = + json!({"changed":"intent"}) + } + "grant" => { + state.objects.get_mut(GRANT).unwrap()["metadata"]["uid"] = "replacement".into() + } + "grant-policy" => { + state.objects.get_mut(GRANT).unwrap()["spec"]["agentKeys"] = + json!(["OTHER_KEY"]) + } + "source" => { + state.objects.get_mut(SOURCE).unwrap()["metadata"]["uid"] = + "replacement".into(); + } + "source-version" => { + state.objects.get_mut(SOURCE).unwrap()["metadata"]["resourceVersion"] = + "2".into(); + state.objects.get_mut(GRANT).unwrap()["status"]["sources"][0]["resourceVersion"] = + "2".into(); + } + "workspace" => { + state.objects.get_mut(GRANT).unwrap()["spec"]["workspaceUid"] = + "replacement".into() + } + _ => unreachable!(), + } + } + let (status, _) = preview(&cluster, receipt["token"].as_str()).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(mutations(&state.lock().unwrap()).len(), writes); + assert_eq!(state.lock().unwrap().source_value_reads, 0); + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn credential_review_valid_receipt_still_fences_changes_between_review_and_submission() { + for change in ["target", "grant", "source"] { + let (cluster, state, server, _, receipt) = stored_conflict().await; + let (_, fresh) = preview(&cluster, receipt["token"].as_str()).await; + let writes = mutations(&state.lock().unwrap()).len(); + { + let mut state = state.lock().unwrap(); + let path = match change { + "target" => TARGET, + "grant" => GRANT, + _ => SOURCE, + }; + state.objects.get_mut(path).unwrap()["metadata"]["resourceVersion"] = "99".into(); + } + let (status, _) = submit(&cluster, &fresh, PRIVATE).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(mutations(&state.lock().unwrap()).len(), writes); + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn credential_review_collision_denial_and_lost_ack_never_issue_continuation_authority() { + for fault in [ + Fault::CreateAck, + Fault::BindAck, + Fault::Api(403), + Fault::Api(422), + ] { + let (cluster, state, server) = fixture().await; + prepare(&mut state.lock().unwrap()); + let (_, first) = preview(&cluster, None).await; + state.lock().unwrap().fault = Some(fault); + let (status, body) = submit(&cluster, &first, PRIVATE).await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(body["error"].get("credentialContinuation").is_none()); + server.abort(); + let _ = server.await; + } + let (cluster, state, server) = fixture().await; + prepare(&mut state.lock().unwrap()); + let (_, first) = preview(&cluster, None).await; + state.lock().unwrap().objects.insert(SOURCE.into(), json!({ + "apiVersion":"v1","kind":"Secret","metadata":{"name":"kars-credential-input-sandbox-target", + "namespace":"work","uid":"foreign","resourceVersion":"1"},"type":"Opaque", + })); + let (status, body) = submit(&cluster, &first, PRIVATE).await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(body["error"].get("credentialContinuation").is_none()); + assert_eq!( + mutations(&state.lock().unwrap()), + [("POST", "/api/v1/namespaces/work/secrets")] + ); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_review_limits_explicit_submissions_without_source_rewrite_or_automatic_bind_retry() + { + let (cluster, state, server, _, mut receipt) = stored_conflict().await; + for submission in [2, 3] { + let (_, fresh) = preview(&cluster, receipt["token"].as_str()).await; + assert_eq!(fresh["submission"], submission); + // Status advances after each explicit review; the server must not rebase. + { + let mut state = state.lock().unwrap(); + let target = state.objects.get_mut(TARGET).unwrap(); + let version = target["metadata"]["resourceVersion"] + .as_str() + .unwrap() + .parse::() + .unwrap() + + 1; + target["metadata"]["resourceVersion"] = version.to_string().into(); + } + let (status, failure) = submit(&cluster, &fresh, PRIVATE).await; + assert_eq!(status, StatusCode::CONFLICT); + if submission == 2 { + receipt = failure["error"]["credentialContinuation"].clone(); + assert!(receipt.is_object()); + } else { + assert!(failure["error"].get("credentialContinuation").is_none()); + } + } + assert_eq!( + mutations(&state.lock().unwrap()) + .iter() + .filter(|(method, _)| *method == "POST") + .count(), + 1 + ); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_review_rejects_expired_wrong_purpose_and_wrong_audience_signed_tokens() { + use sha2::{Digest, Sha256}; + let (cluster, state, server) = fixture().await; + prepare(&mut state.lock().unwrap()); + let (_, first) = preview(&cluster, None).await; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(first["token"].as_str().unwrap().split('.').nth(1).unwrap()) + .unwrap(); + let original: Value = serde_json::from_slice(&payload).unwrap(); + let mut hash = Sha256::new(); + hash.update(b"kars-bridge/credential-review-signing-key/v1\0"); + hash.update(SIGNING.as_bytes()); + let key = hash.finalize(); + for change in ["expired", "extended", "purpose", "audience"] { + let mut claims = original.clone(); + match change { + "expired" => claims["exp"] = (chrono::Utc::now().timestamp() - 1).into(), + "extended" => claims["exp"] = (chrono::Utc::now().timestamp() + 3600).into(), + "purpose" => claims["purpose"] = "Continuation".into(), + "audience" => claims["aud"] = "other-protocol".into(), + _ => unreachable!(), + } + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &claims, + &jsonwebtoken::EncodingKey::from_secret(&key), + ) + .unwrap(); + let mut review = first.clone(); + review["token"] = token.into(); + let (status, _) = submit(&cluster, &review, PRIVATE).await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(mutations(&state.lock().unwrap()).is_empty()); + } + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn credential_review_status_managed_fields_may_advance_but_other_metadata_cannot() { + let (cluster, state, server, _, receipt) = stored_conflict().await; + { + let mut state = state.lock().unwrap(); + state.objects.get_mut(TARGET).unwrap()["metadata"]["managedFields"] = json!([{ + "manager":"controller", "operation":"Update", "apiVersion":"kars.azure.com/v1alpha1", + "fieldsType":"FieldsV1", "fieldsV1":{"f:status":{}}, "subresource":"status" + }]); + } + let (status, _) = preview(&cluster, receipt["token"].as_str()).await; + assert_eq!(status, StatusCode::OK); + state.lock().unwrap().objects.get_mut(TARGET).unwrap()["metadata"]["labels"] = + json!({"authority":"changed"}); + let (status, _) = preview(&cluster, receipt["token"].as_str()).await; + assert_eq!(status, StatusCode::CONFLICT); + server.abort(); + let _ = server.await; +} diff --git a/bridge/bff/src/kars/credential_targets.rs b/bridge/bff/src/kars/credential_targets.rs new file mode 100644 index 000000000..28e2219b5 --- /dev/null +++ b/bridge/bff/src/kars/credential_targets.rs @@ -0,0 +1,257 @@ +use super::{ + cluster::Cluster, + credential_contract::{CredentialBindings, Identity, Selection, Target}, + credential_transport::{failure, object_api, safe}, + credentials::input_name, +}; +use kube::{ + ResourceExt, + api::{DynamicObject, Patch, PatchParams}, +}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +pub(super) fn legacy_v1(object: &DynamicObject) -> Result { + let reference = &object.data["spec"]["credentialsRef"]; + if reference.is_null() { + return Ok(false); + } + let name = reference["name"] + .as_str() + .ok_or_else(|| failure("Credential reference is malformed"))?; + let uid = reference["uid"] + .as_str() + .filter(|uid| !uid.is_empty()) + .ok_or_else(|| failure("Credential reference UID is missing"))?; + if object.data["spec"]["credentialBindings"].is_object() { + if !name.starts_with("kars-credential-bundle-") + || object + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/credential-bundle-uid")) + .map(String::as_str) + != Some(uid) + { + return Err(failure( + "Mixed or unowned internal credential references require explicit repair", + )); + } + return Ok(false); + } + if name + .strip_prefix("kars-credential-source-") + .is_some_and(|suffix| { + !suffix.is_empty() + && suffix.as_bytes()[0].is_ascii_alphanumeric() + && suffix + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) + && name.len() <= 253 + { + return Ok(true); + } + Err(failure( + "Unbound internal credential references are not a legacy migration approval", + )) +} + +pub(super) fn reviewed_bindings( + object: &DynamicObject, + target: &Target, + grant: &Identity, +) -> Result { + if object.uid().as_deref() != Some(target.uid.as_str()) + || object.metadata.deletion_timestamp.is_some() + || object.namespace().as_deref() != Some(target.namespace.as_str()) + { + return Err(failure("Credential target identity or lifecycle changed")); + } + if target.kind == "KarsSandbox" && legacy_v1(object)? { + return Err(failure( + "A v1 credential consumer requires explicit validated migration, not workspace rebinding", + )); + } + let existing = if target.kind == "KarsSandbox" { + &object.data["spec"]["credentialBindings"] + } else { + &object.data["spec"]["blueprint"]["credentialBindings"] + }; + let bindings: CredentialBindings = if existing.is_null() { + CredentialBindings { + grant: grant.clone(), + sources: Vec::new(), + } + } else { + serde_json::from_value(existing.clone()) + .map_err(|_| failure("Existing credential binding is invalid"))? + }; + if bindings.grant != *grant { + return Err(failure("Target is bound to a different grant UID")); + } + if bindings.sources.len() > 3 { + return Err(failure("Credential binding has too many source scopes")); + } + let mut seen = std::collections::BTreeSet::new(); + for source in &bindings.sources { + if !["workspace", "team", "target"].contains(&source.scope.as_str()) + || !seen.insert(source.scope.as_str()) + || !source.source.name.starts_with("kars-credential-input-") + || source.source.uid.is_empty() + || (source.scope != "workspace" + && source.owner.as_ref().is_none_or(|owner| { + owner.uid.is_empty() || owner.namespace != target.namespace + })) + { + return Err(failure( + "Existing credential authority is malformed or ambiguous", + )); + } + } + Ok(bindings) +} + +pub(super) fn planned_selection( + object: &DynamicObject, + target: &Target, + grant: &Identity, + mut selection: Selection, +) -> Result, kube::Error> { + let mut bindings = reviewed_bindings(object, target, grant)?; + let existing = if target.kind == "KarsSandbox" { + &object.data["spec"]["credentialBindings"] + } else { + &object.data["spec"]["blueprint"]["credentialBindings"] + }; + if let Some(old) = bindings + .sources + .iter() + .find(|old| old.scope == selection.scope) + { + if old.source != selection.source || old.owner != selection.owner { + return Err(failure( + "Source replacement requires explicit operator review", + )); + } + selection.keys.extend(old.keys.clone()); + } + selection.keys.sort(); + selection.keys.dedup(); + bindings.sources.retain(|old| old.scope != selection.scope); + bindings.sources.push(selection); + bindings + .sources + .sort_by_key(|source| match source.scope.as_str() { + "workspace" => 0, + "team" => 1, + _ => 2, + }); + let value = serde_json::to_value(&bindings) + .map_err(|_| failure("Credential binding serialization failed"))?; + if value == *existing { + return Ok(None); + } + if target.kind == "KarsTask" && object.data["spec"]["execution"]["launch"] == true { + return Err(failure( + "An active standalone Task requires an explicit governed credential rebind before its key authority changes", + )); + } + Ok(Some(if target.kind == "KarsSandbox" { + json!({"credentialBindings":bindings}) + } else { + json!({"blueprint":{"credentialBindings":bindings}}) + })) +} + +impl Cluster { + pub async fn attach_created_credentials(&self, target: &Target) -> Result<(), kube::Error> { + let grant = self.credential_grant(&target.namespace).await?; + let names = [ + ("workspace", input_name("Workspace", "")?), + ( + if target.kind == "KarsTeam" { + "team" + } else { + "target" + }, + input_name(&target.kind, &target.name)?, + ), + ]; + for (scope, name) in names { + if let Some(source) = grant.sources.iter().find(|source| source.name == name) { + if source.phase == "Blocked" + || source.target.as_ref().is_some_and(|owner| owner != target) + { + return Err(failure( + "Staged credential source requires operator review; no foreign target is adopted", + )); + } + self.bind_credential_selection( + target, + &grant.identity, + Selection { + scope: scope.into(), + source: Identity { + name: source.name.clone(), + uid: source.uid.clone(), + }, + keys: source.keys.clone(), + owner: if scope == "workspace" { + None + } else { + Some(target.clone()) + }, + }, + ) + .await?; + } + } + Ok(()) + } + + pub async fn finish_created_credentials( + &self, + target: &Target, + active: bool, + ) -> Result<(), kube::Error> { + self.attach_created_credentials(target).await?; + let api = object_api(self, &target.namespace, &target.kind); + let mut object = api + .get(&target.name) + .await + .map_err(|e| safe("Read captured target before activation", e))?; + if object.uid().as_deref() != Some(target.uid.as_str()) { + return Err(failure("Created target was replaced before activation")); + } + if object.data["spec"]["blueprint"]["githubBinding"].is_object() + && !object.data["spec"]["blueprint"]["credentialBindings"].is_object() + { + self.write_agent_credentials( + &target.namespace, + &target.kind, + &target.name, + Some(&target.uid), + BTreeMap::new(), + Vec::new(), + ) + .await?; + object = api + .get(&target.name) + .await + .map_err(|error| safe("Refresh captured keyless consumer", error))?; + if object.uid().as_deref() != Some(target.uid.as_str()) { + return Err(failure("Keyless consumer was replaced before activation")); + } + } + let spec = match target.kind.as_str() { + "KarsTask" => json!({"execution":{"launch":active}}), + "KarsTeam" => json!({"paused":!active}), + _ => return Ok(()), + }; + api.patch(&target.name, &PatchParams::default(), &Patch::Merge(json!({ + "metadata":{"uid":target.uid,"resourceVersion":object.metadata.resource_version},"spec":spec, + }))).await.map_err(|e| safe("Activate captured credential target", e))?; + Ok(()) + } +} diff --git a/bridge/bff/src/kars/credential_tests.rs b/bridge/bff/src/kars/credential_tests.rs new file mode 100644 index 000000000..f433dc857 --- /dev/null +++ b/bridge/bff/src/kars/credential_tests.rs @@ -0,0 +1,362 @@ +use super::cluster::Cluster; +use super::credentials::*; +use axum::{ + Router, + body::Bytes, + extract::State, + http::{HeaderMap, Method, Uri}, + response::{IntoResponse, Response}, +}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; + +#[path = "credential_binding_tests.rs"] +mod binding_repairs; +#[path = "credential_handler_tests.rs"] +mod handler_conflicts; +#[path = "observation_credential_tests.rs"] +mod observations; + +#[test] +fn budget_scope_survives_the_private_consumer_projection_without_defaulting_legacy_budgets() { + let legacy = serde_json::json!({"tokens":100,"usdMicros":null}); + let budget: super::task::TaskBudget = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!( + serde_json::to_value(budget).unwrap(), + serde_json::json!({"tokens":100}) + ); + let governed: super::task::TaskBudget = serde_json::from_value(serde_json::json!({ + "scope":"GovernedInference","tokens":100 + })) + .unwrap(); + assert_eq!( + serde_json::to_value(governed).unwrap()["scope"], + "GovernedInference" + ); +} + +struct TestApi { + calls: Vec<(String, String, Value)>, + secret: Value, + forbidden: bool, + objects: std::collections::BTreeMap, + pending_source_ack: Option, + fault: Option, + source_metadata_reads: usize, + source_value_reads: usize, + bind_source_owner: bool, + publish_ownership_receipt: bool, + ownership_from_override: Option, +} + +async fn handle( + State(state): State>>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + let body: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + let mut state = state.lock().unwrap(); + state + .calls + .push((method.to_string(), uri.path().into(), body.clone())); + if let Some(response) = handler_conflicts::before_request(&mut state, &method, uri.path()) { + return response; + } + if state.forbidden { + return (axum::http::StatusCode::FORBIDDEN,axum::Json(json!({ + "kind":"Status","apiVersion":"v1","status":"Failure","reason":"Forbidden","code":403,"message":"PRIVATE_VALUE_SENTINEL"}))).into_response(); + } + const WORKSPACE_GRANT: &str = + "/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace"; + if method == Method::GET + && uri.path() == WORKSPACE_GRANT + && let Some(mut source) = state.pending_source_ack.take() + { + let previous_version = source["metadata"]["resourceVersion"].clone(); + let annotations = source["metadata"]["annotations"].clone(); + let target_uid = annotations["kars.azure.com/credential-target-uid"].clone(); + if state.bind_source_owner && target_uid.is_string() { + source["metadata"]["ownerReferences"] = json!([{ + "apiVersion":"kars.azure.com/v1alpha1", + "kind":annotations["kars.azure.com/credential-target-kind"], + "name":annotations["kars.azure.com/credential-target"], + "uid":target_uid, "controller":true, "blockOwnerDeletion":false + }]); + source["metadata"]["resourceVersion"] = + (previous_version.as_str().unwrap().parse::().unwrap() + 1) + .to_string() + .into(); + state.objects.insert( + format!( + "/api/v1/namespaces/work/secrets/{}", + source["metadata"]["name"].as_str().unwrap() + ), + source.clone(), + ); + } + binding_repairs::acknowledge(&mut state, &source); + if state.bind_source_owner && target_uid.is_string() { + let publish = state.publish_ownership_receipt; + let from = state.ownership_from_override.clone(); + let entry = + &mut state.objects.get_mut(WORKSPACE_GRANT).unwrap()["status"]["sources"][0]; + entry["phase"] = "Ready".into(); + entry["target"] = json!({ + "kind":annotations["kars.azure.com/credential-target-kind"], + "namespace":"work", "name":annotations["kars.azure.com/credential-target"], + "uid":target_uid + }); + if publish { + entry["ownershipFromResourceVersion"] = + from.map(Value::String).unwrap_or(previous_version); + } + } + } + if method == Method::GET + && uri + .path() + .starts_with("/api/v1/namespaces/work/secrets/kars-credential-input-") + { + let name = uri.path().rsplit('/').next().unwrap(); + let enrolled = state + .objects + .get(WORKSPACE_GRANT) + .and_then(|grant| grant["status"]["sources"].as_array()) + .is_some_and(|sources| sources.iter().any(|source| source["name"] == name)); + if !enrolled { + return (axum::http::StatusCode::FORBIDDEN,axum::Json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","reason":"Forbidden","code":403, + "message":"Source GET is not enrolled" + }))).into_response(); + } + } + if (method == Method::GET + || (method == Method::POST && uri.path().ends_with("/selfsubjectreviews"))) + && let Some(value) = state.objects.get(uri.path()) + { + let value = value.clone(); + if method == Method::GET && uri.path().contains("/secrets/kars-credential-input-") { + if headers + .get("accept") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("as=PartialObjectMetadata")) + { + let metadata = value["metadata"].clone(); + state.source_metadata_reads += 1; + return axum::Json(json!({"apiVersion":"meta.k8s.io/v1","kind":"PartialObjectMetadata","metadata":metadata})).into_response(); + } + state.source_value_reads += 1; + } + return axum::Json(value).into_response(); + } + if method == Method::GET { + for (resource, kind) in [ + ("karsteams", "KarsTeam"), + ("karstasks", "KarsTask"), + ("karssandboxes", "KarsSandbox"), + ] { + if uri.path().ends_with(&format!("/{resource}")) { + let items = state + .objects + .iter() + .filter(|(path, _)| path.starts_with(&format!("{}/", uri.path()))) + .map(|(_, value)| value.clone()) + .collect::>(); + return axum::Json( + json!({"apiVersion":"kars.azure.com/v1alpha1","kind":format!("{kind}List"), + "metadata":{},"items":items}), + ) + .into_response(); + } + } + } + if method == Method::POST && uri.path() == "/api/v1/namespaces/work/secrets" { + let name = body["metadata"]["name"].as_str().unwrap(); + let path = format!("{}/{name}", uri.path()); + if state.objects.contains_key(&path) { + return (axum::http::StatusCode::CONFLICT,axum::Json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","reason":"AlreadyExists","code":409 + }))).into_response(); + } + let mut value = body.clone(); + value["metadata"]["uid"] = "created-source".into(); + value["metadata"]["resourceVersion"] = "1".into(); + for (key, text) in body["stringData"].as_object().into_iter().flatten() { + value["data"][key] = json!(k8s_openapi::ByteString( + text.as_str().unwrap().as_bytes().to_vec() + )); + } + value.as_object_mut().unwrap().remove("stringData"); + let name = value["metadata"]["name"].as_str().unwrap().to_string(); + state + .objects + .insert(format!("{}/{name}", uri.path()), value.clone()); + state.pending_source_ack = Some(value.clone()); + if let Some(response) = handler_conflicts::after_write(&mut state, &method, uri.path()) { + return response; + } + return (axum::http::StatusCode::CREATED, axum::Json(value)).into_response(); + } + if method == Method::PATCH && state.objects.contains_key(uri.path()) { + let mut value = state.objects[uri.path()].clone(); + let old_spec = value.get("spec").cloned(); + if body.is_array() { + let patch: json_patch::Patch = serde_json::from_value(body).unwrap(); + if json_patch::patch(&mut value, &patch).is_err() { + return ( + axum::http::StatusCode::CONFLICT, + axum::Json( + json!({"kind":"Status","apiVersion":"v1","code":409,"reason":"Conflict"}), + ), + ) + .into_response(); + } + } else { + if body["metadata"]["uid"] != value["metadata"]["uid"] + || body["metadata"]["resourceVersion"] != value["metadata"]["resourceVersion"] + { + return handler_conflicts::api_failure(409); + } + binding_repairs::merge(&mut value, &body); + } + if value.get("spec") != old_spec.as_ref() + && let Some(generation) = value["metadata"]["generation"].as_i64() + { + value["metadata"]["generation"] = (generation + 1).into(); + } + let version = value["metadata"]["resourceVersion"] + .as_str() + .unwrap() + .parse::() + .unwrap() + + 1; + value["metadata"]["resourceVersion"] = version.to_string().into(); + state.objects.insert(uri.path().into(), value.clone()); + if uri.path().contains("/secrets/kars-credential-input-") { + state.pending_source_ack = Some(value.clone()); + } + if let Some(response) = handler_conflicts::after_write(&mut state, &method, uri.path()) { + return response; + } + return axum::Json(value).into_response(); + } + let value=match (method,uri.path()) { + (Method::GET,"/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace")=>json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsCredentialGrant", + "metadata":{"name":"workspace","namespace":"work","uid":"grant","resourceVersion":"1","generation":1}, + "spec":{"enabled":true,"workspaceUid":"namespace","agentKeys":["GITHUB_TOKEN"], + "integrationStores":[{"secret":{"name":"test-teams","uid":"secret"},"purpose":"teams"}],"legacyImports":[]}, + "status":{"phase":"Ready","observedGeneration":1,"sources":[],"legacySources":[]}}), + (Method::GET,"/api/v1/namespaces/work")=>json!({"metadata":{"name":"work","uid":"namespace","resourceVersion":"1"}}), + (Method::GET,"/api/v1/namespaces/work/secrets/test-teams")=>state.secret.clone(), + (Method::PATCH,"/api/v1/namespaces/work/secrets/test-teams")=>{ + let mut current=state.secret.clone(); + let patch:json_patch::Patch=serde_json::from_value(body).unwrap(); + if json_patch::patch(&mut current,&patch).is_err() { + return (axum::http::StatusCode::UNPROCESSABLE_ENTITY,axum::Json(json!({ + "kind":"Status","apiVersion":"v1","status":"Failure","reason":"Invalid","code":422,"message":"CAS rejected"}))).into_response(); + } + state.secret=current;state.secret.clone() + } + _=>return (axum::http::StatusCode::NOT_FOUND,axum::Json(json!({ + "kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","code":404,"message":"missing"}))).into_response(), + }; + axum::Json(value).into_response() +} + +async fn fixture() -> (Cluster, Arc>, tokio::task::JoinHandle<()>) { + let state = Arc::new(Mutex::new(TestApi { + calls: Vec::new(), + forbidden: false, + objects: std::collections::BTreeMap::new(), + pending_source_ack: None, + fault: None, + source_metadata_reads: 0, + source_value_reads: 0, + bind_source_owner: false, + publish_ownership_receipt: false, + ownership_from_override: None, + secret: json!({ + "apiVersion":"v1","kind":"Secret","type":"Opaque","metadata":{"name":"test-teams","namespace":"work","uid":"secret","resourceVersion":"2"}, + "data":{"client-id":"b2xk","bff-internal-secret":"cHJlc2VydmVk"}}), + })); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().fallback(handle).with_state(state.clone()); + let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = + kube::Client::try_from(kube::Config::new(format!("http://{addr}").parse().unwrap())) + .unwrap(); + (Cluster::for_test_client(client), state, task) +} + +#[tokio::test] +async fn credential_store_updates_keep_uid_and_unrelated_keys_with_real_cas() { + let (cluster, state, server) = fixture().await; + cluster + .mutate_integration("work", "test-teams", |keys| { + keys.insert("client-id".into(), "updated".into()); + }) + .await + .unwrap(); + { + let state = state.lock().unwrap(); + assert_eq!(state.secret["metadata"]["uid"], "secret"); + assert_eq!(state.secret["data"]["bff-internal-secret"], "cHJlc2VydmVk"); + let patch = &state + .calls + .iter() + .find(|(method, _, _)| method == "PATCH") + .unwrap() + .2; + assert_eq!( + patch[0], + json!({"op":"test","path":"/metadata/uid","value":"secret"}) + ); + assert_eq!( + patch[1], + json!({"op":"test","path":"/metadata/resourceVersion","value":"2"}) + ); + assert!( + !state + .calls + .iter() + .any(|(method, _, _)| method == "DELETE" || method == "POST") + ); + } + server.abort(); +} + +#[tokio::test] +async fn credential_reads_do_not_fallback_on_forbidden_or_recreated_store() { + let (cluster, state, server) = fixture().await; + state.lock().unwrap().forbidden = true; + let error = cluster + .integration_store("work", "test-teams") + .await + .unwrap_err() + .to_string(); + assert!(!error.contains("PRIVATE_VALUE_SENTINEL")); + state.lock().unwrap().forbidden = false; + state.lock().unwrap().secret["metadata"]["uid"] = "replacement".into(); + assert!( + cluster + .integration_store("work", "test-teams") + .await + .is_err() + ); + assert!( + state + .lock() + .unwrap() + .calls + .iter() + .all(|(method, _, _)| method == "GET") + ); + server.abort(); +} diff --git a/bridge/bff/src/kars/credential_transport.rs b/bridge/bff/src/kars/credential_transport.rs new file mode 100644 index 000000000..510afa639 --- /dev/null +++ b/bridge/bff/src/kars/credential_transport.rs @@ -0,0 +1,47 @@ +use super::{cluster::Cluster, credential_contract::Identity}; +use kube::{ + Api, ResourceExt, + api::DynamicObject, + core::{ApiResource, GroupVersionKind}, +}; + +pub fn failure(message: &str) -> kube::Error { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + reason: "CredentialAuthorityUnavailable".into(), + message: message.into(), + code: 409, + }) +} + +pub(super) fn safe(stage: &str, error: kube::Error) -> kube::Error { + let code = if let kube::Error::Api(ref error) = error { + error.code + } else { + 502 + }; + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + reason: "CredentialOperationFailed".into(), + message: format!("{stage}: Kubernetes status {code}"), + code, + }) +} + +pub(super) fn object_api(cluster: &Cluster, namespace: &str, kind: &str) -> Api { + Api::namespaced_with( + cluster.client.clone(), + namespace, + &ApiResource::from_gvk(&GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind)), + ) +} + +pub(super) fn identity(object: &DynamicObject) -> Result { + Ok(Identity { + name: object.name_any(), + uid: object + .uid() + .filter(|s| !s.is_empty()) + .ok_or_else(|| failure("API target UID missing"))?, + }) +} diff --git a/bridge/bff/src/kars/credentials.rs b/bridge/bff/src/kars/credentials.rs new file mode 100644 index 000000000..b64aca8c9 --- /dev/null +++ b/bridge/bff/src/kars/credentials.rs @@ -0,0 +1,867 @@ +// Governed credential adapter. Values stay in native, operator-authorized Secrets. + +use super::cluster::Cluster; +pub use super::credential_contract::{ + CredentialBindings, Grant, Identity, Legacy, Selection, SourceState, Store, Target, +}; +use super::credential_review::{ + CredentialReview, CredentialWriteFailure, ReviewedWrite, StoredSource, +}; +pub use super::credential_transport::failure; +use super::credential_transport::{identity, object_api, safe}; +use k8s_openapi::api::core::v1::{Namespace, Secret}; +use kube::{ + Api, ResourceExt, + api::{Patch, PatchParams, PostParams}, +}; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; + +const GRANT: &str = "workspace"; +const PREFIX: &str = "kars-credential-input-"; +const REMOVED_KEYS: &str = "kars.azure.com/credential-removed-keys"; + +struct AgentCredentialWrite<'a> { + namespace: &'a str, + kind: &'a str, + name: &'a str, + expected_target_uid: Option<&'a str>, + set: BTreeMap, + remove: Vec, +} + +pub fn input_name(kind: &str, name: &str) -> Result { + match kind { + "Workspace" => Ok(format!("{PREFIX}workspace")), + "KarsSandbox" => Ok(format!("{PREFIX}sandbox-{name}")), + "KarsTask" => Ok(format!("{PREFIX}task-{name}")), + "KarsTeam" => Ok(format!("{PREFIX}team-{name}")), + _ => Err(failure( + "target kind must be KarsSandbox, KarsTask or KarsTeam", + )), + } +} +impl Cluster { + pub fn core_namespace(&self) -> String { + std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()) + } + pub fn integration_namespace(&self) -> String { + std::env::var("BRIDGE_INSTALL_NAMESPACE").unwrap_or_else(|_| self.core_namespace()) + } + fn operator_store_namespace(&self, requested: &str, name: &str) -> String { + if [ + "kars-inference-providers", + "kars-foundry-credentials", + "kars-github-app", + "kars-github-connection", + "kars-credential-controller-settings", + ] + .contains(&name) + || name.starts_with("kars-provider-") + { + self.core_namespace() + } else { + requested.into() + } + } + pub async fn credential_grant(&self, namespace: &str) -> Result { + let document = object_api(self, namespace, "KarsCredentialGrant") + .get(GRANT) + .await + .map_err(|e| safe("Read workspace credential grant", e))?; + if document.metadata.deletion_timestamp.is_some() + || document.data["spec"]["enabled"] != true + || document.data["status"]["phase"] != "Ready" + || document.data["status"]["observedGeneration"] != json!(document.metadata.generation) + { + return Err(failure( + "Workspace credential authority is unready; an operator must enroll or repair it before credential changes", + )); + } + let namespace_object = Api::::all(self.client.clone()) + .get(namespace) + .await + .map_err(|e| safe("Read credential workspace identity", e))?; + if document.data["spec"]["workspaceUid"] != json!(namespace_object.metadata.uid) + || namespace_object.metadata.deletion_timestamp.is_some() + { + return Err(failure("Credential workspace identity changed")); + } + let parse = |value: &Value| value.clone(); + Ok(Grant { + identity: identity(&document)?, + agent_keys: serde_json::from_value(parse(&document.data["spec"]["agentKeys"])) + .unwrap_or_default(), + stores: serde_json::from_value(parse(&document.data["spec"]["integrationStores"])) + .map_err(|_| failure("Credential store grant is malformed"))?, + sources: serde_json::from_value(parse(&document.data["status"]["sources"])) + .map_err(|_| failure("Credential metadata inventory is malformed"))?, + legacy: serde_json::from_value(parse(&document.data["status"]["legacySources"])) + .unwrap_or_default(), + reviewed_legacy: serde_json::from_value(parse(&document.data["spec"]["legacyImports"])) + .unwrap_or_default(), + document, + }) + } + + pub async fn credential_target( + &self, + namespace: &str, + kind: &str, + name: &str, + ) -> Result, kube::Error> { + input_name(kind, name)?; + let Some(object) = object_api(self, namespace, kind) + .get_opt(name) + .await + .map_err(|e| safe("Read credential target", e))? + else { + return Ok(None); + }; + if object.metadata.deletion_timestamp.is_some() { + return Err(failure("Credential target is terminating")); + } + Ok(Some(Target { + kind: kind.into(), + namespace: namespace.into(), + name: name.into(), + uid: identity(&object)?.uid, + })) + } + + pub async fn integration_store( + &self, + namespace: &str, + name: &str, + ) -> Result<(Store, Secret), kube::Error> { + let namespace = self.operator_store_namespace(namespace, name); + let grant = self.credential_grant(&namespace).await?; + let store=grant.stores.into_iter().find(|store|store.secret.name==name) + .ok_or_else(||failure("Integration store is not explicitly enrolled; no broad Secret fallback is allowed"))?; + let secret = Api::::namespaced(self.client.clone(), &namespace) + .get(name) + .await + .map_err(|e| safe("Read enrolled integration store", e))?; + if secret.uid().as_deref() != Some(store.secret.uid.as_str()) + || secret.type_.as_deref() != Some("Opaque") + || secret.metadata.deletion_timestamp.is_some() + { + return Err(failure( + "Integration store UID/type changed; replacement preserved", + )); + } + Ok((store, secret)) + } + + pub async fn patch_credential_keys( + &self, + namespace: &str, + name: &str, + uid: &str, + version: &str, + set: &BTreeMap, + remove: &[String], + ) -> Result { + let api: Api = Api::namespaced(self.client.clone(), namespace); + let current = api + .get(name) + .await + .map_err(|e| safe("Read exact credential collection", e))?; + if current.uid().as_deref() != Some(uid) + || current.resource_version().as_deref() != Some(version) + { + return Err(kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + reason: "Conflict".into(), + message: "Credential UID/resourceVersion changed before mutation".into(), + code: 409, + })); + } + let mut data = serde_json::to_value(current.data.unwrap_or_default()) + .map_err(|_| failure("Credential data encoding failed"))?; + for (key, value) in set { + if value.contains('\0') { + return Err(failure("Credential values must not contain NUL")); + } + data[key] = serde_json::to_value(k8s_openapi::ByteString(value.as_bytes().to_vec())) + .map_err(|_| failure("Credential encoding failed"))?; + } + for key in remove { + data.as_object_mut() + .ok_or_else(|| failure("Credential data is not an object"))? + .remove(key); + } + let mut operations = vec![ + json!({"op":"test","path":"/metadata/uid","value":uid}), + json!({"op":"test","path":"/metadata/resourceVersion","value":version}), + json!({"op":"add","path":"/data","value":data}), + ]; + if name.starts_with(PREFIX) { + let mut annotations = current.metadata.annotations.unwrap_or_default(); + let prior: Vec = annotations + .get(REMOVED_KEYS) + .map(|raw| serde_json::from_str(raw)) + .transpose() + .map_err(|_| failure("Credential removal intent is malformed"))? + .unwrap_or_default(); + let mut removed = prior.into_iter().collect::>(); + for key in set.keys() { + removed.remove(key); + } + removed.extend(remove.iter().cloned()); + if removed.len() > 128 { + return Err(failure("Credential removal intent exceeds its bound")); + } + annotations.insert( + REMOVED_KEYS.into(), + serde_json::to_string(&removed) + .map_err(|_| failure("Credential removal intent serialization failed"))?, + ); + operations.push(json!({"op":"add","path":"/metadata/annotations","value":annotations})); + } + let patch: json_patch::Patch = serde_json::from_value(json!(operations)) + .map_err(|_| failure("Credential patch serialization failed"))?; + api.patch(name, &PatchParams::default(), &Patch::Json::(patch)) + .await + .map_err(|e| safe("Apply UID/resourceVersion-fenced credential keys", e)) + } + + pub async fn mutate_integration( + &self, + namespace: &str, + name: &str, + mutate: impl Fn(&mut BTreeMap), + ) -> Result<(), kube::Error> { + let namespace = self.operator_store_namespace(namespace, name); + for _ in 0..6 { + let (_, secret) = self.integration_store(&namespace, name).await?; + let before = secret + .data + .as_ref() + .into_iter() + .flatten() + .map(|(key, value)| { + String::from_utf8(value.0.clone()).map(|value| (key.clone(), value)) + }) + .collect::, _>>() + .map_err(|_| failure("Integration store contains non-UTF8 values"))?; + let mut after = before.clone(); + mutate(&mut after); + let removed = before + .keys() + .filter(|key| !after.contains_key(*key)) + .cloned() + .collect::>(); + let changed = after + .into_iter() + .filter(|(key, value)| before.get(key) != Some(value)) + .collect::>(); + if changed.is_empty() && removed.is_empty() { + return Ok(()); + } + let uid = secret + .uid() + .ok_or_else(|| failure("Integration UID missing"))?; + let rv = secret + .resource_version() + .ok_or_else(|| failure("Integration resourceVersion missing"))?; + match self + .patch_credential_keys(&namespace, name, &uid, &rv, &changed, &removed) + .await + { + Ok(_) => return Ok(()), + Err(kube::Error::Api(error)) if error.code == 409 || error.code == 422 => continue, + Err(error) => return Err(error), + } + } + Err(failure( + "Concurrent integration update did not converge; no unrelated keys were overwritten", + )) + } + + pub async fn write_agent_credentials( + &self, + namespace: &str, + kind: &str, + name: &str, + expected_target_uid: Option<&str>, + set: BTreeMap, + remove: Vec, + ) -> Result { + let mut stored = None; + let mut attempted = false; + self.apply_agent_credentials( + AgentCredentialWrite { + namespace, + kind, + name, + expected_target_uid, + set, + remove, + }, + None, + &mut stored, + &mut attempted, + ) + .await + } + + pub async fn write_reviewed_agent_credentials( + &self, + reviewed: &ReviewedWrite, + value: String, + ) -> Result> { + let review = &reviewed.review; + let mut stored = reviewed.stored.clone(); + let mut attempted = false; + let result = self + .apply_agent_credentials( + AgentCredentialWrite { + namespace: &review.target.namespace, + kind: &review.target.kind, + name: &review.target.name, + expected_target_uid: review.target.uid.as_deref(), + set: BTreeMap::from([(review.key.clone(), value)]), + remove: Vec::new(), + }, + Some(reviewed), + &mut stored, + &mut attempted, + ) + .await; + result.map_err(|error| { + Box::new(CredentialWriteFailure { + error, + stored, + write_attempted: attempted, + }) + }) + } + + async fn apply_agent_credentials( + &self, + input: AgentCredentialWrite<'_>, + reviewed: Option<&ReviewedWrite>, + stored: &mut Option, + attempted: &mut bool, + ) -> Result { + let AgentCredentialWrite { + namespace, + kind, + name, + expected_target_uid, + set, + remove, + } = input; + let grant = self.credential_grant(namespace).await?; + if let Some(reviewed) = reviewed { + self.check_reviewed_grant(&reviewed.review, &grant)?; + self.check_credential_review(&reviewed.review).await?; + } + if set.values().any(|value| value.contains('\0')) + || set.values().map(String::len).sum::() > 131_072 + { + return Err(failure( + "Credential values must be UTF-8 without NUL and within 128 KiB", + )); + } + let target = if kind == "Workspace" { + None + } else { + self.credential_target(namespace, kind, name).await? + }; + if target.is_none() + && kind != "Workspace" + && Api::::all(self.client.clone()) + .get_opt(&format!("kars-{name}")) + .await + .map_err(|e| safe("Preflight prelaunch runtime namespace", e))? + .is_some() + { + return Err(failure( + "An existing runtime namespace makes prelaunch migration ambiguous; stage the real paused target and review its UID and legacy keys first", + )); + } + if expected_target_uid + .is_some_and(|uid| target.as_ref().is_none_or(|target| target.uid != uid)) + { + return Err(failure("Reviewed credential target UID changed")); + } + let source_name = input_name(kind, name)?; + let legacy = grant + .legacy + .iter() + .filter(|entry| entry.source_name == source_name && entry.target == target) + .collect::>(); + if legacy + .iter() + .any(|entry| !grant.reviewed_legacy.contains(entry)) + { + return Err(failure( + "Existing legacy credentials require read-only operator UID/resourceVersion/key-name review before migration", + )); + } + if set + .keys() + .chain(remove.iter()) + .any(|key| !grant.approves_agent_key(key)) + { + return Err(failure( + "Credential key needs an explicit operator agent-key grant", + )); + } + let prior = grant + .sources + .iter() + .find(|source| source.name == source_name); + if prior.is_some_and(|source| { + source + .target + .as_ref() + .is_some_and(|owner| Some(owner) != target.as_ref()) + }) { + return Err(failure("Credential source belongs to another target UID")); + } + let mut keys = prior.map(|s| s.keys.clone()).unwrap_or_default(); + keys.extend(set.keys().cloned()); + keys.extend(remove.iter().cloned()); + keys.extend(legacy.iter().flat_map(|entry| { + entry + .keys + .iter() + .filter(|key| key.as_str() != "TEAMS_ENABLED") + .cloned() + })); + keys.sort(); + keys.dedup(); + let api: Api = Api::namespaced(self.client.clone(), namespace); + let workspace_plan = if kind == "Workspace" { + use super::workspace_credential_plan::WorkspaceSource; + let source = match prior { + Some(prior) => WorkspaceSource::Existing(Identity { + name: source_name.clone(), + uid: prior.uid.clone(), + }), + // New names are CREATE-only until core enrollment grants GET. + // Existence is decided by exclusive CREATE, never by a 403. + None => WorkspaceSource::Create { + name: source_name.clone(), + }, + }; + Some( + self.plan_workspace_credentials(namespace, &grant.identity, source, keys.clone()) + .await?, + ) + } else { + None + }; + if let Some(reviewed) = reviewed { + self.check_credential_review(&reviewed.review).await?; + } + let mut written = if let Some(resume) = reviewed.and_then(|review| review.stored.as_ref()) { + let metadata = api + .get_metadata(&source_name) + .await + .map_err(|error| safe("Verify acknowledged continuation source", error))?; + let current = StoredSource::from_metadata(&metadata.metadata)?; + if ¤t != resume || metadata.metadata.deletion_timestamp.is_some() { + return Err(failure( + "Continuation source changed; no source write or binding was attempted", + )); + } + current + } else if let Some(prior) = prior { + let version = if prior.phase == "Blocked" { + prior.resource_version.clone() + } else { + let meta = api + .get_metadata(&source_name) + .await + .map_err(|e| safe("Refresh source metadata", e))?; + if meta.uid().as_deref() != Some(prior.uid.as_str()) { + return Err(failure("Source UID changed")); + } + meta.resource_version() + .ok_or_else(|| failure("Source resourceVersion missing"))? + }; + if reviewed.is_some_and(|review| { + review.review.source.version.as_deref() != Some(version.as_str()) + }) { + return Err(failure("Source version changed after review")); + } + *attempted = true; + let written = self + .patch_credential_keys(namespace, &source_name, &prior.uid, &version, &set, &remove) + .await?; + StoredSource::from_metadata(&written.metadata)? + } else { + if let Some(target) = &target { + let object = object_api(self, namespace, kind) + .get(name) + .await + .map_err(|e| safe("Inspect existing target binding", e))?; + let configured = if kind == "KarsSandbox" { + &object.data["spec"]["credentialBindings"] + } else { + &object.data["spec"]["blueprint"]["credentialBindings"] + }; + if configured["sources"].as_array().is_some_and(|sources| { + sources + .iter() + .any(|source| source["source"]["name"] == source_name) + }) { + return Err(failure( + "A previously referenced source disappeared; explicit operator replacement is required", + )); + } + if object.uid().as_deref() != Some(target.uid.as_str()) { + return Err(failure("Target changed during source preflight")); + } + } + let mut annotations = json!({ + "kars.azure.com/credential-purpose":"agent-input-v2", + "kars.azure.com/credential-workspace":namespace, + "kars.azure.com/credential-target-kind":kind, + "kars.azure.com/credential-target":name, + "kars.azure.com/credential-binding-intent":"explicit-reference-v2", + "kars.azure.com/credential-grant-uid":grant.identity.uid, + REMOVED_KEYS:serde_json::to_string(&remove.iter().cloned().collect::>()) + .map_err(|_|failure("Credential removal intent serialization failed"))?, + }); + if let Some(target) = &target { + annotations["kars.azure.com/credential-target-uid"] = target.uid.clone().into(); + } + let values = set + .iter() + .filter(|(key, _)| !remove.contains(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + let secret:Secret=serde_json::from_value(json!({"apiVersion":"v1","kind":"Secret","type":"Opaque", + "metadata":{"name":source_name,"namespace":namespace,"annotations":annotations},"stringData":values})) + .map_err(|_|failure("Credential source serialization failed"))?; + *attempted = true; + let written = api + .create(&PostParams::default(), &secret) + .await + .map_err(|e| safe("Create unbound credential source without adoption", e))?; + StoredSource::from_metadata(&written.metadata)? + }; + if written.name != source_name { + return Err(failure("Stored source name changed")); + } + if reviewed.is_some_and(|review| { + review + .review + .source + .uid + .as_ref() + .is_some_and(|uid| uid != &written.uid) + }) { + return Err(failure("Reviewed source UID changed after its write")); + } + *stored = Some(written.clone()); + let source = Identity { + name: source_name.clone(), + uid: written.uid.clone(), + }; + if let Some(plan) = workspace_plan { + self.await_source_metadata(namespace, &source).await?; + let current = api + .get_metadata(&source_name) + .await + .map_err(|e| safe("Verify written workspace source before binding", e))?; + if current.uid().as_deref() != Some(source.uid.as_str()) + || current.metadata.resource_version.as_deref() != Some(written.version.as_str()) + || current.metadata.deletion_timestamp.is_some() + { + return Err(failure( + "Workspace source changed after its write; consumer bindings were not applied", + )); + } + self.apply_workspace_plan(plan, &source).await?; + } + if let Some(reviewed) = reviewed { + self.await_source_metadata(namespace, &source).await?; + written = self + .check_written_credential_review(&reviewed.review, &written) + .await?; + *stored = Some(written.clone()); + } + let bound = if let Some(target) = &target { + Some( + self.bind_credential_selection_reviewed( + target, + &grant.identity, + Selection { + scope: if kind == "KarsTeam" { "team" } else { "target" }.into(), + source: source.clone(), + keys, + owner: Some(target.clone()), + }, + reviewed.map(|review| &review.review), + ) + .await?, + ) + } else { + None + }; + self.await_source_metadata(namespace, &source).await?; + if let Some(reviewed) = reviewed { + self.check_completed_credential_review(&reviewed.review, &written, bound.as_ref()) + .await?; + } + Ok( + json!({"stored":true,"source":source,"namespace":namespace,"target":target, + "resourceVersion":written.version, + "phase":if target.is_some() {"AwaitingController"} else {"Unbound"}, + "note":"Stored in the governed workspace source. No runtime namespace was created; delivery requires the current controller's UID-bound acknowledgement."}), + ) + } + + pub async fn bind_credential_selection( + &self, + target: &Target, + grant: &Identity, + selection: Selection, + ) -> Result<(), kube::Error> { + self.bind_credential_selection_reviewed(target, grant, selection, None) + .await + .map(|_| ()) + } + + async fn bind_credential_selection_reviewed( + &self, + target: &Target, + grant: &Identity, + selection: Selection, + review: Option<&CredentialReview>, + ) -> Result { + let api = object_api(self, &target.namespace, &target.kind); + let object = api + .get(&target.name) + .await + .map_err(|e| safe("Read target before credential binding", e))?; + if let Some(review) = review { + self.check_reviewed_target(review, &object)?; + } + let Some(spec) = + super::credential_targets::planned_selection(&object, target, grant, selection)? + else { + return Ok(object); + }; + let result = api.patch(&target.name,&PatchParams::default(),&Patch::Merge(json!({ + "metadata":{"uid":target.uid,"resourceVersion":object.metadata.resource_version},"spec":spec, + }))).await.map_err(|e|safe("Bind the captured target and source UIDs",e))?; + if review.is_some() { + self.check_bound_credential_target(&object, &spec, &result)?; + } + Ok(result) + } + + pub(super) async fn await_source_metadata( + &self, + namespace: &str, + source: &Identity, + ) -> Result<(), kube::Error> { + for _ in 0..120 { + let grant = self.credential_grant(namespace).await?; + if let Some(observed) = grant + .sources + .iter() + .find(|entry| entry.name == source.name && entry.uid == source.uid) + { + if observed.phase == "Blocked" { + return Err(failure(&observed.reason)); + } + let current = Api::::namespaced(self.client.clone(), namespace) + .get_metadata(&source.name) + .await + .map_err(|e| safe("Read observed source metadata", e))?; + if current.uid().as_deref() != Some(source.uid.as_str()) { + return Err(failure("Source was replaced during observation")); + } + if current.resource_version().as_deref() == Some(observed.resource_version.as_str()) + { + return Ok(()); + } + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + Err(failure( + "Source was stored but the controller has not acknowledged its current UID/resourceVersion", + )) + } + + pub async fn configured_channel_keys( + &self, + namespace: &str, + target: Option<&Target>, + ) -> Result, kube::Error> { + let grant = self.credential_grant(namespace).await?; + let mut keys = BTreeSet::new(); + if let Some(workspace) = grant + .sources + .iter() + .find(|source| source.name == format!("{PREFIX}workspace")) + { + if workspace.phase == "Blocked" { + return Err(failure(&workspace.reason)); + } + keys.extend(workspace.keys.iter().cloned()); + } + if let Some(target) = target { + let object = object_api(self, namespace, &target.kind) + .get(&target.name) + .await + .map_err(|e| safe("Read channel target", e))?; + if object.uid().as_deref() != Some(target.uid.as_str()) { + return Err(failure("Channel target UID changed")); + } + let bindings = &object.data["spec"]["blueprint"]["credentialBindings"]; + if let Some(sources) = bindings["sources"].as_array() { + for selection in sources { + let uid = selection["source"]["uid"] + .as_str() + .ok_or_else(|| failure("Channel source UID missing"))?; + let source = grant + .sources + .iter() + .find(|source| source.uid == uid) + .ok_or_else(|| { + failure("Channel source is missing or has not been observed") + })?; + if source.phase == "Blocked" { + return Err(failure(&source.reason)); + } + for key in selection["keys"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + if source.keys.iter().any(|present| present == key) { + keys.insert(key.into()); + } else { + keys.remove(key); + } + } + } + } + } + Ok(keys.into_iter().collect()) + } + + pub async fn write_controller_environment( + &self, + changes: Vec, + ) -> Result<(), kube::Error> { + let namespace = self.core_namespace(); + let grant = self.credential_grant(&namespace).await?; + if grant.document.data["spec"]["controller"]["name"] != "kars-controller" + || grant.document.data["spec"]["controller"]["uid"] + .as_str() + .is_none_or(str::is_empty) + { + return Err(failure( + "The controller Deployment UID must be enrolled before provider configuration", + )); + } + let mut incoming = Vec::new(); + for entry in changes { + let name = entry["name"] + .as_str() + .ok_or_else(|| failure("Controller setting name missing"))?; + if entry["$patch"] == "delete" { + incoming.push(json!({"name":name,"remove":true})); + } else if let Some(secret) = entry.get("valueFrom").and_then(|v| v.get("secretKeyRef")) + { + let secret_name = secret["name"] + .as_str() + .ok_or_else(|| failure("Controller Secret reference name missing"))?; + let store = grant + .stores + .iter() + .find(|store| store.secret.name == secret_name) + .ok_or_else(|| failure("Controller credential Secret is not enrolled"))?; + incoming.push(json!({"name":name,"secret":{"name":secret_name,"uid":store.secret.uid,"key":secret["key"]}})); + } else if let Some(value) = entry["value"].as_str() { + incoming.push(json!({"name":name,"value":value})); + } else { + return Err(failure("Unsupported controller environment change")); + } + } + self.mutate_integration(&namespace, "kars-credential-controller-settings", |keys| { + let mut values = keys + .get("configuration") + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .unwrap_or_default(); + for change in &incoming { + values.retain(|existing| existing["name"] != change["name"]); + values.push(change.clone()); + } + keys.insert( + "configuration".into(), + serde_json::to_string(&values).expect("environment settings serialize"), + ); + }) + .await + } + + pub async fn request_teams_reconcile( + &self, + namespace: &str, + gateway: &str, + bff: &str, + ) -> Result<(), kube::Error> { + let grant = self.credential_grant(namespace).await?; + let consumers = &grant.document.data["spec"]["bridgeConsumers"]; + if consumers["gateway"]["name"] != gateway || consumers["bff"]["name"] != bff { + return Err(failure( + "Teams Deployment identities must be enrolled; Bridge cannot patch arbitrary Deployments", + )); + } + if let Some(error) = grant.document.data["status"]["integrationError"].as_str() { + return Err(failure(error)); + } + Ok(()) + } + + pub async fn teams_configured(&self) -> Result { + let namespace = self.integration_namespace(); + let name = std::env::var("BRIDGE_TEAMS_SECRET_NAME") + .unwrap_or_else(|_| "kars-bridge-teams".into()); + let Some(document) = object_api(self, &namespace, "KarsCredentialGrant") + .get_opt(GRANT) + .await + .map_err(|e| safe("Read optional Teams authority", e))? + else { + return Ok(false); + }; + if !document.data["spec"]["integrationStores"] + .as_array() + .is_some_and(|stores| { + stores + .iter() + .any(|store| store["secret"]["name"] == name && store["purpose"] == "teams") + }) + { + return Ok(false); + } + let (_, secret) = self.integration_store(&namespace, &name).await?; + Ok([ + "client-id", + "tenant-id", + "client-secret", + "entra-role-map", + "bff-internal-secret", + ] + .iter() + .all(|key| { + secret + .data + .as_ref() + .and_then(|values| values.get(*key)) + .is_some_and(|value| !value.0.is_empty()) + })) + } +} diff --git a/bridge/bff/src/kars/github_grants.rs b/bridge/bff/src/kars/github_grants.rs new file mode 100644 index 000000000..d5e15fdbb --- /dev/null +++ b/bridge/bff/src/kars/github_grants.rs @@ -0,0 +1,67 @@ +use super::{ + cluster::Cluster, + credential_contract::{GitHubBinding, Identity}, + credentials::failure, +}; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::{Api, ResourceExt}; +use sha2::{Digest, Sha256}; + +impl Cluster { + pub async fn github_connection_grant( + &self, + namespace: &str, + subject: &str, + repositories: Vec, + write: bool, + ) -> Result { + let grant = self.credential_grant(namespace).await?; + let name = format!( + "kars-github-connection-{}", + hex::encode(&Sha256::digest(subject.as_bytes())[..8]) + ); + let connection = Api::::namespaced(self.client.clone(), namespace) + .get(&name) + .await?; + let uid = connection + .uid() + .ok_or_else(|| failure("GitHub connection UID is missing"))?; + let approved=grant.document.data["spec"]["githubConnections"].as_array() + .and_then(|entries|entries.iter().find(|entry| + entry["ownerSubject"]==subject && entry["connection"]["name"]==name && entry["connection"]["uid"]==uid)) + .ok_or_else(||failure("GitHub connection requires explicit operator App/installation/repository enrollment; no legacy token fallback"))?; + let repositories = repositories + .into_iter() + .map(|repo| repo.to_ascii_lowercase()) + .collect::>(); + let allowed = approved["repositories"] + .as_array() + .ok_or_else(|| failure("GitHub repository grant is malformed"))?; + let installation = connection + .data + .as_ref() + .and_then(|data| data.get("installation_id")) + .and_then(|id| id.parse::().ok()); + if connection.metadata.deletion_timestamp.is_some() + || installation != approved["installationId"].as_u64() + || repositories.is_empty() + || repositories.len() > 32 + || repositories.iter().any(|repo| { + !allowed + .iter() + .any(|value| value.as_str() == Some(repo.as_str())) + }) + || (write && approved["write"] != true) + { + return Err(failure( + "GitHub connection incarnation, installation, repositories or write authority changed", + )); + } + Ok(GitHubBinding { + grant: grant.identity, + connection: Identity { name, uid }, + repositories, + write, + }) + } +} diff --git a/bridge/bff/src/kars/mod.rs b/bridge/bff/src/kars/mod.rs new file mode 100644 index 000000000..af0f40d58 --- /dev/null +++ b/bridge/bff/src/kars/mod.rs @@ -0,0 +1,19 @@ +// kars Bridge BFF — kars cluster contract + access. + +pub mod approval; +pub mod cluster; +pub mod credential_contract; +pub mod credential_review; +mod credential_targets; +#[cfg(test)] +mod credential_tests; +mod credential_transport; +pub mod credentials; +mod github_grants; +pub mod operator_credentials; +pub mod receipt; +pub(crate) mod receipt_log; +pub mod sre_action; +pub mod task; +pub mod team; +mod workspace_credential_plan; diff --git a/bridge/bff/src/kars/observation_credential_tests.rs b/bridge/bff/src/kars/observation_credential_tests.rs new file mode 100644 index 000000000..b5f254308 --- /dev/null +++ b/bridge/bff/src/kars/observation_credential_tests.rs @@ -0,0 +1,232 @@ +use super::*; +use base64::{Engine, engine::general_purpose::STANDARD}; + +fn inventory(workspace: &str) -> std::collections::BTreeMap { + let identity = json!({"sandbox":{"namespace":workspace,"name":"agent","uid":"sandbox"}, + "namespace_uid":"runtime","task":null,"task_authorization":null,"task_generation":null,"managed":true}); + let binding = json!({"capability":"kars.azure.com/egress-observation/v1","identity":identity, + "grant":{"namespace":workspace,"name":"workspace","uid":"grant","generation":1}, + "recipients":[{"namespace":"bridge","namespaceUid":"bridge","name":"bff","uid":"bff"}], + "privacyRevision":"kars.azure.com/sre-privacy/v2","privacyEpoch":null, + "workspaceUid":"workspace","expiresAt":chrono::Utc::now().timestamp()+600, + "verifier":{"capability":"kars.azure.com/observation-privacy/v1"}, + "serverName":"observer-sandbox.kars.internal","caPem":"not-used-by-rejection-cases"}); + std::collections::BTreeMap::from([ + ( + format!( + "/apis/kars.azure.com/v1alpha1/namespaces/{workspace}/karscredentialgrants/workspace" + ), + json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsCredentialGrant", + "metadata":{"name":"workspace","namespace":workspace,"uid":"grant","generation":1,"resourceVersion":"1"}, + "spec":{"enabled":true,"workspaceUid":"workspace","agentKeys":[],"integrationStores":[], + "observationTargets":[{"kind":"KarsSandbox","namespace":workspace,"name":"agent","uid":"sandbox"}]}, + "status":{"phase":"Ready","observedGeneration":1,"sources":[],"legacySources":[]} + }), + ), + ( + format!("/api/v1/namespaces/{workspace}"), + json!({"apiVersion":"v1","kind":"Namespace", + "metadata":{"name":workspace,"uid":"workspace","resourceVersion":"1"}}), + ), + ( + format!("/apis/kars.azure.com/v1alpha1/namespaces/{workspace}/karssandboxes/agent"), + json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":"agent","namespace":workspace,"uid":"sandbox","resourceVersion":"1", + "annotations":{"kars.azure.com/namespace-uid":"runtime"}}, + "status":{"serviceObservation":{"capability":"kars.azure.com/egress-observation/v1", + "phase":"Ready","grant":{"uid":"grant"},"namespaceUid":"runtime","secret":{"name":"router-services-observer","uid":"secret"}, + "version":"secret:1","deploymentUid":"deployment","privacyRevision":"kars.azure.com/sre-privacy/v2","privacyEpoch":null}} + }), + ), + ( + "/api/v1/namespaces/kars-agent".into(), + json!({"apiVersion":"v1","kind":"Namespace", + "metadata":{"name":"kars-agent","uid":"runtime","resourceVersion":"1","annotations":{ + "kars.azure.com/namespace-claim-version":"v1","kars.azure.com/sandbox-namespace":workspace, + "kars.azure.com/sandbox-name":"agent","kars.azure.com/sandbox-uid":"sandbox"}}}), + ), + ( + "/api/v1/namespaces/kars-agent/secrets/router-services-observer".into(), + json!({ + "apiVersion":"v1","kind":"Secret","type":"Opaque", + "metadata":{"name":"router-services-observer","namespace":"kars-agent","uid":"secret","resourceVersion":"1", + "annotations":{"kars.azure.com/sandbox-uid":"sandbox","kars.azure.com/namespace-uid":"runtime"}}, + "data":{"observation-token":STANDARD.encode("o".repeat(64)),"config.json":STANDARD.encode(binding.to_string())} + }), + ), + ( + "/apis/authentication.k8s.io/v1/selfsubjectreviews".into(), + json!({ + "apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview","status":{"userInfo":{ + "uid":"bff","username":"system:serviceaccount:bridge:bff"}}}), + ), + ( + "/api/v1/namespaces/bridge".into(), + json!({"apiVersion":"v1","kind":"Namespace", + "metadata":{"name":"bridge","uid":"bridge","resourceVersion":"1"}}), + ), + ( + "/api/v1/namespaces/bridge/serviceaccounts/bff".into(), + json!({"apiVersion":"v1","kind":"ServiceAccount", + "metadata":{"name":"bff","namespace":"bridge","uid":"bff","resourceVersion":"1"}}), + ), + ( + "/apis/apps/v1/namespaces/kars-agent/deployments/agent".into(), + json!({ + "apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"agent","namespace":"kars-agent","uid":"deployment","resourceVersion":"1"}}), + ), + ( + "/api/v1/namespaces/kars-agent/pods".into(), + json!({ + "apiVersion":"v1","kind":"PodList","metadata":{},"items":[{ + "metadata":{"name":"agent-pod","namespace":"kars-agent","uid":"pod","resourceVersion":"1", + "annotations":{"kars.azure.com/services-observer-version":"secret:1"}, + "ownerReferences":[{"apiVersion":"apps/v1","kind":"ReplicaSet","name":"agent-rs","uid":"rs","controller":true}]}, + "status":{"phase":"Running","podIP":"127.0.0.1"} + }]}), + ), + ( + "/apis/apps/v1/namespaces/kars-agent/replicasets/agent-rs".into(), + json!({ + "apiVersion":"apps/v1","kind":"ReplicaSet","metadata":{"name":"agent-rs","namespace":"kars-agent","uid":"rs", + "ownerReferences":[{"apiVersion":"apps/v1","kind":"Deployment","name":"agent","uid":"deployment","controller":true}]} + }), + ), + ]) +} + +#[tokio::test] +async fn observation_private_adapter_rejects_old_core_and_foreign_uid_before_contacting_router() { + let (cluster, state, server) = fixture().await; + let workspace = cluster.core_namespace(); + let sandbox = + format!("/apis/kars.azure.com/v1alpha1/namespaces/{workspace}/karssandboxes/agent"); + for (path, pointer, value, expected_last) in [ + ( + sandbox.as_str(), + "/status/serviceObservation/capability", + json!("old-core"), + sandbox.as_str(), + ), + ( + sandbox.as_str(), + "/metadata/uid", + json!("replacement"), + sandbox.as_str(), + ), + ( + "/api/v1/namespaces/kars-agent", + "/metadata/uid", + json!("replacement"), + "/api/v1/namespaces/kars-agent", + ), + ( + "/api/v1/namespaces/kars-agent/secrets/router-services-observer", + "/metadata/uid", + json!("replacement"), + "/api/v1/namespaces/kars-agent/secrets/router-services-observer", + ), + ( + "/api/v1/namespaces/bridge/serviceaccounts/bff", + "/metadata/uid", + json!("replacement"), + "/api/v1/namespaces/bridge/serviceaccounts/bff", + ), + ( + "/api/v1/namespaces/bridge", + "/metadata/uid", + json!("replacement"), + "/api/v1/namespaces/bridge/serviceaccounts/bff", + ), + ( + "/apis/apps/v1/namespaces/kars-agent/deployments/agent", + "/metadata/uid", + json!("replacement"), + "/apis/apps/v1/namespaces/kars-agent/deployments/agent", + ), + ( + "/apis/apps/v1/namespaces/kars-agent/replicasets/agent-rs", + "/metadata/uid", + json!("replacement"), + "/apis/apps/v1/namespaces/kars-agent/replicasets/agent-rs", + ), + ] { + { + let mut data = state.lock().unwrap(); + data.calls.clear(); + data.objects = inventory(&workspace); + *data + .objects + .get_mut(path) + .unwrap() + .pointer_mut(pointer) + .unwrap() = value; + } + assert!( + cluster.private_learned_domains("agent").await.is_err(), + "{path}{pointer}" + ); + let data = state.lock().unwrap(); + assert_eq!( + data.calls.last().unwrap().1, + expected_last, + "{path}{pointer}" + ); + assert!(data.calls.iter().all(|(method, path, _)| method == "GET" + || path == "/apis/authentication.k8s.io/v1/selfsubjectreviews")); + assert!( + data.calls + .iter() + .filter(|(_, path, _)| path.contains("/secrets/")) + .all(|(_, path, _)| path.ends_with("/router-services-observer")) + ); + } + server.abort(); +} + +#[tokio::test] +async fn observation_private_adapter_surfaces_forbidden_without_legacy_fallback() { + let (cluster, state, server) = fixture().await; + state.lock().unwrap().forbidden = true; + let error = cluster + .private_learned_domains("agent") + .await + .unwrap_err() + .to_string(); + assert!(!error.contains("PRIVATE_VALUE_SENTINEL")); + assert_eq!(state.lock().unwrap().calls.len(), 1); + server.abort(); +} + +#[tokio::test] +async fn observation_private_adapter_requires_current_rpc_capability_and_unexpired_token() { + let (cluster, state, server) = fixture().await; + let workspace = cluster.core_namespace(); + let path = "/api/v1/namespaces/kars-agent/secrets/router-services-observer"; + for (field, value) in [ + ("verifier", Value::Null), + ("expiresAt", json!(chrono::Utc::now().timestamp() - 1)), + ("workspaceUid", json!("replaced")), + ] { + { + let mut data = state.lock().unwrap(); + data.calls.clear(); + data.objects = inventory(&workspace); + let secret = data.objects.get_mut(path).unwrap(); + let raw = STANDARD + .decode(secret["data"]["config.json"].as_str().unwrap()) + .unwrap(); + let mut config: Value = serde_json::from_slice(&raw).unwrap(); + config[field] = value; + secret["data"]["config.json"] = STANDARD.encode(config.to_string()).into(); + } + assert!( + cluster.private_learned_domains("agent").await.is_err(), + "{field}" + ); + assert_eq!(state.lock().unwrap().calls.last().unwrap().1, path); + } + server.abort(); +} diff --git a/bridge/bff/src/kars/operator_credentials.rs b/bridge/bff/src/kars/operator_credentials.rs new file mode 100644 index 000000000..6f3ef261b --- /dev/null +++ b/bridge/bff/src/kars/operator_credentials.rs @@ -0,0 +1,316 @@ +// Private observations are separate from legacy admin, control and App credentials. + +use super::{cluster::Cluster, credentials::failure}; +use k8s_openapi::api::{ + apps::v1::{Deployment, ReplicaSet}, + authentication::v1::SelfSubjectReview, + core::v1::{Namespace, Pod, Secret, ServiceAccount}, +}; +use kube::{ + Api, ResourceExt, + api::{ListParams, PostParams}, + core::{ApiResource, DynamicObject, GroupVersionKind}, +}; +use serde_json::{Value, json}; +use std::net::{IpAddr, SocketAddr}; + +const CAPABILITY: &str = "kars.azure.com/egress-observation/v1"; +const PRIVACY_VERIFIER: &str = "kars.azure.com/observation-privacy/v1"; +const SECRET: &str = "router-services-observer"; +const PORT: u16 = 9447; + +fn unavailable() -> kube::Error { + failure( + "Private read-only observation capability is unavailable; legacy admin credentials are not a fallback", + ) +} + +impl Cluster { + pub async fn private_learned_domains(&self, name: &str) -> Result, kube::Error> { + let workspace = self.core_namespace(); + let grant = self.credential_grant(&workspace).await?; + let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( + "kars.azure.com", + "v1alpha1", + "KarsSandbox", + )); + let sandbox_api = + Api::::namespaced_with(self.client.clone(), &workspace, &resource); + let sandbox = sandbox_api.get(name).await?; + let uid = sandbox.uid().ok_or_else(unavailable)?; + if !grant.document.data["spec"]["observationTargets"] + .as_array() + .is_some_and(|targets| { + targets.iter().any(|target| { + target["kind"] == "KarsSandbox" + && target["namespace"] == workspace + && target["name"] == name + && target["uid"] == uid + }) + }) + { + return Err(unavailable()); + } + let observation = &sandbox.data["status"]["serviceObservation"]; + if sandbox.metadata.deletion_timestamp.is_some() + || observation["capability"] != CAPABILITY + || observation["phase"] != "Ready" + || observation["grant"]["uid"] != grant.identity.uid + { + return Err(unavailable()); + } + let runtime = format!("kars-{name}"); + let namespace = Api::::all(self.client.clone()) + .get(&runtime) + .await?; + let namespace_uid = namespace.uid().ok_or_else(unavailable)?; + let annotations = namespace + .metadata + .annotations + .as_ref() + .ok_or_else(unavailable)?; + if namespace.metadata.deletion_timestamp.is_some() + || annotations + .get("kars.azure.com/namespace-claim-version") + .map(String::as_str) + != Some("v1") + || annotations.get("kars.azure.com/sandbox-namespace") != Some(&workspace) + || annotations + .get("kars.azure.com/sandbox-name") + .map(String::as_str) + != Some(name) + || annotations.get("kars.azure.com/sandbox-uid") != Some(&uid) + || sandbox + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/namespace-uid")) + != Some(&namespace_uid) + || observation["namespaceUid"] != namespace_uid + { + return Err(unavailable()); + } + let secret = Api::::namespaced(self.client.clone(), &runtime) + .get(SECRET) + .await?; + let secret_uid = secret.uid().ok_or_else(unavailable)?; + let version = format!( + "{}:{}", + secret_uid, + secret.resource_version().ok_or_else(unavailable)? + ); + if secret.metadata.deletion_timestamp.is_some() + || secret.type_.as_deref() != Some("Opaque") + || observation["secret"]["name"] != SECRET + || observation["secret"]["uid"] != secret_uid + || observation["version"] != version + || secret + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/sandbox-uid")) + != Some(&uid) + || secret + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/namespace-uid")) + != Some(&namespace_uid) + { + return Err(unavailable()); + } + let data = secret.data.as_ref().ok_or_else(unavailable)?; + if data + .keys() + .any(|key| !["observation-token", "config.json"].contains(&key.as_str())) + { + return Err(unavailable()); + } + let token = String::from_utf8( + data.get("observation-token") + .ok_or_else(unavailable)? + .0 + .clone(), + ) + .map_err(|_| unavailable())?; + let config: Value = + serde_json::from_slice(&data.get("config.json").ok_or_else(unavailable)?.0) + .map_err(|_| unavailable())?; + if token.len() != 64 + || config["capability"] != CAPABILITY + || config["verifier"]["capability"] != PRIVACY_VERIFIER + || config["expiresAt"] + .as_i64() + .is_none_or(|expiry| expiry <= chrono::Utc::now().timestamp()) + || config["workspaceUid"] != grant.document.data["spec"]["workspaceUid"] + || config["identity"]["sandbox"]["uid"] != uid + || config["identity"]["namespace_uid"] != namespace_uid + || config["grant"]["uid"] != grant.identity.uid + || config["grant"]["generation"] != json!(grant.document.metadata.generation) + || config["privacyRevision"] != observation["privacyRevision"] + || config["privacyEpoch"] != observation["privacyEpoch"] + { + return Err(unavailable()); + } + let caller = Api::::all(self.client.clone()) + .create(&PostParams::default(), &SelfSubjectReview::default()) + .await?; + let caller = serde_json::to_value(caller).map_err(|_| unavailable())?; + let caller_uid = caller["status"]["userInfo"]["uid"] + .as_str() + .ok_or_else(unavailable)?; + let caller_name = caller["status"]["userInfo"]["username"] + .as_str() + .ok_or_else(unavailable)?; + let recipient = config["recipients"] + .as_array() + .and_then(|recipients| { + recipients.iter().find(|recipient| { + recipient["uid"] == caller_uid + && caller_name + == format!( + "system:serviceaccount:{}:{}", + recipient["namespace"].as_str().unwrap_or_default(), + recipient["name"].as_str().unwrap_or_default() + ) + }) + }) + .ok_or_else(unavailable)?; + let receiver_namespace = recipient["namespace"].as_str().ok_or_else(unavailable)?; + let receiver_name = recipient["name"].as_str().ok_or_else(unavailable)?; + let receiver_ns = Api::::all(self.client.clone()) + .get(receiver_namespace) + .await?; + let receiver_sa = + Api::::namespaced(self.client.clone(), receiver_namespace) + .get(receiver_name) + .await?; + if recipient["namespaceUid"] != json!(receiver_ns.metadata.uid) + || recipient["uid"] != json!(receiver_sa.metadata.uid) + || receiver_ns.metadata.deletion_timestamp.is_some() + || receiver_sa.metadata.deletion_timestamp.is_some() + { + return Err(unavailable()); + } + let deployment = Api::::namespaced(self.client.clone(), &runtime) + .get(name) + .await?; + if observation["deploymentUid"] != json!(deployment.metadata.uid) + || deployment.metadata.deletion_timestamp.is_some() + { + return Err(unavailable()); + } + let pods = Api::::namespaced(self.client.clone(), &runtime) + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={name}"))) + .await?; + let mut address = None; + for pod in pods { + if pod.metadata.deletion_timestamp.is_some() + || pod.status.as_ref().and_then(|s| s.phase.as_deref()) != Some("Running") + || pod + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/services-observer-version")) + != Some(&version) + { + continue; + } + let Some(owner) = pod.metadata.owner_references.as_ref().and_then(|owners| { + owners + .iter() + .find(|owner| owner.kind == "ReplicaSet" && owner.controller == Some(true)) + }) else { + continue; + }; + let set = Api::::namespaced(self.client.clone(), &runtime) + .get(&owner.name) + .await?; + if set.uid().as_deref() != Some(owner.uid.as_str()) + || set.metadata.deletion_timestamp.is_some() + || set.metadata.owner_references.as_ref().is_none_or(|owners| { + !owners.iter().any(|owner| { + owner.kind == "Deployment" + && owner.controller == Some(true) + && Some(owner.uid.as_str()) == deployment.metadata.uid.as_deref() + }) + }) + { + continue; + } + if let Some(ip) = pod + .status + .as_ref() + .and_then(|s| s.pod_ip.as_deref()) + .and_then(|ip| ip.parse::().ok()) + { + address = Some(SocketAddr::new(ip, PORT)); + break; + } + } + let address = address.ok_or_else(unavailable)?; + let host = config["serverName"].as_str().ok_or_else(unavailable)?; + if host != format!("observer-{uid}.kars.internal") { + return Err(unavailable()); + } + let ca = reqwest::Certificate::from_pem( + config["caPem"].as_str().ok_or_else(unavailable)?.as_bytes(), + ) + .map_err(|_| unavailable())?; + let client = reqwest::Client::builder() + .no_proxy() + .https_only(true) + .tls_built_in_root_certs(false) + .add_root_certificate(ca) + .redirect(reqwest::redirect::Policy::none()) + .resolve(host, address) + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| unavailable())?; + let origin = format!("https://{host}:{PORT}"); + let scope = client + .get(format!("{origin}/internal/observations/scope")) + .bearer_auth(&token) + .send() + .await + .map_err(|_| unavailable())?; + if !scope.status().is_success() { + return Err(unavailable()); + } + let scope: Value = scope.json().await.map_err(|_| unavailable())?; + if scope["capability"] != CAPABILITY + || scope["identity"] != config["identity"] + || scope["privacy_verifier"] != PRIVACY_VERIFIER + { + return Err(unavailable()); + } + let response = client + .get(format!("{origin}/internal/observations/egress/learned")) + .bearer_auth(&token) + .header( + "x-kars-service-scope", + scope["scope_id"].as_str().ok_or_else(unavailable)?, + ) + .send() + .await + .map_err(|_| unavailable())?; + if !response.status().is_success() { + return Err(unavailable()); + } + let value: Value = response.json().await.map_err(|_| unavailable())?; + if value["capability"] != CAPABILITY || value["scope_id"] != scope["scope_id"] { + return Err(unavailable()); + } + Ok(value["domains"] + .as_array() + .ok_or_else(unavailable)? + .iter() + .filter_map(|domain| { + domain + .as_str() + .map(str::to_string) + .or_else(|| domain["domain"].as_str().map(str::to_string)) + }) + .collect()) + } +} diff --git a/bridge/bff/src/kars/receipt.rs b/bridge/bff/src/kars/receipt.rs new file mode 100644 index 000000000..bb29f14a6 --- /dev/null +++ b/bridge/bff/src/kars/receipt.rs @@ -0,0 +1,104 @@ +// kars Bridge BFF — typed view of the `KarsReceipt` CRD. +// +// CONTRACT OWNERSHIP: the `KarsReceipt` schema is owned by core kars +// (`Azure/kars`, controller/src/kars_receipt.rs). This is a *consumer* +// projection — it mirrors only the fields the Bridge surfaces in the +// Governance Receipt evidence view, with matching group/version/kind and +// camelCase serde so the wire shape is identical. +// +// Rendering a receipt does not establish validity. The BFF's explicit +// verification endpoint and `kars receipt verify` perform separate +// cryptographic checks against the controller-published trust anchor. + +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::kars::task::LocalObjectRef; + +/// `KarsReceipt.spec` — the signed Governance Receipt for one task. +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsReceipt", + namespaced, + status = "KarsReceiptStatus" +)] +#[serde(rename_all = "camelCase")] +pub struct KarsReceiptSpec { + /// The task this receipt attests. + pub task_ref: LocalObjectRef, + /// `sha256:` digest of the trust envelope the task ran under. + pub envelope_digest: String, + /// in-toto predicate type URI. + pub predicate_type: String, + /// Signing scheme, e.g. `DSSEv1+ed25519`. + pub scheme: String, + /// Hex SHA-256 fingerprint of the signing public key. + pub key_id: String, + /// The DSSE envelope: base64 in-toto Statement + Ed25519 signature(s). + pub dsse: DsseEnvelope, + /// Optional unsigned echo. UI claims come from the signed predicate; + /// a nonempty contradictory echo invalidates the receipt. + #[serde(default)] + pub claims: Vec, +} + +/// A DSSE envelope. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DsseEnvelope { + /// Base64 of the in-toto Statement JSON (the signed payload). + pub payload: String, + /// The DSSE payload type. + pub payload_type: String, + /// The signatures over the PAE. + #[serde(default)] + pub signatures: Vec, +} + +/// One DSSE signature. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct DsseSignature { + pub keyid: String, + pub sig: String, +} + +/// One claim-class assertion. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReceiptClaim { + /// `integrity` | `conformance` | `completeness` | `regulatory`. + pub class: String, + /// `PASS` | `PARTIAL` | `FAIL` | `OMITTED`. + pub status: String, + /// Human-readable justification, surfaced verbatim. + pub detail: String, +} + +/// `KarsReceipt.status` — informational echo (the authority is the signature). +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsReceiptStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issued_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_task_generation: Option, + /// Sequence number in the hash-chained receipt inclusion log. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inclusion_seq: Option, + /// Hash of this receipt's inclusion-log entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inclusion_entry_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inclusion_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inclusion_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_segment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint_tree_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub witnessed: Option, +} diff --git a/bridge/bff/src/kars/receipt_log.rs b/bridge/bff/src/kars/receipt_log.rs new file mode 100644 index 000000000..cc3f8bfdf --- /dev/null +++ b/bridge/bff/src/kars/receipt_log.rs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! One complete, namespace-bound snapshot for receipt-log readers and summaries. + +use std::collections::BTreeMap; + +use k8s_openapi::api::core::v1::ConfigMap; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ListMeta; +use kube::{Api, api::ListParams}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use super::cluster::Cluster; + +const HEAD: &str = "kars-receipt-log"; +const PREFIX: &str = "kars-receipt-log-"; +const COMPONENT: &str = "app.kubernetes.io/component"; +const SEGMENT_COMPONENT: &str = "receipt-inclusion-log-segment"; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChainEntry { + pub seq: i64, + pub receipt: String, + pub payload_sha256: String, + pub prev_hash: String, + pub entry_hash: String, +} + +pub(crate) fn chain_entry_hash( + seq: i64, + receipt: &str, + payload_sha256: &str, + prev_hash: &str, +) -> String { + let mut hash = Sha256::new(); + hash.update(seq.to_string().as_bytes()); + hash.update(b"|"); + hash.update(receipt.as_bytes()); + hash.update(b"|"); + hash.update(payload_sha256.as_bytes()); + hash.update(b"|"); + hash.update(prev_hash.as_bytes()); + hex::encode(hash.finalize()) +} + +#[derive(Debug, Default)] +pub(crate) struct ReceiptLog { + pub present: bool, + pub entries: Vec, + pub checkpoint: Option>, + pub witness: Option>, + pub public_key: Option>, +} + +impl ReceiptLog { + pub fn anchor(&self) -> Option<(String, String, String)> { + let data = self.public_key.as_ref()?; + Some(( + data.get("keyId")?.clone(), + data.get("publicKey")?.clone(), + data.get("scheme").cloned().unwrap_or_default(), + )) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ReceiptLogError { + #[error("receipt log snapshot API status {0}")] + Api(u16), + #[error("receipt log snapshot transport or decoding failed")] + Read, + #[error("invalid receipt log: {0}")] + Invalid(&'static str), +} + +fn invalid(message: &'static str) -> ReceiptLogError { + ReceiptLogError::Invalid(message) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ReceiptLogSnapshot { + api_version: String, + kind: String, + metadata: ListMeta, + items: Vec, +} + +fn validate_identity(map: &ConfigMap, namespace: &str) -> Result<(), ReceiptLogError> { + if map.metadata.namespace.as_deref() != Some(namespace) + || map.metadata.name.as_deref().is_none_or(str::is_empty) + || map.metadata.uid.as_deref().is_none_or(str::is_empty) + || map + .metadata + .resource_version + .as_deref() + .is_none_or(str::is_empty) + || map.metadata.deletion_timestamp.is_some() + { + return Err(invalid("missing or foreign ConfigMap identity")); + } + Ok(()) +} + +fn parse_snapshot( + snapshot: ReceiptLogSnapshot, + namespace: &str, +) -> Result { + if snapshot.api_version != "v1" + || snapshot.kind != "ConfigMapList" + || snapshot + .metadata + .resource_version + .as_deref() + .is_none_or(str::is_empty) + || snapshot + .metadata + .continue_ + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + return Err(invalid("incomplete or foreign API snapshot")); + } + let mut log = ReceiptLog::default(); + let mut segments = BTreeMap::new(); + for map in snapshot.items { + if map.metadata.namespace.as_deref() != Some(namespace) { + return Err(invalid("foreign namespace in API snapshot")); + } + let name = map + .metadata + .name + .as_deref() + .ok_or_else(|| invalid("unnamed ConfigMap"))?; + let component = map + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(COMPONENT)) + .map(String::as_str); + let index = if name == HEAD { + if component.is_some_and(|value| value != "receipt-inclusion-log") { + return Err(invalid("foreign head component")); + } + Some(0) + } else if let Some(suffix) = name.strip_prefix(PREFIX) { + let index = suffix + .parse::() + .map_err(|_| invalid("invalid segment name"))?; + if index == 0 + || name != format!("{PREFIX}{index:06}") + || component != Some(SEGMENT_COMPONENT) + { + return Err(invalid("segment name or component mismatch")); + } + Some(index) + } else { + if component == Some(SEGMENT_COMPONENT) { + return Err(invalid("segment label has an unexpected name")); + } + None + }; + if let Some(index) = index { + validate_identity(&map, namespace)?; + if map + .metadata + .owner_references + .as_ref() + .is_some_and(|owners| !owners.is_empty()) + { + return Err(invalid("receipt segment has a foreign owner")); + } + if segments.insert(index, map).is_some() { + return Err(invalid("duplicate segment")); + } + } else { + let destination = match name { + "kars-receipt-checkpoint" => &mut log.checkpoint, + "kars-receipt-witness" => &mut log.witness, + "kars-receipt-pubkey" => &mut log.public_key, + _ => continue, + }; + validate_identity(&map, namespace)?; + if destination.is_some() { + return Err(invalid("duplicate receipt artifact")); + } + *destination = Some( + map.data + .ok_or_else(|| invalid("receipt artifact has no data"))?, + ); + } + } + let mut previous = "genesis".to_string(); + for (position, (index, map)) in segments.into_iter().enumerate() { + if index != position as u64 { + return Err(invalid("missing head or segment gap")); + } + let data = map + .data + .ok_or_else(|| invalid("receipt segment has no data"))?; + let raw = data + .get("chain.json") + .ok_or_else(|| invalid("receipt segment has no chain.json"))?; + let entries: Vec = + serde_json::from_str(raw).map_err(|_| invalid("malformed chain.json"))?; + if index > 0 + && (entries.is_empty() + || data.get("segmentIndex") != Some(&index.to_string()) + || data.get("previousRootHash") != Some(&previous)) + { + return Err(invalid("empty segment or mismatched index/previous root")); + } + for entry in entries { + if entry.seq != log.entries.len() as i64 + || entry.prev_hash != previous + || chain_entry_hash( + entry.seq, + &entry.receipt, + &entry.payload_sha256, + &entry.prev_hash, + ) != entry.entry_hash + { + return Err(invalid("noncontiguous or corrupt entry chain")); + } + previous = entry.entry_hash.clone(); + log.entries.push(entry); + } + log.present = true; + } + if !log.present + && log.checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.get("treeSize").map(String::as_str) != Some("0") + || checkpoint.get("rootHash").map(String::as_str) != Some("genesis") + }) + { + return Err(invalid("checkpoint refers to a missing log")); + } + Ok(log) +} + +impl Cluster { + pub(crate) async fn receipt_log(&self) -> Result { + self.receipt_log_in(&self.core_namespace()).await + } + + async fn receipt_log_in(&self, namespace: &str) -> Result { + let maps: Api = Api::namespaced(self.client.clone(), namespace); + let request = kube::core::Request::new(maps.resource_url()) + .list(&ListParams::default()) + .map_err(|_| ReceiptLogError::Read)?; + // kube's JSON request helper logs malformed bodies and normalizes null + // list items to empty. Preserve authentication, but validate the raw reply here. + let response = self + .client + .send(request.map(kube::client::Body::from)) + .await + .map_err(|_| ReceiptLogError::Read)?; + if response.status() != http::StatusCode::OK { + return Err(ReceiptLogError::Api(response.status().as_u16())); + } + let bytes = axum::body::to_bytes(axum::body::Body::new(response.into_body()), usize::MAX) + .await + .map_err(|_| ReceiptLogError::Read)?; + let snapshot = + serde_json::from_slice(&bytes).map_err(|_| invalid("malformed API snapshot"))?; + parse_snapshot(snapshot, namespace) + } +} + +#[cfg(test)] +mod tests; diff --git a/bridge/bff/src/kars/receipt_log/tests.rs b/bridge/bff/src/kars/receipt_log/tests.rs new file mode 100644 index 000000000..ffa60eb43 --- /dev/null +++ b/bridge/bff/src/kars/receipt_log/tests.rs @@ -0,0 +1,667 @@ +use super::*; +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::State, + http::{Method, Request, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::get, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tower::ServiceExt; + +const PRIVATE: &str = "PRIVATE_RECEIPT_OR_API_BODY_MUST_NOT_ESCAPE"; + +fn entries(count: usize) -> Vec { + let mut result = Vec::new(); + let mut previous = "genesis".to_string(); + for index in 0..count { + let payload = format!("older-opaque-payload-{index}"); + let receipt = format!("work/task-{index}"); + let hash = chain_entry_hash(index as i64, &receipt, &payload, &previous); + result.push( + json!({"seq":index,"receipt":receipt,"payloadSha256":payload, + "prevHash":previous,"entryHash":hash}), + ); + previous = hash; + } + result +} + +fn map(namespace: &str, name: &str, data: Value) -> Value { + json!({"apiVersion":"v1","kind":"ConfigMap", + "metadata":{"name":name,"namespace":namespace,"uid":format!("uid-{name}"),"resourceVersion":"1"}, + "data":data}) +} + +fn log_maps(namespace: &str, chain: &[Value], split: Option) -> Vec { + let cut = split.unwrap_or(chain.len()); + let mut maps = vec![map( + namespace, + HEAD, + json!({"chain.json":serde_json::to_string(&chain[..cut]).unwrap()}), + )]; + if let Some(split) = split { + maps[0]["immutable"] = true.into(); + let mut segment = map( + namespace, + "kars-receipt-log-000001", + json!({ + "chain.json":serde_json::to_string(&chain[split..]).unwrap(), + "segmentIndex":"1","previousRootHash":chain[split-1]["entryHash"]}), + ); + segment["metadata"]["labels"] = json!({COMPONENT:SEGMENT_COMPONENT}); + maps.push(segment); + } + maps +} + +fn wire_snapshot(maps: Vec) -> Value { + json!({"apiVersion":"v1","kind":"ConfigMapList","metadata":{"resourceVersion":"snapshot-1"},"items":maps}) +} + +fn parsed(value: Value, namespace: &str) -> Result { + let snapshot = serde_json::from_value(value).map_err(|_| invalid("malformed API snapshot"))?; + parse_snapshot(snapshot, namespace) +} + +#[test] +fn receipt_log_distinguishes_absent_legacy_empty_and_rotated_history() { + assert!(!parsed(wire_snapshot(vec![]), "work").unwrap().present); + let empty = parsed(wire_snapshot(log_maps("work", &[], None)), "work").unwrap(); + assert!(empty.present); + assert!(empty.entries.is_empty()); + for split in [None, Some(2)] { + let log = parsed(wire_snapshot(log_maps("work", &entries(5), split)), "work").unwrap(); + assert!(log.present); + assert_eq!(log.entries.len(), 5); + assert_eq!(log.entries[4].payload_sha256, "older-opaque-payload-4"); + } +} + +fn invalid_snapshots(namespace: &str) -> Vec { + let valid = wire_snapshot(log_maps(namespace, &entries(3), Some(1))); + let mut cases = Vec::new(); + let mut push = |value: Value| cases.push(value); + let mut value = valid.clone(); + value["items"][0]["data"]["chain.json"] = PRIVATE.into(); + push(value); + let mut value = valid.clone(); + value["items"][0].as_object_mut().unwrap().remove("data"); + push(value); + let mut value = valid.clone(); + value["items"][0]["data"] = json!({}); + push(value); + let mut value = valid.clone(); + value["items"].as_array_mut().unwrap().remove(0); + push(value); + let mut value = valid.clone(); + value["items"][1]["metadata"]["name"] = "kars-receipt-log-000002".into(); + value["items"][1]["data"]["segmentIndex"] = "2".into(); + push(value); + let mut value = valid.clone(); + value["items"][1]["data"]["previousRootHash"] = PRIVATE.into(); + push(value); + let mut value = valid.clone(); + value["items"][1]["data"] + .as_object_mut() + .unwrap() + .remove("segmentIndex"); + push(value); + let mut value = valid.clone(); + value["items"][1]["data"]["segmentIndex"] = "2".into(); + push(value); + let mut value = valid.clone(); + value["items"][1]["data"]["chain.json"] = "[]".into(); + push(value); + let mut value = valid.clone(); + value["items"][1]["metadata"]["namespace"] = "foreign".into(); + push(value); + let mut value = valid.clone(); + value["items"][1]["metadata"]["name"] = "foreign-name".into(); + push(value); + let mut value = valid.clone(); + value["items"][0]["metadata"]["labels"] = json!({COMPONENT:"foreign"}); + push(value); + let mut value = valid.clone(); + value["items"][0]["metadata"]["ownerReferences"] = json!([ + {"apiVersion":"v1","kind":"Pod","name":"foreign","uid":"foreign"}]); + push(value); + for key in ["uid", "resourceVersion"] { + let mut value = valid.clone(); + value["items"][0]["metadata"] + .as_object_mut() + .unwrap() + .remove(key); + push(value); + } + let mut value = valid.clone(); + value["items"][0]["metadata"]["deletionTimestamp"] = "2026-09-11T00:00:00Z".into(); + push(value); + let mut value = valid.clone(); + value["metadata"]["continue"] = "another-page".into(); + push(value); + let mut value = valid.clone(); + value["metadata"] + .as_object_mut() + .unwrap() + .remove("resourceVersion"); + push(value); + let mut value = valid.clone(); + value["kind"] = "SecretList".into(); + push(value); + let mut value = valid.clone(); + value["apiVersion"] = "foreign/v1".into(); + push(value); + let mut value = valid.clone(); + value["items"] = Value::Null; + push(value); + let mut value = valid.clone(); + value.as_object_mut().unwrap().remove("items"); + push(value); + let mut value = valid.clone(); + let duplicate = value["items"][1].clone(); + value["items"].as_array_mut().unwrap().push(duplicate); + push(value); + let mut broken = entries(3); + broken[2]["payloadSha256"] = PRIVATE.into(); + push(wire_snapshot(log_maps(namespace, &broken, Some(1)))); + let mut broken = entries(3); + broken[2]["seq"] = 7.into(); + push(wire_snapshot(log_maps(namespace, &broken, Some(1)))); + push(wire_snapshot(vec![map("foreign", "unrelated", json!({}))])); + cases +} + +#[test] +fn receipt_log_rejects_invalid_history_and_foreign_or_incomplete_snapshots_without_payload_errors() +{ + for value in invalid_snapshots("work") { + let error = parsed(value, "work").unwrap_err(); + assert!(!error.to_string().contains(PRIVATE)); + } +} + +struct ApiState { + snapshot: Value, + subsequent_snapshot: Option, + snapshot_reads: usize, + status: u16, + core_namespace: String, + calls: Vec<(String, String)>, + tasks: Vec, + receipts: Vec, + receipt_details: BTreeMap, +} + +async fn api(State(state): State>>, method: Method, uri: Uri) -> Response { + let mut state = state.lock().unwrap(); + state.calls.push((method.to_string(), uri.to_string())); + assert_eq!( + method, + Method::GET, + "receipt readers must not mutate Kubernetes" + ); + if let Some(value) = state.receipt_details.get(uri.path()) { + return Json(value.clone()).into_response(); + } + if uri.path() == format!("/api/v1/namespaces/{}/configmaps", state.core_namespace) + && uri.query().is_none_or(str::is_empty) + { + if state.status != 200 { + return ( + StatusCode::from_u16(state.status).unwrap(), + Json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","reason":"Forbidden", + "code":state.status,"message":PRIVATE + })), + ) + .into_response(); + } + state.snapshot_reads += 1; + let snapshot = if state.snapshot_reads > 1 { + state + .subsequent_snapshot + .as_ref() + .unwrap_or(&state.snapshot) + } else { + &state.snapshot + }; + return Json(snapshot.clone()).into_response(); + } + if uri.path().ends_with("/karstasks") { + return Json( + json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTaskList", + "metadata":{"resourceVersion":"1"},"items":state.tasks}), + ) + .into_response(); + } + if uri.path().ends_with("/karsreceipts") { + return Json( + json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsReceiptList", + "metadata":{"resourceVersion":"1"},"items":state.receipts}), + ) + .into_response(); + } + if uri.path().ends_with("/configmaps") { + return Json(wire_snapshot(vec![])).into_response(); + } + if uri.path().ends_with("/karssandboxes") || uri.path().ends_with("/karsapprovals") { + return Json(json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"List", + "metadata":{"resourceVersion":"1"},"items":[]})) + .into_response(); + } + ( + StatusCode::NOT_FOUND, + Json(json!({"apiVersion":"v1","kind":"Status", + "status":"Failure","reason":"NotFound","code":404,"message":"missing"})), + ) + .into_response() +} + +async fn fixture( + namespace: &str, +) -> ( + Cluster, + crate::state::AppState, + Arc>, + tokio::task::JoinHandle<()>, +) { + let state = Arc::new(Mutex::new(ApiState { + snapshot: wire_snapshot(vec![]), + subsequent_snapshot: None, + snapshot_reads: 0, + status: 200, + core_namespace: namespace.into(), + calls: vec![], + tasks: vec![], + receipts: vec![], + receipt_details: BTreeMap::new(), + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let application = Router::new().fallback(api).with_state(state.clone()); + let server = tokio::spawn(async move { axum::serve(listener, application).await.unwrap() }); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = kube::Client::try_from(kube::Config::new( + format!("http://{address}").parse().unwrap(), + )) + .unwrap(); + ( + Cluster::for_test_client(client.clone()), + crate::state::AppState::for_test_client(client, "work"), + state, + server, + ) +} + +async fn request(state: crate::state::AppState, path: &str, owner: bool) -> (StatusCode, Value) { + let app = Router::new() + .route("/api/insights", get(crate::routes::insights::get_insights)) + .route("/api/system", get(crate::routes::system::get_system)) + .route( + "/api/operator/audit", + get(crate::routes::operator::get_audit), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/receipt/verify", + get(crate::routes::receipts::verify_receipt), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/receipt", + get(crate::routes::receipts::get_receipt), + ) + .with_state(state); + let mut request = Request::get(path).body(Body::empty()).unwrap(); + request.extensions_mut().insert(crate::auth::Principal { + sub: "owner".into(), + name: "owner".into(), + roles: vec![if owner { "user" } else { "operator" }.into()], + }); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert!(!String::from_utf8_lossy(&body).contains(PRIVATE)); + (status, serde_json::from_slice(&body).unwrap()) +} + +#[tokio::test] +async fn receipt_summary_handlers_count_legacy_and_overflow_from_one_complete_snapshot() { + let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); + let (_, state, api, server) = fixture(&namespace).await; + for split in [None, Some(1)] { + for path in ["/api/insights", "/api/system", "/api/operator/audit"] { + let separate_trace_read = path == "/api/system" && namespace == "kars-system"; + { + let mut api = api.lock().unwrap(); + api.snapshot = wire_snapshot(log_maps(&namespace, &entries(4), split)); + api.snapshot_reads = 0; + // System also lists legacy trace ConfigMaps. Its separate read + // must not provide or overwrite the receipt snapshot. + api.subsequent_snapshot = separate_trace_read.then(|| { + wire_snapshot(vec![map( + &namespace, + "kars-mission-trace-other", + json!({"trace.json":"{\"frames\":[]}","assignmentNonce":"other"}), + )]) + }); + api.calls.clear(); + } + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::OK, "{path}"); + let size = if path == "/api/system" { + &body["counts"]["inclusion_log_size"] + } else { + &body["inclusion_log_size"] + }; + assert_eq!(size, &json!(4), "{path}"); + if separate_trace_read { + assert_eq!(body["counts"]["trace_records"], 1); + } + if path == "/api/operator/audit" { + assert_eq!(body["integrity"]["tree_size"], 4); + assert_eq!(body["integrity"]["chain_consistent"], true); + assert_eq!(body["integrity"]["checkpoint_verified"], false); + } + let api = api.lock().unwrap(); + assert_eq!( + api.calls + .iter() + .filter(|(_, path)| path.trim_end_matches('?') + == format!("/api/v1/namespaces/{namespace}/configmaps")) + .count(), + if separate_trace_read { 2 } else { 1 }, + "{path}: receipt snapshot plus the existing independent trace read" + ); + } + } + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn receipt_summary_handlers_never_turn_malformed_history_or_api_failure_into_zero() { + let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); + let (_, state, api, server) = fixture(&namespace).await; + for snapshot in invalid_snapshots(&namespace) { + api.lock().unwrap().snapshot = snapshot; + for path in ["/api/insights", "/api/system", "/api/operator/audit"] { + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::BAD_GATEWAY, "{path}"); + assert_eq!(body["error"]["code"], "upstream_error"); + assert!(body.get("inclusion_log_size").is_none()); + assert!(body.get("integrity").is_none()); + } + } + for status in [206, 403, 503] { + api.lock().unwrap().status = status; + for path in ["/api/insights", "/api/system", "/api/operator/audit"] { + assert_eq!( + request(state.clone(), path, false).await.0, + StatusCode::BAD_GATEWAY + ); + } + } + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn receipt_summary_absence_is_zero_and_configured_namespace_is_not_replaced() { + let (cluster, _, api, server) = fixture("custom-core").await; + let absent = cluster.receipt_log_in("custom-core").await.unwrap(); + assert!(!absent.present); + api.lock().unwrap().snapshot = wire_snapshot(log_maps("custom-core", &entries(3), Some(1))); + assert_eq!( + cluster + .receipt_log_in("custom-core") + .await + .unwrap() + .entries + .len(), + 3 + ); + assert!( + api.lock() + .unwrap() + .calls + .iter() + .all(|(_, path)| path.starts_with("/api/v1/namespaces/custom-core/")) + ); + server.abort(); + let _ = server.await; + let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); + let (_, state, _, server) = fixture(&namespace).await; + for path in ["/api/insights", "/api/system", "/api/operator/audit"] { + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + if path == "/api/system" { + &body["counts"]["inclusion_log_size"] + } else { + &body["inclusion_log_size"] + }, + &json!(0) + ); + if path == "/api/operator/audit" { + assert_eq!(body["integrity"]["chain_consistent"], false); + assert_eq!(body["integrity"]["checkpoint_verified"], false); + } + } + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn receipt_summary_owner_count_is_filtered_to_actual_owned_inclusions() { + let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); + let (_, state, api, server) = fixture(&namespace).await; + { + let mut api = api.lock().unwrap(); + api.snapshot = wire_snapshot(log_maps(&namespace, &entries(4), Some(1))); + api.tasks = vec![ + json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTask", + "metadata":{"name":"task-3","namespace":"work","annotations":{"kars.azure.com/owner-sub":"owner"}}, + "spec":{},"status":{}}), + ]; + api.receipts = vec![ + json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsReceipt", + "metadata":{"name":"task-3","namespace":"work"},"spec":{"taskRef":{"name":"task-3"}}}), + ]; + } + let (status, body) = request(state, "/api/insights", true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["inclusion_log_size"], 1); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn receipt_detail_handler_reads_legacy_overflow_and_absence_without_hiding_log_errors() { + let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); + let (_, state, api, server) = fixture(&namespace).await; + let key = SigningKey::from_bytes(&[42; 32]); + let payload_type = "application/vnd.in-toto+json"; + let predicate_type = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; + let digest = "0123456789abcdef0123456789abcdef"; + let payload = serde_json::to_vec(&json!({ + "_type":"https://in-toto.io/Statement/v1","predicateType":predicate_type, + "subject":[{"name":"work/task-3","digest":{"sha256":digest}}], + "predicate":{"claims":[{"class":"integrity","status":"PASS","detail":"signed detail"}]} + })) + .unwrap(); + let mut pae = format!( + "DSSEv1 {} {payload_type} {} ", + payload_type.len(), + payload.len() + ) + .into_bytes(); + pae.extend_from_slice(&payload); + let receipt = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsReceipt", + "metadata":{"name":"task-3","namespace":"work","uid":"receipt","resourceVersion":"1"}, + "spec":{"taskRef":{"name":"task-3"},"envelopeDigest":format!("sha256:{digest}"), + "predicateType":predicate_type,"scheme":"DSSEv1+ed25519","keyId":"test", + "dsse":{"payloadType":payload_type,"payload":STANDARD.encode(&payload), + "signatures":[{"keyid":"test","sig":STANDARD.encode(key.sign(&pae).to_bytes())}]}, + "claims":[]},"status":{"inclusionSeq":3}}); + let mut chain = entries(4); + chain[3]["payloadSha256"] = hex::encode(Sha256::digest(&payload)).into(); + chain[3]["entryHash"] = chain_entry_hash( + 3, + "work/task-3", + chain[3]["payloadSha256"].as_str().unwrap(), + chain[3]["prevHash"].as_str().unwrap(), + ) + .into(); + let receipt_path = "/apis/kars.azure.com/v1alpha1/namespaces/work/karsreceipts/task-3"; + api.lock() + .unwrap() + .receipt_details + .insert(receipt_path.into(), receipt); + let path = "/api/namespaces/work/tasks/task-3/receipt"; + for split in [None, Some(1)] { + let mut maps = log_maps(&namespace, &chain, split); + maps.push(map(&namespace, "kars-receipt-checkpoint", json!({ + "treeSize":"4","rootHash":chain[3]["entryHash"],"keyId":"test","publishedAt":"2026-09-11T00:00:00Z"}))); + api.lock().unwrap().snapshot = wire_snapshot(maps); + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["name"], "task-3"); + assert_eq!(body["namespace"], "work"); + assert_eq!(body["task"], "task-3"); + assert_eq!(body["inclusion_seq"], 3); + assert_eq!(body["claims"][0]["detail"], "signed detail"); + assert_eq!(body["checkpoint"]["tree_size"], 4); + assert_eq!(body["checkpoint"]["root_hash"], chain[3]["entryHash"]); + } + api.lock().unwrap().snapshot = wire_snapshot(vec![]); + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::OK); + assert!(body["checkpoint"].is_null()); + for snapshot in invalid_snapshots(&namespace) { + api.lock().unwrap().snapshot = snapshot; + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!(body["error"]["code"], "upstream_error"); + assert!(body.get("checkpoint").is_none()); + assert!(body.get("statement").is_none()); + } + for code in [403, 503] { + api.lock().unwrap().status = code; + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!(body["error"]["code"], "upstream_error"); + } + api.lock().unwrap().receipt_details.clear(); + assert_eq!(request(state, path, false).await.0, StatusCode::NOT_FOUND); + server.abort(); + let _ = server.await; +} + +#[test] +fn receipt_log_integrity_still_verifies_real_signed_checkpoints_and_exact_tree_size() { + let chain = entries(4); + let key = SigningKey::from_bytes(&[42; 32]); + for tree_size in [4, 3] { + let root = chain.last().unwrap()["entryHash"].as_str().unwrap(); + let signature = key.sign(format!("kars-receipt-log\n{tree_size}\n{root}\n").as_bytes()); + let mut maps = log_maps("work", &chain, Some(1)); + maps.push(map("work", "kars-receipt-pubkey", json!({ + "keyId":"test","publicKey":STANDARD.encode(key.verifying_key().to_bytes()),"scheme":"DSSEv1+ed25519"}))); + maps.push(map( + "work", + "kars-receipt-checkpoint", + json!({ + "treeSize":tree_size.to_string(),"rootHash":root,"keyId":"test", + "signature":STANDARD.encode(signature.to_bytes())}), + )); + let log = parsed(wire_snapshot(maps), "work").unwrap(); + let integrity = crate::routes::receipts::verify_log_integrity(&log); + assert!(integrity.chain_consistent); + assert_eq!(integrity.tree_size, 4); + assert_eq!(integrity.checkpoint_verified, tree_size == 4); + } +} + +#[tokio::test] +async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflow_inclusion() { + let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); + let (_, state, api, server) = fixture(&namespace).await; + let key = SigningKey::from_bytes(&[42; 32]); + let payload_type = "application/vnd.in-toto+json"; + let predicate_type = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; + let digest = "0123456789abcdef0123456789abcdef"; + let payload = serde_json::to_vec(&json!({ + "_type":"https://in-toto.io/Statement/v1","predicateType":predicate_type, + "subject":[{"name":"work/task-3","digest":{"sha256":digest}}], + "predicate":{"claims":[{"class":"integrity","status":"PASS","detail":"signed"}]} + })) + .unwrap(); + let mut pae = format!( + "DSSEv1 {} {payload_type} {} ", + payload_type.len(), + payload.len() + ) + .into_bytes(); + pae.extend_from_slice(&payload); + let receipt = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsReceipt", + "metadata":{"name":"task-3","namespace":"work","uid":"receipt","resourceVersion":"1"}, + "spec":{"taskRef":{"name":"task-3"},"envelopeDigest":format!("sha256:{digest}"), + "predicateType":predicate_type,"scheme":"DSSEv1+ed25519","keyId":"test", + "dsse":{"payloadType":payload_type,"payload":STANDARD.encode(&payload), + "signatures":[{"keyid":"test","sig":STANDARD.encode(key.sign(&pae).to_bytes())}]}, + "claims":[]},"status":{"inclusionSeq":3}}); + let mut chain = entries(4); + chain[3]["payloadSha256"] = hex::encode(Sha256::digest(&payload)).into(); + chain[3]["entryHash"] = chain_entry_hash( + 3, + "work/task-3", + chain[3]["payloadSha256"].as_str().unwrap(), + chain[3]["prevHash"].as_str().unwrap(), + ) + .into(); + let root = chain[3]["entryHash"].as_str().unwrap(); + let mut maps = log_maps(&namespace, &chain, Some(1)); + maps.push(map( + &namespace, + "kars-receipt-pubkey", + json!({"keyId":"test", + "publicKey":STANDARD.encode(key.verifying_key().to_bytes()),"scheme":"DSSEv1+ed25519"}), + )); + maps.push(map(&namespace, "kars-receipt-checkpoint", json!({"treeSize":"4","rootHash":root, + "keyId":"test","signature":STANDARD.encode(key.sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()).to_bytes())}))); + maps.push(map( + &namespace, + "kars-receipt-witness", + json!({"witnessKeyId":"advisory-only", + "witnessSignature":"present-not-independently-verified"}), + )); + let receipt_path = "/apis/kars.azure.com/v1alpha1/namespaces/work/karsreceipts/task-3"; + { + let mut api = api.lock().unwrap(); + api.snapshot = wire_snapshot(maps); + api.receipt_details.insert(receipt_path.into(), receipt); + } + let path = "/api/namespaces/work/tasks/task-3/receipt/verify"; + let (status, body) = request(state.clone(), path, false).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["verified"], true); + assert_eq!(body["evidence"]["inclusion"]["tree_size"], 4); + assert_eq!(body["evidence"]["inclusion"]["seq"], 3); + api.lock() + .unwrap() + .receipt_details + .get_mut(receipt_path) + .unwrap()["spec"]["dsse"]["signatures"][0]["sig"] = STANDARD.encode([0; 64]).into(); + assert_eq!( + request(state.clone(), path, false).await.1["verified"], + false + ); + api.lock().unwrap().status = 403; + assert_eq!(request(state, path, false).await.1["verified"], false); + server.abort(); + let _ = server.await; +} diff --git a/bridge/bff/src/kars/sre_action.rs b/bridge/bff/src/kars/sre_action.rs new file mode 100644 index 000000000..ede3a0161 --- /dev/null +++ b/bridge/bff/src/kars/sre_action.rs @@ -0,0 +1,78 @@ +// kars Bridge BFF — typed view of the `KarsSREAction` CRD. +// +// CONTRACT OWNERSHIP: the `KarsSREAction` schema is owned by core kars +// (`Azure/kars`, controller/src/kars_sre_action.rs). This is a *consumer* +// projection mirroring only what the operator console needs, with matching +// group/version/kind and camelCase serde. +// +// A short-lived, single-action, operator-approved fix proposal from the +// kars-sre agent: it diagnoses a workload incident, proposes ONE typed +// remediation, and an operator approves/rejects. On approval the controller +// mints a narrowly-scoped one-shot token and executes; the BFF never touches +// the cluster directly for the actual remediation — it only ever patches +// `spec.approval`, mirroring the exact `decide_approval` pattern used for +// `KarsApproval`. + +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// `KarsSREAction.spec` — one typed-action proposal from the kars-sre agent. +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsSREAction", + namespaced, + status = "KarsSREActionStatus" +)] +#[serde(rename_all = "camelCase")] +pub struct KarsSREActionSpec { + /// The action proposed. Closed-set type + free-form params. + pub action: SreActionSpec, + /// One-paragraph rationale (audit-grade text, ≤2048 chars). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rationale: Option, + /// Short-form "Symptom:"/"Root cause:" diagnosis (≤512 chars). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diagnosis: Option, + /// Operator decision. `Pending` until an operator flips it. + pub approval: SreApprovalSpec, + /// Max age in minutes before the proposal auto-expires (default 15). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_minutes: Option, +} + +/// Typed-action descriptor (closed set: `DeleteResourceQuota`, +/// `PatchDeploymentImage`, `ScaleDeployment`, `RolloutRestart`, `DeletePod`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SreActionSpec { + #[serde(rename = "type")] + pub kind: String, + #[serde(default)] + pub params: BTreeMap, +} + +/// Operator decision payload on a `KarsSREAction`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SreApprovalSpec { + /// `Pending`, `Approved`, or `Rejected`. + pub state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +/// `KarsSREAction.status` — controller-managed phase + observation. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsSREActionStatus { + /// `Proposed` → `Approved` → `Applied` → `Recovered`|`Failed`, or + /// `Rejected`/`Expired`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied_at: Option, +} diff --git a/bridge/bff/src/kars/task.rs b/bridge/bff/src/kars/task.rs new file mode 100644 index 000000000..4acd1f9c6 --- /dev/null +++ b/bridge/bff/src/kars/task.rs @@ -0,0 +1,416 @@ +// kars Bridge BFF — typed view of the `KarsTask` CRD. +// +// CONTRACT OWNERSHIP: the `KarsTask` schema is owned by core kars +// (`Azure/kars`, controller/src/kars_task.rs). This module is a *consumer* +// projection — the standard kube-rs pattern for a client that reads/writes a +// CRD it does not own. It deliberately mirrors only the fields the Bridge UI +// needs, with matching group/version/kind and camelCase serde so the wire +// shape is identical. +// +// One-way dependency (design note §21): Bridge depends on the kars contract, +// never the reverse. When core kars extracts its CRD types into a shared +// library crate, this projection should be replaced by a dependency on that +// crate — until then, the schema contract is the seam. + +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// `KarsTask.spec` — the subset the Bridge reads/writes. +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsTask", + namespaced, + status = "KarsTaskStatus" +)] +#[serde(rename_all = "camelCase")] +pub struct KarsTaskSpec { + /// Plain-language statement of the work to be performed. + pub objective: String, + /// The trust envelope that governs this task and bounds delegation. + pub envelope: TaskEnvelope, + /// Optional parent task (same namespace) this task is a delegated child of. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_ref: Option, + /// Execution gate — when launched, the controller materializes a sandbox. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, + /// The concrete, editable run blueprint reviewed on the launch package — + /// runtime/model/instructions/tools/MCP/egress/isolation/memory. The + /// controller compiles it into the InferencePolicy + KarsSandbox. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blueprint: Option, + /// Optional short label for listings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Per-task retention override, in seconds — mirrors the core contract + /// (controller/src/kars_task.rs::KarsTaskSpec.retention_ttl_seconds). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_ttl_seconds: Option, +} + +/// `KarsTask.spec.blueprint` — the editable composition. Mirrors the core +/// contract (controller/src/kars_task.rs::TaskBlueprint); every field maps to a +/// real field on the materialized InferencePolicy / KarsSandbox. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskBlueprint { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub model_fallbacks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential_bindings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub github_binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mcp_servers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub egress: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub egress_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub isolation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_write: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_plan: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionPlan { + pub schema: String, + pub roles: Vec, + pub max_parallel: i32, + pub synthesis: ExecutionSynthesis, + #[serde(default)] + pub deliverables: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionRole { + pub name: String, + pub objective: String, + #[serde(default)] + pub depends_on: Vec, + pub phases: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionPhase { + pub name: String, + pub objective: String, + #[serde(default)] + pub capabilities: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required_tool_calls: Vec, + #[serde(default)] + pub min_tool_calls: i32, + pub max_tool_calls: i32, + #[serde(default)] + pub fresh_context: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionRequiredToolCall { + pub name: String, + #[serde(default)] + pub arguments: std::collections::BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionSynthesis { + pub objective: String, + #[serde(default)] + pub capabilities: Vec, + pub max_tool_calls: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionDeliverable { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GitWriteConfig { + pub connection_config_map_ref: LocalObjectRef, + #[serde(default)] + pub repos: Vec, +} + +/// A model route: provider tag + deployment name. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskModel { + pub provider: String, + pub deployment: String, +} + +/// A network destination the mission may reach (host + optional port). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskEgress { + pub host: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +/// Execution settings — the launch gate between governed and executing. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskExecution { + #[serde(default)] + pub launch: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, +} + +/// The trust envelope carried by a `KarsTask`. Every field is a ceiling a +/// delegated child may narrow but never exceed. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskEnvelope { + /// Autonomy tier (1..5). + pub tier: i32, + /// Optional resource budget for the task subtree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget: Option, + /// Same-namespace `ToolPolicy` reference bounding callable tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_policy_ref: Option, + /// Same-namespace egress allow-list reference bounding destinations. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub egress_allowlist_ref: Option, + /// Remaining delegation hops this task may still spawn (>= 0). + #[serde(default)] + pub delegation_depth: i32, + /// Maximum autonomy tier any descendant may hold (1..5, <= tier). + pub authority_ceiling: i32, +} + +/// Optional resource budget for a task subtree. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +pub enum BudgetScope { + GovernedInference, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskBudget { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// Maximum total tokens for the task subtree. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + /// Maximum total spend in micro-USD (1e-6 USD). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usd_micros: Option, +} + +/// Same-namespace object reference (name only). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LocalObjectRef { + pub name: String, +} + +/// `KarsTask.status` — the subset the Bridge surfaces. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsTaskStatus { + /// `Pending` | `Ready` | `Degraded`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + /// The generation most recently reconciled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + /// `sha256:` digest of the validated trust envelope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub envelope_digest: Option, + /// Ancestry (oldest-first) for a delegated task; empty for a root task. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lineage: Vec, + /// Standard K8s conditions; the BFF surfaces the `Ready` message so the UI + /// can show *why* a task is Degraded (e.g. an amplification rejection). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditions: Vec, + /// Execution phase: `Idle` | `Launching` | `Running` | `Degraded`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_phase: Option, + /// Name of the materialized sandbox, when launched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_ref: Option, + /// Human-readable execution detail (e.g. the kind/Foundry caveat). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assignment_events: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment_sequence: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskAssignmentStatus { + pub task_id: String, + pub state: String, + pub worker_did: Option, + pub stage: Option, + pub child_task_id: Option, + pub child_role: Option, + pub last_progress_at: Option, + pub completed_at: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskAssignmentEvent { + pub sequence: i64, + pub event_id: String, + pub task_id: String, + pub event_type: String, + pub state: String, + pub at: String, + pub worker_did: Option, + pub stage: Option, + pub child_task_id: Option, + pub child_role: Option, + pub outcome: Option, + pub message: Option, +} + +/// A subset of a K8s `Condition` — enough to surface the Ready reason/message. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskCondition { + #[serde(rename = "type")] + pub type_: String, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_write_matches_core_camelcase_contract() { + let blueprint = TaskBlueprint { + git_write: Some(GitWriteConfig { + connection_config_map_ref: LocalObjectRef { + name: "kars-github-connection-0123456789abcdef".into(), + }, + repos: vec!["owner/repo".into()], + }), + ..Default::default() + }; + let value = serde_json::to_value(blueprint).expect("serializes"); + assert_eq!( + value["gitWrite"]["connectionConfigMapRef"]["name"], + "kars-github-connection-0123456789abcdef" + ); + assert_eq!(value["gitWrite"]["repos"][0], "owner/repo"); + } + + #[test] + fn execution_plan_retains_required_empty_arrays() { + let plan = ExecutionPlan { + schema: "kars.execution-plan/v1".into(), + roles: vec![ExecutionRole { + name: "worker".into(), + objective: "Produce deterministic evidence for this request.".into(), + depends_on: Vec::new(), + phases: vec![ExecutionPhase { + name: "execute".into(), + objective: "Complete the bounded execution phase with evidence.".into(), + capabilities: Vec::new(), + required_tool_calls: Vec::new(), + min_tool_calls: 0, + max_tool_calls: 0, + fresh_context: true, + }], + budget_tokens: None, + }], + max_parallel: 1, + synthesis: ExecutionSynthesis { + objective: "Publish a concise evidence-backed result.".into(), + capabilities: Vec::new(), + max_tool_calls: 0, + }, + deliverables: Vec::new(), + }; + + let value = serde_json::to_value(plan).expect("serializes"); + assert_eq!(value["roles"][0]["dependsOn"], serde_json::json!([])); + assert_eq!( + value["roles"][0]["phases"][0]["capabilities"], + serde_json::json!([]) + ); + assert_eq!(value["synthesis"]["capabilities"], serde_json::json!([])); + assert_eq!(value["deliverables"], serde_json::json!([])); + } + + #[test] + fn execution_plan_preserves_provider_neutral_web_search_capability() { + let plan = ExecutionPlan { + schema: "kars.execution-plan/v1".into(), + roles: vec![ExecutionRole { + name: "source-scout".into(), + objective: "Discover exact URLs and fetch the evidence.".into(), + depends_on: Vec::new(), + phases: vec![ExecutionPhase { + name: "discover".into(), + objective: "Search and fetch the exact URLs.".into(), + capabilities: vec!["web-search".into(), "network".into()], + required_tool_calls: Vec::new(), + min_tool_calls: 1, + max_tool_calls: 4, + fresh_context: true, + }], + budget_tokens: None, + }], + max_parallel: 1, + synthesis: ExecutionSynthesis { + objective: "Return the verified answer.".into(), + capabilities: Vec::new(), + max_tool_calls: 0, + }, + deliverables: Vec::new(), + }; + + let value = serde_json::to_value(plan).expect("serializes"); + assert_eq!( + value["roles"][0]["phases"][0]["capabilities"], + serde_json::json!(["web-search", "network"]) + ); + } +} diff --git a/bridge/bff/src/kars/team.rs b/bridge/bff/src/kars/team.rs new file mode 100644 index 000000000..a4c32117e --- /dev/null +++ b/bridge/bff/src/kars/team.rs @@ -0,0 +1,153 @@ +// kars Bridge BFF — typed view of the `KarsTeam` CRD. +// +// CONTRACT OWNERSHIP: the `KarsTeam` schema is owned by core kars +// (`Azure/kars`, controller/src/kars_team.rs). This module is a *consumer* +// projection — the standard kube-rs pattern for a client that reads a CRD it +// does not own. It mirrors only the fields the Bridge Teams surface needs, with +// matching group/version/kind and camelCase serde so the wire shape is +// identical. +// +// A KarsTeam is the durability-axis primitive (design note §11): a standing +// org with a charter, a roster (org chart), and a cadence loop that mints +// task-force KarsTasks autonomously. Bridge renders it; it never authors the +// member/principal tasks itself (the controller does). + +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::kars::task::{LocalObjectRef, TaskBlueprint, TaskEnvelope}; + +/// `KarsTeam.spec` — the subset the Bridge reads. +#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[kube( + group = "kars.azure.com", + version = "v1alpha1", + kind = "KarsTeam", + namespaced, + status = "KarsTeamStatus" +)] +#[serde(rename_all = "camelCase")] +pub struct KarsTeamSpec { + /// The standing mandate that generates the team's work. + pub charter: String, + /// The team's full trust envelope — the authority ceiling for every member + /// and generated task. + pub envelope: TaskEnvelope, + /// Member roles — each a seat in the org chart holding an attenuated subset + /// of the team envelope. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub roster: Vec, + /// Standing-operation cadence — how often the charter loop mints a + /// task-force task. Absent ⇒ a passive org (no autonomous tick). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cadence: Option, + /// Default run blueprint for the principal + generated tasks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blueprint: Option, + /// The human owner the team reports to (apex of the org chart). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reporting_to: Option, + /// Name of the team's knowledge commons (shared memory). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub knowledge_commons: Option, + /// Runtime retention policy for assignments executed by this standing team. + /// Missing on older teams means the compatibility `ephemeral` behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle_mode: Option, + /// Idle window before a resource-optimized runtime is suspended. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warm_idle_seconds: Option, + /// When `true` the team hibernates: members idle, charter loop paused. + #[serde(default)] + pub paused: bool, + /// Optional short label for listings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Profile this team is instantiated from (§17). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_ref: Option, + /// A requested higher autonomy tier (§12) — drives a governed promotion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requested_tier: Option, +} + +/// A member role in the team roster. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TeamRole { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub envelope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blueprint: Option, + /// Skills (KarsSkill names) this role acquires (§13). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, +} + +/// The team's standing-operation cadence. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TeamCadence { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub every_minutes: Option, +} + +/// `KarsTeam.status` — the controller is the sole writer. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KarsTeamStatus { + /// `Forming` | `Active` | `Hibernating` | `Degraded` | `Retired`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub envelope_digest: Option, + /// The materialized principal task. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub principal_ref: Option, + /// The materialized member tasks (org chart). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub member_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_count: Option, + /// How many task-force tasks the charter loop has minted (autonomy proof). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_task_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_generated_task: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_run_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_run_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub health: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runs_succeeded: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens_spent_total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commons_entry_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_success_at: Option, + /// Effective lifecycle mode, echoed by the controller for older teams too. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lifecycle_mode: Option, + /// `Working` | `Warm` | `Hibernating` | `Idle`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assignment_nonce: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_assignment_task: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_activity_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_deadline_at: Option, +} diff --git a/bridge/bff/src/kars/workspace_credential_plan.rs b/bridge/bff/src/kars/workspace_credential_plan.rs new file mode 100644 index 000000000..e9bc6012c --- /dev/null +++ b/bridge/bff/src/kars/workspace_credential_plan.rs @@ -0,0 +1,225 @@ +use super::{ + cluster::Cluster, + credential_contract::{Identity, Selection, Target}, + credential_targets::{legacy_v1, planned_selection, reviewed_bindings}, + credential_transport::{failure, object_api, safe}, +}; +use kube::{ + ResourceExt, + api::{DynamicObject, ListParams, Patch, PatchParams}, +}; +use serde_json::json; + +pub(super) enum WorkspaceSource { + Existing(Identity), + Create { name: String }, +} + +struct Consumer { + target: Target, + version: String, + snapshot: DynamicObject, +} + +pub(super) struct WorkspaceCredentialPlan { + namespace: String, + grant: Identity, + source: WorkspaceSource, + keys: Vec, + consumers: Vec, +} + +fn selection(source: &Identity, keys: &[String]) -> Selection { + Selection { + scope: "workspace".into(), + source: source.clone(), + keys: keys.to_vec(), + owner: None, + } +} + +impl Cluster { + pub(super) async fn plan_workspace_credentials( + &self, + namespace: &str, + grant: &Identity, + source: WorkspaceSource, + keys: Vec, + ) -> Result { + let canonical = super::credentials::input_name("Workspace", "")?; + if match &source { + WorkspaceSource::Existing(source) => source.name != canonical || source.uid.is_empty(), + WorkspaceSource::Create { name } => *name != canonical, + } { + return Err(failure( + "Workspace plan requires its canonical source name and any existing exact UID", + )); + } + let mut plan = WorkspaceCredentialPlan { + namespace: namespace.into(), + grant: grant.clone(), + source, + keys, + consumers: Vec::new(), + }; + for kind in ["KarsTeam", "KarsTask", "KarsSandbox"] { + let objects = object_api(self, namespace, kind) + .list(&ListParams::default()) + .await + .map_err(|e| safe("Read workspace credential consumers", e))?; + for object in objects { + if object.metadata.deletion_timestamp.is_some() { + continue; + } + let owned_by = |kind: &str| { + object + .metadata + .owner_references + .as_ref() + .is_some_and(|owners| { + owners + .iter() + .any(|owner| owner.kind == kind && owner.controller == Some(true)) + }) + }; + if kind == "KarsSandbox" && owned_by("KarsTask") { + continue; + } + if kind == "KarsSandbox" && legacy_v1(&object)? { + continue; + } + if kind == "KarsSandbox" + && !object.data["spec"]["credentialBindings"].is_object() + && object.data.get("status").is_some_and(|status| { + !status.is_null() && status.as_object().is_none_or(|s| !s.is_empty()) + }) + { + continue; + } + if kind == "KarsTask" + && (owned_by("KarsTeam") || object.data["spec"]["execution"]["launch"] != true) + { + continue; + } + let target = Target { + kind: kind.into(), + namespace: namespace.into(), + name: object.name_any(), + uid: object + .uid() + .filter(|uid| !uid.is_empty()) + .ok_or_else(|| failure("Credential consumer UID missing"))?, + }; + let version = object + .resource_version() + .filter(|rv| !rv.is_empty()) + .ok_or_else(|| failure("Credential consumer resourceVersion missing"))?; + match &plan.source { + WorkspaceSource::Existing(source) => { + planned_selection(&object, &target, grant, selection(source, &plan.keys))?; + } + WorkspaceSource::Create { .. } => { + let existing = reviewed_bindings(&object, &target, grant)?; + if existing + .sources + .iter() + .any(|source| source.scope == "workspace") + { + return Err(failure( + "A referenced workspace source disappeared or differs; explicit replacement review is required", + )); + } + if kind == "KarsTask" && object.data["spec"]["execution"]["launch"] == true + { + return Err(failure( + "An active standalone Task requires explicit governed rebinding before new source authority", + )); + } + } + } + plan.consumers.push(Consumer { + target, + version, + snapshot: object, + }); + } + } + self.recheck_workspace_plan(&plan).await?; + Ok(plan) + } + + async fn recheck_workspace_plan( + &self, + plan: &WorkspaceCredentialPlan, + ) -> Result<(), kube::Error> { + for consumer in &plan.consumers { + let current = object_api(self, &plan.namespace, &consumer.target.kind) + .get(&consumer.target.name) + .await + .map_err(|e| safe("Recheck complete credential consumer plan", e))?; + if current.uid().as_deref() != Some(consumer.target.uid.as_str()) + || current.resource_version().as_deref() != Some(consumer.version.as_str()) + || current.metadata.deletion_timestamp.is_some() + { + return Err(failure("Credential consumer plan changed before mutation")); + } + } + Ok(()) + } + + pub(super) async fn apply_workspace_plan( + &self, + plan: WorkspaceCredentialPlan, + source: &Identity, + ) -> Result<(), kube::Error> { + if source.uid.is_empty() + || match &plan.source { + WorkspaceSource::Existing(expected) => source != expected, + WorkspaceSource::Create { name } => source.name != *name, + } + { + return Err(failure( + "Created/written source identity differs from the reviewed plan", + )); + } + let mut patches = Vec::new(); + for consumer in &plan.consumers { + if let Some(spec) = planned_selection( + &consumer.snapshot, + &consumer.target, + &plan.grant, + selection(source, &plan.keys), + )? { + patches.push((consumer, spec)); + } + } + // This catches concurrent changes after source creation/write. It is + // not a multi-resource transaction: a later CAS conflict remains an error. + self.recheck_workspace_plan(&plan).await?; + for (consumer, spec) in patches { + object_api(self,&plan.namespace,&consumer.target.kind).patch(&consumer.target.name,&PatchParams::default(), + &Patch::Merge(json!({"metadata":{"uid":consumer.target.uid,"resourceVersion":consumer.version},"spec":spec}))) + .await.map_err(|e|safe("Apply reviewed credential consumer plan",e))?; + } + Ok(()) + } + + #[cfg(test)] + pub async fn bind_workspace_credentials( + &self, + namespace: &str, + grant: &Identity, + source: &Identity, + keys: Vec, + ) -> Result<(), kube::Error> { + let plan = self + .plan_workspace_credentials( + namespace, + grant, + WorkspaceSource::Existing(source.clone()), + keys, + ) + .await?; + self.apply_workspace_plan(plan, source).await + } +} diff --git a/bridge/bff/src/lib.rs b/bridge/bff/src/lib.rs new file mode 100644 index 000000000..913b87f66 --- /dev/null +++ b/bridge/bff/src/lib.rs @@ -0,0 +1,13 @@ +// Copyright (c) Pal Lakatos-Toth. +// kars Bridge BFF — library surface. +// +// The crate is split into a thin binary (`main.rs`) and this library so the +// HTTP router and supporting modules can be exercised directly in integration +// tests without binding a socket. + +pub mod auth; +pub mod config; +pub mod error; +pub mod kars; +pub mod routes; +pub mod state; diff --git a/bridge/bff/src/main.rs b/bridge/bff/src/main.rs new file mode 100644 index 000000000..1e94a71af --- /dev/null +++ b/bridge/bff/src/main.rs @@ -0,0 +1,203 @@ +// Copyright (c) Pal Lakatos-Toth. +// kars Bridge BFF — secure backend-for-frontend for the kars Bridge web app. +// +// This process is the *only* server-side path between the browser and the +// kars cluster. The browser never holds kube credentials, signing keys, or +// the Entra confidential-client secret — all privileged access terminates +// here. (Inc 0: platform shell. Auth + cluster access land in later slices, +// each fully wired, never stubbed.) + +use std::time::Duration; + +use anyhow::Context; +use axum::http::{HeaderValue, Method}; +use kars_bridge_bff::config::Config; +use kars_bridge_bff::routes; +use kars_bridge_bff::state::AppState; +use tokio::net::TcpListener; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::prelude::*; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // kube-rs (rustls 0.23) requires a process-level CryptoProvider to be + // installed before any TLS client is built. Install the aws-lc-rs provider + // explicitly so cluster connections don't panic on first use. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .map_err(|_| anyhow::anyhow!("failed to install default rustls CryptoProvider"))?; + + let config = Config::from_env().context("loading configuration")?; + init_tracing(&config); + + tracing::info!( + bind = %config.bind_addr, + web_origin = %config.web_origin, + "starting kars-bridge-bff" + ); + + let cors = build_cors(&config)?; + + let state = AppState::new(config.default_namespace.clone()) + .await + .with_api_token(config.api_token.clone()) + .with_principal_secret(config.principal_secret.clone()) + .with_teams_internal_secret(config.teams_internal_secret.clone()) + .with_teams_entra_role_map(config.teams_entra_role_map.clone()); + if config.api_token.is_none() { + tracing::warn!( + "BRIDGE_API_TOKEN not set — mutating endpoints are UNAUTHENTICATED. \ + Set BRIDGE_API_TOKEN in production to require a bearer token on writes." + ); + } + if config.principal_secret.is_none() { + tracing::warn!( + "BRIDGE_PRINCIPAL_SECRET not set — per-user API authentication and \ + persona authorization are disabled (local dev mode only)." + ); + } + + let poller_interval = Duration::from_secs(config.engineering_poller_interval_seconds); + routes::engineering::spawn_poller(state.clone(), poller_interval); + + // Orchestrator inference path. Two options, in priority order: + // 1. Direct endpoint — set BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} to + // route compose straight at a managed model (Azure AI Foundry / Azure + // OpenAI). Scales with the managed service — preferred for many teams / + // high query volume — and stands up NO pod. + // 2. Self-contained — otherwise, stand up a single standing + // `bridge-orchestrator` sandbox the composer always routes through, so + // "intent → recommendations" works out of the box with no external + // dependency (one idle pod). + let has_direct_endpoint = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") + .map(|e| !e.trim().is_empty()) + .unwrap_or(false); + if !has_direct_endpoint && let Some(cluster) = state.cluster() { + match cluster.ensure_orchestrator_sandbox().await { + Ok(()) => tracing::info!( + "orchestrator: standing `bridge-orchestrator` sandbox ensured (compose inference path)" + ), + Err(e) => tracing::warn!( + "orchestrator: could not ensure standing sandbox (compose falls back to any running sandbox): {e}" + ), + } + } else if has_direct_endpoint { + tracing::info!( + "orchestrator: using direct endpoint (BRIDGE_ORCHESTRATOR_*) — no standing pod" + ); + } + + let app = routes::router(state.clone()) + .layer(axum::middleware::from_fn_with_state( + state, + kars_bridge_bff::auth::require_token, + )) + .layer(axum::middleware::from_fn(ensure_utf8_charset)) + .layer(cors) + .layer(TraceLayer::new_for_http()); + + let listener = TcpListener::bind(config.bind_addr) + .await + .with_context(|| format!("binding {}", config.bind_addr))?; + + tracing::info!(addr = %config.bind_addr, "listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("server error")?; + + tracing::info!("shutdown complete"); + Ok(()) +} + +/// Ensure `application/json` responses declare `charset=utf-8`. JSON is always +/// UTF-8 per RFC 8259, but naive/Latin-1 viewers mis-decode multi-byte +/// characters (en-dashes, curly quotes) into mojibake (`1â€"5`) when the charset +/// is absent. Stamping it makes every response render correctly everywhere. SSE +/// and other content types are left untouched. +async fn ensure_utf8_charset( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let mut res = next.run(req).await; + let headers = res.headers_mut(); + if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) + && ct + .to_str() + .map(|v| v.trim() == "application/json") + .unwrap_or(false) + { + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json; charset=utf-8"), + ); + } + res +} + +/// Initialize structured logging. JSON in production, pretty locally. +fn init_tracing(config: &Config) { + let filter = EnvFilter::try_new(&config.log_filter).unwrap_or_else(|_| EnvFilter::new("info")); + let registry = tracing_subscriber::registry().with(filter); + if config.log_json { + registry + .with(tracing_subscriber::fmt::layer().json()) + .init(); + } else { + registry.with(tracing_subscriber::fmt::layer()).init(); + } +} + +/// Build a strict CORS layer: only the configured web origin, only the +/// methods and headers the app uses, credentials allowed for cookie auth. +fn build_cors(config: &Config) -> anyhow::Result { + let origin: HeaderValue = config + .web_origin + .parse() + .with_context(|| format!("invalid BRIDGE_WEB_ORIGIN `{}`", config.web_origin))?; + Ok(CorsLayer::new() + .allow_origin(origin) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + ]) + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::AUTHORIZATION, + ]) + .allow_credentials(true) + .max_age(Duration::from_secs(600))) +} + +/// Resolve when the process receives SIGINT or SIGTERM, enabling graceful +/// shutdown of in-flight requests. +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("shutdown signal received"); +} diff --git a/bridge/bff/src/routes/approvals.rs b/bridge/bff/src/routes/approvals.rs new file mode 100644 index 000000000..3704c4115 --- /dev/null +++ b/bridge/bff/src/routes/approvals.rs @@ -0,0 +1,412 @@ +// kars Bridge BFF — Governance steering: the HITL approval inbox. +// +// These endpoints back the steering inbox — the fleet-wide list of human +// decisions a task fleet is waiting on — and the approve/deny action. The BFF +// only ever patches `spec.decision`; the controller is the sole writer of +// status and drives the terminal transition. The browser never touches the +// cluster directly. + +use axum::Json; +use axum::extract::{Extension, Path, Query, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::approval::{ApprovalDecision, KarsApproval}; +use crate::state::AppState; +use kube::ResourceExt; +use kube::api::{Api, ListParams, Patch, PatchParams}; + +fn map_kube_err(e: kube::Error) -> AppError { + if let kube::Error::Api(resp) = &e + && (400..500).contains(&resp.code) + { + return AppError::Rejected(resp.message.clone()); + } + AppError::Upstream(e.to_string()) +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +/// Browser-facing approval shape. +#[derive(Debug, Serialize)] +pub struct ApprovalDto { + pub name: String, + pub namespace: String, + pub task: String, + pub team: Option, + pub milestone: Option, + pub action_kind: String, + pub summary: String, + pub detail: Option, + pub requested_tier: Option, + pub phase: String, + pub decider: Option, + pub requested_at: Option, + pub decided_at: Option, + pub expires_at: Option, + pub bound_envelope_digest: Option, + pub run_nonce: Option, + pub resource_version: String, + pub generation: i64, + /// Whether a human can still act on this (only a Pending approval). + pub actionable: bool, +} + +fn to_dto(ns: &str, a: &KarsApproval) -> ApprovalDto { + let status = a.status.clone().unwrap_or_default(); + let phase = status.phase.unwrap_or_else(|| "Pending".to_string()); + let actionable = phase == "Pending"; + let metadata_value = |key: &str| { + a.annotations() + .get(key) + .cloned() + .or_else(|| a.labels().get(key).cloned()) + }; + ApprovalDto { + name: a.name_any(), + namespace: ns.to_string(), + task: a.spec.task_ref.name.clone(), + team: metadata_value("kars.azure.com/team"), + milestone: metadata_value("kars.azure.com/milestone"), + action_kind: a.spec.action.kind.clone(), + summary: a.spec.action.summary.clone(), + detail: a.spec.action.detail.clone(), + requested_tier: a.spec.action.requested_tier, + phase, + decider: status.decider, + requested_at: status.requested_at, + decided_at: status.decided_at, + expires_at: status.expires_at, + bound_envelope_digest: status.bound_envelope_digest, + run_nonce: a.annotations().get("kars.azure.com/req-run").cloned(), + resource_version: a.metadata.resource_version.clone().unwrap_or_default(), + generation: a.metadata.generation.unwrap_or_default(), + actionable, + } +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + /// When `true`, only undecided (Pending) approvals — the steering inbox. + #[serde(default)] + pub pending: bool, + /// Operator/admin-only fleet view. Workspace callers omit this and receive + /// only approvals owned by their immutable OIDC subject. + #[serde(default)] + pub scope_all: bool, +} + +fn owner_subject(a: &KarsApproval) -> Option<&str> { + a.annotations() + .get("kars.azure.com/owner-sub") + .map(String::as_str) +} + +fn is_owner(a: &KarsApproval, principal: &Principal) -> bool { + owner_subject(a).is_some_and(|subject| subject == principal.sub) +} + +fn can_view_all(principal: &Principal) -> bool { + principal + .roles + .iter() + .any(|role| role == "operator" || role == "admin") +} + +fn is_team_milestone_review(approval: &KarsApproval) -> bool { + approval.spec.action.kind == "checkpoint" + && (approval.annotations().contains_key("kars.azure.com/team") + || approval.labels().contains_key("kars.azure.com/team")) + && (approval + .annotations() + .contains_key("kars.azure.com/milestone") + || approval.labels().contains_key("kars.azure.com/milestone")) +} + +/// `GET /api/namespaces/:ns/approvals?pending=` — the fleet-wide steering +/// inbox. Pending-first, then most recently decided. +pub async fn list_approvals( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, + Query(q): Query, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let api: Api = cluster.approvals(&ns); + let list = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + if q.scope_all && !can_view_all(&principal) { + return Err(AppError::Forbidden( + "operator or admin role required for fleet approval scope".into(), + )); + } + let mut dtos: Vec = list + .items + .iter() + .filter(|a| q.scope_all || is_owner(a, &principal)) + .map(|a| to_dto(&ns, a)) + .collect(); + if q.pending { + dtos.retain(|d| d.phase == "Pending"); + } + // Pending first (actionable), then the rest; stable by name within a group. + dtos.sort_by(|a, b| { + b.actionable + .cmp(&a.actionable) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(Json(dtos)) +} + +/// `GET /api/namespaces/:ns/tasks/:name/approvals` — approvals gating one task. +pub async fn list_task_approvals( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let api: Api = cluster.approvals(&ns); + let list = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + let mut dtos: Vec = list + .items + .iter() + .filter(|a| a.spec.task_ref.name == name && is_owner(a, &principal)) + .map(|a| to_dto(&ns, a)) + .collect(); + dtos.sort_by(|a, b| { + b.actionable + .cmp(&a.actionable) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(Json(dtos)) +} + +/// Decision request from the UI. +#[derive(Debug, Deserialize)] +pub struct DecisionRequest { + /// `approve` or `deny`. + pub verdict: String, + pub reason: Option, + /// Optimistic-concurrency token for the exact approval the human reviewed. + pub resource_version: String, + /// Envelope digest shown to the reviewer; stale/moved envelopes cannot be + /// approved by replaying an old browser tab. + pub bound_envelope_digest: Option, +} + +/// `POST /api/namespaces/:ns/approvals/:name/decision` — record a human +/// decision by patching `spec.decision`. The controller drives the transition. +pub async fn decide_approval( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + if req.verdict != "approve" && req.verdict != "deny" { + return Err(AppError::Rejected(format!( + "verdict must be 'approve' or 'deny', got '{}'", + req.verdict + ))); + } + + let cluster = require_cluster(&state)?; + let api: Api = cluster.approvals(&ns); + let current = api.get(&name).await.map_err(map_kube_err)?; + if req.verdict == "deny" + && is_team_milestone_review(¤t) + && req + .reason + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + return Err(AppError::Rejected( + "requesting changes for a Team milestone requires written feedback".into(), + )); + } + let can_expand_authority = principal + .roles + .iter() + .any(|role| role == "operator" || role == "admin"); + let owned_by_principal = is_owner(¤t, &principal); + let owner_may_decide = owned_by_principal + && matches!( + current.spec.action.kind.as_str(), + "clarification" | "egress" + ); + if !owner_may_decide && !can_expand_authority { + return Err(AppError::Forbidden( + "operator or admin role required for this approval decision".into(), + )); + } + if req.verdict == "approve" + && current.spec.action.kind != "clarification" + && current + .spec + .requested_by + .as_ref() + .is_some_and(|actor| actor.subject == principal.sub) + { + return Err(AppError::Forbidden( + "requester cannot approve their own authority expansion".into(), + )); + } + let phase = current + .status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .unwrap_or("Pending"); + if phase != "Pending" || current.spec.decision.is_some() { + return Err(AppError::Conflict(format!( + "approval is already terminal ({phase})" + ))); + } + let current_rv = current + .metadata + .resource_version + .clone() + .unwrap_or_default(); + if req.resource_version != current_rv { + return Err(AppError::Conflict( + "approval changed since it was displayed; reload before deciding".into(), + )); + } + let current_digest = current + .status + .as_ref() + .and_then(|s| s.bound_envelope_digest.clone()); + if req.bound_envelope_digest != current_digest { + return Err(AppError::Conflict( + "the governed envelope changed since review; reload before deciding".into(), + )); + } + + let decision = ApprovalDecision { + verdict: req.verdict, + decider: principal.name, + decider_subject: Some(principal.sub), + decider_roles: principal.roles, + reason: req.reason.filter(|r| !r.trim().is_empty()), + }; + let patch = json_patch::Patch(vec![ + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["metadata", "resourceVersion"]), + value: serde_json::Value::String(current_rv), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["spec", "decision"]), + value: serde_json::to_value(decision).map_err(|e| AppError::Internal(e.into()))?, + }), + ]); + let patched = api + .patch( + &name, + &PatchParams::default(), + &Patch::Json::(patch), + ) + .await + .map_err(|e| { + if matches!(e, kube::Error::Api(ref ae) if ae.code == 409 || ae.code == 422) { + AppError::Conflict("approval was decided concurrently; reload".into()) + } else { + map_kube_err(e) + } + })?; + Ok(Json(to_dto(&ns, &patched))) +} + +#[cfg(test)] +mod tests { + use super::{is_owner, is_team_milestone_review, owner_subject, to_dto}; + use crate::auth::Principal; + use crate::kars::approval::{ApprovalAction, KarsApproval, KarsApprovalSpec}; + use crate::kars::task::LocalObjectRef; + + fn approval(owner: Option<&str>) -> KarsApproval { + let mut value = KarsApproval::new( + "ask", + KarsApprovalSpec { + task_ref: LocalObjectRef { + name: "task".into(), + }, + action: ApprovalAction { + kind: "clarification".into(), + summary: "Which environment?".into(), + detail: None, + requested_tier: None, + }, + requested_by: None, + ttl: None, + decision: None, + }, + ); + if let Some(owner) = owner { + value + .metadata + .annotations + .get_or_insert_with(Default::default) + .insert("kars.azure.com/owner-sub".into(), owner.into()); + } + value + } + + #[test] + fn approval_visibility_uses_immutable_subject() { + let principal = Principal { + sub: "subject-a".into(), + name: "same-name".into(), + roles: vec!["user".into()], + }; + let owned = approval(Some("subject-a")); + let other = approval(Some("subject-b")); + assert_eq!(owner_subject(&owned), Some("subject-a")); + assert!(is_owner(&owned, &principal)); + assert!(!is_owner(&other, &principal)); + assert!(!is_owner(&approval(None), &principal)); + } + + #[test] + fn checkpoint_approval_projects_team_and_milestone_identity() { + let approval: KarsApproval = serde_json::from_value(serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "metadata": { + "name": "checkpoint-abc", + "namespace": "kars-system", + "resourceVersion": "7", + "labels": { + "kars.azure.com/team": "engineering", + "kars.azure.com/milestone": "pr-34" + } + }, + "spec": { + "taskRef": {"name": "engineering-principal"}, + "action": { + "kind": "checkpoint", + "summary": "Review PR #34" + } + }, + "status": { + "phase": "Pending", + "boundEnvelopeDigest": "sha256:abc" + } + })) + .expect("approval"); + + let dto = to_dto("kars-system", &approval); + + assert_eq!(dto.team.as_deref(), Some("engineering")); + assert_eq!(dto.milestone.as_deref(), Some("pr-34")); + assert!(dto.actionable); + assert!(is_team_milestone_review(&approval)); + } +} diff --git a/bridge/bff/src/routes/artifacts.rs b/bridge/bff/src/routes/artifacts.rs new file mode 100644 index 000000000..66434d918 --- /dev/null +++ b/bridge/bff/src/routes/artifacts.rs @@ -0,0 +1,395 @@ +// kars Bridge BFF — the cross-mission Artifacts index. +// +// The Artifacts surface (design note §16) lists the real deliverables missions +// have produced — the files captured by the controller from the agent loop over +// the mesh, persisted as durable `kars-mission-output-*` / `kars-mission-artifacts-*` +// ConfigMaps. This is a read-only projection of those real records; it never +// fabricates a deliverable and is honestly empty until a mission produces one. + +use axum::{ + Json, + extract::{Extension, State}, +}; +use kube::ResourceExt; +use serde::Deserialize; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::output_is_owned_by; +use crate::state::AppState; + +/// `sha256:` content-address over arbitrary bytes (16-byte short form, +/// matching the controller's receipt-digest convention). +fn content_address(bytes: &[u8]) -> String { + let full = Sha256::digest(bytes); + let mut out = String::from("sha256:"); + for b in &full[..16] { + out.push_str(&format!("{b:02x}")); + } + out +} + +#[derive(Debug, Serialize)] +pub struct ArtifactFileDto { + pub name: String, + pub size_bytes: Option, + pub has_content: bool, + /// `sha256:` over the file's actual content — the artifact's + /// content-address. None for binary artifacts whose bytes aren't inlined. + pub content_address: Option, + /// Content-addressed identifier (`did:kars:`) derived from the + /// file's content-address, so the deliverable is referenceable by identity. + pub did: Option, +} + +#[derive(Debug, Serialize)] +pub struct MissionArtifactsDto { + /// The mission (KarsTask) that produced these deliverables. + pub task: String, + /// Exact nonce-scoped evidence archive backing this row. + pub evidence_key: Option, + /// Standing team that produced this task-force run, when applicable. + pub team: Option, + /// True when the disposable run was GC'd and this row comes from durable + /// team shared memory. + pub archived: bool, + /// A clean human title for the mission (never the loop scaffold / raw slug). + pub display_name: Option, + /// The mission's objective (for context in the index). + pub objective: Option, + /// The model the run used. + pub model: Option, + /// When the deliverable was produced (RFC3339). + pub finished_at: Option, + /// `ok` / `error` — the run status the deliverable reflects. + pub status: Option, + /// Review status for this deliverable: `none` | `approved` | + /// `changes_requested` (the §16 review-loop state). + pub review_status: String, + /// Current review revision (incremented on each request-changes). + pub review_revision: i64, + /// The artifact file set (names + sizes). Content lives on the mission page. + pub files: Vec, + /// The final text deliverable (the agent's summary), when present. + pub summary: Option, + /// A clean 2–3 line preview for cards/rows — never the raw transcript. + pub excerpt: Option, + /// Pull requests the mission opened (a first-class deliverable type). Parsed + /// from the deliverable; the router authored them via the keyless git proxy. + pub pull_requests: Vec, + /// Content-addressed deliverable identity: `did:kars:` over the + /// ordered file content-addresses + summary. Pins the exact deliverable + /// set the receipt vouches for; stable across reads, sensitive to content. + pub deliverable_did: Option, +} + +#[derive(Debug, Serialize)] +pub struct ArtifactsIndexDto { + pub missions: Vec, +} + +#[derive(Debug, Deserialize)] +struct ArchivedCommonsEntry { + id: String, + title: String, + source_task: String, + created_at: String, + digest: String, +} + +fn commons_content_key(id: &str) -> String { + let safe: String = id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '_' + } + }) + .collect(); + format!("entry-{safe}") +} + +fn pull_requests_with_repo_hint( + text: &str, + repos: &[String], +) -> Vec { + let mut pull_requests = crate::routes::tasks::extract_pull_requests(text); + if repos.len() == 1 { + let repo = &repos[0]; + let lower = text.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + let mut cursor = 0; + while let Some(offset) = lower[cursor..].find("pr #") { + let start = cursor + offset + 4; + let number_text: String = bytes[start..] + .iter() + .map(|byte| *byte as char) + .take_while(char::is_ascii_digit) + .collect(); + if let Ok(number) = number_text.parse::() { + let candidate = crate::routes::tasks::PullRequestRef { + repo: repo.clone(), + number, + url: format!("https://github.com/{repo}/pull/{number}"), + }; + if !pull_requests.contains(&candidate) { + pull_requests.push(candidate); + } + } + cursor = start.saturating_add(number_text.len()).min(lower.len()); + if cursor >= lower.len() { + break; + } + } + } + pull_requests +} + +/// `GET /api/artifacts` — the cross-mission deliverable index, built from the +/// real persisted mission-output records. +pub async fn list_artifacts( + State(state): State, + Extension(principal): Extension, +) -> AppResult> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + + let outputs = cluster.list_mission_output_evidence().await; + let mut missions = Vec::with_capacity(outputs.len()); + let mut live_tasks = HashSet::new(); + let mut live_evidence = HashSet::new(); + for record in outputs { + let task = record.task_name; + let evidence_key = record.evidence_key; + let data = record.data; + if !output_is_owned_by(&data, &principal) { + continue; + } + live_tasks.insert(task.clone()); + live_evidence.insert(evidence_key.clone()); + // Read the real artifact content so each file can be content-addressed. + let contents = cluster + .read_mission_artifacts(&evidence_key) + .await + .unwrap_or_default(); + // The artifact manifest (names + sizes) is recorded on the output CM by + // the controller; parse it for the file list. Absent → no files (the + // single-turn run path produces only a text summary, no file set). + let files: Vec = data + .get("artifacts") + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .map(|entries| { + entries + .into_iter() + .filter_map(|e| { + let name = e.get("name")?.as_str()?.to_string(); + let size_bytes = e.get("size_bytes").and_then(|v| v.as_i64()); + // Inline text content lets us content-address; binary + // artifacts may carry a manifest digest instead. + let inline = contents.get(&name); + let content_address = inline + .map(|c| content_address(c.as_bytes())) + .or_else(|| e.get("sha256").and_then(|v| v.as_str()).map(String::from)); + let did = content_address + .as_ref() + .map(|ca| format!("did:kars:{}", ca.trim_start_matches("sha256:"))); + Some(ArtifactFileDto { + name, + size_bytes, + // Honest downloadability: the Bridge can only serve a + // file whose bytes are inlined in the artifacts CM. + // A manifest-digest-only (binary) entry is listed with + // its provenance but NOT marked downloadable — else the + // download link would 404. + has_content: inline.is_some(), + content_address, + did, + }) + }) + .collect() + }) + .unwrap_or_default(); + + let assignment_identity = data + .get("assignmentNonce") + .cloned() + .unwrap_or_else(|| evidence_key.clone()); + let task_review = cluster.read_review(&task).await.unwrap_or_default(); + let review = if task_review.get("assignmentNonce") == Some(&assignment_identity) { + task_review + } else { + Default::default() + }; + let raw_output = data.get("output").cloned().unwrap_or_default(); + let mut status = data.get("status").cloned(); + if crate::routes::tasks::is_failure_shaped_output(&raw_output) { + status = Some("error".into()); + } + // Gate: a hung / errored / zero-output / no-material-change run is NOT a + // deliverable and must not appear in the index or as "latest deliverable" + // (audit f9/f13). It has no captured output the receipt can vouch for. + let has_files = !files.is_empty(); + if !has_files && !crate::routes::tasks::is_real_deliverable(status.as_deref(), &raw_output) + { + continue; + } + let summary = data + .get("output") + .map(|o| crate::routes::tasks::deliverable_text(o)); + let excerpt = data + .get("output") + .map(|o| crate::routes::tasks::deliverable_excerpt(o)) + .filter(|s| !s.is_empty()); + // Deliverable DID: ordered file addresses + summary digest → one identity. + let mut lineage = String::new(); + for f in &files { + if let Some(ca) = &f.content_address { + lineage.push_str(ca); + lineage.push('\n'); + } + } + if let Some(s) = &summary { + lineage.push_str(&content_address(s.as_bytes())); + } + let deliverable_did = (!lineage.is_empty()).then(|| { + format!( + "did:kars:{}", + content_address(lineage.as_bytes()).trim_start_matches("sha256:") + ) + }); + missions.push(MissionArtifactsDto { + evidence_key: Some(evidence_key), + team: data.get("team").cloned(), + archived: false, + display_name: crate::routes::tasks::clean_display_name( + &data.get("displayName").cloned(), + data.get("objective").map(|s| s.as_str()).unwrap_or(""), + ), + objective: data + .get("objective") + .map(|o| crate::routes::tasks::clean_objective(o)), + model: data.get("model").cloned(), + finished_at: data.get("finishedAt").cloned(), + status, + review_status: review + .get("status") + .cloned() + .unwrap_or_else(|| "none".to_string()), + review_revision: review + .get("revision") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0), + summary, + excerpt, + files, + pull_requests: crate::routes::tasks::extract_pull_requests(&raw_output), + deliverable_did, + task, + }); + } + + // Team run resources are intentionally garbage-collected, but their + // principal synthesis remains in the team commons. Include those archived + // deliveries so old run links and PR provenance do not disappear. + let can_view_all = principal + .roles + .iter() + .any(|role| matches!(role.as_str(), "admin" | "operator")); + for team in cluster.list_kind_all("KarsTeam").await.unwrap_or_default() { + let owned = team + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get("kars.azure.com/owner-sub")) + .is_some_and(|owner| owner == &principal.sub); + if !owned && !can_view_all { + continue; + } + let team_name = team.name_any(); + let commons_name = team + .data + .pointer("/spec/knowledgeCommons") + .and_then(serde_json::Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(&team_name); + let Some(commons) = cluster.read_commons(commons_name).await else { + continue; + }; + let entries = commons + .get("index.json") + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .unwrap_or_default(); + let repos = team + .data + .pointer("/spec/blueprint/gitWrite/repos") + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + let charter = team + .data + .pointer("/spec/charter") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + for entry in entries { + let taskforce = entry.source_task.starts_with(&format!("{team_name}-run-")); + let persistent = entry + .source_task + .starts_with(&format!("{team_name}-principal-assign-")); + if !taskforce && !persistent { + continue; + } + if live_tasks.contains(&entry.source_task) || live_evidence.contains(&entry.source_task) + { + continue; + } + let Some(content) = commons.get(&commons_content_key(&entry.id)).cloned() else { + continue; + }; + if !crate::routes::tasks::is_real_deliverable(Some("ok"), &content) { + continue; + } + let task_name = if persistent { + format!("{team_name}-principal") + } else { + entry.source_task.clone() + }; + missions.push(MissionArtifactsDto { + task: task_name, + evidence_key: Some(entry.source_task), + team: Some(team_name.clone()), + archived: true, + display_name: Some(entry.title), + objective: (!charter.is_empty()).then(|| charter.to_string()), + model: None, + finished_at: Some(entry.created_at), + status: Some("ok".into()), + review_status: "none".into(), + review_revision: 0, + files: Vec::new(), + summary: Some(crate::routes::tasks::deliverable_text(&content)), + excerpt: Some(crate::routes::tasks::deliverable_excerpt(&content)), + pull_requests: pull_requests_with_repo_hint(&content, &repos), + deliverable_did: Some(format!( + "did:kars:{}", + entry.digest.trim_start_matches("sha256:") + )), + }); + } + } + + // Newest deliverable first, so "Latest deliverable" surfaces and the index + // reads chronologically rather than in arbitrary cluster-list order. + missions.sort_by(|a, b| b.finished_at.cmp(&a.finished_at)); + + Ok(Json(ArtifactsIndexDto { missions })) +} diff --git a/bridge/bff/src/routes/budgets.rs b/bridge/bff/src/routes/budgets.rs new file mode 100644 index 000000000..4f74e16d5 --- /dev/null +++ b/bridge/bff/src/routes/budgets.rs @@ -0,0 +1,530 @@ +// kars Bridge BFF — hierarchical, editable inference token budgets. +// +// The user asked for a real budget HIERARCHY over inference token spend: +// +// Cluster ─▶ Workspace (org/user tenant = namespace) ─▶ Sandbox +// +// The per-SANDBOX level already exists: the controller compiles an +// InferencePolicy from each task's envelope budget and the router enforces it +// (429) per sandbox. What was missing is the aggregate CLUSTER and WORKSPACE +// levels — a cluster-wide meter and cap, and per-workspace caps — plus the three +// enforcement MODES the user specified: +// +// • passive — never blocks; raises an alert when over budget. +// • buffer — allows up to `limit × (1 + bufferPercent/100)`, then blocks +// (a cluster/org admin must raise the budget to proceed). +// • strict — blocks at 100% of the limit; only an admin can raise it. +// +// The cluster + workspace levels can't be enforced by a per-sandbox router (each +// router only sees its own sandbox), so the Bridge — which owns orchestration — +// enforces them at the point a mission/team is launched, against the REAL +// measured daily token utilization aggregated from completed runs. The config is +// stored in the `kars-inference-budgets` ConfigMap (cluster-native, editable). + +use axum::Json; +use axum::extract::{Path, State}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::error::{AppError, AppResult}; +use crate::kars::cluster::Cluster; +use crate::state::AppState; + +fn require_cluster(state: &AppState) -> AppResult<&Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +/// A single budget rule at one level of the hierarchy. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BudgetRule { + /// Daily token cap. `0` (or absent) ⇒ no cap (measured, never blocked). + #[serde(default)] + pub daily_tokens: i64, + /// Enforcement mode: `passive` | `buffer` | `strict`. + #[serde(default = "default_mode")] + pub mode: String, + /// Buffer headroom (percent) allowed above `daily_tokens` in `buffer` mode. + #[serde(default)] + pub buffer_percent: i64, +} + +fn default_mode() -> String { + "passive".to_string() +} + +impl BudgetRule { + fn normalized(mut self) -> Self { + self.mode = match self.mode.as_str() { + "buffer" | "strict" | "passive" => self.mode, + _ => "passive".to_string(), + }; + if self.daily_tokens < 0 { + self.daily_tokens = 0; + } + self.buffer_percent = self.buffer_percent.clamp(0, 1000); + self + } + + /// The effective hard cap (where enforcement blocks). For `buffer` mode this + /// is `daily_tokens × (1 + bufferPercent/100)`; otherwise `daily_tokens`. + fn hard_cap(&self) -> i64 { + if self.daily_tokens == 0 { + return 0; // uncapped + } + match self.mode.as_str() { + "buffer" => self.daily_tokens + self.daily_tokens * self.buffer_percent / 100, + _ => self.daily_tokens, + } + } +} + +/// The persisted hierarchy (stored as `budgets.json`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BudgetHierarchy { + #[serde(default)] + pub cluster: Option, + #[serde(default)] + pub workspaces: BTreeMap, + /// Per-USER caps, keyed by the `kars.azure.com/created-by` identity the Bridge + /// stamps on each mission/team. The spec's "per-user" tier — genuinely + /// functional (attributed from real run ownership), below the workspace tier. + #[serde(default)] + pub users: BTreeMap, +} + +impl BudgetHierarchy { + async fn load(cluster: &Cluster) -> Self { + serde_json::from_str(&cluster.read_inference_budgets().await).unwrap_or_default() + } + async fn save(&self, cluster: &Cluster) -> AppResult<()> { + let json = serde_json::to_string(self).map_err(|e| AppError::Internal(e.into()))?; + cluster + .write_inference_budgets(&json) + .await + .map_err(AppError::Internal) + } +} + +// ─── Usage aggregation ─────────────────────────────────────────────────────── + +/// Today's (UTC) measured token utilization, attributed to the full tenancy +/// hierarchy: the cluster total, a per-WORKSPACE (namespace) breakdown, and a +/// per-USER (created-by) breakdown — joined from the real run ownership, not a +/// hardcoded namespace. Same `totalTokens` the efficiency engine reads, scoped to +/// the UTC day so it composes with the per-sandbox daily budget model. +async fn usage_today(cluster: &Cluster) -> (i64, BTreeMap, BTreeMap) { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + let meta = cluster.list_task_meta().await; + let mut total: i64 = 0; + let mut by_ns: BTreeMap = BTreeMap::new(); + let mut by_user: BTreeMap = BTreeMap::new(); + for record in cluster.list_mission_output_evidence().await { + let task = record.task_name; + let data = record.data; + let finished = data.get("finishedAt").cloned().unwrap_or_default(); + if !finished.starts_with(&today) { + continue; + } + let tokens = data + .get("totalTokens") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + if tokens <= 0 { + continue; + } + total += tokens; + // Attribute to the OWNING task's namespace + creator (real ownership). + let (ns, user) = meta + .get(&task) + .cloned() + .unwrap_or_else(|| ("kars-system".to_string(), "unattributed".to_string())); + *by_ns.entry(ns).or_insert(0) += tokens; + *by_user.entry(user).or_insert(0) += tokens; + } + (total, by_ns, by_user) +} + +// ─── DTOs ──────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct BudgetLevelDto { + pub scope: String, + pub label: String, + pub daily_tokens: i64, + pub mode: String, + pub buffer_percent: i64, + pub used_today: i64, + /// `ok` | `alert` (passive over budget) | `over_buffer` (buffer past + /// headroom) | `blocking` (strict/at-hard-cap; launches refused). + pub status: String, + /// Fraction of the base daily cap used (0..>1), for the meter bar. + pub percent: f64, + pub hard_cap: i64, +} + +#[derive(Serialize)] +pub struct BudgetAlert { + pub scope: String, + pub label: String, + /// `alert` (passive over budget) | `over_buffer` | `blocking`. + pub severity: String, + pub message: String, +} + +#[derive(Serialize)] +pub struct BudgetsDto { + pub cluster: Option, + pub cluster_used_today: i64, + pub workspaces: Vec, + /// Per-user caps (the spec's per-user tier), keyed by created-by identity. + pub users: Vec, + pub default_namespace: String, + /// Namespaces that have measured usage today but no explicit budget yet + /// (so the operator can add one with a click). + pub unbudgeted_namespaces: Vec, + /// Users that have measured usage today but no explicit per-user budget yet. + pub unbudgeted_users: Vec, + /// Active budget ALERTS — every level currently over its cap (passive), + /// in buffer headroom, or blocking. This is the "raise alerts" surface: + /// operators/admins see + act on breaches without hunting through meters. + pub alerts: Vec, +} + +/// Build an alert for a level whose status indicates a breach (or `None` when ok). +fn alert_for(level: &BudgetLevelDto) -> Option { + let (severity, message) = match level.status.as_str() { + "alert" => ( + "alert", + format!( + "{} is OVER its passive budget — {} of {} daily tokens ({}%). Alerting only; no work is blocked.", + level.label, + level.used_today, + level.daily_tokens, + (level.percent * 100.0).round() as i64 + ), + ), + "over_buffer_headroom" => ( + "over_buffer", + format!( + "{} is in its +{}% buffer headroom — {} of {} daily tokens. New work still runs, but an admin should review before it hits the hard cap ({}).", + level.label, + level.buffer_percent, + level.used_today, + level.daily_tokens, + level.hard_cap + ), + ), + "blocking" => ( + "blocking", + format!( + "{} is BLOCKING new work — {} of {} daily tokens (cap {}). Only a cluster/org admin can raise it.", + level.label, level.used_today, level.daily_tokens, level.hard_cap + ), + ), + _ => return None, + }; + Some(BudgetAlert { + scope: level.scope.clone(), + label: level.label.clone(), + severity: severity.to_string(), + message, + }) +} + +fn level_dto(scope: &str, label: &str, rule: &BudgetRule, used: i64) -> BudgetLevelDto { + let hard = rule.hard_cap(); + let status = if rule.daily_tokens == 0 { + "ok" + } else if used >= hard && rule.mode != "passive" { + "blocking" + } else if used >= rule.daily_tokens { + match rule.mode.as_str() { + "passive" => "alert", + "buffer" => "over_buffer_headroom", + _ => "blocking", + } + } else { + "ok" + }; + let percent = if rule.daily_tokens > 0 { + used as f64 / rule.daily_tokens as f64 + } else { + 0.0 + }; + BudgetLevelDto { + scope: scope.to_string(), + label: label.to_string(), + daily_tokens: rule.daily_tokens, + mode: rule.mode.clone(), + buffer_percent: rule.buffer_percent, + used_today: used, + status: status.to_string(), + percent, + hard_cap: hard, + } +} + +// ─── Read ──────────────────────────────────────────────────────────────────── + +/// `GET /api/operator/inference-budgets` — the hierarchy + live measured usage. +pub async fn get_budgets(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let h = BudgetHierarchy::load(cluster).await; + let (cluster_used, by_ns, by_user) = usage_today(cluster).await; + let default_ns = "kars-system".to_string(); + + let cluster_level = h + .cluster + .as_ref() + .map(|r| level_dto("cluster", "Whole cluster", r, cluster_used)); + + let mut workspaces: Vec = h + .workspaces + .iter() + .map(|(ns, r)| level_dto(ns, ns, r, *by_ns.get(ns).unwrap_or(&0))) + .collect(); + workspaces.sort_by(|a, b| a.scope.cmp(&b.scope)); + + let mut users: Vec = h + .users + .iter() + .map(|(u, r)| level_dto(u, u, r, *by_user.get(u).unwrap_or(&0))) + .collect(); + users.sort_by(|a, b| a.scope.cmp(&b.scope)); + + let unbudgeted: Vec = by_ns + .keys() + .filter(|ns| !h.workspaces.contains_key(*ns)) + .cloned() + .collect(); + let unbudgeted_users: Vec = by_user + .keys() + .filter(|u| !h.users.contains_key(*u) && *u != "unattributed") + .cloned() + .collect(); + + // Active alerts across every configured level (the "raise alerts" surface). + let mut alerts: Vec = Vec::new(); + if let Some(c) = &cluster_level { + alerts.extend(alert_for(c)); + } + for w in &workspaces { + alerts.extend(alert_for(w)); + } + for u in &users { + alerts.extend(alert_for(u)); + } + + Ok(Json(BudgetsDto { + cluster: cluster_level, + cluster_used_today: cluster_used, + workspaces, + users, + default_namespace: default_ns, + unbudgeted_namespaces: unbudgeted, + unbudgeted_users, + alerts, + })) +} + +// ─── Write ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct SetRuleRequest { + #[serde(default)] + pub daily_tokens: i64, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default)] + pub buffer_percent: i64, + /// When true, remove the rule entirely (uncap this level). + #[serde(default)] + pub clear: bool, +} + +/// `PUT /api/operator/inference-budgets/cluster` — set/clear the cluster cap. +pub async fn set_cluster_budget( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let mut h = BudgetHierarchy::load(cluster).await; + if req.clear { + h.cluster = None; + } else { + h.cluster = Some( + BudgetRule { + daily_tokens: req.daily_tokens, + mode: req.mode, + buffer_percent: req.buffer_percent, + } + .normalized(), + ); + } + h.save(cluster).await?; + get_budgets(State(state)).await +} + +/// `PUT /api/operator/inference-budgets/workspaces/{ns}` — set/clear a workspace. +pub async fn set_workspace_budget( + State(state): State, + Path(ns): Path, + Json(req): Json, +) -> AppResult> { + if ns.trim().is_empty() { + return Err(AppError::BadRequest( + "workspace namespace is required".into(), + )); + } + let cluster = require_cluster(&state)?; + let mut h = BudgetHierarchy::load(cluster).await; + if req.clear { + h.workspaces.remove(&ns); + } else { + h.workspaces.insert( + ns.clone(), + BudgetRule { + daily_tokens: req.daily_tokens, + mode: req.mode, + buffer_percent: req.buffer_percent, + } + .normalized(), + ); + } + h.save(cluster).await?; + get_budgets(State(state)).await +} + +/// `PUT /api/operator/inference-budgets/users/{user}` — set/clear a per-user cap. +pub async fn set_user_budget( + State(state): State, + Path(user): Path, + Json(req): Json, +) -> AppResult> { + if user.trim().is_empty() { + return Err(AppError::BadRequest("user identity is required".into())); + } + let cluster = require_cluster(&state)?; + let mut h = BudgetHierarchy::load(cluster).await; + if req.clear { + h.users.remove(&user); + } else { + h.users.insert( + user.clone(), + BudgetRule { + daily_tokens: req.daily_tokens, + mode: req.mode, + buffer_percent: req.buffer_percent, + } + .normalized(), + ); + } + h.save(cluster).await?; + get_budgets(State(state)).await +} + +// ─── Enforcement (called at mission/team launch) ───────────────────────────── + +/// Enforce the cluster + workspace + user budgets before a run is launched in +/// `ns` by `user`. Returns `Ok` to proceed (passive over-budget is allowed but +/// surfaces as an alert), or `AppError::Rejected` when a `buffer`/`strict` level +/// would be breached. The per-sandbox cap is separately enforced by the router; +/// this is the aggregate hierarchy gate the Bridge owns. +pub async fn enforce_launch_budget(cluster: &Cluster, ns: &str, user: &str) -> AppResult<()> { + let h = BudgetHierarchy::load(cluster).await; + if h.cluster.is_none() && h.workspaces.is_empty() && h.users.is_empty() { + return Ok(()); // no hierarchy configured — nothing to enforce. + } + let (cluster_used, by_ns, by_user) = usage_today(cluster).await; + + if let Some(rule) = &h.cluster { + check_level(rule, cluster_used, "cluster-wide")?; + } + if let Some(rule) = h.workspaces.get(ns) { + let used = *by_ns.get(ns).unwrap_or(&0); + check_level(rule, used, &format!("workspace “{ns}”"))?; + } + if let Some(rule) = h.users.get(user) { + let used = *by_user.get(user).unwrap_or(&0); + check_level(rule, used, &format!("user “{user}”"))?; + } + Ok(()) +} + +fn check_level(rule: &BudgetRule, used: i64, scope: &str) -> AppResult<()> { + if rule.daily_tokens == 0 { + return Ok(()); + } + match rule.mode.as_str() { + "passive" => Ok(()), // alert-only; never blocks a launch. + "buffer" => { + let hard = rule.hard_cap(); + if used >= hard { + Err(AppError::Rejected(format!( + "The {scope} inference budget is exhausted — {used} of {} daily tokens used, past the +{}% buffer ({hard}). A cluster or org admin must raise the budget before new work can start today.", + rule.daily_tokens, rule.buffer_percent + ))) + } else { + Ok(()) // within buffer headroom — allowed. + } + } + _ => { + // strict + if used >= rule.daily_tokens { + Err(AppError::Rejected(format!( + "The {scope} inference budget is reached — {used} of {} daily tokens used (strict enforcement). Only a cluster or org admin can raise it; new work is blocked for today.", + rule.daily_tokens + ))) + } else { + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(daily: i64, mode: &str, buf: i64) -> BudgetRule { + BudgetRule { + daily_tokens: daily, + mode: mode.into(), + buffer_percent: buf, + } + } + + #[test] + fn passive_never_blocks_but_alerts() { + let r = rule(100, "passive", 0); + assert!(check_level(&r, 150, "x").is_ok()); + let dto = level_dto("x", "x", &r, 150); + assert_eq!(dto.status, "alert"); + } + + #[test] + fn strict_blocks_at_limit() { + let r = rule(100, "strict", 0); + assert!(check_level(&r, 99, "x").is_ok()); + assert!(check_level(&r, 100, "x").is_err()); + assert_eq!(level_dto("x", "x", &r, 100).status, "blocking"); + } + + #[test] + fn buffer_allows_headroom_then_blocks() { + let r = rule(100, "buffer", 20); // hard cap 120 + assert!(check_level(&r, 100, "x").is_ok()); // within headroom + assert!(check_level(&r, 119, "x").is_ok()); + assert!(check_level(&r, 120, "x").is_err()); // past +20% + assert_eq!(r.hard_cap(), 120); + assert_eq!(level_dto("x", "x", &r, 110).status, "over_buffer_headroom"); + assert_eq!(level_dto("x", "x", &r, 120).status, "blocking"); + } + + #[test] + fn zero_is_uncapped() { + let r = rule(0, "strict", 0); + assert!(check_level(&r, 999_999, "x").is_ok()); + assert_eq!(level_dto("x", "x", &r, 999).status, "ok"); + } +} diff --git a/bridge/bff/src/routes/channels.rs b/bridge/bff/src/routes/channels.rs new file mode 100644 index 000000000..f19bd8cd0 --- /dev/null +++ b/bridge/bff/src/routes/channels.rs @@ -0,0 +1,290 @@ +// kars Bridge BFF — workspace-level, AGENT-AGNOSTIC communication channels. +// +// The user asked to move channel wiring (Telegram / Slack / Discord / WhatsApp) +// under the Connections tab, alongside GitHub, and out of the per-team envelope. +// A channel configured here applies to the whole WORKSPACE: the controller +// propagates the `kars-workspace-channels` secret into EVERY run sandbox — +// mission or team — so any agent can report over it, regardless of harness. A +// standing team may still layer its own channel secret on top. +// +// SECURITY: identical to the team-channel API — the token is written straight +// into a K8s Secret and NEVER logged or returned. GET only reveals which +// channels are enabled, never the token. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::Deserialize; + +use crate::auth::Principal; + +use crate::error::{AppError, AppResult}; +use crate::routes::teams::{ChannelsDto, channel_env_keys, channels_from_keys}; +use crate::state::AppState; + +fn teams_runtime_names() -> (String, String, String, String) { + ( + std::env::var("BRIDGE_INSTALL_NAMESPACE").unwrap_or_else(|_| "kars-system".into()), + std::env::var("BRIDGE_TEAMS_SECRET_NAME").unwrap_or_else(|_| "kars-bridge-teams".into()), + std::env::var("BRIDGE_TEAMS_GATEWAY_DEPLOYMENT") + .unwrap_or_else(|_| "kars-bridge-teams-gateway".into()), + std::env::var("BRIDGE_BFF_DEPLOYMENT").unwrap_or_else(|_| "kars-bridge-bff".into()), + ) +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +#[derive(Debug, Deserialize)] +pub struct SetChannelRequest { + /// Channel id: telegram | slack | discord | whatsapp | teams. + pub channel: String, + /// The channel's bot token / OAuth token. For whatsapp send "true". + /// Not required when `channel == "teams"` (uses structured fields instead). + #[serde(default)] + pub token: String, + /// Telegram only: comma-separated allowed numeric user IDs. + #[serde(default)] + pub allow_from: Option, + /// Teams only: structured credentials (separate fields, no composite strings). + #[serde(default)] + pub teams: Option, +} + +/// Teams channel structured credential fields — write-only; never returned. +#[derive(Debug, Deserialize)] +pub struct TeamsChannelCredentials { + pub client_id: String, + pub tenant_id: String, + pub client_secret: String, + /// JSON identity map: [{"entra_subject":"","bridge_subject":"","roles":["operator"],"name":"Alice"}] + pub entra_role_map: String, +} + +/// `GET /api/namespaces/:ns/channels` — which workspace channels are enabled. +pub async fn get_channels( + State(state): State, + Path(ns): Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let keys = cluster + .workspace_channel_keys(&ns) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&keys); + Ok(Json(ChannelsDto { + enabled, + statuses: Vec::new(), + })) +} + +/// `POST /api/namespaces/:ns/channels` — enable/update a workspace channel. The +/// token is written into the workspace channel Secret and never echoed back. +/// Teams channel credentials require operator or admin role. +pub async fn set_channel( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, + Json(b): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let keys = channel_env_keys(b.channel.as_str()); + if keys.is_empty() { + return Err(AppError::BadRequest(format!( + "unknown channel '{}': use telegram|slack|discord|whatsapp|teams", + b.channel + ))); + } + + // The identity map can bind Entra identities to immutable Bridge owner + // subjects. Only an admin may create or replace that trust mapping. + if b.channel == "teams" && !principal.roles.iter().any(|r| r == "admin") { + return Err(AppError::Forbidden( + "admin role required to configure Teams identity mapping".into(), + )); + } + + let mut data = std::collections::BTreeMap::new(); + + if b.channel == "teams" { + let teams = b.teams.ok_or_else(|| { + AppError::BadRequest( + "teams channel requires structured 'teams' credentials object".into(), + ) + })?; + if teams.client_id.trim().is_empty() + || teams.tenant_id.trim().is_empty() + || teams.client_secret.trim().is_empty() + || teams.entra_role_map.trim().is_empty() + { + return Err(AppError::BadRequest( + "all Teams credential fields (client_id, tenant_id, client_secret, entra_role_map) are required".into(), + )); + } + // Server-side validation of the role map: reject operator/admin grants by + // non-admin principals, and reject unknown role values entirely. + const VALID_ROLES: &[&str] = &["user", "operator", "admin", "auditor"]; + let role_map: serde_json::Value = serde_json::from_str(teams.entra_role_map.trim()) + .map_err(|_| AppError::BadRequest("entra_role_map must be valid JSON".into()))?; + let entries = role_map + .as_array() + .ok_or_else(|| AppError::BadRequest("entra_role_map must be a JSON array".into()))?; + if entries.is_empty() { + return Err(AppError::BadRequest( + "entra_role_map must not be empty".into(), + )); + } + for entry in entries { + for field in ["entra_subject", "bridge_subject", "name"] { + if entry + .get(field) + .and_then(|value| value.as_str()) + .map(str::trim) + .is_none_or(str::is_empty) + { + return Err(AppError::BadRequest(format!( + "each role map entry must have a non-empty '{field}'" + ))); + } + } + let roles = entry + .get("roles") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + AppError::BadRequest("each role map entry must have a 'roles' array".into()) + })?; + for role in roles { + let role_str = role + .as_str() + .ok_or_else(|| AppError::BadRequest("role values must be strings".into()))?; + if !VALID_ROLES.contains(&role_str) { + return Err(AppError::BadRequest(format!( + "invalid role '{role_str}': allowed values are user, operator, admin, auditor" + ))); + } + } + } + // Teams credentials go in a DEDICATED Secret (kars-bridge-teams), NOT in + // kars-workspace-channels which is propagated to sandbox pods. + let mut teams_data = std::collections::BTreeMap::new(); + teams_data.insert("client-id".to_string(), teams.client_id.trim().to_string()); + teams_data.insert("tenant-id".to_string(), teams.tenant_id.trim().to_string()); + teams_data.insert( + "client-secret".to_string(), + teams.client_secret.trim().to_string(), + ); + teams_data.insert( + "entra-role-map".to_string(), + teams.entra_role_map.trim().to_string(), + ); + // Also store a cryptographically random BFF internal secret for gateway↔BFF auth + let internal_secret = if let Some(existing) = state.teams_internal_secret() { + existing.to_string() + } else { + use std::io::Read; + let mut buf = [0u8; 32]; + std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .map_err(|e| AppError::Internal(anyhow::anyhow!("CSPRNG failed: {e}")))?; + hex::encode(buf) + }; + teams_data.insert("bff-internal-secret".to_string(), internal_secret); + let (namespace, secret, gateway, bff) = teams_runtime_names(); + cluster + .write_dedicated_teams_secret(&namespace, &secret, teams_data) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + cluster + .reconcile_teams_deployments(&namespace, &gateway, &bff, true) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } else { + if b.token.trim().is_empty() { + return Err(AppError::BadRequest("token is required".into())); + } + data.insert(keys[0].to_string(), b.token.trim().to_string()); + if b.channel == "telegram" + && let Some(allow) = b + .allow_from + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + data.insert("TELEGRAM_ALLOW_FROM".to_string(), allow.to_string()); + } + } + + if b.channel != "teams" { + cluster + .merge_workspace_channel(&ns, data) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } + let after = cluster + .workspace_channel_keys(&ns) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&after); + Ok(Json(ChannelsDto { + enabled, + statuses: Vec::new(), + })) +} + +/// `DELETE /api/namespaces/:ns/channels/:channel` — disable a workspace channel +/// (removes its env keys; deletes the Secret when the last channel is removed). +/// For Teams: also deletes the dedicated kars-bridge-teams Secret to revoke all +/// credentials, and annotates the gateway Deployment to trigger a rollout (so the +/// gateway picks up the missing secret and stops processing). +pub async fn delete_channel( + State(state): State, + Extension(principal): Extension, + Path((ns, channel)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let keys = channel_env_keys(channel.as_str()); + if keys.is_empty() { + return Err(AppError::BadRequest(format!( + "unknown channel '{channel}': use telegram|slack|discord|whatsapp|teams" + ))); + } + // Teams disconnect requires operator/admin + if channel == "teams" + && !principal + .roles + .iter() + .any(|r| r == "operator" || r == "admin") + { + return Err(AppError::Forbidden( + "operator or admin role required to disconnect Teams integration".into(), + )); + } + // For Teams: delete the dedicated secret and trigger gateway rollout + if channel == "teams" { + let (namespace, secret, gateway, bff) = teams_runtime_names(); + cluster + .disable_dedicated_teams_secret(&namespace, &secret) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + cluster + .reconcile_teams_deployments(&namespace, &gateway, &bff, false) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } + let owned: Vec = keys.iter().map(|s| s.to_string()).collect(); + if channel != "teams" { + cluster + .remove_workspace_channel_keys(&ns, &owned) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } + let after = cluster + .workspace_channel_keys(&ns) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&after); + Ok(Json(ChannelsDto { + enabled, + statuses: Vec::new(), + })) +} diff --git a/bridge/bff/src/routes/compose.rs b/bridge/bff/src/routes/compose.rs new file mode 100644 index 000000000..f17aab935 --- /dev/null +++ b/bridge/bff/src/routes/compose.rs @@ -0,0 +1,4175 @@ +// kars Bridge BFF — the launch-package orchestrator (§20 "intent → package"). +// +// Turns a plain-language objective into a *proposed*, fully-governed launch +// package — the trust envelope (autonomy tier, budget), the model, harness, +// standing instructions, tool policy, connected MCP servers, network egress, +// isolation, and shared memory — composed by an LLM that is constrained to the +// REAL building blocks this cluster offers (from `/api/options`). +// +// Honesty + safety: +// - This is a PROPOSAL, not an action. Nothing is provisioned. The operator +// reviews and edits every field, then the §20 pre-flight validation gate and +// the explicit Launch step still govern what actually runs. +// - The LLM may only reference building blocks that exist; the server +// re-validates the proposal against the live options and drops/normalizes +// anything that doesn't, so the orchestrator can never invent a model, tool +// policy, MCP server, or isolation level the cluster can't honor. +// - The orchestrator endpoint + credentials are BFF-side config. When they are +// not set the endpoint reports `available: false` and the UI falls back to the +// manual composer — never a fabricated package. + +use axum::{ + Json, + extract::{Extension, Path, State}, +}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::options::ModelOption; +use crate::routes::options::build_options; +use crate::state::AppState; + +/// The cluster-default governance policy. Assigned to any envelope the +/// orchestrator (or operator) leaves un-governed, so the sandbox is BOTH +/// governed and functional: the agent runtime always initializes its AGT +/// engine and fails closed on an empty policy set, so an un-governed envelope +/// otherwise yields a sandbox that hangs (no tool/inference/mesh permitted). +/// `kars-default` allows inference/tool/mesh/spawn and denies dangerous shell. +pub const DEFAULT_TOOL_POLICY: &str = "kars-default"; +const MISSION_COMPOSE_MAX_TOKENS: u32 = 4_096; +const LOOP_COMPOSE_MAX_TOKENS: u32 = 1_200; +// A team proposal can contain eight milestone contracts plus four role contracts. +// Keep enough output room for the model's complete JSON rather than accepting a +// syntactically truncated proposal and wasting the single repair attempt. +const TEAM_COMPOSE_MAX_TOKENS: u32 = 8_192; + +/// Resolve a governance policy for an envelope the orchestrator left +/// un-governed: prefer `kars-default` when the cluster has it, otherwise the +/// first installed policy. Returns `None` only when the cluster has no policies +/// at all (nothing to assign). +pub fn default_tool_policy(o: &crate::routes::options::Options) -> Option { + if o.tool_policies + .iter() + .any(|tp| tp.name == DEFAULT_TOOL_POLICY) + { + return Some(DEFAULT_TOOL_POLICY.to_string()); + } + o.tool_policies.first().map(|tp| tp.name.clone()) +} + +fn orchestrator_quality_score(deployment: &str) -> Option { + let model = deployment.to_ascii_lowercase().replace(['.', '_'], "-"); + if model.contains("embedding") + || model.contains("image") + || model.contains("flux") + || model.contains("dall-e") + { + return None; + } + let score = if model.contains("gpt-5-6") || model.contains("gpt-5.6") { + 1_000 + } else if model.contains("claude-opus-4-8") { + 990 + } else if model.contains("claude-opus-4-7") { + 980 + } else if model.contains("gpt-5-4-pro") { + 970 + } else if model.contains("gpt-5-4") { + 950 + } else if model.contains("claude-sonnet-5") { + 940 + } else if model.contains("gpt-4-1") { + 900 + } else if model.contains("gpt-oss-120b") { + 850 + } else if model.contains("gpt-5") || model.contains("claude") { + 800 + } else { + 500 + }; + Some(score) +} + +fn catalogue_has_model(models: &[ModelOption], provider: &str, deployment: &str) -> bool { + models + .iter() + .any(|model| model.provider == provider && model.deployment == deployment) +} + +fn catalogue_has_model_key(models: &[ModelOption], key: &str) -> bool { + key.split_once("::") + .is_some_and(|(provider, deployment)| catalogue_has_model(models, provider, deployment)) +} + +fn recommendation_is_actionable(recommended: Option<&str>, low_confidence: bool) -> bool { + recommended.is_some() && !low_confidence +} + +fn select_orchestrator_route( + options: &crate::routes::options::Options, + efficiency: &crate::routes::efficiency::EfficiencyDto, +) -> Option<(String, String, String)> { + let actionable_recommendation = efficiency.recommended.as_deref().filter(|_| { + recommendation_is_actionable( + efficiency.recommended.as_deref(), + efficiency.recommended_low_confidence, + ) + }); + options + .models + .iter() + .filter_map(|model| { + let quality = orchestrator_quality_score(&model.deployment)?; + let route = efficiency + .routes + .iter() + .find(|route| route.route == model.deployment); + let frontier_bonus = if actionable_recommendation == Some(model.deployment.as_str()) { + 80 + } else { + 0 + }; + let evidence_bonus = route + .map(|route| (route.acceptance_rate * 50.0).round() as i64) + .unwrap_or(0); + Some((quality + frontier_bonus + evidence_bonus, model)) + }) + .max_by_key(|(score, _)| *score) + .map(|(_, model)| { + let basis = if actionable_recommendation == Some(model.deployment.as_str()) { + format!( + "Selected {} from {} as the strongest orchestration-capable model and current efficiency-frontier recommendation.", + model.deployment, model.provider + ) + } else { + format!( + "Selected {} from {} as the strongest orchestration-capable model in the configured catalogue.", + model.deployment, model.provider + ) + }; + (model.provider.clone(), model.deployment.clone(), basis) + }) +} + +#[derive(Debug, Deserialize)] +pub struct ComposeRequest { + pub objective: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeModel { + pub provider: String, + pub deployment: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeEgress { + pub host: String, + pub port: Option, +} + +fn complete_egress_recommendation( + egress: Vec, + intent: &str, + mcp_servers: &[String], +) -> Vec { + let mut endpoints = std::collections::BTreeMap::>::new(); + let lower = intent.to_ascii_lowercase(); + let npm_intent = ["npm", "node", "javascript", "typescript", "package.json"] + .iter() + .any(|term| lower.contains(term)); + let python_intent = ["python", "pip", "pypi", "requirements.txt"] + .iter() + .any(|term| lower.contains(term)); + for endpoint in egress { + let host = endpoint.host.to_ascii_lowercase(); + if host.contains("githubcopilot.com") + || host.ends_with(".openai.azure.com") + || host.ends_with(".services.ai.azure.com") + { + continue; + } + if host == "registry.npmjs.org" && !npm_intent { + continue; + } + if matches!(host.as_str(), "pypi.org" | "files.pythonhosted.org") && !python_intent { + continue; + } + endpoints.insert(host, endpoint.port.or(Some(443))); + } + let github = mcp_servers + .iter() + .any(|server| server.to_ascii_lowercase().contains("github")) + || [ + "github", + "repository", + "pull request", + "dependabot", + "code scanning", + ] + .iter() + .any(|term| lower.contains(term)); + let mut add = |host: &str| { + endpoints.entry(host.to_string()).or_insert(Some(443)); + }; + if github { + for host in [ + "api.github.com", + "github.com", + "raw.githubusercontent.com", + "codeload.github.com", + "objects.githubusercontent.com", + "patch-diff.githubusercontent.com", + ] { + add(host); + } + } + if npm_intent { + add("registry.npmjs.org"); + } + if python_intent { + add("pypi.org"); + add("files.pythonhosted.org"); + } + if [ + "security advisory", + "security advisories", + "vulnerability", + "vulnerabilities", + "cve", + ] + .iter() + .any(|term| lower.contains(term)) + { + add("api.osv.dev"); + } + if ["rust", "cargo", "crates.io"] + .iter() + .any(|term| lower.contains(term)) + { + add("index.crates.io"); + add("static.crates.io"); + } + endpoints + .into_iter() + .map(|(host, port)| ComposeEgress { host, port }) + .collect() +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeDelegationRole { + pub name: String, + pub objective: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeDelegation { + pub mode: String, + pub roles: Vec, + pub max_parallel: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeProposal { + pub tier: i32, + pub model: Option, + /// Ordered routes that independently qualify the complete Mission package. + pub model_fallbacks: Vec, + /// Plain-language basis for the model choice, so the reviewer sees WHY this + /// model was proposed — the learned efficiency frontier, the orchestrator's + /// objective-driven pick, or the cluster default. Never fabricated. + pub model_basis: Option, + pub runtime: String, + pub instructions: String, + pub tool_policy: Option, + pub mcp_servers: Vec, + pub skills: Vec, + pub egress: Vec, + pub isolation: String, + pub memory: Option, + pub budget_tokens: Option, + pub execution_plan: Option, + pub delegation: ComposeDelegation, +} + +#[derive(Debug, Serialize)] +pub struct ComposeResponse { + /// Whether the orchestrator is configured + reachable on this deployment. + pub available: bool, + /// Why it isn't available (only set when `available` is false), so the UI + /// can explain the manual-composer fallback honestly. + pub reason: Option, + /// The composed, validated launch package — ready to review and edit. + pub proposal: Option, + /// A short, plain-language rationale for the choices (for the reviewer). + pub rationale: Option, + /// The model that composed the package (provenance). + pub source: Option, +} + +fn mission_proposal_blueprint( + proposal: &ComposeProposal, +) -> Result< + ( + crate::routes::tasks::BlueprintDto, + &ComposeModel, + std::collections::BTreeSet, + i32, + ), + String, +> { + let model = proposal + .model + .as_ref() + .ok_or_else(|| "the proposal has no model route".to_string())?; + let blueprint = crate::routes::tasks::BlueprintDto { + runtime: Some(proposal.runtime.clone()), + model: Some(crate::routes::tasks::ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }), + model_fallbacks: proposal + .model_fallbacks + .iter() + .map(|model| crate::routes::tasks::ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }) + .collect(), + instructions: Some(proposal.instructions.clone()), + tool_policy: proposal.tool_policy.clone(), + mcp_servers: proposal.mcp_servers.clone(), + egress: proposal + .egress + .iter() + .map(|endpoint| crate::routes::tasks::EgressDto { + host: endpoint.host.clone(), + port: endpoint.port.map(i32::from), + }) + .collect(), + egress_mode: Some("strict".into()), + isolation: Some(proposal.isolation.clone()), + memory: proposal.memory.clone(), + skills: proposal.skills.clone(), + execution_plan: proposal.execution_plan.clone(), + }; + let (required, max_parallel) = + crate::routes::validate::qualification_requirements(&blueprint, None); + Ok((blueprint, model, required, max_parallel)) +} + +fn apply_mission_budget_floor(proposal: &mut ComposeProposal) -> Result, String> { + let (_, model, required, max_parallel) = mission_proposal_blueprint(proposal)?; + let minimum = crate::routes::options::route_minimum_tokens( + &proposal.runtime, + &model.provider, + &model.deployment, + &required, + max_parallel, + )?; + let Some(minimum) = minimum else { + return Ok(None); + }; + + let mut changed = false; + let mut required_total = minimum; + if let Some(plan) = proposal.execution_plan.as_mut() + && !plan.roles.is_empty() + { + let (role_budgets_changed, role_budget_total) = + apply_weighted_role_budget_floors(plan, minimum.max(plan.roles.len() as i64)); + changed |= role_budgets_changed; + required_total = required_total.max(role_budget_total); + } + if proposal + .budget_tokens + .is_none_or(|current| current < required_total) + { + proposal.budget_tokens = Some(required_total); + changed = true; + } + Ok(changed.then_some(required_total)) +} + +fn mission_proposal_qualification(proposal: &ComposeProposal) -> Result<(), String> { + let (_, model, required, max_parallel) = mission_proposal_blueprint(proposal)?; + let qualified = crate::routes::options::route_qualification( + &proposal.runtime, + &model.provider, + &model.deployment, + &required, + max_parallel, + proposal.budget_tokens, + )?; + if qualified { + return Ok(()); + } + let missing = crate::routes::options::route_qualification_gap( + &proposal.runtime, + &model.provider, + &model.deployment, + &required, + max_parallel, + proposal.budget_tokens, + )?; + Err(format!( + "{} · {}::{} lacks retained qualification for [{}] at max_parallel={max_parallel}", + proposal.runtime, + model.provider, + model.deployment, + missing.into_iter().collect::>().join(", "), + )) +} + +fn option_named<'a>( + options: &'a [crate::routes::options::RefOption], + name: &str, +) -> Option<&'a crate::routes::options::RefOption> { + options.iter().find(|option| option.name == name) +} + +fn mission_resource_qualification( + proposal: &ComposeProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + let (_, model, _, _) = mission_proposal_blueprint(proposal)?; + let route = + crate::routes::options::route_label(&proposal.runtime, &model.provider, &model.deployment); + for server in &proposal.mcp_servers { + let option = option_named(&options.mcp_servers, server) + .ok_or_else(|| format!("MCP server `{server}` is not in the live options catalogue"))?; + if !crate::routes::options::mcp_server_qualified_for_route( + &proposal.runtime, + &model.provider, + &model.deployment, + option, + )? { + return Err(format!( + "MCP server `{server}` lacks retained resource qualification for {route} at current schema {}. Generic route records do not prove this server.", + option.tool_schema_digest.as_deref().unwrap_or("missing"), + )); + } + } + if let Some(memory) = proposal.memory.as_deref() { + let option = option_named(&options.memories, memory) + .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; + if !crate::routes::options::memory_binding_qualified_for_route( + &proposal.runtime, + &model.provider, + &model.deployment, + option, + )? { + return Err(format!( + "Memory `{memory}` lacks retained resource qualification for {route} at backend {} / compiled digest {}. Generic route records do not prove this binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + )); + } + } + for skill in &proposal.skills { + let option = option_named(&options.skills, skill) + .ok_or_else(|| format!("skill `{skill}` is not in the approved live catalogue"))?; + if !crate::routes::options::skill_version_qualified_for_route( + &proposal.runtime, + &model.provider, + &model.deployment, + option, + )? { + return Err(format!( + "Skill `{skill}` lacks retained resource qualification for {route} at current version digest {}. Generic route records do not prove this approved version.", + option.version_digest.as_deref().unwrap_or("missing"), + )); + } + } + Ok(()) +} + +fn mission_proposal_launchability( + proposal: &ComposeProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + mission_proposal_qualification(proposal)?; + mission_resource_qualification(proposal, options) +} + +/// `POST /api/namespaces/:ns/compose` — orchestrate a launch package from an +/// objective. The `ns` is accepted for symmetry with the other task routes but +/// composition reads cluster-wide building blocks. +pub async fn compose( + State(state): State, + Extension(principal): Extension, + Json(req): Json, +) -> AppResult> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + + let objective = req.objective.trim(); + if objective.is_empty() { + return Err(AppError::BadRequest("objective is required".into())); + } + + let options = build_options(cluster).await?; + let efficiency = crate::routes::efficiency::compute_efficiency_for_owner( + cluster, + Some(principal.sub.as_str()), + ) + .await; + let orchestrator_route = select_orchestrator_route(&options, &efficiency); + + let qualification_constraints = crate::routes::options::qualification_constraints_summary() + .unwrap_or_else(|error| format!(" (qualification records unavailable: {error})")); + let resource_qualification_constraints = + crate::routes::options::resource_qualification_summary(&options).unwrap_or_else(|error| { + format!(" (resource qualification records unavailable: {error})") + }); + let system = build_system_prompt( + &options, + &efficiency, + &qualification_constraints, + &resource_qualification_constraints, + ); + let user = format!( + "Objective:\n{objective}\n\nCompose the launch package now. Respond with ONLY the JSON object." + ); + + // Resolve the model used to CALL the orchestrator. An explicit + // BRIDGE_ORCHESTRATOR_* env triple overrides (and carries its own model), so + // it makes `default_model` irrelevant. Otherwise we need a real cluster + // model — never a fabricated one, which would fail opaquely on a non-Anthropic + // cluster. When there is neither, say so honestly instead of guessing. + let env_orchestrator = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") + .is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_TOKEN").is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_MODEL").is_ok_and(|v| !v.trim().is_empty()); + let pinned_orchestrator_model = cluster.bridge_orchestrator_model().await; + let resolved_model = orchestrator_route + .as_ref() + .map(|(_, deployment, _)| deployment.clone()) + .or(pinned_orchestrator_model) + .or_else(|| { + options + .models + .iter() + .find(|m| m.is_default) + .or_else(|| options.models.first()) + .map(|m| m.deployment.clone()) + }); + if resolved_model.is_none() && !env_orchestrator { + return Ok(Json(ComposeResponse { + available: false, + reason: Some( + "No inference models are configured on this cluster, so the AI composer can't run. Add an inference provider in the Operator Console, or compose the package manually below.".into(), + ), + proposal: None, + rationale: None, + source: None, + })); + } + let default_model = resolved_model.unwrap_or_default(); + if !env_orchestrator + && let Some((provider, deployment, _)) = &orchestrator_route + && let Err(error) = cluster + .configure_bridge_orchestrator_model(provider, deployment) + .await + { + return Ok(Json(ComposeResponse { + available: false, + reason: Some(format!( + "The best orchestrator route ({provider}/{deployment}) could not be configured: {error}" + )), + proposal: None, + rationale: None, + source: None, + })); + } + + let (raw, mut source) = match orchestrator_complete( + cluster, + &system, + &user, + &default_model, + MISSION_COMPOSE_MAX_TOKENS, + ) + .await + { + Ok(r) => r, + Err(e) => { + // A reachable-but-failing orchestrator is reported honestly, not + // papered over with a fabricated package. + return Ok(Json(ComposeResponse { + available: false, + reason: Some(format!( + "The orchestrator could not compose a package ({e}). Compose it manually below — every field is the same one the orchestrator would propose." + )), + proposal: None, + rationale: None, + source: None, + })); + } + }; + + let (mut proposal, mut rationale) = parse_and_validate(&raw, &options, &efficiency, objective); + if let Ok(Some(minimum)) = apply_mission_budget_floor(&mut proposal) { + let note = format!( + "The token budget was raised to the retained qualification floor of {minimum} tokens." + ); + rationale = Some(match rationale { + Some(existing) if !existing.trim().is_empty() => format!("{existing} {note}"), + _ => note, + }); + } + if let Err(error) = mission_proposal_launchability(&proposal, &options) { + let repair_user = format!( + "{user}\n\nYour previous proposal was not launchable: {error}\n\ + Recompose it so the complete runtime/model/capability/max_parallel requirement fits \ + ONE qualified execution record below. Qualification records do not compose. If a \ + selected MCP server, memory binding, or approved skill is used, it MUST have a \ + retained resource-scoped qualification record at the CURRENT digest on the chosen \ + route — generic route records do not count. If a requested binary or file-writing \ + deliverable needs an unqualified capability, choose a launchable text/JSON \ + alternative and represent diagrams inline with quoted Mermaid flowchart labels \ + whenever they contain parser-sensitive punctuation.\n\n\ + QUALIFIED EXECUTION RECORDS:\n{qualification_constraints}\n\n\ + RESOURCE QUALIFICATION RECORDS:\n{resource_qualification_constraints}\n\n\ + Return ONLY the complete JSON object." + ); + if let Ok((repair_raw, repair_source)) = orchestrator_complete( + cluster, + &system, + &repair_user, + &default_model, + MISSION_COMPOSE_MAX_TOKENS, + ) + .await + { + (proposal, rationale) = + parse_and_validate(&repair_raw, &options, &efficiency, objective); + let _ = apply_mission_budget_floor(&mut proposal); + source = repair_source; + } + } + if let Err(error) = mission_proposal_launchability(&proposal, &options) { + return Ok(Json(ComposeResponse { + available: false, + reason: Some(format!( + "The orchestrator could not produce a launchable package after retry: {error}" + )), + proposal: None, + rationale: None, + source: Some(source), + })); + } + + proposal.model_fallbacks = qualified_mission_fallbacks(&proposal, &options); + Ok(Json(ComposeResponse { + available: true, + reason: None, + proposal: Some(proposal), + rationale, + source: Some(source), + })) +} + +fn qualified_mission_fallbacks( + proposal: &ComposeProposal, + options: &crate::routes::options::Options, +) -> Vec { + let primary = proposal + .model + .as_ref() + .map(|model| format!("{}::{}", model.provider, model.deployment)); + let mut candidates = options + .models + .iter() + .map(|model| (model.provider.clone(), model.deployment.clone())) + .collect::>(); + candidates.sort(); + candidates.dedup(); + candidates + .into_iter() + .filter(|(provider, deployment)| { + primary.as_deref() != Some(format!("{provider}::{deployment}").as_str()) + }) + .filter_map(|(provider, deployment)| { + let mut trial = proposal.clone(); + trial.model = Some(ComposeModel { + provider: provider.clone(), + deployment: deployment.clone(), + }); + trial.model_fallbacks.clear(); + mission_proposal_launchability(&trial, options) + .is_ok() + .then_some(ComposeModel { + provider, + deployment, + }) + }) + .take(8) + .collect() +} + +/// `POST /api/namespaces/:ns/propose-loop` — the orchestrator turns a raw intent +/// into a PROPOSED loop (2026 loop engineering): it picks the feedback-loop +/// pattern that fits and drafts the goal + success criteria. The web then shows +/// this in the Loop Designer for the user to REVIEW and tweak before executing — +/// so the loop is orchestrator-defined, human-reviewed, then run. Falls back to a +/// keyword heuristic when the orchestrator is unreachable (never a dead end). +#[derive(Debug, serde::Deserialize)] +pub struct ProposeLoopRequest { + pub intent: String, + /// "mission" (single run) or "team" (standing cadence loop). + #[serde(default)] + pub surface: String, +} + +#[derive(Debug, Serialize)] +pub struct ProposeLoopResponse { + /// Chosen loop pattern id (matches the web catalog: react, reflect, + /// plan-execute, eval-iterate, explore-branch, standing-watch). + pub pattern: String, + /// The goal the orchestrator distilled from the intent. + pub goal: String, + /// Draft success criteria (one per line). + pub criteria: String, + /// One-line why-this-pattern rationale. + pub rationale: String, + /// "orchestrator" when the model chose it, "heuristic" on fallback. + pub source: String, +} + +const LOOP_PATTERN_IDS: [&str; 6] = [ + "react", + "reflect", + "plan-execute", + "eval-iterate", + "explore-branch", + "standing-watch", +]; + +/// Keyword heuristic used both to seed the orchestrator and as the fallback. +fn heuristic_pattern(intent: &str, surface: &str) -> &'static str { + let t = intent.to_ascii_lowercase(); + if surface == "team" + || t.contains("watch") + || t.contains("monitor") + || t.contains("keep an eye") + || t.contains("on cadence") + || t.contains("every ") + { + return "standing-watch"; + } + if t.contains("test") + || t.contains("verify") + || t.contains("pass") + || t.contains("acceptance") + || t.contains("ci") + { + return "eval-iterate"; + } + if t.contains("research") + || t.contains("investigate") + || t.contains("browse") + || t.contains("search") + || t.contains("find ") + { + return "react"; + } + if t.contains("write") + || t.contains("draft") + || t.contains("report") + || t.contains("polish") + || t.contains("review") + { + return "reflect"; + } + if t.contains("design") + || t.contains("compare") + || t.contains("options") + || t.contains("approach") + || t.contains("brainstorm") + { + return "explore-branch"; + } + "plan-execute" +} + +pub async fn propose_loop( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let intent = req.intent.trim(); + if intent.is_empty() { + return Err(AppError::BadRequest("intent is required".into())); + } + let surface = if req.surface == "team" { + "team" + } else { + "mission" + }; + let heuristic = heuristic_pattern(intent, surface); + + let options = build_options(cluster).await?; + // Don't fabricate a specific model when none is configured — an empty model + // makes the orchestrator call fail cleanly and fall back to the heuristic + // below, rather than pretending a named model exists on this cluster. + let default_model = options + .models + .iter() + .find(|m| m.is_default) + .or_else(|| options.models.first()) + .map(|m| m.deployment.clone()) + .unwrap_or_default(); + + let system = format!( + "You are a loop-engineering orchestrator. Given a user's intent, pick the ONE feedback-loop \ + pattern that best fits and draft the loop. Patterns: react (reason+act with tools), reflect \ + (draft, self-critique, revise), plan-execute (plan then do), eval-iterate (define acceptance \ + checks first, loop until they pass), explore-branch (generate candidates, prune), \ + standing-watch (periodic observe->detect change->act, for standing {surface} work). \ + Respond with ONLY a JSON object: {{\"pattern\": one of [{}], \"goal\": string, \ + \"criteria\": string with one success criterion per line, \"rationale\": one short sentence}}.", + LOOP_PATTERN_IDS.join(", ") + ); + let user = format!( + "Surface: {surface}\nIntent:\n{intent}\n\nA reasonable default pattern is '{heuristic}', but \ + choose the best fit. Respond with ONLY the JSON object." + ); + + // Ask the orchestrator; parse its JSON. Any failure → honest heuristic. + match orchestrator_complete( + cluster, + &system, + &user, + &default_model, + LOOP_COMPOSE_MAX_TOKENS, + ) + .await + { + Ok((raw, _src)) => { + if let Some(v) = extract_json_object(&raw) { + let pattern = v + .get("pattern") + .and_then(|p| p.as_str()) + .filter(|p| LOOP_PATTERN_IDS.contains(p)) + .unwrap_or(heuristic) + .to_string(); + let goal = v + .get("goal") + .and_then(|g| g.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| intent.to_string()); + let criteria = v + .get("criteria") + .and_then(|c| c.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let rationale = v + .get("rationale") + .and_then(|r| r.as_str()) + .unwrap_or("Best fit for this intent.") + .trim() + .to_string(); + return Ok(Json(ProposeLoopResponse { + pattern, + goal, + criteria, + rationale, + source: "orchestrator".into(), + })); + } + // Unparseable model output → heuristic. + } + Err(_) => { /* orchestrator unreachable → heuristic */ } + } + + Ok(Json(ProposeLoopResponse { + pattern: heuristic.to_string(), + goal: intent.to_string(), + criteria: String::new(), + rationale: "Chosen from your intent's keywords (orchestrator unavailable).".into(), + source: "heuristic".into(), + })) +} + +/// Find and parse the first top-level JSON object in a model response (it may be +/// fenced or prefixed with prose). Returns `None` when there's no parseable object. +fn extract_json_object(raw: &str) -> Option { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + if end <= start { + return None; + } + serde_json::from_str(&raw[start..=end]).ok() +} + +/// Complete the orchestrator prompt, returning `(raw_model_output, source)`. +/// +/// Two reachable paths, in priority order: +/// 1. **Ops override** — an explicit `BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL}` +/// triple (a dedicated composer endpoint the operator configured). +/// 2. **Native** — route through a Running sandbox's inference router via the +/// `pods/proxy` subresource. The router injects the provider's auth + +/// integration headers (Copilot/Foundry) and enforces governance, so this +/// works on workload-identity clusters with NO static token in the Bridge — +/// reusing exactly the secure path agents use. +async fn orchestrator_complete( + cluster: &crate::kars::cluster::Cluster, + system: &str, + user: &str, + default_model: &str, + max_tokens: u32, +) -> anyhow::Result<(String, String)> { + // 1. Ops override — direct endpoint/token/model. + if let (Ok(endpoint), Ok(token), Ok(model)) = ( + std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT"), + std::env::var("BRIDGE_ORCHESTRATOR_TOKEN"), + std::env::var("BRIDGE_ORCHESTRATOR_MODEL"), + ) && !endpoint.trim().is_empty() + && !token.trim().is_empty() + && !model.trim().is_empty() + { + let raw = call_llm(&endpoint, &token, &model, system, user, max_tokens).await?; + return Ok((raw, model)); + } + + // 2. Native — through a running sandbox's secure inference router. Try each + // stable candidate in turn so a sandbox with stale provider auth or a + // warming router is skipped rather than failing the whole compose. + // Claude models use the native Anthropic `/v1/messages` path (the + // OpenAI-compat path returns empty content for Claude). + let candidates = cluster.running_sandbox_candidates().await; + if candidates.is_empty() { + anyhow::bail!( + "orchestrator has no inference path yet — the standing `bridge-orchestrator` sandbox is still starting (retry shortly), or set BRIDGE_ORCHESTRATOR_{{ENDPOINT,TOKEN,MODEL}} to route directly at Azure AI Foundry / Azure OpenAI (scales better for many teams)" + ); + } + let is_claude = default_model.to_ascii_lowercase().contains("claude"); + let mut last_err = String::from("no candidate router returned content"); + for (ns, pod) in candidates.iter().take(4) { + match orchestrator_via_router( + cluster, + ns, + pod, + default_model, + OrchestratorPrompt { system, user }, + is_claude, + max_tokens, + ) + .await + { + Ok(content) if !content.trim().is_empty() => { + return Ok((content, format!("{default_model} (cluster router)"))); + } + Ok(_) => last_err = "router returned empty content".into(), + Err(e) => last_err = e.to_string(), + } + } + anyhow::bail!("{last_err}") +} + +struct OrchestratorPrompt<'a> { + system: &'a str, + user: &'a str, +} + +/// Single orchestrator completion against one sandbox router (Anthropic +/// `/v1/messages` for Claude, OpenAI `/chat/completions` otherwise). +async fn orchestrator_via_router( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + pod: &str, + model: &str, + prompt: OrchestratorPrompt<'_>, + is_claude: bool, + max_tokens: u32, +) -> anyhow::Result { + let OrchestratorPrompt { system, user } = prompt; + if is_claude { + let body = serde_json::json!({ + "model": model, + "system": system, + "messages": [{ "role": "user", "content": user }], + "max_tokens": max_tokens, + }); + let text = cluster.router_messages(ns, pod, &body).await?; + let parsed: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("router returned non-JSON: {e}"))?; + let content: String = parsed + .get("content") + .and_then(|c| c.as_array()) + .map(|blocks| { + blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("") + }) + .unwrap_or_default(); + return Ok(content); + } + + let body = serde_json::json!({ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "max_tokens": max_tokens, + }); + let text = cluster.router_chat(ns, pod, &body).await?; + let parsed: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("router returned non-JSON: {e}"))?; + Ok(parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or_default() + .to_string()) +} + +/// Build the system prompt enumerating the real building blocks + the strict +/// JSON contract. The model is told it may ONLY use these exact identifiers, +/// and is given the learned efficiency frontier so its model choice is grounded +/// in what actually performs on this cluster — not a blind pick. +fn build_system_prompt( + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + qualification_constraints: &str, + resource_qualification_constraints: &str, +) -> String { + let models = o + .models + .iter() + .map(|m| { + format!( + " - deployment=\"{}\" provider=\"{}\"{}", + m.deployment, + m.provider, + if m.is_default { " (default)" } else { "" } + ) + }) + .collect::>() + .join("\n"); + let runtimes = o + .runtimes + .iter() + .filter(|r| r.wired && r.kind != "BYO") + .map(|r| format!(" - \"{}\"", r.kind)) + .collect::>() + .join("\n"); + let isolation = o + .isolation + .iter() + .map(|i| format!(" - \"{}\" — {}", i.value, i.note)) + .collect::>() + .join("\n"); + let policies = if o.tool_policies.is_empty() { + " (none)".to_string() + } else { + o.tool_policies + .iter() + .map(|p| { + format!( + " - \"{}\"{}", + p.name, + p.summary + .as_deref() + .map(|s| format!(" — {s}")) + .unwrap_or_default() + ) + }) + .collect::>() + .join("\n") + }; + let mcp = if o.mcp_servers.is_empty() { + " (none)".to_string() + } else { + o.mcp_servers + .iter() + .map(|m| { + format!( + " - \"{}\"{}{}{}{}{}", + m.name, + m.summary + .as_deref() + .map(|s| format!(" — {s}")) + .unwrap_or_default(), + m.mode + .as_deref() + .map(|mode| format!(" · mode={mode}")) + .unwrap_or_default(), + if m.discovered_tools.is_empty() { + String::new() + } else { + format!(" · tools=[{}]", m.discovered_tools.join(", ")) + }, + m.tool_schema_digest + .as_deref() + .map(|digest| format!(" · schema_digest={digest}")) + .unwrap_or_else(|| " · schema_digest=missing".into()), + m.readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default() + ) + }) + .collect::>() + .join("\n") + }; + let memories = if o.memories.is_empty() { + " (none)".to_string() + } else { + o.memories + .iter() + .map(|m| { + format!( + " - \"{}\"{}{}{}{}", + m.name, + m.summary + .as_deref() + .map(|summary| format!(" — {summary}")) + .unwrap_or_default(), + m.backend + .as_deref() + .map(|backend| format!(" · backend={backend}")) + .unwrap_or_else(|| " · backend=missing".into()), + m.compiled_digest + .as_deref() + .map(|digest| format!(" · compiled_digest={digest}")) + .unwrap_or_else(|| " · compiled_digest=missing".into()), + m.readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default(), + ) + }) + .collect::>() + .join("\n") + }; + let skills = if o.skills.is_empty() { + " (none)".to_string() + } else { + o.skills + .iter() + .map(|s| { + format!( + " - \"{}\"{}{}{}{}{}", + s.name, + s.summary + .as_deref() + .map(|v| format!(" — {v}")) + .unwrap_or_default(), + s.version + .as_deref() + .map(|version| format!(" · version={version}")) + .unwrap_or_default(), + s.version_digest + .as_deref() + .map(|digest| format!(" · version_digest={digest}")) + .unwrap_or_else(|| " · version_digest=missing".into()), + s.recipe + .as_deref() + .map(|recipe| { + format!(" · recipe={}", recipe.chars().take(180).collect::()) + }) + .unwrap_or_default(), + s.readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default() + ) + }) + .collect::>() + .join("\n") + }; + + // The learned efficiency frontier — grounds the model choice in real + // outcomes. Honest: when no runs have completed yet, say so rather than + // inventing a recommendation. + let efficiency = if eff.routes.is_empty() { + " (no completed runs yet — choose the default model unless the objective clearly warrants another)".to_string() + } else { + let mut lines = eff + .routes + .iter() + .take(6) + .map(|r| { + // Structured, honest per-route signal. Absent metrics (pass^k + // with no repeats, USD with no price table) are omitted rather + // than faked, so the model never reasons over invented numbers. + let reliability = match (r.reliability_rate, r.reliability_k) { + (Some(rate), Some(k)) => { + format!(", pass^{k} reliability {:.0}% (n={})", rate * 100.0, r.reliability_samples) + } + _ => String::new(), + }; + let latency = if r.avg_wall_ms > 0 { + format!(", ~{:.0}s wall (p95 {:.0}s)", r.avg_wall_ms as f64 / 1000.0, r.p95_wall_ms as f64 / 1000.0) + } else { + String::new() + }; + let toolfail = if r.avg_tool_calls > 0.0 { + format!(", {:.0}% tool-fail", r.tool_fail_rate * 100.0) + } else { + String::new() + }; + let usd = match r.usd_per_outcome { + Some(u) => format!(", ${:.3}/outcome", u), + None => String::new(), + }; + let fault = if r.top_fault.is_empty() { + String::new() + } else { + format!(", top miss: {}", r.top_fault) + }; + format!( + " - route \"{}\": {:.0}% accepted, {:.0}% delivered, {} tokens/outcome{usd}{reliability}{latency}{toolfail}{fault} over {} run(s){}", + r.route, + r.acceptance_rate * 100.0, + r.success_rate * 100.0, + r.tokens_per_outcome, + r.runs, + if !eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← recommended" + } else if eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← best observed, insufficient evidence" + } else { + "" + } + ) + }) + .collect::>() + .join("\n"); + if !eff.recommended_low_confidence + && let Some(rec) = &eff.recommended + { + lines.push_str(&format!( + "\n Prefer the recommended route's model (route contains its deployment: \"{rec}\") unless the objective clearly needs a stronger or cheaper model." + )); + } else if eff.recommended_low_confidence { + lines.push_str( + "\n The retained route history is too sparse for automatic model selection. Use the cluster default unless the objective itself clearly requires a stronger or more specialized model.", + ); + } + lines + }; + + format!( + r#"You are the kars launch-package orchestrator. You turn a user's plain-language objective into a single, well-governed launch package for a sandboxed AI agent on the kars runtime. You propose; a human reviews and approves before anything runs. + +You MUST only use the building blocks listed below — never invent a model, tool policy, MCP server, isolation level, or memory store that is not listed. + +AVAILABLE MODELS (pick exactly one by its deployment string): +{models} + +EFFICIENCY FRONTIER (learned from completed runs on THIS cluster — the honest signal is human ACCEPTANCE, not emitted tokens): +{efficiency} + +QUALIFIED EXECUTION RECORDS (the complete proposed package MUST fit one record; records do not compose): +{qualification_constraints} + +RESOURCE QUALIFICATION RECORDS (selected MCP servers, memory bindings, and skills MUST match one current-digest record on the chosen route; generic route records do not count): +{resource_qualification_constraints} + +MODEL ROUTING: the model running this composer is not automatically the model that should execute the mission. For routine, bounded, low-risk work, prefer the cluster default or a proven efficient route. Reserve the strongest model for objectives with substantial ambiguity, synthesis, security impact, long context, or difficult tool orchestration. Sparse history with few or zero accepted outcomes is not a recommendation. + +HARNESSES (pick exactly one): +{runtimes} + +ISOLATION LEVELS (pick exactly one): +{isolation} + +TOOL POLICIES (optional; pick one name or null): +{policies} + +MCP SERVERS (optional; pick zero or more names; if you pick any, you MUST also set a tool_policy): +{mcp} +Foundry-native web search, file search, memory, and code execution are Kars plugin tools and do not require MCP. If the customer explicitly requests an installed MCP server, select it and declare `mcp`; the complete capability combination must match one atomic qualification record. + +APPROVED SKILLS (optional; pick zero or more names): +{skills} + +SHARED MEMORY STORES (optional; pick one name or null): +{memories} + +AUTONOMY TIERS (pick the lowest tier that fits the objective): + 1 = Manual (proposes every step, acts on nothing) + 2 = Shared (acts only on low-risk steps) + 3 = Conditional (acts, but pauses before anything costly/external/irreversible) + 4 = Supervised (autonomous with periodic checkpoints) + 5 = Full (fully autonomous within budget) +Default to tier 3 unless the objective clearly warrants more or less. + +EGRESS: list the external network hosts the agent legitimately needs (e.g. an API host), as objects {{"host": "...", "port": 443}}. Prefer an empty list — the model path is always allowed; only add hosts the task truly requires. + +INSTRUCTIONS: write a concise, specific system prompt (2–5 sentences) framing the agent's role and standards for THIS objective. + +EXECUTION PLAN: when the objective benefits from decomposition, propose a workload-neutral typed execution plan. Choose arbitrary role names from the objective — never use a fixed role template. Each role has dependency-aware phases. Each phase declares only the generic capabilities it needs: filesystem-read, filesystem-write, shell, network, web-search, mcp, memory. `min_tool_calls` is the minimum successful evidence-producing calls required; set it to at least 1 whenever the phase outcome depends on tools or external evidence. `max_tool_calls` is the explicit upper bound. Set `fresh_context=true` when a phase should consume only prior handbacks instead of the full earlier transcript. Use null for a small single-agent objective. + +LAUNCHABILITY: every required capability, the runtime/model route, and max_parallel MUST fit one qualified execution record above. Never merge capabilities from separate records. If you select an MCP server, memory binding, or approved skill, state in the rationale which current-digest resource qualification record makes it launchable. Generic route records do not prove a specific server, backend, or skill version. If the requested output format requires an unqualified capability, propose a supported alternative (for example a Markdown report with inline Mermaid diagrams instead of generated binary images) and explain that choice in the rationale. When you emit Mermaid flowcharts, quote every label that contains parser-sensitive punctuation such as :, (), [], {{}}, or /. + +RESEARCH EVIDENCE: for current-events, incident, or authoritative-source research, declare `web-search` on the source-discovery phase and `network` on the exact-URL fetch phase (or declare both on one combined phase). The first fetch-capable phase must discover exact source URLs with an available search tool (`foundry_web_search` or `web_search`) before fetching pages. Never invent article paths. A timeout, non-success response, blocked page, or search snippet is not evidence for a factual claim. Later phases and synthesis may cite only URLs and facts retained from successful source-discovery/fetch tool results; if authoritative evidence is unavailable, report the gap instead of reconstructing unsupported details. + +BUDGET: optionally propose a token budget (integer) appropriate to the scope, or null for no cap. + +Respond with ONLY a JSON object (no prose, no code fences) of exactly this shape: +{{ + "tier": , + "model": {{"provider": "", "deployment": ""}}, + "runtime": "", + "instructions": "", + "tool_policy": "", + "mcp_servers": ["", ...], + "skills": ["", ...], + "egress": [{{"host": "", "port": }}], + "isolation": "", + "memory": "", + "budget_tokens": , + "execution_plan": {{ + "schema": "kars.execution-plan/v1", + "roles": [{{ + "name": "", + "objective": "", + "depends_on": ["", ...], + "budget_tokens": , + "phases": [{{ + "name": "", + "objective": "", + "capabilities": ["", ...], + "min_tool_calls": , + "max_tool_calls": , + "fresh_context": + }}] + }}], + "max_parallel": , + "synthesis": {{ + "objective": "", + "capabilities": [], + "max_tool_calls": 0 + }}, + "deliverables": [{{"name":"","media_type":""}}] + }} | null, + "rationale": "<1-3 sentences explaining the key choices for the reviewer>" +}}"# + ) +} + +/// Call the orchestrator LLM (OpenAI-compatible chat/completions) and return +/// the assistant's text content. +async fn call_llm( + endpoint: &str, + token: &str, + model: &str, + system: &str, + user: &str, + max_tokens: u32, +) -> anyhow::Result { + let url = format!("{}/chat/completions", endpoint.trim_end_matches('/')); + let body = serde_json::json!({ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "max_tokens": max_tokens, + }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(45)) + .build()?; + let resp = client + .post(&url) + .bearer_auth(token) + .json(&body) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!( + "HTTP {status}: {}", + text.chars().take(200).collect::() + ); + } + let parsed: serde_json::Value = serde_json::from_str(&text)?; + let content = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("no completion content"))?; + Ok(content) +} + +/// Parse the model's JSON (tolerating code fences / surrounding prose) and +/// Whether a harness runs a PRODUCTIVE one-shot / cadence autonomous session — +/// i.e. it consumes a delivered objective (over the mesh) and returns a real +/// deliverable, with no human chat channel required. Only these may back an +/// autonomous mission or a standing team member. +/// +/// - `OpenClaw` — the reference autonomous harness (always-on agent loop). +/// - `Hermes` — verified autonomous: its mesh worker executes a delivered +/// `task_request` in-process and replies (the "idle daemon" boot line is a +/// red herring — the gateway still loads the plugin + worker). Confirmed E2E. +/// - `BYO` — the operator's own image, contractually responsible for consuming +/// the objective + delivering; we take it at its word rather than downgrade it. +/// +/// Everything else in the catalogue today (Anthropic / OpenAIAgents / MAF / +/// LangGraph / PydanticAi) is a **bootstrap-only** adapter: it pins a provider +/// URL + OTel and exits, with no task-execution loop, so it delivers NOTHING +/// autonomously. Routing autonomous work to one is a silent no-op — so we +/// correct it to OpenClaw (attested), exactly like the mission path. +pub(crate) fn is_autonomous_harness(kind: &str) -> bool { + kind.eq_ignore_ascii_case("OpenClaw") + || kind.eq_ignore_ascii_case("Hermes") + || kind.eq_ignore_ascii_case("BYO") +} + +/// Inverse of [`is_autonomous_harness`]: a harness that cannot run a productive +/// autonomous session (a bootstrap-only SDK/graph adapter today). Autonomous +/// missions and team members routed to one must be corrected/refused. +pub(crate) fn is_non_autonomous_harness(kind: &str) -> bool { + !kind.trim().is_empty() && !is_autonomous_harness(kind) +} + +/// validate every field against the real options — the server is the authority, +/// not the model. Anything invalid is dropped or normalized to a safe default. +fn parse_and_validate( + raw: &str, + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + intent: &str, +) -> (ComposeProposal, Option) { + let json = extract_json(raw).unwrap_or_else(|| serde_json::json!({})); + + // Tier: clamp to 1..=5, default 3. + let tier = json + .get("tier") + .and_then(|v| v.as_i64()) + .map(|t| t.clamp(1, 5) as i32) + .unwrap_or(3); + + // ── Model selection (efficiency-driven) ───────────────────────────────── + // Priority: (1) the orchestrator's explicit, valid choice — objective-aware; + // (2) the learned efficiency frontier's recommended route — grounded in real + // accepted outcomes; (3) the cluster default; (4) the first catalogue model. + // `model_basis` records which of these fired so the reviewer sees WHY. + let orchestrator_pick = json.get("model").and_then(|m| { + let provider = m.get("provider").and_then(|p| p.as_str())?; + let dep = m.get("deployment").and_then(|d| d.as_str())?; + o.models + .iter() + .find(|mo| mo.provider == provider && mo.deployment == dep) + .map(|mo| ComposeModel { + provider: mo.provider.clone(), + deployment: mo.deployment.clone(), + }) + }); + + let recommended_model = eff + .recommended + .as_ref() + .filter(|_| { + recommendation_is_actionable(eff.recommended.as_deref(), eff.recommended_low_confidence) + }) + .and_then(|route| { + o.models + .iter() + .find(|mo| mo.deployment == *route) + .map(|mo| ComposeModel { + provider: mo.provider.clone(), + deployment: mo.deployment.clone(), + }) + }); + + let (model, mut model_basis, model_from_reco) = if let Some(m) = orchestrator_pick { + let is_reco = recommendation_is_actionable( + eff.recommended.as_deref(), + eff.recommended_low_confidence, + ) && eff.recommended.as_ref().is_some_and(|r| *r == m.deployment); + let basis = if is_reco { + efficiency_basis(eff, &m.deployment) + } else { + "Chosen by the orchestrator for this objective.".to_string() + }; + (Some(m), Some(basis), is_reco) + } else if let Some(m) = recommended_model { + let basis = efficiency_basis(eff, &m.deployment); + (Some(m), Some(basis), true) + } else { + let m = o + .models + .iter() + .find(|m| m.is_default) + .or_else(|| o.models.first()) + .map(|m| ComposeModel { + provider: m.provider.clone(), + deployment: m.deployment.clone(), + }); + let basis = m.as_ref().map(|_| { + if eff.total_runs == 0 { + "Cluster default — no completed runs yet to learn a better route.".to_string() + } else if eff.recommended.is_some() { + // There IS a learned recommendation, but it doesn't map to a live + // model — be honest rather than implying the default was "chosen". + "Cluster default — the recommended route is no longer in the catalogue.".to_string() + } else { + "Cluster default — the objective didn't clearly warrant another route.".to_string() + } + }); + (m, basis, false) + }; + + // Runtime: the orchestrator's valid choice wins; else, when the model came + // from the efficiency frontier, adopt the harness that ACTUALLY won on that + // route (so we propose the whole winning route, not the model on a default + // harness); else OpenClaw. Any adopted harness must be a wired runtime. + let mut runtime = json + .get("runtime") + .and_then(|v| v.as_str()) + .filter(|r| o.runtimes.iter().any(|ro| ro.wired && ro.kind == *r)) + .map(str::to_string) + .or_else(|| { + if model_from_reco { + eff.recommended_harness + .as_ref() + .filter(|h| o.runtimes.iter().any(|ro| ro.wired && ro.kind == **h)) + .cloned() + } else { + None + } + }) + .unwrap_or_else(|| "OpenClaw".to_string()); + + // Honesty guard (B1): the model can come from the recommended route while + // the orchestrator proposes a DIFFERENT harness. In that case the basis must + // NOT imply we adopted the whole recommended route — the reviewer was seeing + // "Best learned route … on OpenClaw" next to a package that actually ran on + // Hermes. Rewrite the basis to name the real divergence. + if model_from_reco + && let Some(rec_h) = eff.recommended_harness.as_deref() + && !rec_h.is_empty() + && rec_h != runtime + { + let dep = model + .as_ref() + .map(|m| m.deployment.clone()) + .unwrap_or_else(|| "the recommended model".to_string()); + model_basis = Some(format!( + "Recommended model ({dep}), proposed on the {runtime} harness — \ + note the learned best route ran on {rec_h}, so this is not the \ + full recommended route." + )); + } + + // ── Hard capability match (0.4) ───────────────────────────────────────── + // A one-shot MISSION requires an autonomous harness — one that consumes a + // delivered objective and returns a deliverable. A bootstrap-only adapter + // (Anthropic/OpenAIAgents/MAF/LangGraph/PydanticAi) has no task-execution + // loop and delivers nothing autonomously, so routing a mission to one is a + // silent no-op. This is a capability mismatch, not a preference, so we BLOCK + // it rather than soft-warn: it's corrected to OpenClaw (the autonomous + // default) and the decision is recorded in the rationale + stamped on the + // task at launch (kars.azure.com/harness-corrected). (Hermes and BYO are + // autonomous and pass through unchanged.) + let harness_correction: Option = if is_non_autonomous_harness(&runtime) { + let note = format!( + "Capability match: {runtime} is a bootstrap-only adapter with no autonomous \ + task-execution loop, so it cannot run a one-shot mission — routed to OpenClaw \ + (autonomous harness).", + ); + runtime = "OpenClaw".to_string(); + Some(note) + } else { + None + }; + + // Isolation: must be a real level; else standard. + let isolation = json + .get("isolation") + .and_then(|v| v.as_str()) + .filter(|i| o.isolation.iter().any(|io| io.value == *i)) + .unwrap_or("standard") + .to_string(); + + // Tool policy: must be a real policy name. Every envelope MUST carry a + // governance policy — the agent runtime always initializes its AGT engine + // and fails closed on an empty policy set, so an envelope with no tool + // policy yields a sandbox that hangs (no tool/inference/mesh is allowed). + // When the orchestrator doesn't name one, fall back to the cluster default + // governance policy (`kars-default`, which allows inference/tool/mesh/spawn + // and denies dangerous shell) so the sandbox is governed AND functional. + let requested_tool_policy = json + .get("tool_policy") + .and_then(|v| v.as_str()) + .filter(|p| !p.is_empty() && o.tool_policies.iter().any(|tp| tp.name == *p)) + .map(str::to_string) + .or_else(|| default_tool_policy(o)); + let (tool_policy, policy_correction) = if requested_tool_policy.as_deref() + == Some("kars-team-member") + { + ( + default_tool_policy(o), + Some( + "Capability match: kars-team-member is reserved for declared standing-team specialists; routed this standalone mission to kars-default." + .to_string(), + ), + ) + } else { + (requested_tool_policy, None) + }; + + // MCP servers: subset of real servers. + let mut mcp_servers: Vec = json + .get("mcp_servers") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .filter(|name| o.mcp_servers.iter().any(|m| m.name == *name)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + // Least privilege: a model sometimes lists the same server more than once — + // dedupe (order-preserving) so the package never carries a redundant MCP + // grant (the audit saw the same Playwright server selected twice). + { + let mut seen = std::collections::HashSet::new(); + mcp_servers.retain(|s| seen.insert(s.clone())); + } + // Governance invariant: MCP access requires a bounding tool policy. If the + // model asked for MCP without one, drop the MCP servers rather than emit an + // un-admittable package (the reviewer can re-add with a policy). + if !mcp_servers.is_empty() && tool_policy.is_none() { + mcp_servers.clear(); + } + + let requested_skills: Vec = json + .get("skills") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .filter(|name| o.skills.iter().any(|s| s.name == *name)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let (skills, skill_correction) = if runtime == "OpenClaw" { + (requested_skills, None) + } else if requested_skills.is_empty() { + (Vec::new(), None) + } else { + ( + Vec::new(), + Some(format!( + "Capability match: {runtime} does not support controller-mounted file skills; omitted them instead of claiming they would be installed." + )), + ) + }; + + // Memory: must be a real store; else None. + let memory = json + .get("memory") + .and_then(|v| v.as_str()) + .filter(|m| !m.is_empty() && o.memories.iter().any(|mo| mo.name == *m)) + .map(str::to_string); + + // Egress: sanitized host list. + let egress = json + .get("egress") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host").and_then(|h| h.as_str())?.trim().to_string(); + if host.is_empty() { + return None; + } + let port = e + .get("port") + .and_then(|p| p.as_u64()) + .and_then(|p| u16::try_from(p).ok()); + Some(ComposeEgress { host, port }) + }) + .take(20) + .collect() + }) + .unwrap_or_default(); + let egress = complete_egress_recommendation(egress, intent, &mcp_servers); + + let instructions = json + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let execution_plan = parse_execution_plan(&json); + let delegation = execution_plan + .as_ref() + .map(delegation_from_execution_plan) + .unwrap_or_else(single_agent_delegation); + let proposed_budget = json + .get("budget_tokens") + .and_then(|v| v.as_i64()) + .filter(|t| *t > 0) + .filter(|tokens| *tokens > 0); + let budget_tokens = proposed_budget; + + let rationale = json + .get("rationale") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // Surface the capability correction to the reviewer alongside the rationale so + // the harness swap is never silent. + let corrections = [ + harness_correction.as_deref(), + policy_correction.as_deref(), + skill_correction.as_deref(), + ] + .into_iter() + .flatten() + .collect::>() + .join(" "); + let rationale = match (rationale, corrections.is_empty()) { + (Some(r), false) => Some(format!("{r} {corrections}")), + (None, false) => Some(corrections), + (r, true) => r, + }; + + ( + ComposeProposal { + tier, + model, + model_fallbacks: Vec::new(), + model_basis, + runtime, + instructions, + tool_policy, + mcp_servers, + skills, + egress, + isolation, + memory, + budget_tokens, + execution_plan, + delegation, + }, + rationale, + ) +} + +fn valid_delegation_role(value: &serde_json::Value) -> Option { + let name = value.get("name")?.as_str()?.trim().to_ascii_lowercase(); + let objective = value.get("objective")?.as_str()?.trim().to_string(); + if name.is_empty() + || name.len() > 48 + || objective.len() < 20 + || objective.len() > 600 + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || name.starts_with('-') + || name.ends_with('-') + { + return None; + } + Some(ComposeDelegationRole { name, objective }) +} + +fn single_agent_delegation() -> ComposeDelegation { + ComposeDelegation { + mode: "single-agent".into(), + roles: Vec::new(), + max_parallel: 1, + } +} + +fn delegation_from_execution_plan( + plan: &crate::routes::tasks::ExecutionPlanDto, +) -> ComposeDelegation { + ComposeDelegation { + mode: "principal-specialists".into(), + roles: plan + .roles + .iter() + .map(|role| ComposeDelegationRole { + name: role.name.clone(), + objective: role.objective.clone(), + }) + .collect(), + max_parallel: plan.max_parallel, + } +} + +const EXECUTION_CAPABILITIES: &[&str] = &[ + "filesystem-read", + "filesystem-write", + "shell", + "network", + "web-search", + "mcp", + "memory", +]; + +const EXECUTION_ROLE_PHASE_BASE_WEIGHT: i64 = 4; + +fn valid_plan_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 48 + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !name.starts_with('-') + && !name.ends_with('-') +} + +fn valid_capabilities(capabilities: &[String]) -> bool { + let mut seen = std::collections::HashSet::new(); + capabilities.iter().all(|capability| { + EXECUTION_CAPABILITIES.contains(&capability.as_str()) && seen.insert(capability) + }) +} + +fn role_budget_weight(role: &crate::routes::tasks::ExecutionRoleDto) -> i64 { + role.phases + .iter() + .map(|phase| EXECUTION_ROLE_PHASE_BASE_WEIGHT + i64::from(phase.max_tool_calls)) + .sum::() + .max(1) +} + +fn weighted_role_budget_floors( + total_tokens: i64, + plan: &crate::routes::tasks::ExecutionPlanDto, +) -> Vec { + let weights = plan + .roles + .iter() + .map(role_budget_weight) + .collect::>(); + let total_weight = weights + .iter() + .map(|weight| i128::from(*weight)) + .sum::(); + let total_tokens_i128 = i128::from(total_tokens); + let mut floors = weights + .iter() + .map(|weight| ((total_tokens_i128 * i128::from(*weight)) / total_weight) as i64) + .collect::>(); + let assigned = floors.iter().sum::(); + let mut remainders = weights + .iter() + .enumerate() + .map(|(index, weight)| { + ( + (total_tokens_i128 * i128::from(*weight)) % total_weight, + index, + ) + }) + .collect::>(); + remainders.sort_by( + |(left_remainder, left_index), (right_remainder, right_index)| { + right_remainder + .cmp(left_remainder) + .then(left_index.cmp(right_index)) + }, + ); + for (_, index) in remainders + .into_iter() + .take((total_tokens - assigned) as usize) + { + floors[index] += 1; + } + floors +} + +fn apply_weighted_role_budget_floors( + plan: &mut crate::routes::tasks::ExecutionPlanDto, + total_tokens: i64, +) -> (bool, i64) { + let mut changed = false; + let floors = weighted_role_budget_floors(total_tokens, plan); + for (role, floor) in plan.roles.iter_mut().zip(floors) { + let floor = floor.max(1); + if role.budget_tokens.is_none_or(|current| current < floor) { + role.budget_tokens = Some(floor); + changed = true; + } + } + let total = plan + .roles + .iter() + .filter_map(|role| role.budget_tokens) + .sum(); + (changed, total) +} + +fn valid_deliverable_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.starts_with('.') + && !name.contains('/') + && !name.contains('\\') + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +fn execution_plan_is_acyclic(plan: &crate::routes::tasks::ExecutionPlanDto) -> bool { + let dependencies = plan + .roles + .iter() + .map(|role| (role.name.as_str(), role.depends_on.as_slice())) + .collect::>(); + fn visit<'a>( + role: &'a str, + dependencies: &std::collections::HashMap<&'a str, &'a [String]>, + visiting: &mut std::collections::HashSet<&'a str>, + visited: &mut std::collections::HashSet<&'a str>, + ) -> bool { + if visited.contains(role) { + return true; + } + if !visiting.insert(role) { + return false; + } + for dependency in dependencies.get(role).copied().unwrap_or_default() { + if !visit(dependency, dependencies, visiting, visited) { + return false; + } + } + visiting.remove(role); + visited.insert(role); + true + } + let mut visiting = std::collections::HashSet::new(); + let mut visited = std::collections::HashSet::new(); + plan.roles + .iter() + .all(|role| visit(&role.name, &dependencies, &mut visiting, &mut visited)) +} + +pub(crate) fn validate_execution_plan( + plan: &crate::routes::tasks::ExecutionPlanDto, +) -> Result<(), String> { + if plan.schema != "kars.execution-plan/v1" { + return Err("execution plan schema must be kars.execution-plan/v1".into()); + } + if plan.roles.is_empty() || plan.roles.len() > 8 { + return Err("execution plan requires 1-8 roles".into()); + } + if plan.max_parallel < 1 || plan.max_parallel > plan.roles.len() as i32 { + return Err("execution plan max_parallel must be within the role count".into()); + } + let role_names = plan + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(); + if role_names.len() != plan.roles.len() || role_names.iter().any(|name| !valid_plan_name(name)) + { + return Err("execution plan role names must be unique DNS-safe labels".into()); + } + for role in &plan.roles { + if !(20..=1200).contains(&role.objective.len()) { + return Err(format!("role {} has an invalid objective", role.name)); + } + if role.phases.is_empty() || role.phases.len() > 8 { + return Err(format!("role {} requires 1-8 phases", role.name)); + } + if role.budget_tokens.is_some_and(|budget| budget <= 0) { + return Err(format!("role {} has an invalid budget", role.name)); + } + let mut phase_names = std::collections::HashSet::new(); + for phase in &role.phases { + if !valid_plan_name(&phase.name) || !phase_names.insert(phase.name.as_str()) { + return Err(format!("role {} has invalid phase names", role.name)); + } + if !(20..=1200).contains(&phase.objective.len()) + || phase.min_tool_calls < 0 + || phase.min_tool_calls > phase.max_tool_calls + || !(0..=32).contains(&phase.max_tool_calls) + || !valid_capabilities(&phase.capabilities) + { + return Err(format!( + "role {} phase {} is invalid", + role.name, phase.name + )); + } + if phase.required_tool_calls.len() > 8 + || phase.required_tool_calls.len() as i32 > phase.max_tool_calls + { + return Err(format!( + "role {} phase {} has invalid required tool calls", + role.name, phase.name + )); + } + for call in &phase.required_tool_calls { + if call.name != "github_actions_job_logs" + || !phase + .capabilities + .iter() + .any(|capability| capability == "mcp") + || ["owner", "repo", "job_id"].iter().any(|key| { + call.arguments + .get(*key) + .is_none_or(|value| value.trim().is_empty()) + }) + || call.arguments.get("tail_lines").is_some_and(|lines| { + lines + .parse::() + .ok() + .is_none_or(|value| !(1..=2000).contains(&value)) + }) + { + return Err(format!( + "role {} phase {} has an unsupported required tool call", + role.name, phase.name + )); + } + } + } + let mut dependencies = std::collections::HashSet::new(); + if role.depends_on.iter().any(|dependency| { + dependency == &role.name + || !role_names.contains(dependency.as_str()) + || !dependencies.insert(dependency) + }) { + return Err(format!("role {} has invalid dependencies", role.name)); + } + } + if !(20..=1200).contains(&plan.synthesis.objective.len()) + || !(0..=32).contains(&plan.synthesis.max_tool_calls) + || !valid_capabilities(&plan.synthesis.capabilities) + { + return Err("execution plan synthesis is invalid".into()); + } + let mut deliverables = std::collections::HashSet::new(); + if plan.deliverables.len() > 16 + || plan.deliverables.iter().any(|deliverable| { + !valid_deliverable_name(&deliverable.name) + || !deliverables.insert(deliverable.name.as_str()) + }) + { + return Err("execution plan deliverables are invalid".into()); + } + execution_plan_is_acyclic(plan) + .then_some(()) + .ok_or_else(|| "execution plan dependencies must be acyclic".into()) +} + +fn parse_execution_plan_result( + json: &serde_json::Value, +) -> Result { + let value = json + .get("execution_plan") + .ok_or_else(|| "execution_plan is missing".to_string())?; + let plan = serde_json::from_value(value.clone()) + .map_err(|error| format!("execution_plan does not match the required schema: {error}"))?; + validate_execution_plan(&plan)?; + Ok(plan) +} + +fn parse_execution_plan( + json: &serde_json::Value, +) -> Option { + parse_execution_plan_result(json).ok() +} + +fn execution_plan_error_from_raw(raw: &str) -> Option { + let json = + extract_json(raw).ok_or_else(|| "response did not contain a JSON object".to_string()); + match json { + Ok(json) => parse_execution_plan_result(&json).err(), + Err(error) => Some(error), + } +} + +pub(crate) fn validate_delegation(delegation: &ComposeDelegation) -> Result<(), String> { + match delegation.mode.as_str() { + "single-agent" if delegation.roles.is_empty() && delegation.max_parallel == 1 => Ok(()), + "principal-specialists" + if (2..=4).contains(&delegation.roles.len()) + && delegation.max_parallel >= 1 + && delegation.max_parallel <= delegation.roles.len() as i32 + && delegation.roles.iter().all(|role| { + let value = serde_json::json!({ + "name": role.name, + "objective": role.objective, + }); + valid_delegation_role(&value).is_some() + }) => + { + let unique = delegation + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(); + (unique.len() == delegation.roles.len()) + .then_some(()) + .ok_or_else(|| "delegation role names must be unique".to_string()) + } + "single-agent" => { + Err("single-agent delegation must have no roles and max_parallel=1".into()) + } + "principal-specialists" => Err( + "principal-specialists delegation requires 2–4 unique valid leaf roles and a bounded max_parallel" + .into(), + ), + _ => Err("delegation mode must be single-agent or principal-specialists".into()), + } +} + +pub(crate) fn delegation_budget_allocation( + total_tokens: i64, + role_count: usize, +) -> Result<(i64, i64), String> { + if role_count == 0 || total_tokens < role_count as i64 { + return Err( + "execution plans require a positive total budget with capacity for every role".into(), + ); + } + Ok((total_tokens, total_tokens / role_count as i64)) +} + +/// A one-line, plain-language basis for recommending `deployment`, drawn from +/// the learned efficiency frontier — real accepted-outcome counts, never +/// fabricated. Falls back to a generic line if the route has no stats yet. +fn efficiency_basis(eff: &crate::routes::efficiency::EfficiencyDto, deployment: &str) -> String { + if let Some(r) = eff.routes.iter().find(|r| r.route == deployment) { + let acc = (r.acceptance_rate * 100.0).round() as i64; + // Distinguish a CONFIDENT recommendation (enough runs, a real acceptance + // rate) from the best of a sparse/weak set. Without this, a route that is + // merely "least-bad" — e.g. 7% accepted over a handful of runs — read as a + // glowing endorsement next to the word "Recommended", which is dishonest. + let strong = r.runs >= 5 && r.acceptance_rate >= 0.5; + let mut s = if strong { + format!( + "Best learned route — {acc}% accepted over {} run{}", + r.runs, + if r.runs == 1 { "" } else { "s" } + ) + } else { + format!( + "Best available route so far (limited signal) — {acc}% accepted over {} run{}", + r.runs, + if r.runs == 1 { "" } else { "s" } + ) + }; + if !r.harness.is_empty() { + s.push_str(&format!(" on {}", r.harness)); + } + // Reliability of 0% over a tiny sample is "not yet established", not a + // meaningful "0%" — report it honestly so it doesn't read as "0% reliable". + match (r.reliability_rate, r.reliability_k, r.reliability_samples) { + (Some(rel), Some(k), samples) if samples >= 3 && rel > 0.0 => { + s.push_str(&format!( + ", pass^{k} reliability {}%", + (rel * 100.0).round() as i64 + )); + } + (Some(_), _, samples) => { + s.push_str(&format!(", reliability not yet established (n={samples})")); + } + _ => {} + } + if let Some(usd) = r.usd_per_outcome { + s.push_str(&format!(", ${usd:.2}/outcome")); + } + s.push('.'); + s + } else { + "Recommended by the learned efficiency frontier.".to_string() + } +} + +/// Extract the first balanced JSON object from a string, tolerating code fences +/// and leading/trailing prose that some models add despite instructions. +fn extract_json(raw: &str) -> Option { + if let Ok(v) = serde_json::from_str::(raw.trim()) { + return Some(v); + } + let bytes = raw.as_bytes(); + let start = raw.find('{')?; + let mut depth = 0i32; + let mut in_str = false; + let mut esc = false; + for i in start..bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => in_str = true, + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return serde_json::from_str(&raw[start..=i]).ok(); + } + } + _ => {} + } + } + None +} + +// ─── Team orchestrator: charter → org chart ────────────────────────────────── +// +// The symmetric counterpart to the mission orchestrator. From a standing-team +// charter it proposes a full org chart — a roster of member roles, each with a +// purpose-fit harness and model — informed by the SAME efficiency frontier the +// mission composer uses. This is the bread-and-butter: different roles can run +// different harnesses/models, chosen by what actually performs. A human reviews +// and edits before the team is created. + +#[derive(Debug, Deserialize)] +pub struct ComposeTeamRequest { + pub charter: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeTeamRole { + pub name: String, + pub system_prompt: String, + /// Harness kind (validated against real wired runtimes) or empty for the + /// team default. + pub runtime: String, + /// Model as `provider::deployment` (validated) or empty for team default. + pub model: String, + pub skills: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeTeamMilestone { + pub id: String, + pub title: String, + pub description: String, + pub owner_role: Option, + pub depends_on: Vec, + pub acceptance_criteria: Vec, + pub review_required: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeTeamProposal { + pub tier: i32, + pub cadence_minutes: i64, + pub instructions: String, + /// Principal/default model as `provider::deployment`. + pub model: String, + /// Ordered routes that independently qualify the complete Team contract. + pub model_fallbacks: Vec, + /// Evidence-backed reason for the principal model choice. + pub model_basis: Option, + /// Historical cost of the selected route, when the efficiency graph has + /// enough retained outcomes to measure it. + pub expected_tokens_per_outcome: Option, + pub efficiency_sample_runs: i64, + pub mcp_servers: Vec, + pub memory: Option, + pub egress: Vec, + pub egress_mode: String, + pub engineering_enabled: bool, + pub engineering_signals: Vec, + pub engineering_poll_interval_seconds: i64, + pub engineering_auto_run: bool, + pub roles: Vec, + pub execution_plan: Option, + pub milestones: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ComposeTeamResponse { + pub available: bool, + pub reason: Option, + pub proposal: Option, + pub rationale: Option, + pub source: Option, +} + +/// `POST /api/namespaces/:ns/compose-team` — orchestrate an org chart from a +/// charter. Same honesty contract as the mission composer: absent/failing +/// orchestrator returns `available:false` with a reason so the UI falls back to +/// manual composition. +pub async fn compose_team( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, + Json(req): Json, +) -> AppResult> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let charter = req.charter.trim(); + if charter.len() < 8 { + return Err(AppError::BadRequest("a real charter is required".into())); + } + + let mut options = build_options(cluster).await?; + options.mcp_servers.retain(|server| server.namespace == ns); + options.memories.retain(|memory| memory.namespace == ns); + options.skills.retain(|skill| skill.namespace == ns); + let efficiency = crate::routes::efficiency::compute_efficiency_for_owner( + cluster, + Some(principal.sub.as_str()), + ) + .await; + let orchestrator_route = select_orchestrator_route(&options, &efficiency); + let qualification_constraints = crate::routes::options::qualification_constraints_summary() + .unwrap_or_else(|error| format!(" (qualification records unavailable: {error})")); + let resource_qualification_constraints = + crate::routes::options::resource_qualification_summary(&options).unwrap_or_else(|error| { + format!(" (resource qualification records unavailable: {error})") + }); + let system = build_team_system_prompt( + &options, + &efficiency, + &qualification_constraints, + &resource_qualification_constraints, + ); + let user = format!( + "Team charter:\n{charter}\n\nCompose the org chart now. Respond with ONLY the JSON object." + ); + + let env_orchestrator = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") + .is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_TOKEN").is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_MODEL").is_ok_and(|v| !v.trim().is_empty()); + let pinned_orchestrator_model = cluster.bridge_orchestrator_model().await; + let resolved_model = orchestrator_route + .as_ref() + .map(|(_, deployment, _)| deployment.clone()) + .or(pinned_orchestrator_model) + .or_else(|| { + options + .models + .iter() + .find(|m| m.is_default) + .or_else(|| options.models.first()) + .map(|m| m.deployment.clone()) + }); + if resolved_model.is_none() && !env_orchestrator { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some( + "No inference models are configured on this cluster, so the org-composer can't run. Add an inference provider in the Operator Console, or shape the org manually below.".into(), + ), + proposal: None, + rationale: None, + source: None, + })); + } + let default_model = resolved_model.unwrap_or_default(); + if !env_orchestrator + && let Some((provider, deployment, _)) = &orchestrator_route + && let Err(error) = cluster + .configure_bridge_orchestrator_model(provider, deployment) + .await + { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The best orchestrator route ({provider}/{deployment}) could not be configured: {error}" + )), + proposal: None, + rationale: None, + source: None, + })); + } + + let (raw, mut source) = match orchestrator_complete( + cluster, + &system, + &user, + &default_model, + TEAM_COMPOSE_MAX_TOKENS, + ) + .await + { + Ok(r) => r, + Err(e) => { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The org-composer could not compose ({e}). Shape the org manually below — the same building blocks the orchestrator would use." + )), + proposal: None, + rationale: None, + source: None, + })); + } + }; + + let mut execution_plan_error = execution_plan_error_from_raw(&raw); + let (mut proposal, mut rationale) = + parse_and_validate_team(&raw, &options, &efficiency, charter); + if !team_proposal_is_complete(&proposal) { + let repair_user = format!( + "{user}\n\nYour previous response was incomplete. The exact structural problem was: {}. \ + Return the complete JSON object now; preserve a small roster of independent evidence \ + roles, make every execution-plan role name exactly match one roster role name, and do \ + not include prose or code fences.", + incomplete_team_proposal_detail(&proposal, execution_plan_error.as_deref()) + ); + if let Ok((repair_raw, repair_source)) = orchestrator_complete( + cluster, + &system, + &repair_user, + &default_model, + TEAM_COMPOSE_MAX_TOKENS, + ) + .await + { + execution_plan_error = execution_plan_error_from_raw(&repair_raw); + (proposal, rationale) = + parse_and_validate_team(&repair_raw, &options, &efficiency, charter); + source = repair_source; + } + } + if !team_proposal_is_complete(&proposal) { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The org-composer returned an incomplete proposal twice (missing: {}). Shape the org manually below rather than treating generic fallback roles as an AI recommendation.", + incomplete_team_proposal_detail(&proposal, execution_plan_error.as_deref()), + )), + proposal: None, + rationale: None, + source: Some(source), + })); + } + if let Err(error) = normalize_team_proposal_route(&mut proposal, &options) { + let repair_user = format!( + "{user}\n\nYour previous response was not launchable: {error}\n\ + Recompose it so the principal route, every role route, and every selected MCP \ + server, memory binding, and approved skill fit retained qualification evidence at \ + the CURRENT digest. Generic route records do not prove a specific resource.\n\n\ + QUALIFIED EXECUTION RECORDS:\n{qualification_constraints}\n\n\ + RESOURCE QUALIFICATION RECORDS:\n{resource_qualification_constraints}\n\n\ + Return ONLY the complete JSON object." + ); + if let Ok((repair_raw, repair_source)) = orchestrator_complete( + cluster, + &system, + &repair_user, + &default_model, + TEAM_COMPOSE_MAX_TOKENS, + ) + .await + { + (proposal, rationale) = + parse_and_validate_team(&repair_raw, &options, &efficiency, charter); + source = repair_source; + } + } + if let Err(error) = normalize_team_proposal_route(&mut proposal, &options) { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The org-composer could not produce a launchable team after retry: {error}" + )), + proposal: None, + rationale: None, + source: Some(source), + })); + } + proposal.model_fallbacks = qualified_team_fallbacks(&proposal, &options); + Ok(Json(ComposeTeamResponse { + available: true, + reason: None, + proposal: Some(proposal), + rationale, + source: Some(source), + })) +} + +fn team_proposal_is_complete(proposal: &ComposeTeamProposal) -> bool { + !proposal.instructions.trim().is_empty() + && !proposal.roles.is_empty() + && proposal.execution_plan.is_some() +} + +fn incomplete_team_proposal_detail( + proposal: &ComposeTeamProposal, + execution_plan_error: Option<&str>, +) -> String { + let mut missing = Vec::new(); + if proposal.instructions.trim().is_empty() { + missing.push("instructions"); + } + if proposal.roles.is_empty() { + missing.push("independent roles"); + } + if proposal.execution_plan.is_none() { + missing.push( + execution_plan_error + .unwrap_or("valid execution_plan with role names exactly matching the roster"), + ); + } + if missing.is_empty() { + "unknown structural mismatch".into() + } else { + missing.join(", ") + } +} + +fn default_model_route(options: &crate::routes::options::Options) -> Option { + options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)) +} + +fn team_role_qualification_requirements( + plan: &crate::routes::tasks::ExecutionPlanDto, + role_name: &str, +) -> std::collections::BTreeSet { + let mut required = + std::collections::BTreeSet::from(["team".to_string(), "telemetry".to_string()]); + if let Some(role) = plan.roles.iter().find(|role| role.name == role_name) { + for phase in &role.phases { + required.extend(phase.capabilities.iter().cloned()); + } + } + required +} + +fn qualify_role_resource( + runtime: &str, + provider: &str, + deployment: &str, + route: &str, + label: &str, + qualified: Result, + detail: impl FnOnce() -> String, +) -> Result<(), String> { + match qualified { + Ok(true) => Ok(()), + Ok(false) => Err(format!( + "{label} lacks retained resource qualification for {route}. {}", + detail() + )), + Err(error) => Err(format!( + "{label} could not be matched against qualification records for {runtime} · {provider}::{deployment}: {error}" + )), + } +} + +fn team_proposal_qualification( + proposal: &ComposeTeamProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + let principal_runtime = "OpenClaw"; + let principal_route = proposal + .model + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + .or_else(|| { + default_model_route(options).and_then(|route| { + route + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + }) + }) + .ok_or_else(|| "the team proposal has no launchable principal model route".to_string())?; + let principal_blueprint = crate::routes::tasks::BlueprintDto { + runtime: Some(principal_runtime.to_string()), + model: Some(crate::routes::tasks::ModelDto { + provider: principal_route.0.clone(), + deployment: principal_route.1.clone(), + }), + model_fallbacks: Vec::new(), + instructions: Some(proposal.instructions.clone()), + tool_policy: None, + mcp_servers: proposal.mcp_servers.clone(), + egress: proposal + .egress + .iter() + .map(|entry| crate::routes::tasks::EgressDto { + host: entry.host.clone(), + port: entry.port.map(i32::from), + }) + .collect(), + egress_mode: Some(proposal.egress_mode.clone()), + isolation: None, + memory: proposal.memory.clone(), + skills: proposal + .roles + .iter() + .flat_map(|role| role.skills.iter().cloned()) + .collect(), + execution_plan: proposal.execution_plan.clone(), + }; + let (required, max_parallel) = + crate::routes::validate::qualification_requirements(&principal_blueprint, Some("team")); + if !crate::routes::options::route_qualification( + principal_runtime, + &principal_route.0, + &principal_route.1, + &required, + max_parallel, + None, + )? { + let missing = crate::routes::options::route_qualification_gap( + principal_runtime, + &principal_route.0, + &principal_route.1, + &required, + max_parallel, + None, + )?; + return Err(format!( + "{} lacks retained qualification for [{}] at max_parallel={max_parallel}", + crate::routes::options::route_label( + principal_runtime, + &principal_route.0, + &principal_route.1 + ), + missing.into_iter().collect::>().join(", "), + )); + } + let principal_route_label = crate::routes::options::route_label( + principal_runtime, + &principal_route.0, + &principal_route.1, + ); + for server in &proposal.mcp_servers { + let option = option_named(&options.mcp_servers, server) + .ok_or_else(|| format!("MCP server `{server}` is not in the live options catalogue"))?; + qualify_role_resource( + principal_runtime, + &principal_route.0, + &principal_route.1, + &principal_route_label, + &format!("MCP server `{server}`"), + crate::routes::options::mcp_server_qualified_for_route( + principal_runtime, + &principal_route.0, + &principal_route.1, + option, + ), + || { + format!( + "Current schema digest: {}. Generic route records do not prove this server.", + option.tool_schema_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + if let Some(memory) = proposal.memory.as_deref() { + let option = option_named(&options.memories, memory) + .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; + qualify_role_resource( + principal_runtime, + &principal_route.0, + &principal_route.1, + &principal_route_label, + &format!("memory `{memory}`"), + crate::routes::options::memory_binding_qualified_for_route( + principal_runtime, + &principal_route.0, + &principal_route.1, + option, + ), + || { + format!( + "Current backend/digest: {}/{}. Generic route records do not prove this memory binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + let Some(plan) = proposal.execution_plan.as_ref() else { + return Err("the team proposal has no typed execution plan".into()); + }; + let default_role_route = format!("{}::{}", principal_route.0, principal_route.1); + for role in &proposal.roles { + let route = if role.model.trim().is_empty() { + default_role_route.as_str() + } else { + role.model.trim() + }; + let (provider, deployment) = route + .split_once("::") + .ok_or_else(|| format!("role {} has no valid model route", role.name))?; + let runtime = if role.runtime.trim().is_empty() { + principal_runtime + } else { + role.runtime.trim() + }; + let role_required = team_role_qualification_requirements(plan, &role.name); + if !crate::routes::options::route_qualification( + runtime, + provider, + deployment, + &role_required, + 1, + None, + )? { + let missing = crate::routes::options::route_qualification_gap( + runtime, + provider, + deployment, + &role_required, + 1, + None, + )?; + return Err(format!( + "role `{}` route {} lacks retained qualification for [{}]", + role.name, + crate::routes::options::route_label(runtime, provider, deployment), + missing.into_iter().collect::>().join(", "), + )); + } + let role_route_label = crate::routes::options::route_label(runtime, provider, deployment); + if role_required.contains("mcp") { + for server in &proposal.mcp_servers { + let option = option_named(&options.mcp_servers, server).ok_or_else(|| { + format!("MCP server `{server}` is not in the live options catalogue") + })?; + qualify_role_resource( + runtime, + provider, + deployment, + &role_route_label, + &format!("role `{}` MCP server `{server}`", role.name), + crate::routes::options::mcp_server_qualified_for_route( + runtime, provider, deployment, option, + ), + || { + format!( + "Current schema digest: {}. Generic route records do not prove this server.", + option.tool_schema_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + } + if role_required.contains("memory") + && let Some(memory) = proposal.memory.as_deref() + { + let option = option_named(&options.memories, memory) + .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; + qualify_role_resource( + runtime, + provider, + deployment, + &role_route_label, + &format!("role `{}` memory `{memory}`", role.name), + crate::routes::options::memory_binding_qualified_for_route( + runtime, provider, deployment, option, + ), + || { + format!( + "Current backend/digest: {}/{}. Generic route records do not prove this memory binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + for skill in &role.skills { + let option = option_named(&options.skills, skill) + .ok_or_else(|| format!("skill `{skill}` is not in the approved live catalogue"))?; + qualify_role_resource( + runtime, + provider, + deployment, + &role_route_label, + &format!("role `{}` skill `{skill}`", role.name), + crate::routes::options::skill_version_qualified_for_route( + runtime, provider, deployment, option, + ), + || { + format!( + "Current version digest: {}. Generic route records do not prove this approved skill version.", + option.version_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + } + Ok(()) +} + +fn normalize_team_proposal_route( + proposal: &mut ComposeTeamProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + for role in &mut proposal.roles { + if is_non_autonomous_harness(&role.runtime) { + role.runtime = "OpenClaw".into(); + } + } + let initial_error = match team_proposal_qualification(proposal, options) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + let original_model = proposal.model.clone(); + let original_role_routes = proposal + .roles + .iter() + .map(|role| (role.runtime.clone(), role.model.clone())) + .collect::>(); + let mut candidates = Vec::new(); + if let Some(default) = default_model_route(options) { + candidates.push(default); + } + + candidates.extend( + options + .models + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)), + ); + let mut seen = std::collections::HashSet::new(); + for route in candidates + .into_iter() + .filter(|route| seen.insert(route.clone())) + { + proposal.model = route.clone(); + for role in &mut proposal.roles { + role.runtime.clear(); + role.model.clear(); + } + if team_proposal_qualification(proposal, options).is_ok() { + proposal.model_basis = Some(format!( + "Bridge selected {route} because the complete team plan and its reviewed resources match one retained qualification record; the orchestrator's proposed route did not." + )); + proposal.expected_tokens_per_outcome = None; + proposal.efficiency_sample_runs = 0; + return Ok(()); + } + } + proposal.model = original_model; + for (role, (runtime, model)) in proposal.roles.iter_mut().zip(original_role_routes) { + role.runtime = runtime; + role.model = model; + } + Err(initial_error) +} + +fn qualified_team_fallbacks( + proposal: &ComposeTeamProposal, + options: &crate::routes::options::Options, +) -> Vec { + let mut candidates = options + .models + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .filter(|route| route != &proposal.model) + .collect::>(); + candidates.sort(); + candidates.dedup(); + candidates + .into_iter() + .filter(|route| { + let mut trial = proposal.clone(); + trial.model = route.clone(); + trial.model_fallbacks.clear(); + for role in &mut trial.roles { + role.model = route.clone(); + } + team_proposal_qualification(&trial, options).is_ok() + }) + .take(8) + .collect() +} + +fn should_strengthen_team_principal( + role_count: usize, + current_deployment: &str, + strongest_deployment: &str, +) -> bool { + if role_count < 3 || current_deployment == strongest_deployment { + return false; + } + let current = orchestrator_quality_score(current_deployment).unwrap_or(0); + let strongest = orchestrator_quality_score(strongest_deployment).unwrap_or(0); + current < 900 && strongest >= 950 +} + +fn efficient_member_route_is_qualified(runs: i64, acceptance_rate: f64) -> bool { + // A lower-cost member route needs repeated evidence before a newly composed + // team inherits it. This is intentionally stricter on sample count than a + // descriptive efficiency-basis label because it changes live execution. + runs >= 3 && acceptance_rate >= 0.67 +} + +/// Build the team-orchestrator system prompt. Enumerates the real harnesses + +/// models + the efficiency frontier, and asks for an org chart where roles are +/// purpose-fit and may use DIFFERENT harnesses/models per their function and +/// what the frontier shows performs. +fn build_team_system_prompt( + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + qualification_constraints: &str, + resource_qualification_constraints: &str, +) -> String { + let models = o + .models + .iter() + .map(|m| { + format!( + " - \"{}::{}\"{}", + m.provider, + m.deployment, + if m.is_default { " (default)" } else { "" } + ) + }) + .collect::>() + .join("\n"); + let runtimes = o + .runtimes + .iter() + .filter(|r| r.wired && r.kind != "BYO") + .map(|r| format!(" - \"{}\" — {} ({})", r.kind, r.label, r.status)) + .collect::>() + .join("\n"); + let mcp_servers = if o.mcp_servers.is_empty() { + " (none installed)".to_string() + } else { + o.mcp_servers + .iter() + .map(|server| { + format!( + " - \"{}\"{}{}{}{}", + server.name, + server + .summary + .as_deref() + .map(|s| format!(" — {s}")) + .unwrap_or_default(), + if server.discovered_tools.is_empty() { + String::new() + } else { + format!(" · tools=[{}]", server.discovered_tools.join(", ")) + }, + server + .tool_schema_digest + .as_deref() + .map(|digest| format!(" · schema_digest={digest}")) + .unwrap_or_else(|| " · schema_digest=missing".into()), + server + .mode + .as_deref() + .map(|mode| format!(" · mode={mode}")) + .unwrap_or_default(), + ) + }) + .collect::>() + .join("\n") + }; + let memories = if o.memories.is_empty() { + " (none configured)".to_string() + } else { + o.memories + .iter() + .map(|memory| { + format!( + " - \"{}\"{}{}{}{}", + memory.name, + memory + .summary + .as_deref() + .map(|summary| format!(" — {summary}")) + .unwrap_or_default(), + memory + .backend + .as_deref() + .map(|backend| format!(" · backend={backend}")) + .unwrap_or_else(|| " · backend=missing".into()), + memory + .compiled_digest + .as_deref() + .map(|digest| format!(" · compiled_digest={digest}")) + .unwrap_or_else(|| " · compiled_digest=missing".into()), + memory + .readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default(), + ) + }) + .collect::>() + .join("\n") + }; + let skills = if o.skills.is_empty() { + " (none approved)".to_string() + } else { + o.skills + .iter() + .map(|skill| { + format!( + " - \"{}\"{}{}{}{}", + skill.name, + skill + .summary + .as_deref() + .map(|summary| format!(" — {summary}")) + .unwrap_or_default(), + skill + .version + .as_deref() + .map(|version| format!(" · version={version}")) + .unwrap_or_default(), + skill + .version_digest + .as_deref() + .map(|digest| format!(" · version_digest={digest}")) + .unwrap_or_else(|| " · version_digest=missing".into()), + skill + .recipe + .as_deref() + .map(|recipe| { + format!(" · recipe={}", recipe.chars().take(180).collect::()) + }) + .unwrap_or_default(), + ) + }) + .collect::>() + .join("\n") + }; + let efficiency = if eff.routes.is_empty() { + " (no completed runs yet — use the default model for roles unless a role clearly needs a stronger one)".to_string() + } else { + let mut lines = eff + .routes + .iter() + .take(6) + .map(|r| { + format!( + " - route \"{}\": {:.0}% accepted, {} tokens/outcome over {} run(s){}", + r.route, + r.acceptance_rate * 100.0, + r.tokens_per_outcome, + r.runs, + if !eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← recommended" + } else if eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← best observed, insufficient evidence" + } else { + "" + } + ) + }) + .collect::>() + .join("\n"); + if eff.recommended_low_confidence { + lines.push_str("\n Evidence is too sparse for automatic route inheritance. Use the team default for routine roles and a stronger model only where the role's reasoning or orchestration burden clearly requires it."); + } else { + lines.push_str("\n Use the frontier to assign models: give cheap/high-acceptance routes to routine roles, and a stronger model only to roles whose work demands it."); + } + lines + }; + + format!( + r#"You are the kars team orchestrator. You turn a standing-team CHARTER into an org chart: a small roster of member roles that together fulfil the charter. Each role can run a DIFFERENT harness and model — choose what fits its job and what the efficiency frontier shows performs. You propose; a human reviews and edits before the team is created. + +You MUST only use the harnesses and models listed below — never invent one. + +HARNESSES (pick per role, or "" for the team default): +{runtimes} + +MODELS (pick per role as "provider::deployment", or "" for the team default): +{models} + +CONNECTED SERVICES / MCP (select only services the charter genuinely needs): +{mcp_servers} +Foundry-native web search, file search, memory, and code execution are Kars plugin tools and do not require MCP. If the customer explicitly requests an installed MCP server, select it and declare `mcp`; the complete capability combination must match one atomic qualification record. + +SHARED MEMORY STORES (optional; default to a qualified Foundry-backed store when one is already configured and useful for continuity): +{memories} + +APPROVED SKILLS (assign only when a role genuinely benefits from the recipe below): +{skills} + +EFFICIENCY FRONTIER (learned from completed runs; honest signal is human ACCEPTANCE): +{efficiency} + +QUALIFIED EXECUTION RECORDS (the full team plan MUST fit one route record; records do not compose): +{qualification_constraints} + +RESOURCE QUALIFICATION RECORDS (selected MCP servers, memory bindings, and skills MUST match one current-digest record on the chosen route; generic route records do not count): +{resource_qualification_constraints} + +GUIDANCE: +- Propose 2–4 focused roles (rarely more). Each role does ONE clear part of the charter. +- Produce one typed `execution_plan` whose role names exactly match the proposed roster. Define explicit dependencies, one or more bounded phases per role, and only the generic capabilities each phase requires: filesystem-read, filesystem-write, shell, network, web-search, mcp, memory. Set `min_tool_calls` to at least 1 when a phase must produce tool-backed evidence. Do not infer capabilities from role names. +- The principal owns orchestration and the final synthesis. Never propose a coordinator, editor, integrator, or synthesis-only member whose job is merely to reconcile other roles' handbacks or write the final report. Every member must collect, inspect, test, or verify independent evidence. +- Give each role a short, specific system prompt (1–2 sentences). +- Assign harness + model per role deliberately: a research/analysis role may warrant a stronger model; a routine triage/watch role should use an efficient one. Leave model/runtime "" to inherit the team default when no strong reason exists. +- For research charters, declare `web-search` on source-discovery phases and `network` on exact-URL fetch phases (or both on one combined phase) so the retained qualification stays atomic on one route. +- Select the smallest `mcp_servers` set needed by the whole team. Use the discovered tool names and schema digests above to choose the right server. A browser/UX investigator needs a browser MCP when one is installed. +- If you assign a skill, use the recipe and version digest above to justify it. If you select MCP, memory, or skills, the rationale must name the current-digest resource qualification record that makes the choice launchable. +- Select a team-default `model` for the principal; roles may override it only when their work needs a different route. +- Propose only the external `egress` hosts genuinely required by the charter. Do not invent internal/private hosts. Use `learning` for a reviewed discovery run or `strict` when the host list is complete. +- AUTONOMY TIER for the team: 1=Manual .. 5=Full. Default 3 unless the charter warrants otherwise. +- CADENCE minutes: how often the team wakes to act (0 = passive/on-demand). Pick a sensible value for the charter (e.g. 60 for hourly monitoring), else 0. +- If the charter is continuous repository maintenance, set `engineering_enabled=true`, choose the relevant signals from `dependabot_pr`, `dependabot_alert`, `code_scanning_alert`, `secret_scanning_alert`, choose a poll interval >=300 seconds, and normally set `engineering_auto_run=true`. Otherwise disable it. +- For a concrete build, launch, research campaign, migration, or other long-horizon deliverable, propose 2–8 topologically ordered `milestones`. Each milestone owns explicit acceptance criteria and may depend only on earlier milestone IDs. Set `review_required=true` at consequential handoff/release boundaries so dependent work pauses for customer approval. Use an empty milestone list only for genuinely continuous monitoring with no finite delivery. +- If you include Mermaid flowcharts in any deliverable description or rationale, quote every label containing parser-sensitive punctuation such as :, (), [], {{}}, or /. + +Respond with ONLY a JSON object (no prose, no code fences) of exactly this shape: +{{ + "tier": , + "cadence_minutes": , + "instructions": "<1-2 sentence team-level mandate>", + "model": "", + "mcp_servers": [""], + "memory": "", + "egress": [{{"host":"","port":443}}], + "egress_mode": "", + "engineering_enabled": , + "engineering_signals": [""], + "engineering_poll_interval_seconds": =300>, + "engineering_auto_run": , + "roles": [ + {{"name": "", "system_prompt": "", "runtime": "", "model": "", "skills": []}} + ], + "execution_plan": {{ + "schema": "kars.execution-plan/v1", + "roles": [{{ + "name": "", + "objective": "", + "depends_on": ["", ...], + "budget_tokens": , + "phases": [{{ + "name": "", + "objective": "", + "capabilities": ["", ...], + "min_tool_calls": , + "max_tool_calls": , + "fresh_context": + }}] + }}], + "max_parallel": , + "synthesis": {{ + "objective": "", + "capabilities": [], + "max_tool_calls": 0 + }}, + "deliverables": [] + }}, + "milestones": [ + {{"id":"","title":"","description":"","owner_role":"","depends_on":[""],"acceptance_criteria":[""],"review_required":}} + ], + "rationale": "<1-3 sentences explaining the org shape + key model/harness choices>" +}}"# + ) +} + +fn is_synthesis_only_team_role(name: &str, system_prompt: &str) -> bool { + let name = name.to_ascii_lowercase(); + let prompt = system_prompt.to_ascii_lowercase(); + let explicitly_reconciles_handbacks = [ + "reconcile specialist handbacks", + "reconcile the specialist handbacks", + "synthesize specialist handbacks", + "synthesize the specialist handbacks", + "combine specialist handbacks", + "combine the specialist handbacks", + ] + .iter() + .any(|phrase| prompt.contains(phrase)); + let principal_like_name = [ + "readiness-editor", + "synthesis-editor", + "final-synthesizer", + "report-integrator", + ] + .contains(&name.as_str()); + + explicitly_reconciles_handbacks + || (principal_like_name + && ["final report", "final synthesis", "principal deliverable"] + .iter() + .any(|phrase| prompt.contains(phrase))) +} + +/// Validate the team orchestrator's JSON against real options — runtimes and +/// models must exist (or be empty for the default); tier/cadence clamped. +fn parse_and_validate_team( + raw: &str, + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + charter: &str, +) -> (ComposeTeamProposal, Option) { + let json = extract_json(raw).unwrap_or_else(|| serde_json::json!({})); + + let tier = json + .get("tier") + .and_then(|v| v.as_i64()) + .map(|t| t.clamp(1, 5) as i32) + .unwrap_or(3); + let cadence_minutes = json + .get("cadence_minutes") + .and_then(|v| v.as_i64()) + .filter(|c| *c >= 0) + .unwrap_or(0); + let mut instructions = json + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let mcp_servers = json + .get("mcp_servers") + .and_then(|v| v.as_array()) + .map(|servers| { + servers + .iter() + .filter_map(|server| server.as_str().map(str::trim)) + .filter(|server| o.mcp_servers.iter().any(|option| option.name == *server)) + .scan(std::collections::BTreeSet::new(), |seen, server| { + seen.insert(server.to_string()).then(|| server.to_string()) + }) + .take(8) + .collect::>() + }) + .unwrap_or_default(); + let requested_memory = json + .get("memory") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|memory| !memory.is_empty()) + .filter(|memory| o.memories.iter().any(|option| option.name == *memory)) + .map(str::to_string); + + let valid_runtime = |rt: &str| o.runtimes.iter().any(|r| r.wired && r.kind == rt); + let valid_model = |model: &str| catalogue_has_model_key(&o.models, model); + let mut model = json + .get("model") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|model| valid_model(model)) + .unwrap_or("") + .to_string(); + let selected_deployment = model + .split_once("::") + .map(|(_, deployment)| deployment) + .unwrap_or(""); + let selected_efficiency = eff + .routes + .iter() + .find(|route| route.route == selected_deployment); + let mut model_basis = if selected_deployment.is_empty() { + Some("Team default — no explicit principal model was proposed.".to_string()) + } else if recommendation_is_actionable( + eff.recommended.as_deref(), + eff.recommended_low_confidence, + ) && eff + .recommended + .as_deref() + .is_some_and(|recommended| recommended == selected_deployment) + { + Some(efficiency_basis(eff, selected_deployment)) + } else if selected_efficiency.is_some() { + Some( + "Chosen by the org orchestrator for this charter; historical route evidence is shown for comparison." + .to_string(), + ) + } else { + Some("Chosen by the org orchestrator for this charter; no retained route history is available yet.".to_string()) + }; + let principal_runtime = "OpenClaw"; + let principal_route = model + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + .or_else(|| { + default_model_route(o).and_then(|route| { + route + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + }) + }); + let memory = if let Some(memory) = requested_memory { + Some(memory) + } else if let Some((provider, deployment)) = principal_route.as_ref() { + o.memories.iter().find_map(|option| { + let foundry_like = option + .backend + .as_deref() + .is_some_and(|backend| backend.to_ascii_lowercase().contains("foundry")); + let ready = option.readiness.as_deref().is_some_and(|readiness| { + readiness == "Ready" || readiness.starts_with("Ready=True") + }); + let qualified = crate::routes::options::memory_binding_qualified_for_route( + principal_runtime, + provider, + deployment, + option, + ) + .unwrap_or(false); + (foundry_like && ready && qualified).then(|| option.name.clone()) + }) + } else { + None + }; + let egress = json + .get("egress") + .and_then(|value| value.as_array()) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let host = entry.get("host")?.as_str()?.trim(); + let valid = !host.is_empty() + && host.contains('.') + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')); + valid.then(|| ComposeEgress { + host: host.to_ascii_lowercase(), + port: entry + .get("port") + .and_then(|port| port.as_u64()) + .and_then(|port| u16::try_from(port).ok()), + }) + }) + .take(16) + .collect::>() + }) + .unwrap_or_default(); + let egress = complete_egress_recommendation(egress, charter, &mcp_servers); + let egress_mode = match json + .get("egress_mode") + .and_then(|value| value.as_str()) + .unwrap_or("learning") + .to_ascii_lowercase() + .as_str() + { + "strict" => "strict", + _ => "learning", + } + .to_string(); + + let mut roles = json + .get("roles") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| { + let name = r.get("name").and_then(|v| v.as_str())?.trim().to_string(); + if name.is_empty() || name.eq_ignore_ascii_case("principal") { + return None; + } + let system_prompt = r + .get("system_prompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if is_synthesis_only_team_role(&name, &system_prompt) { + return None; + } + // Harness capability: a bootstrap-only adapter can't run a + // standing member autonomously — correct it to OpenClaw so the + // role actually produces work (Hermes/BYO pass through). + let runtime = { + let rt = r + .get("runtime") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| valid_runtime(s)) + .unwrap_or("") + .to_string(); + if is_non_autonomous_harness(&rt) { + "OpenClaw".to_string() + } else { + rt + } + }; + let model = r + .get("model") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| valid_model(s)) + .unwrap_or("") + .to_string(); + let skills = r + .get("skills") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str()) + .filter(|skill| o.skills.iter().any(|option| option.name == *skill)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some(ComposeTeamRole { + name, + system_prompt, + runtime, + model, + skills, + }) + }) + .take(6) + .collect::>() + }) + .unwrap_or_default(); + let execution_plan = parse_execution_plan(&json).inspect(|plan| { + let proposed_roles = roles.clone(); + roles = plan + .roles + .iter() + .enumerate() + .map(|(index, planned_role)| { + let mut role = proposed_roles + .iter() + .find(|role| role.name == planned_role.name) + .cloned() + .or_else(|| proposed_roles.get(index).cloned()) + .unwrap_or_else(|| ComposeTeamRole { + name: planned_role.name.clone(), + system_prompt: planned_role.objective.clone(), + runtime: String::new(), + model: String::new(), + skills: Vec::new(), + }); + role.name = planned_role.name.clone(); + if role.system_prompt.trim().is_empty() { + role.system_prompt = planned_role.objective.clone(); + } + role + }) + .collect(); + }); + let role_names = roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(); + let mut seen_milestones = std::collections::BTreeSet::new(); + let normalize_milestone_id = |value: &str| { + value + .trim() + .to_ascii_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' { + character + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .chars() + .take(63) + .collect::() + }; + let mut milestones_invalid = false; + let milestones = json + .get("milestones") + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let id = normalize_milestone_id( + entry.get("id").and_then(serde_json::Value::as_str)?, + ); + let title = entry + .get("title") + .and_then(serde_json::Value::as_str)? + .trim() + .to_string(); + if id.is_empty() || title.is_empty() || seen_milestones.contains(&id) { + return None; + } + let requested_dependencies = entry + .get("depends_on") + .and_then(serde_json::Value::as_array) + .map(|dependencies| { + dependencies + .iter() + .filter_map(serde_json::Value::as_str) + .map(normalize_milestone_id) + .filter(|dependency| !dependency.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + if requested_dependencies + .iter() + .any(|dependency| !seen_milestones.contains(dependency)) + { + milestones_invalid = true; + return None; + } + let depends_on = requested_dependencies; + let acceptance_criteria = entry + .get("acceptance_criteria") + .and_then(serde_json::Value::as_array) + .map(|criteria| { + criteria + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|criterion| !criterion.is_empty()) + .take(20) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + let owner_role = entry + .get("owner_role") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|owner| role_names.contains(*owner)) + .map(str::to_string); + let description = entry + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let review_required = entry + .get("review_required") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + seen_milestones.insert(id.clone()); + Some(ComposeTeamMilestone { + id, + title, + description, + owner_role, + depends_on, + acceptance_criteria, + review_required, + }) + }) + .take(8) + .collect::>() + }) + .unwrap_or_default(); + if milestones_invalid { + instructions.clear(); + } + + let mut rationale = json + .get("rationale") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some((provider, deployment, basis)) = select_orchestrator_route(o, eff) { + let current_route = if model.is_empty() { + o.models + .iter() + .find(|option| option.is_default) + .map(|option| format!("{}::{}", option.provider, option.deployment)) + .unwrap_or_default() + } else { + model.clone() + }; + let current_deployment = current_route + .split_once("::") + .map(|(_, deployment)| deployment) + .unwrap_or(""); + if should_strengthen_team_principal(roles.len(), current_deployment, &deployment) { + let current_evidence = eff + .routes + .iter() + .find(|route| route.route == current_deployment); + let keep_efficient_members = current_evidence.is_some_and(|route| { + efficient_member_route_is_qualified(route.runs, route.acceptance_rate) + }); + let frontier_route = format!("{provider}::{deployment}"); + let member_route = if keep_efficient_members { + current_route.clone() + } else { + frontier_route.clone() + }; + for role in &mut roles { + if role.model.is_empty() { + role.model = member_route.clone(); + } + } + model = frontier_route; + let member_basis = if keep_efficient_members { + format!( + "member roles retain the qualified {} route ({} historical run(s))", + current_deployment, + current_evidence.map(|route| route.runs).unwrap_or(0) + ) + } else { + format!( + "member roles also use {} until the proposed {} route has enough accepted outcomes to qualify", + deployment, current_deployment + ) + }; + model_basis = Some(format!( + "{basis} The principal coordinates {} independent roles; {member_basis}.", + roles.len(), + )); + let note = format!( + "The principal route was strengthened to {deployment} for multi-role orchestration reliability; {member_basis}." + ); + rationale = Some(match rationale { + Some(existing) => format!("{existing} {note}"), + None => note, + }); + } + } + let engineering_enabled = json + .get("engineering_enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let allowed_engineering_signals = [ + "dependabot_pr", + "dependabot_alert", + "code_scanning_alert", + "secret_scanning_alert", + ]; + let engineering_signals = json + .get("engineering_signals") + .and_then(serde_json::Value::as_array) + .map(|signals| { + signals + .iter() + .filter_map(serde_json::Value::as_str) + .filter(|signal| allowed_engineering_signals.contains(signal)) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + let engineering_poll_interval_seconds = json + .get("engineering_poll_interval_seconds") + .and_then(serde_json::Value::as_i64) + .unwrap_or(900) + .clamp(300, 86_400); + let engineering_auto_run = json + .get("engineering_auto_run") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + ( + ComposeTeamProposal { + tier, + cadence_minutes, + instructions, + model, + model_fallbacks: Vec::new(), + model_basis, + expected_tokens_per_outcome: selected_efficiency + .map(|route| route.tokens_per_outcome) + .filter(|tokens| *tokens > 0), + efficiency_sample_runs: selected_efficiency.map(|route| route.runs).unwrap_or(0), + mcp_servers, + memory, + egress, + egress_mode, + engineering_enabled: engineering_enabled && !engineering_signals.is_empty(), + engineering_signals, + engineering_poll_interval_seconds, + engineering_auto_run, + roles, + execution_plan, + milestones, + }, + rationale, + ) +} + +#[cfg(test)] +mod capability_tests { + use super::{ + ComposeEgress, ComposeTeamProposal, TEAM_COMPOSE_MAX_TOKENS, + apply_weighted_role_budget_floors, build_system_prompt, build_team_system_prompt, + catalogue_has_model_key, complete_egress_recommendation, delegation_budget_allocation, + efficient_member_route_is_qualified, execution_plan_error_from_raw, is_autonomous_harness, + is_non_autonomous_harness, is_synthesis_only_team_role, orchestrator_quality_score, + parse_execution_plan, recommendation_is_actionable, should_strengthen_team_principal, + team_proposal_is_complete, validate_execution_plan, weighted_role_budget_floors, + }; + use crate::routes::efficiency::EfficiencyDto; + use crate::routes::options::{IsolationOption, ModelOption, Options, RuntimeOption}; + + fn test_options() -> Options { + Options { + models: vec![ModelOption { + provider: "github-copilot".into(), + deployment: "gpt-5.6-sol".into(), + is_default: true, + detail: None, + }], + default_model: Some("gpt-5.6-sol".into()), + provider: None, + runtimes: vec![RuntimeOption { + kind: "OpenClaw".into(), + label: "OpenClaw".into(), + wired: true, + status: "validated".into(), + note: "ready".into(), + }], + isolation: vec![IsolationOption { + value: "standard".into(), + label: "Standard".into(), + note: "sandboxed".into(), + }], + tool_policies: Vec::new(), + mcp_servers: Vec::new(), + mcp_profiles: Vec::new(), + memories: Vec::new(), + skills: Vec::new(), + } + } + + fn test_efficiency() -> EfficiencyDto { + EfficiencyDto { + routes: Vec::new(), + recommended: None, + recommended_harness: None, + recommended_basis: None, + recommended_low_confidence: false, + total_runs: 0, + priced: false, + } + } + + #[test] + fn autonomous_set_is_openclaw_hermes_byo() { + for k in ["OpenClaw", "openclaw", "Hermes", "hermes", "BYO", "byo"] { + assert!(is_autonomous_harness(k), "{k} should be autonomous"); + assert!( + !is_non_autonomous_harness(k), + "{k} should not be non-autonomous" + ); + } + } + + #[test] + fn synthesis_only_role_is_reserved_for_the_principal() { + assert!(is_synthesis_only_team_role( + "readiness-editor", + "Reconcile specialist handbacks into one truthful readiness report." + )); + assert!(!is_synthesis_only_team_role( + "ci-health-auditor", + "Verify exact-head CI checks and return independent evidence." + )); + } + + #[test] + fn empty_team_proposal_is_not_reported_as_available() { + let proposal = ComposeTeamProposal { + tier: 3, + cadence_minutes: 0, + instructions: String::new(), + model: String::new(), + model_fallbacks: Vec::new(), + model_basis: None, + expected_tokens_per_outcome: None, + efficiency_sample_runs: 0, + mcp_servers: Vec::new(), + memory: None, + egress: Vec::new(), + egress_mode: "learning".into(), + engineering_enabled: false, + engineering_signals: Vec::new(), + engineering_poll_interval_seconds: 900, + engineering_auto_run: false, + roles: Vec::new(), + execution_plan: None, + milestones: Vec::new(), + }; + assert!(!team_proposal_is_complete(&proposal)); + } + + #[test] + fn team_composer_budget_covers_full_milestone_contract() { + const { assert!(TEAM_COMPOSE_MAX_TOKENS >= 8_192) }; + } + + #[test] + fn collaborative_principal_uses_frontier_when_available() { + assert!(should_strengthen_team_principal( + 3, + "gpt-oss-120b", + "gpt-5.6-sol" + )); + assert!(!should_strengthen_team_principal( + 2, + "gpt-oss-120b", + "gpt-5.6-sol" + )); + assert!(!should_strengthen_team_principal( + 3, + "gpt-oss-120b", + "gpt-oss-120b" + )); + assert!(!efficient_member_route_is_qualified(2, 1.0)); + assert!(!efficient_member_route_is_qualified(3, 0.66)); + assert!(efficient_member_route_is_qualified(3, 0.67)); + } + + #[test] + fn sparse_route_history_does_not_drive_execution_model_selection() { + assert!(!recommendation_is_actionable(Some("gpt-5.6-sol"), true)); + assert!(recommendation_is_actionable(Some("gpt-5.6-sol"), false)); + assert!(!recommendation_is_actionable(None, false)); + } + + #[test] + fn bootstrap_only_adapters_are_non_autonomous() { + for k in [ + "Anthropic", + "OpenAIAgents", + "MicrosoftAgentFramework", + "LangGraph", + "PydanticAi", + ] { + assert!(!is_autonomous_harness(k), "{k} should NOT be autonomous"); + assert!(is_non_autonomous_harness(k), "{k} should be non-autonomous"); + } + } + + #[test] + fn empty_harness_is_not_treated_as_non_autonomous() { + // Empty = "inherit default" — must not trigger a correction. + assert!(!is_non_autonomous_harness("")); + assert!(!is_non_autonomous_harness(" ")); + } + + #[test] + fn arbitrary_execution_plan_is_preserved_without_role_rewrites() { + let value = serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [ + { + "name": "source-reader", + "objective": "Read the supplied source material and retain exact evidence.", + "depends_on": [], + "phases": [{ + "name": "collect", + "objective": "Collect the required source evidence without synthesis.", + "capabilities": ["filesystem-read"], + "max_tool_calls": 4, + "fresh_context": true + }] + }, + { + "name": "decision-writer", + "objective": "Produce the requested decision from the retained source evidence.", + "depends_on": ["source-reader"], + "phases": [{ + "name": "draft", + "objective": "Draft the decision using only retained dependency evidence.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + }] + } + ], + "max_parallel": 1, + "synthesis": { + "objective": "Reconcile the role handbacks into the final answer.", + "capabilities": [], + "max_tool_calls": 0 + }, + "deliverables": [{"name":"decision.md","media_type":"text/markdown"}] + } + }); + let plan = parse_execution_plan(&value).expect("valid plan"); + assert_eq!( + plan.roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(), + vec!["source-reader", "decision-writer"] + ); + assert_eq!(plan.roles[1].depends_on, vec!["source-reader"]); + } + + #[test] + fn execution_plan_rejects_unknown_capability_and_cycles() { + let mut plan = parse_execution_plan(&serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [{ + "name": "one", + "objective": "Perform one arbitrary evidence task for the mission.", + "depends_on": [], + "phases": [{ + "name": "work", + "objective": "Perform the arbitrary evidence task completely.", + "capabilities": ["shell"], + "max_tool_calls": 2, + "fresh_context": true + }] + }], + "max_parallel": 1, + "synthesis": { + "objective": "Return the final mission answer from the handback.", + "capabilities": [], + "max_tool_calls": 0 + }, + "deliverables": [] + } + })) + .expect("valid baseline"); + plan.roles[0].phases[0].capabilities = vec!["repository-security".into()]; + assert!(validate_execution_plan(&plan).is_err()); + plan.roles[0].phases[0].capabilities = vec!["shell".into()]; + plan.roles[0].depends_on = vec!["one".into()]; + assert!(validate_execution_plan(&plan).is_err()); + } + + #[test] + fn execution_plan_parse_error_identifies_the_exact_repair() { + let raw = serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [{ + "name": "triage", + "objective": "Triage", + "phases": [{ + "name": "inspect", + "objective": "Inspect the repository backlog and retain exact evidence.", + "capabilities": ["filesystem-read"], + "max_tool_calls": 1 + }] + }], + "max_parallel": 1, + "synthesis": { + "objective": "Present the verified maintenance recommendation to the reviewer.", + "capabilities": [], + "max_tool_calls": 0 + } + } + }) + .to_string(); + + assert_eq!( + execution_plan_error_from_raw(&raw).as_deref(), + Some("role triage has an invalid objective") + ); + } + + #[test] + fn research_prompts_require_web_search_and_quoted_mermaid_labels() { + let options = test_options(); + let efficiency = test_efficiency(); + let mission_prompt = build_system_prompt(&options, &efficiency, " (none)", " (none)"); + assert!(mission_prompt.contains("web-search")); + assert!(mission_prompt.contains("quote every label")); + + let team_prompt = build_team_system_prompt(&options, &efficiency, " (none)", " (none)"); + assert!(team_prompt.contains("web-search")); + assert!(team_prompt.contains("qualification stays atomic")); + } + + #[test] + fn weighted_budget_distribution_funds_scout_and_preserves_larger_explicit_roles() { + let plan = parse_execution_plan(&serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [ + { + "name": "source-scout", + "objective": "Discover the authoritative URLs and fetch evidence.", + "depends_on": [], + "phases": [{ + "name": "discover", + "objective": "Search and fetch the exact URLs with evidence.", + "capabilities": ["web-search", "network"], + "min_tool_calls": 1, + "max_tool_calls": 32, + "fresh_context": true + }] + }, + { + "name": "analyst", + "objective": "Inspect the retained sources and extract facts.", + "depends_on": ["source-scout"], + "phases": [ + { + "name": "inspect", + "objective": "Inspect the retained source bundle.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + }, + { + "name": "summarize", + "objective": "Summarize the retained evidence only.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + } + ] + }, + { + "name": "reporter", + "objective": "Draft the downstream report from retained evidence.", + "depends_on": ["analyst"], + "phases": [ + { + "name": "outline", + "objective": "Outline the downstream report.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + }, + { + "name": "draft", + "objective": "Draft the downstream report.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + } + ] + } + ], + "max_parallel": 1, + "synthesis": { + "objective": "Return the final answer from the retained evidence.", + "capabilities": [], + "max_tool_calls": 0 + }, + "deliverables": [] + } + })) + .expect("valid weighted plan"); + let floors = weighted_role_budget_floors(320_000, &plan); + assert_eq!(floors.iter().sum::(), 320_000); + assert!(floors[0] > floors[1] * 4); + assert_eq!(floors[1], floors[2]); + + let mut explicit = plan.clone(); + explicit.roles[1].budget_tokens = Some(90_000); + let (changed, updated_total) = apply_weighted_role_budget_floors(&mut explicit, 320_000); + assert!(changed); + assert_eq!(explicit.roles[1].budget_tokens, Some(90_000)); + assert!(updated_total > 320_000); + assert!(explicit.roles[0].budget_tokens.expect("scout budget") > 200_000); + } + + #[test] + fn decomposed_budget_preserves_the_parent_ceiling() { + assert_eq!( + delegation_budget_allocation(600_000, 3).expect("allocation"), + (600_000, 200_000) + ); + assert_eq!( + delegation_budget_allocation(400_000, 3).expect("allocation"), + (400_000, 133_333) + ); + assert_eq!( + delegation_budget_allocation(260_000, 3).expect("allocation"), + (260_000, 86_666) + ); + assert_eq!( + delegation_budget_allocation(3, 3).expect("minimum allocation"), + (3, 1) + ); + assert!(delegation_budget_allocation(2, 3).is_err()); + } + + #[test] + fn orchestration_quality_prefers_reasoning_frontier_models() { + assert!( + orchestrator_quality_score("gpt-5.6-sol") > orchestrator_quality_score("gpt-oss-120b") + ); + assert!(orchestrator_quality_score("claude-opus-4.8").is_some()); + assert!(orchestrator_quality_score("text-embedding-3-small").is_none()); + assert!(orchestrator_quality_score("gpt-image-1").is_none()); + } + + #[test] + fn model_assignment_requires_an_exact_catalogue_pair() { + let models = vec![ + ModelOption { + provider: "github-copilot".into(), + deployment: "shared-name".into(), + is_default: true, + detail: None, + }, + ModelOption { + provider: "local-inference".into(), + deployment: "local-only".into(), + is_default: false, + detail: None, + }, + ]; + assert!(catalogue_has_model_key( + &models, + "github-copilot::shared-name" + )); + assert!(!catalogue_has_model_key( + &models, + "local-inference::shared-name" + )); + assert!(!catalogue_has_model_key(&models, "shared-name")); + } + + #[test] + fn repository_egress_is_inferred_and_provider_hosts_are_removed() { + let result = complete_egress_recommendation( + vec![ComposeEgress { + host: "api.githubcopilot.com".into(), + port: Some(443), + }], + "Maintain a TypeScript GitHub repository and its package.json", + &["github".into()], + ); + let hosts = result + .iter() + .map(|endpoint| endpoint.host.as_str()) + .collect::>(); + assert!(!hosts.contains(&"api.githubcopilot.com")); + assert!(hosts.contains(&"api.github.com")); + assert!(hosts.contains(&"raw.githubusercontent.com")); + assert!(hosts.contains(&"patch-diff.githubusercontent.com")); + assert!(hosts.contains(&"registry.npmjs.org")); + } + + #[test] + fn dependabot_security_review_does_not_guess_wrong_package_registry() { + let result = complete_egress_recommendation( + vec![ComposeEgress { + host: "registry.npmjs.org".into(), + port: Some(443), + }], + "Review the newest Dependabot pull request and relevant security advisories", + &["github".into()], + ); + let hosts = result + .iter() + .map(|endpoint| endpoint.host.as_str()) + .collect::>(); + assert!(!hosts.contains(&"registry.npmjs.org")); + assert!(!hosts.contains(&"pypi.org")); + assert!(hosts.contains(&"api.osv.dev")); + assert!(hosts.contains(&"api.github.com")); + } +} diff --git a/bridge/bff/src/routes/credential_review.rs b/bridge/bff/src/routes/credential_review.rs new file mode 100644 index 000000000..343ab0b44 --- /dev/null +++ b/bridge/bff/src/routes/credential_review.rs @@ -0,0 +1,348 @@ +use axum::{ + Json, + extract::{Extension, State}, +}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::operator::{CredentialRequest, credential_write_error, is_dns1123_label, is_env_key}; +use crate::{ + auth::Principal, + error::{AppError, AppResult}, + kars::credential_review::{CredentialReview, ReviewedWrite, StoredSource}, + state::AppState, +}; + +const AUDIENCE: &str = "kars-bridge/credential-review/v1"; +const VALUE_AUDIENCE: &str = "kars-bridge/credential-write-intent/v1"; +const LIFETIME: i64 = 300; +const MAX_SUBMISSIONS: u8 = 3; + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +enum Purpose { + Review, + Continuation, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Claims { + aud: String, + sub: String, + exp: i64, + purpose: Purpose, + submission: u8, + review: CredentialReview, + stored: Option, + value_tag: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CredentialContinuation { + pub token: String, + pub source: Option, + pub outcome: String, +} + +impl std::fmt::Debug for CredentialContinuation { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("CredentialContinuation([redacted])") + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReviewRequest { + pub namespace: String, + pub kind: String, + pub target: String, + pub target_uid: Option, + pub key: String, + pub continuation: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewResponse { + pub token: String, + pub metadata: CredentialReview, + pub expires_at: i64, + pub submission: u8, + pub continuation: bool, + pub binding_only: bool, +} + +fn conflict() -> AppError { + AppError::Conflict("Credential review changed, expired, or does not match this operator and write intent. No automatic retry is permitted.".into()) +} + +fn require_operator(principal: &Principal) -> AppResult<()> { + if principal.sub.is_empty() + || !principal + .roles + .iter() + .any(|role| role == "operator" || role == "admin") + { + return Err(AppError::Forbidden( + "An operator is required for credential review".into(), + )); + } + Ok(()) +} + +fn signing_key(state: &AppState) -> AppResult> { + let secret = state + .principal_secret() + .filter(|secret| !secret.is_empty()) + .ok_or_else(|| { + AppError::Forbidden( + "Signed operator sessions are required for credential review".into(), + ) + })?; + let mut hash = Sha256::new(); + hash.update(b"kars-bridge/credential-review-signing-key/v1\0"); + hash.update(secret.as_bytes()); + Ok(hash.finalize().to_vec()) +} + +fn sign(key: &[u8], claims: &Claims) -> AppResult { + if claims.exp <= chrono::Utc::now().timestamp() { + return Err(conflict()); + } + encode( + &Header::new(Algorithm::HS256), + claims, + &EncodingKey::from_secret(key), + ) + .map_err(|_| AppError::Upstream("Credential review signing failed".into())) +} + +fn verified(key: &[u8], token: &str, principal: &Principal, purpose: Purpose) -> AppResult { + if token.len() > 32768 { + return Err(conflict()); + } + let mut validation = Validation::new(Algorithm::HS256); + validation.set_audience(&[AUDIENCE]); + validation.leeway = 0; + let claims = decode::(token, &DecodingKey::from_secret(key), &validation) + .map_err(|_| conflict())? + .claims; + let now = chrono::Utc::now().timestamp(); + if claims.sub != principal.sub + || claims.purpose != purpose + || claims.exp <= now + || claims.exp > now + LIFETIME + || claims.submission == 0 + || claims.submission > MAX_SUBMISSIONS + { + return Err(conflict()); + } + Ok(claims) +} + +fn value_tag( + key: &[u8], + claims: &Claims, + source: Option<&StoredSource>, + value: &str, +) -> AppResult { + // Only the HS256 signature leaves this function, never the value-bearing payload. + let token = encode(&Header::new(Algorithm::HS256), &serde_json::json!({ + "aud": VALUE_AUDIENCE, "sub": claims.sub, "exp": claims.exp, + "namespace": claims.review.target.namespace, "kind": claims.review.target.kind, + "target": claims.review.target.name, "key": claims.review.key, + "sourceUid": source.map(|source| source.uid.as_str()).or(claims.review.source.uid.as_deref()), + "sourceVersion": source.map(|source| source.version.as_str()).or(claims.review.source.version.as_deref()), + "value": value, + }), &EncodingKey::from_secret(key)).map_err(|_| conflict())?; + token + .rsplit_once('.') + .map(|(_, signature)| signature.to_string()) + .ok_or_else(conflict) +} + +fn equal_tag(first: &str, second: &str) -> bool { + first.len() == second.len() + && first + .as_bytes() + .iter() + .zip(second.as_bytes()) + .fold(0u8, |diff, (a, b)| diff | (a ^ b)) + == 0 +} + +fn validate_input(input: &ReviewRequest) -> AppResult<()> { + if !is_dns1123_label(&input.namespace) + || !is_dns1123_label(&input.target) + || !is_env_key(&input.key) + || !["KarsSandbox", "KarsTask", "KarsTeam"].contains(&input.kind.as_str()) + { + return Err(AppError::BadRequest( + "An explicit credential workspace, target kind/name and key are required".into(), + )); + } + Ok(()) +} + +fn matches_input(claims: &Claims, input: &ReviewRequest) -> bool { + claims.review.target.namespace == input.namespace + && claims.review.target.kind == input.kind + && claims.review.target.name == input.target + && claims.review.key == input.key + && input + .target_uid + .as_ref() + .is_none_or(|uid| claims.review.target.uid.as_ref() == Some(uid)) +} + +pub async fn review( + State(state): State, + Extension(principal): Extension, + Json(input): Json, +) -> AppResult> { + require_operator(&principal)?; + validate_input(&input)?; + let key = signing_key(&state)?; + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let claims = if let Some(token) = &input.continuation { + let mut claims = verified(&key, token, &principal, Purpose::Continuation)?; + if !matches_input(&claims, &input) { + return Err(conflict()); + } + if claims.value_tag.is_none() { + return Err(conflict()); + } + let current = tokio::time::timeout(std::time::Duration::from_secs(30), async { + if let Some(stored) = &claims.stored { + cluster + .review_stored_credentials(&claims.review, stored) + .await + } else { + cluster.review_unwritten_credentials(&claims.review).await + } + }) + .await + .map_err(|_| AppError::Upstream("Credential metadata review deadline".into()))? + .map_err(credential_write_error)?; + claims.review = current; + claims.purpose = Purpose::Review; + claims + } else { + let review = tokio::time::timeout( + std::time::Duration::from_secs(30), + cluster.review_credentials(&input.namespace, &input.kind, &input.target, &input.key), + ) + .await + .map_err(|_| AppError::Upstream("Credential metadata review deadline".into()))? + .map_err(credential_write_error)?; + if input + .target_uid + .as_ref() + .is_some_and(|uid| review.target.uid.as_ref() != Some(uid)) + { + return Err(conflict()); + } + Claims { + aud: AUDIENCE.into(), + sub: principal.sub, + exp: chrono::Utc::now().timestamp() + LIFETIME, + purpose: Purpose::Review, + submission: 1, + review, + stored: None, + value_tag: None, + } + }; + let response = ReviewResponse { + token: sign(&key, &claims)?, + metadata: claims.review, + expires_at: claims.exp, + submission: claims.submission, + continuation: claims.value_tag.is_some(), + binding_only: claims.stored.is_some(), + }; + Ok(Json(response)) +} + +pub(super) async fn write( + state: &AppState, + principal: &Principal, + input: CredentialRequest, +) -> AppResult> { + require_operator(principal)?; + let key = signing_key(state)?; + let token = input.review.as_deref().ok_or_else(conflict)?; + let claims = verified(&key, token, principal, Purpose::Review)?; + if claims.review.target.namespace != input.namespace + || claims.review.target.kind != input.kind + || claims.review.target.name != input.target.trim() + || claims.review.key != input.key.trim() + || claims.review.target.uid.as_deref() != input.target_uid.as_deref() + { + return Err(conflict()); + } + if let Some(expected) = &claims.value_tag { + let actual = value_tag(&key, &claims, claims.stored.as_ref(), &input.value)?; + if !equal_tag(expected, &actual) { + return Err(conflict()); + } + } else if claims.stored.is_some() { + return Err(conflict()); + } + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let write = ReviewedWrite { + review: claims.review.clone(), + stored: claims.stored.clone(), + }; + let value = input.value; + let remaining = (claims.exp - chrono::Utc::now().timestamp()).clamp(0, 30); + if remaining == 0 { + return Err(conflict()); + } + let result = tokio::time::timeout( + std::time::Duration::from_secs(remaining as u64), + cluster.write_reviewed_agent_credentials(&write, value.clone()), + ) + .await + .map_err(|_| { + AppError::Upstream( + "Credential write deadline; outcome is uncertain and cannot be automatically resumed" + .into(), + ) + })?; + match result { + Ok(result) => Ok(Json(result)), + Err(failure) => { + let failure = *failure; + if matches!(&failure.error, kube::Error::Api(status) if status.code == 409) + && claims.submission < MAX_SUBMISSIONS + && (failure.stored.is_some() || !failure.write_attempted) + { + let source = failure.stored; + let tag = value_tag(&key, &claims, source.as_ref(), &value)?; + let continuation = Claims { + purpose: Purpose::Continuation, + submission: claims.submission + 1, + stored: source.clone(), + value_tag: Some(tag), + ..claims + }; + return Err(AppError::CredentialConflict(Box::new( + CredentialContinuation { + token: sign(&key, &continuation)?, + outcome: if source.is_some() { + "source-stored" + } else { + "no-write-attempted" + } + .into(), + source, + }, + ))); + } + Err(credential_write_error(failure.error)) + } + } +} diff --git a/bridge/bff/src/routes/digests.rs b/bridge/bff/src/routes/digests.rs new file mode 100644 index 000000000..f706af76f --- /dev/null +++ b/bridge/bff/src/routes/digests.rs @@ -0,0 +1,75 @@ +// kars Bridge BFF — team digests (design note §20). The standing-operation +// report stream that surfaces in the steering inbox: each team publishes a +// periodic digest (runs/delivered/tokens/knowledge/health), and the inbox shows +// them alongside the decision queue so the operator gets the autonomous- +// monitoring report in one place. + +use axum::{Json, extract::State}; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct DigestDto { + pub team: String, + pub at: String, + pub reporting_to: Option, + pub health: String, + pub summary: String, + pub runs_generated: i64, + pub runs_delivered: i64, + pub tokens_spent: i64, + pub knowledge_entries: i64, + /// Verified reporting channel (team→recipient edge) this report flows on. + pub channel: Option, + pub gated: bool, +} + +/// `GET /api/digests` — the cross-team digest stream, newest first. +pub async fn list_digests(State(state): State) -> AppResult>> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let raw = cluster.list_team_digests().await; + let digests = raw + .into_iter() + .filter_map(|v| { + Some(DigestDto { + team: v.get("team")?.as_str()?.to_string(), + at: v.get("at")?.as_str()?.to_string(), + reporting_to: v + .get("reporting_to") + .and_then(|x| x.as_str()) + .map(str::to_string), + health: v + .get("health") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + summary: v + .get("summary") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + runs_generated: v + .get("runs_generated") + .and_then(|x| x.as_i64()) + .unwrap_or(0), + runs_delivered: v + .get("runs_delivered") + .and_then(|x| x.as_i64()) + .unwrap_or(0), + tokens_spent: v.get("tokens_spent").and_then(|x| x.as_i64()).unwrap_or(0), + knowledge_entries: v + .get("knowledge_entries") + .and_then(|x| x.as_i64()) + .unwrap_or(0), + channel: v + .get("channel") + .and_then(|x| x.as_str()) + .map(str::to_string), + gated: v.get("gated").and_then(|x| x.as_bool()).unwrap_or(false), + }) + }) + .collect(); + Ok(Json(digests)) +} diff --git a/bridge/bff/src/routes/efficiency.rs b/bridge/bff/src/routes/efficiency.rs new file mode 100644 index 000000000..489e3051a --- /dev/null +++ b/bridge/bff/src/routes/efficiency.rs @@ -0,0 +1,858 @@ +// kars Bridge BFF — cross-harness efficiency frontier (design note §3B, Pillar +// B). Built entirely from the REAL per-run telemetry the router captures on +// every mission run and the controller persists (mission-output ConfigMap + +// the per-round/per-tool execution trace). Groups runs by the model/harness +// route they took and computes the 2026 efficiency facts that let an operator +// see — and the orchestrator recommend — the best governed route per outcome. +// +// 2026 metric grounding (SWE-bench Verified resolve-rate, Terminal-Bench 2.0 +// task-resolution, τ²/τ³-bench pass^k reliability + fault attribution): the +// honest question is not "did the model emit tokens" but "did it produce a +// human-ACCEPTED outcome, reliably, at what cost and latency". We therefore +// measure, per route: +// • Outcome — acceptance-rate (human approved) and pass^k reliability +// (fraction of REPEATED packages accepted on every attempt). +// • Cost — tokens/outcome always; USD/outcome when a price is configured. +// • Effort — rounds, tool-calls, and tool-FAILURE rate per outcome. +// • Latency — wall-clock and compute ms per run, and time-to-first-action. +// Honest: a route with no completed runs simply doesn't appear; a metric with +// no data (e.g. USD with no price table, pass^k with no repeats) is reported as +// absent, never fabricated. + +use axum::{ + Json, + extract::{Extension, State}, +}; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::principal_can_view_all; +use crate::state::AppState; + +#[derive(Debug, Serialize, Clone, Default)] +pub struct RouteEfficiency { + /// The model/harness route (e.g. `azure-openai/openai/gpt-4o`). + pub route: String, + /// The harness (agent runtime) this route ran on, e.g. "OpenClaw", + /// "Hermes". "" for legacy runs with no harness recorded. Distinguishes two + /// rows with the same model but different harness. + pub harness: String, + /// Total runs observed on this route. + pub runs: i64, + /// Runs that produced a substantive deliverable (tokens spent, ok). + pub delivered: i64, + /// Delivery success rate (delivered / runs), 0..1. + pub success_rate: f64, + /// Runs whose deliverable a human ACCEPTED (review approved) — the honest + /// outcome signal (not "emitted tokens"). + pub accepted: i64, + /// Acceptance rate (accepted / runs), 0..1 — the real success metric. + pub acceptance_rate: f64, + /// Mean total tokens across delivered runs. + pub avg_tokens: i64, + /// Tokens spent per *successfully delivered* outcome — the cost-per-outcome + /// efficiency metric (lower is better). + pub tokens_per_outcome: i64, + /// Mean model rounds per delivered run (orchestration efficiency). + pub avg_rounds: f64, + /// Mean tool calls per delivered run (orchestration efficiency). + pub avg_tool_calls: f64, + + // ── 2026 enrichments ──────────────────────────────────────────────────── + /// Mean prompt (input) tokens per delivered run. + pub avg_prompt_tokens: i64, + /// Mean completion (output) tokens per delivered run. + pub avg_completion_tokens: i64, + /// Fraction of tool calls that FAILED (ok=false) across delivered runs, + /// 0..1 — rework/effort signal. Lower is better. + pub tool_fail_rate: f64, + /// Mean wall-clock duration (ms) of a delivered run — first trace event to + /// last. The latency a human actually waits (minus human-approval waits). + pub avg_wall_ms: i64, + /// p95 wall-clock duration (ms) across delivered runs — tail latency. + pub p95_wall_ms: i64, + /// Mean time-to-first-action (ms): latency of the first model round — how + /// quickly the agent starts doing something. + pub avg_ttfa_ms: i64, + /// pass^k reliability, 0..1: among packages RUN MORE THAN ONCE on this + /// route, the fraction whose EVERY attempt was accepted. `None` when no + /// package has repeated here yet (can't claim reliability from one shot). + pub reliability_rate: Option, + /// The k behind `reliability_rate` — the minimum attempt-count among the + /// repeated packages counted (so "pass^k" is truthful about k). + pub reliability_k: Option, + /// Number of repeated packages behind `reliability_rate` (the sample size). + pub reliability_samples: i64, + /// USD per accepted outcome — `None` unless a price is configured for this + /// route's model (see `KARS_MODEL_PRICES`). Never a fabricated price. + pub usd_per_outcome: Option, + /// Fraction of prompt (input) tokens served from the provider cache across + /// delivered runs, 0..1 — higher means cheaper input. 0 when the trace has + /// no cache info (older runs / cache-unaware router). + pub cache_hit_rate: f64, + /// The most common fault among this route's UNACCEPTED runs (agent vs + /// environment vs policy vs capacity), or "" when none/all accepted — the + /// honest "why does this route miss" signal. See `classify_fault`. + pub top_fault: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct EfficiencyDto { + pub routes: Vec, + /// The recommended route: the best balance of high acceptance + reliability + /// + low cost among routes with at least one delivered run. `None` until at + /// least one route has delivered. + pub recommended: Option, + /// The harness of the recommended route — so a proposal can adopt BOTH the + /// model and the harness that actually won, not the model with a default + /// harness. `None` when no route has delivered or the harness is unrecorded. + pub recommended_harness: Option, + /// An honest, human-readable basis for the recommendation — the DEFENSIBLE + /// reason (e.g. "Highest delivery success 100% (26/26)"), plus caveats when + /// the acceptance / reliability signal is still sparse. `None` when nothing + /// is recommended yet. + pub recommended_basis: Option, + /// True when the recommendation rests on thin evidence (few human-accepted + /// outcomes or too few repeated packages for reliability). The UI must + /// present it as "best available so far", NOT a confident star. + pub recommended_low_confidence: bool, + /// Total runs across all routes (the sample size behind the frontier). + pub total_runs: i64, + /// True when a model price table is configured, so USD figures are present. + pub priced: bool, +} + +/// `GET /api/efficiency` — the cross-harness efficiency frontier. +pub async fn get_efficiency( + State(state): State, + Extension(principal): Extension, +) -> AppResult> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let owner = (!principal_can_view_all(&principal)).then_some(principal.sub.as_str()); + Ok(Json(compute_efficiency_for_owner(cluster, owner).await)) +} + +/// One run's derived metrics — the honest per-run record, assembled from the +/// mission-output ConfigMap (aggregate) enriched with the per-round/per-tool +/// execution trace (latency, tool failures). A pure value so aggregation is +/// unit-testable without a cluster. +#[derive(Debug, Clone, Default)] +pub struct RunMetrics { + pub route: String, + /// The harness (agent runtime) dimension of the route, e.g. "OpenClaw", + /// "Hermes". Pairs with `route` (model) so the frontier compares harness + /// efficiency, not just model. "" for legacy runs with no harness recorded. + pub harness: String, + /// Normalized package identity (objective) — groups repeats for pass^k. + pub package: String, + pub delivered: bool, + pub accepted: bool, + pub total_tokens: i64, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub rounds: i64, + pub tool_calls: i64, + pub tool_fail: i64, + pub wall_ms: i64, + pub ttfa_ms: i64, + pub cached_tokens: i64, + /// Fault attribution when the run wasn't accepted ("" when accepted / no signal). + pub fault: String, +} + +/// Per-model price ($/1M input tokens, $/1M output tokens), loaded from the +/// `KARS_MODEL_PRICES` env var (JSON object: model-substring → {"in":N,"out":N}). +/// A route is priced when its string contains a configured key. Absent config +/// means USD figures are simply not reported — never guessed. +fn load_price_table() -> BTreeMap { + let mut table = BTreeMap::new(); + let Ok(raw) = std::env::var("KARS_MODEL_PRICES") else { + return table; + }; + let Ok(Value::Object(map)) = serde_json::from_str::(&raw) else { + return table; + }; + for (k, v) in map { + let in_p = v.get("in").and_then(|x| x.as_f64()); + let out_p = v.get("out").and_then(|x| x.as_f64()); + if let (Some(i), Some(o)) = (in_p, out_p) { + table.insert(k.to_lowercase(), (i, o)); + } + } + table +} + +/// Match a route to a configured price by substring (case-insensitive). +fn price_for<'a>(route: &str, table: &'a BTreeMap) -> Option<&'a (f64, f64)> { + let r = route.to_lowercase(); + table + .iter() + .find(|(k, _)| r.contains(k.as_str())) + .map(|(_, v)| v) +} + +/// Normalize an objective into a stable package key for pass^k grouping: +/// lowercased, whitespace-collapsed, length-bounded. Empty when no objective. +fn package_key(objective: &str) -> String { + let collapsed = objective + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase(); + collapsed.chars().take(200).collect() +} + +/// Derive latency + tool-failure + cache facts from a mission's execution trace +/// (`trace.json`: an array of `round` and `tool` events). Returns +/// `(wall_ms, ttfa_ms, tool_fail, cached_tokens)`. Robust to a missing/garbled +/// trace. `cached_tokens` sums the prompt tokens served from the provider cache +/// across rounds (present only on traces from a cache-aware router). +pub fn derive_from_trace(trace_json: &str) -> (i64, i64, i64, i64) { + let Ok(Value::Array(events)) = serde_json::from_str::(trace_json) else { + return (0, 0, 0, 0); + }; + let mut ttfa_ms = 0i64; + let mut tool_fail = 0i64; + let mut cached = 0i64; + let mut first_ts: Option = None; + let mut last_ts: Option = None; + let mut last_ms = 0i64; + let mut seen_round = false; + for ev in &events { + let kind = ev.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + let ms = ev.get("ms").and_then(|m| m.as_i64()).unwrap_or(0); + if let Some(ts) = ev.get("ts").and_then(|t| t.as_str()).and_then(parse_ts_ms) { + if first_ts.is_none() { + first_ts = Some(ts); + } + last_ts = Some(ts); + last_ms = ms; + } + match kind { + "round" => { + if !seen_round { + seen_round = true; + ttfa_ms = ms; + } + cached += ev + .get("cached_tokens") + .and_then(|c| c.as_i64()) + .unwrap_or(0); + } + "tool" if ev.get("ok").and_then(|o| o.as_bool()) == Some(false) => { + tool_fail += 1; + } + _ => {} + } + } + // Wall-clock = span between first and last event timestamps, plus the last + // event's own duration (the final round's latency lands after its ts). + let wall_ms = match (first_ts, last_ts) { + (Some(a), Some(b)) if b >= a => (b - a) + last_ms, + _ => 0, + }; + (wall_ms, ttfa_ms, tool_fail, cached) +} + +/// Parse an RFC3339 timestamp to epoch milliseconds. `None` when unparseable. +fn parse_ts_ms(ts: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp_millis()) +} + +/// The finish reason of the LAST model round in a trace (empty when none). +pub fn last_finish_reason(trace_json: &str) -> String { + let Ok(Value::Array(events)) = serde_json::from_str::(trace_json) else { + return String::new(); + }; + events + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .filter_map(|e| e.get("finish_reason").and_then(|f| f.as_str())) + .next_back() + .unwrap_or("") + .to_string() +} + +/// Deterministic fault attribution for a run that did NOT reach a human-accepted +/// outcome (τ-bench-style: agent vs environment vs policy). Derived only from +/// real signals — the last finish reason and observed tool failures — never a +/// guess. Accepted runs have no fault. Honest buckets: +/// • `environment` — tools/external systems failed (tool_fail present). +/// • `policy` — the provider blocked output (content filter / refusal). +/// • `capacity` — the model hit its length/token ceiling before finishing. +/// • `incomplete` — ended without a clear terminal reason (agent gave up). +/// • `""` — accepted, or no signal to attribute. +fn classify_fault(accepted: bool, tool_fail: i64, finish: &str) -> &'static str { + if accepted { + return ""; + } + let f = finish.to_lowercase(); + if f.contains("content_filter") || f.contains("refus") || f.contains("safety") { + "policy" + } else if f.contains("length") || f.contains("max_tokens") || f.contains("token") { + "capacity" + } else if tool_fail > 0 { + "environment" + } else if !finish.is_empty() + && f != "stop" + && f != "end_turn" + && f != "tool_calls" + && f != "tool_use" + { + "incomplete" + } else { + // Terminated cleanly but the human didn't accept — a quality miss, not a + // mechanical fault we can attribute from signals alone. + "" + } +} + +/// Compute the cross-harness efficiency frontier from real per-run telemetry. +/// Shared by the `/api/efficiency` route and the orchestrators (mission + +/// team) so the LLM's recommendation is grounded in the same learned facts the +/// operator sees — never a separate heuristic. +pub async fn compute_efficiency(cluster: &crate::kars::cluster::Cluster) -> EfficiencyDto { + compute_efficiency_for_owner(cluster, None).await +} + +pub async fn compute_efficiency_for_owner( + cluster: &crate::kars::cluster::Cluster, + owner_subject: Option<&str>, +) -> EfficiencyDto { + let outputs = cluster.list_mission_output_evidence().await; + let mut runs: Vec = Vec::with_capacity(outputs.len()); + + for record in &outputs { + let task = &record.task_name; + let data = &record.data; + if owner_subject + .is_some_and(|owner| data.get("ownerSub").map(String::as_str) != Some(owner)) + { + continue; + } + let route = data + .get("model") + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + let harness = data.get("harness").cloned().unwrap_or_default(); + let total_tokens = data + .get("totalTokens") + .and_then(|t| t.parse::().ok()) + .unwrap_or(0); + let prompt_tokens = data + .get("promptTokens") + .and_then(|t| t.parse::().ok()) + .unwrap_or(0); + let completion_tokens = data + .get("completionTokens") + .and_then(|t| t.parse::().ok()) + .unwrap_or(0); + let ok = data.get("status").map(String::as_str) == Some("ok"); + let artifacts = data + .get("artifactCount") + .and_then(|t| t.parse::().ok()) + .unwrap_or(0); + // "accepted" is the HONEST outcome signal: a human approved the + // deliverable (review status), not merely that the model emitted tokens. + let assignment_identity = data + .get("assignmentNonce") + .cloned() + .unwrap_or_else(|| record.evidence_key.clone()); + let task_review = cluster.read_review(task).await.unwrap_or_default(); + let review = if task_review.get("assignmentNonce") == Some(&assignment_identity) { + task_review + } else { + Default::default() + }; + let accepted = review.get("status").map(String::as_str) == Some("approved"); + // A run a human APPROVED was necessarily delivered — otherwise there'd be + // nothing to approve. Folding `accepted` into `delivered` keeps the funnel + // coherent (accepted ⊆ delivered) and stops "1 accepted, 0 delivered" + // when a delivered run under-reports telemetry (e.g. tokens==0 && + // artifacts==0 on a harness whose token accounting lagged). + let delivered = accepted || (ok && (total_tokens > 0 || artifacts > 0)); + let rounds = data + .get("rounds") + .and_then(|t| t.parse::().ok()) + .unwrap_or(0); + let tool_calls = data + .get("toolCalls") + .and_then(|t| t.parse::().ok()) + .unwrap_or(0); + let package = package_key(data.get("objective").map(String::as_str).unwrap_or("")); + + // Enrich with latency + tool-failure + cache from the execution trace. + let trace = cluster.read_mission_trace(&record.evidence_key).await; + let (wall_ms, ttfa_ms, tool_fail, cached_tokens) = match &trace { + Some(t) => derive_from_trace(t), + None => (0, 0, 0, 0), + }; + let finish = trace.as_deref().map(last_finish_reason).unwrap_or_default(); + let fault = classify_fault(accepted, tool_fail, &finish).to_string(); + + runs.push(RunMetrics { + route, + harness, + package, + delivered, + accepted, + total_tokens, + prompt_tokens, + completion_tokens, + rounds, + tool_calls, + tool_fail, + wall_ms, + ttfa_ms, + cached_tokens, + fault, + }); + } + + aggregate(runs, &load_price_table()) +} + +/// Pure aggregation of per-run metrics into the route frontier — unit-testable. +pub fn aggregate(runs: Vec, prices: &BTreeMap) -> EfficiencyDto { + #[derive(Default)] + struct Acc { + route: String, + harness: String, + runs: i64, + delivered: i64, + accepted: i64, + tokens: i64, + /// Delivered runs that actually REPORTED token usage (> 0). Token + /// averages divide by this, not `delivered`, so a delivered run whose + /// harness under-reported tokens (0) can't dilute the per-run cost and + /// make a route look artificially cheap. + tokens_n: i64, + prompt: i64, + completion: i64, + rounds: i64, + tool_calls: i64, + tool_fail: i64, + cached: i64, + wall: Vec, + ttfa: i64, + ttfa_n: i64, + // fault kind → count (among unaccepted runs). + faults: BTreeMap, + // package → attempts, each `accepted`. + packages: BTreeMap>, + } + + // Group by the composite (model × harness) route so harness efficiency is + // measurable. The map key joins the two with a unit separator that can't + // appear in a model/harness name. + let mut by_route: BTreeMap = BTreeMap::new(); + let total_runs = runs.len() as i64; + + for r in &runs { + let key = format!("{}\u{1}{}", r.route, r.harness); + let acc = by_route.entry(key).or_default(); + if acc.route.is_empty() { + acc.route = r.route.clone(); + acc.harness = r.harness.clone(); + } + acc.runs += 1; + if r.accepted { + acc.accepted += 1; + } + if !r.fault.is_empty() { + *acc.faults.entry(r.fault.clone()).or_insert(0) += 1; + } + if r.delivered { + acc.delivered += 1; + acc.tokens += r.total_tokens; + if r.total_tokens > 0 { + acc.tokens_n += 1; + } + acc.prompt += r.prompt_tokens; + acc.completion += r.completion_tokens; + acc.rounds += r.rounds; + acc.tool_calls += r.tool_calls; + acc.tool_fail += r.tool_fail; + acc.cached += r.cached_tokens; + if r.wall_ms > 0 { + acc.wall.push(r.wall_ms); + } + if r.ttfa_ms > 0 { + acc.ttfa += r.ttfa_ms; + acc.ttfa_n += 1; + } + } + if !r.package.is_empty() { + acc.packages + .entry(r.package.clone()) + .or_default() + .push(r.accepted); + } + } + + let mut routes: Vec = by_route + .into_values() + .map(|mut a| { + let route = a.route.clone(); + let d = a.delivered.max(0); + // Token averages use the count of runs that actually reported tokens + // (tokens_n), not delivered (d), so zero-token deliveries don't dilute + // the per-outcome cost. Rounds/tool-calls still divide by d. + let tn = a.tokens_n.max(0); + let tokens_per_outcome = if tn > 0 { a.tokens / tn } else { 0 }; + let usd_per_outcome = price_for(&route, prices).and_then(|(in_p, out_p)| { + if tn == 0 { + return None; + } + let cost = (a.prompt as f64 / 1_000_000.0) * in_p + + (a.completion as f64 / 1_000_000.0) * out_p; + Some(cost / tn as f64) + }); + // pass^k reliability from repeated packages on this route. + let repeated: Vec<&Vec> = a.packages.values().filter(|v| v.len() >= 2).collect(); + let (reliability_rate, reliability_k, reliability_samples) = if repeated.is_empty() { + (None, None, 0) + } else { + let fully = repeated.iter().filter(|v| v.iter().all(|&x| x)).count(); + let k = repeated.iter().map(|v| v.len()).min().unwrap_or(2) as i64; + ( + Some(fully as f64 / repeated.len() as f64), + Some(k), + repeated.len() as i64, + ) + }; + a.wall.sort_unstable(); + let avg_wall = if a.wall.is_empty() { + 0 + } else { + a.wall.iter().sum::() / a.wall.len() as i64 + }; + let p95_wall = percentile(&a.wall, 0.95); + + RouteEfficiency { + route, + harness: a.harness.clone(), + runs: a.runs, + delivered: a.delivered, + success_rate: if a.runs > 0 { + a.delivered as f64 / a.runs as f64 + } else { + 0.0 + }, + accepted: a.accepted, + acceptance_rate: if a.runs > 0 { + a.accepted as f64 / a.runs as f64 + } else { + 0.0 + }, + avg_tokens: if tn > 0 { a.tokens / tn } else { 0 }, + tokens_per_outcome, + avg_rounds: if d > 0 { + a.rounds as f64 / d as f64 + } else { + 0.0 + }, + avg_tool_calls: if d > 0 { + a.tool_calls as f64 / d as f64 + } else { + 0.0 + }, + avg_prompt_tokens: if tn > 0 { a.prompt / tn } else { 0 }, + avg_completion_tokens: if tn > 0 { a.completion / tn } else { 0 }, + tool_fail_rate: if a.tool_calls > 0 { + a.tool_fail as f64 / a.tool_calls as f64 + } else { + 0.0 + }, + avg_wall_ms: avg_wall, + p95_wall_ms: p95_wall, + avg_ttfa_ms: if a.ttfa_n > 0 { a.ttfa / a.ttfa_n } else { 0 }, + reliability_rate, + reliability_k, + reliability_samples, + usd_per_outcome, + cache_hit_rate: if a.prompt > 0 { + (a.cached as f64 / a.prompt as f64).min(1.0) + } else { + 0.0 + }, + top_fault: a + .faults + .iter() + .max_by_key(|(_, n)| **n) + .map(|(k, _)| k.clone()) + .unwrap_or_default(), + } + }) + .collect(); + + // Sort the frontier: productive routes (any delivery) first, then best + // ACCEPTANCE, then most reliable, then cheapest per outcome. + routes.sort_by(|a, b| { + (b.delivered > 0) + .cmp(&(a.delivered > 0)) + .then( + b.acceptance_rate + .partial_cmp(&a.acceptance_rate) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .then( + b.reliability_rate + .unwrap_or(0.0) + .partial_cmp(&a.reliability_rate.unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .then(a.tokens_per_outcome.cmp(&b.tokens_per_outcome)) + }); + + // Recommend on the HONEST signal: acceptance dominates; reliability + // (pass^k) rewards consistency; cost-per-outcome penalizes. Fall back to + // delivery only when no route has any accepted run yet. + let max_cost = routes + .iter() + .filter(|r| r.delivered > 0) + .map(|r| r.tokens_per_outcome) + .max() + .unwrap_or(1) + .max(1); + let recommended_pick = routes + .iter() + .filter(|r| r.delivered > 0) + .map(|r| { + let cost_norm = r.tokens_per_outcome as f64 / max_cost as f64; + let reliability = r.reliability_rate.unwrap_or(0.0); + // Acceptance dominates; reliability and delivery reward; cost penalizes. + let score = + r.acceptance_rate + 0.3 * reliability + 0.2 * r.success_rate - 0.5 * cost_norm; + (r.route.clone(), r.harness.clone(), score) + }) + .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + let recommended = recommended_pick.as_ref().map(|(route, _, _)| route.clone()); + // The harness of the winning route — so compose can propose BOTH the model + // and the harness that actually won (not the model with a default harness). + let recommended_harness = recommended_pick + .as_ref() + .map(|(_, h, _)| h.clone()) + .filter(|h| !h.is_empty()); + + // Honest basis + confidence. The DEFENSIBLE reason is delivery success; the + // ideal signal (human acceptance) and pass^k reliability are often sparse + // early, so we state them with sample sizes and flag low confidence rather + // than star-recommending a route whose headline acceptance reads as 4%. + let (recommended_basis, recommended_low_confidence) = recommended + .as_ref() + .and_then(|route| routes.iter().find(|r| &r.route == route)) + .map(|r| { + let accepted = r.accepted; + let rel_samples = r.reliability_samples; + let low_conf = accepted < 3 || rel_samples < 3; + let mut basis = format!( + "Highest delivery success — {:.0}% ({}/{} runs delivered)", + r.success_rate * 100.0, + r.delivered, + r.runs + ); + if accepted < 3 { + basis.push_str(&format!( + ". Human acceptance is still low-signal ({} of {} deliverables reviewed) — treat as best-available, not proven-best", + accepted, r.delivered + )); + } + if rel_samples < 3 { + basis.push_str(&format!( + ". Reliability (pass^k) has only {} repeated package(s) so far", + rel_samples + )); + } + basis.push('.'); + (Some(basis), low_conf) + }) + .unwrap_or((None, false)); + + EfficiencyDto { + priced: !prices.is_empty(), + routes, + recommended, + recommended_harness, + recommended_basis, + recommended_low_confidence, + total_runs, + } +} + +/// Nearest-rank percentile of a pre-sorted slice. Empty → 0. +fn percentile(sorted: &[i64], p: f64) -> i64 { + if sorted.is_empty() { + return 0; + } + let rank = (p * sorted.len() as f64).ceil() as usize; + let idx = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[idx] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run( + route: &str, + pkg: &str, + accepted: bool, + tokens: i64, + wall: i64, + ttfa: i64, + fail: i64, + ) -> RunMetrics { + RunMetrics { + route: route.into(), + harness: "OpenClaw".into(), + package: pkg.into(), + delivered: tokens > 0, + accepted, + total_tokens: tokens, + prompt_tokens: tokens / 2, + completion_tokens: tokens / 2, + rounds: 3, + tool_calls: 4, + tool_fail: fail, + wall_ms: wall, + ttfa_ms: ttfa, + cached_tokens: 0, + fault: String::new(), + } + } + + #[test] + fn derive_from_trace_computes_wall_ttfa_and_fails() { + let trace = r#"[ + {"kind":"round","ms":800,"ts":"2026-07-02T10:00:00Z","cached_tokens":100}, + {"kind":"tool","ms":0,"ok":true,"ts":"2026-07-02T10:00:00Z"}, + {"kind":"tool","ms":0,"ok":false,"ts":"2026-07-02T10:00:01Z"}, + {"kind":"round","ms":1200,"ts":"2026-07-02T10:00:05Z","cached_tokens":200} + ]"#; + let (wall, ttfa, fail, cached) = derive_from_trace(trace); + assert_eq!(ttfa, 800, "TTFA is the first round latency"); + assert_eq!(fail, 1, "one failed tool"); + // span 0s→5s = 5000ms + last round ms 1200 + assert_eq!(wall, 6200); + assert_eq!(cached, 300, "cached tokens summed across rounds"); + } + + #[test] + fn derive_from_trace_is_robust_to_garbage() { + assert_eq!(derive_from_trace("not json"), (0, 0, 0, 0)); + assert_eq!(derive_from_trace("{}"), (0, 0, 0, 0)); + } + + #[test] + fn cache_hit_rate_from_cached_tokens() { + let mut r = run("A", "p1", true, 2000, 5000, 500, 0); // prompt = 1000 + r.cached_tokens = 800; + let dto = aggregate(vec![r], &BTreeMap::new()); + assert!( + (dto.routes[0].cache_hit_rate - 0.8).abs() < 1e-6, + "800/1000 cached" + ); + } + + #[test] + fn fault_classification_is_deterministic() { + assert_eq!(classify_fault(true, 5, "length"), "", "accepted → no fault"); + assert_eq!(classify_fault(false, 0, "content_filter"), "policy"); + assert_eq!(classify_fault(false, 0, "length"), "capacity"); + assert_eq!( + classify_fault(false, 3, "stop"), + "environment", + "tool failures → environment" + ); + assert_eq!( + classify_fault(false, 0, "stop"), + "", + "clean stop but unaccepted → quality miss, no mechanical fault" + ); + assert_eq!(classify_fault(false, 0, ""), "", "no signal → unattributed"); + } + + #[test] + fn top_fault_is_the_dominant_one() { + let mut r1 = run("A", "p1", false, 1000, 100, 50, 2); + r1.fault = "environment".into(); + let mut r2 = run("A", "p2", false, 1000, 100, 50, 0); + r2.fault = "environment".into(); + let mut r3 = run("A", "p3", false, 1000, 100, 50, 0); + r3.fault = "capacity".into(); + let dto = aggregate(vec![r1, r2, r3], &BTreeMap::new()); + assert_eq!(dto.routes[0].top_fault, "environment"); + } + + #[test] + fn last_finish_reason_picks_final_round() { + let trace = r#"[ + {"kind":"round","finish_reason":"tool_calls"}, + {"kind":"tool","ok":true}, + {"kind":"round","finish_reason":"length"} + ]"#; + assert_eq!(last_finish_reason(trace), "length"); + assert_eq!(last_finish_reason("garbage"), ""); + } + + #[test] + fn passk_reliability_only_counts_repeated_packages() { + // route A: package p1 run twice (both accepted) → reliable; p2 once (ignored). + let runs = vec![ + run("A", "p1", true, 1000, 5000, 500, 0), + run("A", "p1", true, 1100, 5200, 400, 0), + run("A", "p2", true, 900, 4000, 300, 0), + ]; + let dto = aggregate(runs, &BTreeMap::new()); + let a = dto.routes.iter().find(|r| r.route == "A").unwrap(); + assert_eq!(a.reliability_samples, 1, "only p1 repeated"); + assert_eq!(a.reliability_rate, Some(1.0)); + assert_eq!(a.reliability_k, Some(2)); + } + + #[test] + fn passk_flags_inconsistent_package() { + // p1 accepted once, rejected once → NOT fully reliable. + let runs = vec![ + run("A", "p1", true, 1000, 5000, 500, 0), + run("A", "p1", false, 1100, 5200, 400, 2), + ]; + let dto = aggregate(runs, &BTreeMap::new()); + let a = dto.routes.iter().find(|r| r.route == "A").unwrap(); + assert_eq!( + a.reliability_rate, + Some(0.0), + "inconsistent package fails pass^k" + ); + assert_eq!(a.reliability_samples, 1); + } + + #[test] + fn usd_per_outcome_only_when_priced() { + let runs = vec![run("gpt-4o", "p1", true, 2_000_000, 5000, 500, 0)]; + // No prices → None. + let dto = aggregate(runs.clone(), &BTreeMap::new()); + assert!(dto.routes[0].usd_per_outcome.is_none()); + assert!(!dto.priced); + // Priced: 1M prompt @ $2.5 + 1M completion @ $10 = $12.5 over 1 outcome. + let mut prices = BTreeMap::new(); + prices.insert("gpt-4o".to_string(), (2.5, 10.0)); + let dto = aggregate(runs, &prices); + assert!(dto.priced); + let usd = dto.routes[0].usd_per_outcome.unwrap(); + assert!((usd - 12.5).abs() < 1e-6, "got {usd}"); + } + + #[test] + fn tool_fail_rate_computed() { + let runs = vec![run("A", "p1", true, 1000, 5000, 500, 2)]; // 2 fails of 4 calls + let dto = aggregate(runs, &BTreeMap::new()); + assert!((dto.routes[0].tool_fail_rate - 0.5).abs() < 1e-6); + } +} diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs new file mode 100644 index 000000000..303966bd0 --- /dev/null +++ b/bridge/bff/src/routes/engineering.rs @@ -0,0 +1,3364 @@ +// kars Bridge BFF — durable GitHub engineering intake for standing teams. +// +// This is intentionally Bridge-owned integration workflow. Source configuration, +// cursors, and status live in an owner-annotated ConfigMap; discovered work is +// merged into the controller's existing durable team task ConfigMap. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::time::Duration; + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::ResourceExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::cluster::Cluster; +use crate::routes::github::{ + authorize_repo_set, connection_config_map_name, installation_token, mint_app_jwt, +}; +use crate::routes::tasks::require_cluster; +use crate::routes::teams::{TeamTaskDto, read_task_list, require_owned_team}; +use crate::state::AppState; + +const CONFIG_KEY: &str = "config.json"; +const CURSOR_KEY: &str = "cursor.json"; +const STATUS_KEY: &str = "status.json"; +const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 900; +const MIN_POLL_INTERVAL_SECONDS: u64 = 300; +const MAX_POLL_INTERVAL_SECONDS: u64 = 86_400; +const MAX_REPOS: usize = 20; +const MAX_OPEN_PRS_PER_REPO: usize = 100; +const MAX_ITEMS_PER_SYNC: usize = 200; +const MAX_REVIEW_PRS_PER_SYNC: usize = 50; +const MAX_SOURCES_PER_SWEEP: u32 = 100; +const MAX_GITHUB_PAGES: usize = 10; +const MAX_ALERTS_PER_SIGNAL: usize = 200; + +const OWNER_ANNOTATION: &str = "bridge.kars.azure.com/owner-sub"; +const TEAM_NAMESPACE_ANNOTATION: &str = "bridge.kars.azure.com/team-namespace"; +const TEAM_NAME_ANNOTATION: &str = "bridge.kars.azure.com/team-name"; +const CONNECTION_ANNOTATION: &str = "bridge.kars.azure.com/connection-config-map-ref"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum EngineeringSignal { + DependabotPr, + DependabotAlert, + CodeScanningAlert, + SecretScanningAlert, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct EngineeringSourceConfig { + version: u32, + team_namespace: String, + team_name: String, + owner_sub: String, + connection_config_map_ref: String, + enabled: bool, + #[serde(default = "default_true")] + auto_run: bool, + repos: Vec, + signals: Vec, + poll_interval_seconds: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct EngineeringCursor { + #[serde(default)] + repository_updated_at: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EngineeringSyncState { + Disabled, + Idle, + Syncing, + Ok, + Partial, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EngineeringSourceStatus { + pub state: EngineeringSyncState, + #[serde(default)] + pub sync_claim_id: Option, + #[serde(default)] + pub sync_claim_expires_at: Option, + pub last_sync_at: Option, + pub last_success_at: Option, + pub last_error: Option, + pub items_discovered: usize, + pub items_queued: usize, + pub total_items_queued: u64, + pub next_poll_at: Option, + #[serde(default)] + pub review_items: Vec, + #[serde(default)] + pub ready_for_review: usize, + #[serde(default)] + pub waiting_for_ci: usize, + #[serde(default)] + pub ci_failed: usize, + #[serde(default)] + pub signal_results: Vec, +} + +impl Default for EngineeringSourceStatus { + fn default() -> Self { + Self { + state: EngineeringSyncState::Disabled, + sync_claim_id: None, + sync_claim_expires_at: None, + last_sync_at: None, + last_success_at: None, + last_error: None, + items_discovered: 0, + items_queued: 0, + total_items_queued: 0, + next_poll_at: None, + review_items: Vec::new(), + ready_for_review: 0, + waiting_for_ci: 0, + ci_failed: 0, + signal_results: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EngineeringSignalSyncState { + Ok, + Unavailable, + Forbidden, + Truncated, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EngineeringSignalResult { + pub repo: String, + pub signal: EngineeringSignal, + pub state: EngineeringSignalSyncState, + pub discovered: usize, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EngineeringReviewState { + ReadyForReview, + WaitingForCi, + CiFailed, + Blocked, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EngineeringReviewItem { + pub repo: String, + pub pr_number: u64, + pub pr_url: String, + pub title: String, + pub run: String, + #[serde(default)] + pub source_id: String, + #[serde(default)] + pub work_id: String, + #[serde(default)] + pub task_status: String, + #[serde(default)] + pub run_state: Option, + #[serde(default)] + pub selected_roles: Vec, + #[serde(default)] + pub delivered_roles: Vec, + #[serde(default)] + pub artifact_count: Option, + pub head_sha: String, + pub state: EngineeringReviewState, + pub detail: String, + pub checks_total: usize, + pub checks_passed: usize, + pub observed_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct PutEngineeringSourceRequest { + pub enabled: bool, + #[serde(default = "default_true")] + pub auto_run: bool, + #[serde(default)] + pub repos: Vec, + #[serde(default)] + pub signals: Vec, + #[serde(default = "default_poll_interval")] + pub poll_interval_seconds: u64, +} + +#[derive(Debug, Serialize)] +pub struct EngineeringSourceDto { + pub configured: bool, + pub enabled: bool, + pub auto_run: bool, + pub repos: Vec, + pub signals: Vec, + pub poll_interval_seconds: u64, + pub status: EngineeringSourceStatus, +} + +#[derive(Debug, Deserialize)] +pub struct EngineeringReviewDecisionRequest { + pub decision: String, + pub repo: String, + pub pr_number: u64, + pub pr_url: String, + pub head_sha: String, + pub run: String, + #[serde(default)] + pub comment: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubPull { + number: u64, + html_url: String, + title: String, + #[serde(default)] + draft: bool, + updated_at: String, + user: Option, + base: GithubRef, + head: GithubHead, + #[serde(default)] + labels: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubUser { + login: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubRef { + #[serde(rename = "ref")] + name: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubHead { + #[serde(rename = "ref")] + name: String, + sha: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubLabel { + name: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubCodeScanningAlert { + number: u64, + html_url: String, + rule: GithubCodeScanningRule, + most_recent_instance: Option, + updated_at: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubCodeScanningRule { + id: String, + name: Option, + description: Option, + severity: Option, + security_severity_level: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubCodeScanningInstance { + location: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubCodeScanningLocation { + path: Option, + start_line: Option, + end_line: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubDependabotAlert { + number: u64, + html_url: String, + dependency: GithubDependabotDependency, + security_advisory: Option, + security_vulnerability: GithubSecurityVulnerability, + updated_at: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubDependabotDependency { + package: GithubPackage, + manifest_path: Option, + scope: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubPackage { + ecosystem: String, + name: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubSecurityAdvisory { + ghsa_id: String, + cve_id: Option, + summary: String, + severity: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubSecurityVulnerability { + vulnerable_version_range: String, + first_patched_version: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubPatchedVersion { + identifier: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct GithubSecretScanningAlert { + number: u64, + html_url: String, + secret_type: String, + secret_type_display_name: Option, + resolution: Option, + created_at: Option, + updated_at: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct GithubRepositoryFeatures { + #[serde(default)] + private: bool, + #[serde(default)] + security_and_analysis: Option, +} + +async fn repository_features( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Option { + client + .get(format!("https://api.github.com/repos/{repo}")) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header(reqwest::header::USER_AGENT, "kars-bridge") + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json() + .await + .ok() +} + +fn unavailable_security_product( + features: Option<&GithubRepositoryFeatures>, + signal: EngineeringSignal, + error: &GithubListError, +) -> Option { + let unsupported_signal = matches!( + signal, + EngineeringSignal::CodeScanningAlert | EngineeringSignal::SecretScanningAlert + ); + let private_without_security_product = + features.is_some_and(|repo| repo.private && repo.security_and_analysis.is_none()); + let unsupported_response = matches!( + error.state, + EngineeringSignalSyncState::Forbidden | EngineeringSignalSyncState::Unavailable + ); + (unsupported_signal && private_without_security_product && unsupported_response).then(|| { + GithubListError { + state: EngineeringSignalSyncState::Unavailable, + detail: format!( + "{} is unavailable because GitHub Code Security / Secret Protection is not enabled or licensed for this private repository", + match signal { + EngineeringSignal::CodeScanningAlert => "Code scanning", + EngineeringSignal::SecretScanningAlert => "Secret scanning", + _ => "Security scanning", + } + ), + } + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct DependabotWorkDetails { + signal: EngineeringSignal, + source_id: String, + work_id: String, + repo: String, + pr_number: u64, + pr_url: String, + pr_title: String, + base_ref: String, + head_ref: String, + head_sha: String, + draft: bool, + updated_at: String, + labels: Vec, +} + +struct SyncOutcome { + cursor: EngineeringCursor, + discovered: usize, + queued: usize, + completed_attempts: usize, + errors: Vec, + review_items: Vec, + signal_results: Vec, +} + +fn default_poll_interval() -> u64 { + DEFAULT_POLL_INTERVAL_SECONDS +} + +fn default_true() -> bool { + true +} + +pub(crate) fn source_config_map_name(namespace: &str, team: &str) -> String { + let digest = Sha256::digest(format!("{namespace}/{team}").as_bytes()); + let stem = team.chars().take(40).collect::(); + format!("kars-eng-{stem}-{}", hex::encode(&digest[..6])) +} + +fn source_annotations(config: &EngineeringSourceConfig) -> BTreeMap { + BTreeMap::from([ + (OWNER_ANNOTATION.to_string(), config.owner_sub.clone()), + ( + TEAM_NAMESPACE_ANNOTATION.to_string(), + config.team_namespace.clone(), + ), + (TEAM_NAME_ANNOTATION.to_string(), config.team_name.clone()), + ( + CONNECTION_ANNOTATION.to_string(), + config.connection_config_map_ref.clone(), + ), + ]) +} + +fn source_data( + config: &EngineeringSourceConfig, + cursor: &EngineeringCursor, + status: &EngineeringSourceStatus, +) -> AppResult> { + Ok(BTreeMap::from([ + ( + CONFIG_KEY.to_string(), + serde_json::to_string(config).map_err(|e| AppError::Internal(e.into()))?, + ), + ( + CURSOR_KEY.to_string(), + serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?, + ), + ( + STATUS_KEY.to_string(), + serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?, + ), + ])) +} + +fn parse_source( + cm: &ConfigMap, +) -> Result< + ( + EngineeringSourceConfig, + EngineeringCursor, + EngineeringSourceStatus, + ), + String, +> { + let data = cm + .data + .as_ref() + .ok_or_else(|| "engineering source has no data".to_string())?; + let config = serde_json::from_str::( + data.get(CONFIG_KEY) + .ok_or_else(|| "engineering source is missing config.json".to_string())?, + ) + .map_err(|e| format!("invalid engineering source config: {e}"))?; + if config.version != 1 { + return Err(format!( + "unsupported engineering source config version {}", + config.version + )); + } + let cursor = data + .get(CURSOR_KEY) + .map(|raw| serde_json::from_str(raw)) + .transpose() + .map_err(|e| format!("invalid engineering source cursor: {e}"))? + .unwrap_or_default(); + let status = data + .get(STATUS_KEY) + .map(|raw| serde_json::from_str(raw)) + .transpose() + .map_err(|e| format!("invalid engineering source status: {e}"))? + .unwrap_or_default(); + Ok((config, cursor, status)) +} + +fn verify_source_owner(cm: &ConfigMap, config: &EngineeringSourceConfig, owner_sub: &str) -> bool { + config.owner_sub == owner_sub + && cm + .annotations() + .get(OWNER_ANNOTATION) + .is_some_and(|stored| stored == owner_sub) + && cm + .annotations() + .get(CONNECTION_ANNOTATION) + .is_some_and(|stored| stored == &config.connection_config_map_ref) + && cm + .annotations() + .get(TEAM_NAMESPACE_ANNOTATION) + .is_some_and(|stored| stored == &config.team_namespace) + && cm + .annotations() + .get(TEAM_NAME_ANNOTATION) + .is_some_and(|stored| stored == &config.team_name) +} + +fn to_dto( + configured: bool, + config: Option<&EngineeringSourceConfig>, + status: EngineeringSourceStatus, +) -> EngineeringSourceDto { + EngineeringSourceDto { + configured, + enabled: config.is_some_and(|c| c.enabled), + auto_run: config.is_none_or(|c| c.auto_run), + repos: config.map(|c| c.repos.clone()).unwrap_or_default(), + signals: config.map(|c| c.signals.clone()).unwrap_or_default(), + poll_interval_seconds: config + .map(|c| c.poll_interval_seconds) + .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS), + status, + } +} + +fn validate_repo_name(repo: &str) -> bool { + let mut parts = repo.split('/'); + let Some(owner) = parts.next() else { + return false; + }; + let Some(name) = parts.next() else { + return false; + }; + parts.next().is_none() + && !owner.is_empty() + && !name.is_empty() + && owner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) +} + +fn validate_request( + request: &PutEngineeringSourceRequest, + granted: &[String], +) -> AppResult<(Vec, Vec)> { + if !(MIN_POLL_INTERVAL_SECONDS..=MAX_POLL_INTERVAL_SECONDS) + .contains(&request.poll_interval_seconds) + { + return Err(AppError::BadRequest(format!( + "poll_interval_seconds must be between {MIN_POLL_INTERVAL_SECONDS} and {MAX_POLL_INTERVAL_SECONDS}" + ))); + } + + let repos = authorize_repo_set(&request.repos, granted)?; + if repos.len() > MAX_REPOS { + return Err(AppError::BadRequest(format!( + "at most {MAX_REPOS} repositories can be configured per team" + ))); + } + if let Some(invalid) = repos.iter().find(|repo| !validate_repo_name(repo)) { + return Err(AppError::BadRequest(format!( + "invalid repository name `{invalid}`; expected owner/repo" + ))); + } + + let signals = request + .signals + .iter() + .copied() + .collect::>() + .into_iter() + .collect::>(); + if request.enabled && repos.is_empty() { + return Err(AppError::BadRequest( + "select at least one authorized repository before enabling engineering intake".into(), + )); + } + if request.enabled && signals.is_empty() { + return Err(AppError::BadRequest( + "select at least one engineering signal before enabling intake".into(), + )); + } + Ok((repos, signals)) +} + +fn initial_jitter_seconds(source_name: &str) -> i64 { + let digest = Sha256::digest(source_name.as_bytes()); + i64::from(digest[0] % 60) +} + +fn next_poll_at(config: &EngineeringSourceConfig, now: DateTime) -> String { + (now + chrono::Duration::seconds(config.poll_interval_seconds as i64)).to_rfc3339() +} + +fn is_due(status: &EngineeringSourceStatus, now: DateTime) -> bool { + status + .next_poll_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .is_none_or(|value| value.with_timezone(&Utc) <= now) +} + +fn sync_claim_active(status: &EngineeringSourceStatus, now: DateTime) -> bool { + status.state == EngineeringSyncState::Syncing + && status + .sync_claim_expires_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .is_some_and(|expires| expires.with_timezone(&Utc) > now) +} + +fn is_dependabot_pr(pr: &GithubPull) -> bool { + pr.user.as_ref().is_some_and(|user| { + user.login.eq_ignore_ascii_case("dependabot[bot]") + || user.login.eq_ignore_ascii_case("dependabot-preview[bot]") + }) || pr.head.name.to_ascii_lowercase().starts_with("dependabot/") +} + +fn open_pull_covers_dependabot_alert(pr: &GithubPull, alert: &GithubDependabotAlert) -> bool { + let haystack = format!("{} {}", pr.title, pr.head.name).to_ascii_lowercase(); + if alert + .security_advisory + .as_ref() + .is_some_and(|advisory| haystack.contains(&advisory.ghsa_id.to_ascii_lowercase())) + { + return true; + } + let haystack_terms = haystack + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|term| term.len() >= 3) + .collect::>(); + let package = alert.dependency.package.name.to_ascii_lowercase(); + let package_terms = package + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|term| term.len() >= 3) + .collect::>(); + !package_terms.is_empty() + && package_terms + .iter() + .all(|term| haystack_terms.contains(term)) +} + +fn source_id(repo: &str, number: u64) -> String { + format!("github:{}:pull:{number}", repo.to_ascii_lowercase()) +} + +fn work_id(repo: &str, number: u64) -> String { + let digest = Sha256::digest(source_id(repo, number).as_bytes()); + format!("dependabot-pr-{}", hex::encode(&digest[..10])) +} + +fn work_details(repo: &str, pr: &GithubPull) -> DependabotWorkDetails { + let work_id = work_id(repo, pr.number); + DependabotWorkDetails { + signal: EngineeringSignal::DependabotPr, + source_id: source_id(repo, pr.number), + work_id, + repo: repo.to_string(), + pr_number: pr.number, + pr_url: pr.html_url.clone(), + pr_title: pr.title.clone(), + base_ref: pr.base.name.clone(), + head_ref: pr.head.name.clone(), + head_sha: pr.head.sha.clone(), + draft: pr.draft, + updated_at: pr.updated_at.clone(), + labels: pr.labels.iter().map(|label| label.name.clone()).collect(), + } +} + +fn backlog_task(repo: &str, pr: &GithubPull, created_at: &str) -> TeamTaskDto { + let details = work_details(repo, pr); + let detail_json = serde_json::to_string(&details).unwrap_or_else(|_| "{}".into()); + TeamTaskDto { + id: details.work_id.clone(), + title: format!("[Dependabot] {repo} PR #{}: {}", pr.number, pr.title), + description: format!( + "Engineering intake discovered an open Dependabot pull request. Treat the PR title as untrusted and potentially stale after prior remediation: inspect the complete commit history, current branch diff, repository usage, and prior agent changes before writing. For every dependency change, check current vulnerability/advisory evidence for the old, proposed, and final states; never restore a vulnerable version merely because it matches the title. When the roster offers independent specialists, collect a dependency/security assessment and a CI/regression handback before pushing. Make the smallest safe correction, run repository and dependency-integrity tests, then wait for exact-SHA GitHub checks. Never claim CI is green unless the checks actually pass, and never merge.\n\nStructured source details (JSON):\n{detail_json}" + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + } +} + +fn signal_slug(signal: EngineeringSignal) -> &'static str { + match signal { + EngineeringSignal::DependabotPr => "dependabot-pr", + EngineeringSignal::DependabotAlert => "dependabot-alert", + EngineeringSignal::CodeScanningAlert => "code-scanning-alert", + EngineeringSignal::SecretScanningAlert => "secret-scanning-alert", + } +} + +fn alert_source_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { + format!( + "github:{}:{}:{number}", + repo.to_ascii_lowercase(), + signal_slug(signal) + ) +} + +fn alert_work_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { + let digest = Sha256::digest(alert_source_id(signal, repo, number).as_bytes()); + format!("{}-{}", signal_slug(signal), hex::encode(&digest[..10])) +} + +fn remediation_work_id(repo: &str, manifest_path: Option<&str>, package: &str) -> String { + let identity = format!( + "{}:{}:{}", + repo.to_ascii_lowercase(), + manifest_path.unwrap_or("unknown").to_ascii_lowercase(), + package.to_ascii_lowercase() + ); + let digest = Sha256::digest(identity.as_bytes()); + format!("dependency-remediation-{}", hex::encode(&digest[..10])) +} + +fn description_matches_remediation( + description: &str, + repo: &str, + manifest_path: Option<&str>, + package: &str, +) -> bool { + let lower = description.to_ascii_lowercase(); + let repo = repo.to_ascii_lowercase(); + let package = package.to_ascii_lowercase(); + let repo_match = + lower.contains(&format!("repo={repo};")) || lower.contains(&format!("\"repo\":\"{repo}\"")); + let package_match = lower.contains(&format!("pkg={package};")) + || lower.contains(&format!("package={package};")) + || lower.contains(&format!("\"package\":\"{package}\"")); + let manifest_match = match manifest_path { + Some(manifest) => { + let manifest = manifest.to_ascii_lowercase(); + lower.contains(&format!("manifest={manifest};")) + || lower.contains(&format!("manifest_path={manifest};")) + || lower.contains(&format!("\"manifest_path\":\"{manifest}\"")) + } + None => { + lower.contains("manifest=unknown;") + || lower.contains("manifest_path=unknown;") + || lower.contains("\"manifest_path\":null") + } + }; + repo_match && package_match && manifest_match +} + +fn legacy_alert_retirement(id: &str, remediation_id: &str, created_at: &str) -> TeamTaskDto { + TeamTaskDto { + id: id.to_string(), + title: format!("[Consolidated] Legacy alert work moved to {remediation_id}"), + description: format!( + "This alert-number-scoped task was consolidated into canonical remediation {remediation_id}." + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: false, + status: "done".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: Some(created_at.to_string()), + stuck_since: None, + assignment_nonce: None, + } +} + +struct GithubAlertRef<'a> { + repo: &'a str, + number: u64, +} + +fn alert_backlog_task( + signal: EngineeringSignal, + source: GithubAlertRef<'_>, + title: String, + instruction: &str, + details: serde_json::Value, + work_id_override: Option, + created_at: &str, +) -> TeamTaskDto { + let GithubAlertRef { repo, number } = source; + let source_id = alert_source_id(signal, repo, number); + let work_id = work_id_override.unwrap_or_else(|| alert_work_id(signal, repo, number)); + let structured = serde_json::json!({ + "signal": signal, + "source_id": source_id, + "work_id": work_id, + "repo": repo, + "alert_number": number, + "details": details.clone(), + }); + let source_facts = [ + details + .get("manifest_path") + .and_then(serde_json::Value::as_str) + .map(|value| format!("manifest={value}")), + details + .get("path") + .and_then(serde_json::Value::as_str) + .map(|value| format!("path={value}")), + details + .get("package") + .and_then(serde_json::Value::as_str) + .map(|value| format!("pkg={value}")), + details + .get("vulnerable_version_range") + .and_then(serde_json::Value::as_str) + .map(|value| format!("vuln={value}")), + details + .get("first_patched_version") + .and_then(serde_json::Value::as_str) + .map(|value| format!("fixed={value}")), + details + .get("ghsa_id") + .and_then(serde_json::Value::as_str) + .map(|value| format!("ghsa={value}")), + ] + .into_iter() + .flatten() + .collect::>() + .join("; "); + TeamTaskDto { + id: work_id, + title, + description: format!( + "AUTH SOURCE: {source_facts}. RULE: exact manifest; max2 same target; search open+merged PRs for same GHSA/pkg; fix+PR handbacks; no principal substitution.\n\n{instruction} Validate the finding against the current repository state, make the smallest safe remediation, run relevant tests and security checks, and propose or update a pull request when code changes are needed. Never claim success without current evidence and never merge.\n\nStructured source details (JSON):\n{}", + serde_json::to_string(&structured).unwrap_or_else(|_| "{}".into()) + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + } +} + +fn code_scanning_task( + repo: &str, + alert: &GithubCodeScanningAlert, + created_at: &str, +) -> TeamTaskDto { + let location = alert + .most_recent_instance + .as_ref() + .and_then(|instance| instance.location.as_ref()); + let rule_name = alert.rule.name.as_deref().unwrap_or(&alert.rule.id); + let severity = alert + .rule + .security_severity_level + .as_deref() + .or(alert.rule.severity.as_deref()) + .unwrap_or("unknown"); + alert_backlog_task( + EngineeringSignal::CodeScanningAlert, + GithubAlertRef { + repo, + number: alert.number, + }, + format!( + "[Code scanning] {repo} alert #{}: {rule_name}", + alert.number + ), + "GitHub code scanning reported an open code-quality or security finding.", + serde_json::json!({ + "url": alert.html_url, + "rule_id": alert.rule.id, + "rule_name": rule_name, + "description": alert.rule.description, + "severity": severity, + "path": location.and_then(|value| value.path.clone()), + "start_line": location.and_then(|value| value.start_line), + "end_line": location.and_then(|value| value.end_line), + "updated_at": alert.updated_at, + }), + None, + created_at, + ) +} + +fn dependabot_alert_task( + repo: &str, + alert: &GithubDependabotAlert, + created_at: &str, +) -> TeamTaskDto { + let advisory = alert.security_advisory.as_ref(); + let advisory_id = advisory + .map(|value| value.ghsa_id.as_str()) + .unwrap_or("GitHub advisory"); + alert_backlog_task( + EngineeringSignal::DependabotAlert, + GithubAlertRef { + repo, + number: alert.number, + }, + format!( + "[Dependabot alert] {repo} #{}: {} ({advisory_id})", + alert.number, alert.dependency.package.name + ), + "GitHub Dependabot reported an open vulnerable-dependency alert.", + serde_json::json!({ + "url": alert.html_url, + "package": alert.dependency.package.name, + "ecosystem": alert.dependency.package.ecosystem, + "manifest_path": alert.dependency.manifest_path, + "scope": alert.dependency.scope, + "ghsa_id": advisory.map(|value| value.ghsa_id.clone()), + "cve_id": advisory.and_then(|value| value.cve_id.clone()), + "summary": advisory.map(|value| value.summary.clone()), + "severity": advisory.map(|value| value.severity.clone()), + "vulnerable_version_range": alert.security_vulnerability.vulnerable_version_range, + "first_patched_version": alert.security_vulnerability.first_patched_version.as_ref().map(|value| value.identifier.clone()), + "updated_at": alert.updated_at, + }), + Some(remediation_work_id( + repo, + alert.dependency.manifest_path.as_deref(), + &alert.dependency.package.name, + )), + created_at, + ) +} + +fn secret_scanning_task( + repo: &str, + alert: &GithubSecretScanningAlert, + created_at: &str, +) -> TeamTaskDto { + let display = alert + .secret_type_display_name + .as_deref() + .unwrap_or(&alert.secret_type); + alert_backlog_task( + EngineeringSignal::SecretScanningAlert, + GithubAlertRef { + repo, + number: alert.number, + }, + format!( + "[Secret scanning] {repo} alert #{}: {display}", + alert.number + ), + "GitHub secret scanning reported an open credential exposure. Treat the secret value as sensitive: do not print, persist, or copy it. Verify revocation or rotation, remove the exposure safely, and add prevention coverage.", + serde_json::json!({ + "url": alert.html_url, + "secret_type": alert.secret_type, + "secret_type_display_name": display, + "resolution": alert.resolution, + "created_at": alert.created_at, + "updated_at": alert.updated_at, + }), + None, + created_at, + ) +} + +fn merge_discovered_tasks( + mut existing: Vec, + discovered: Vec, +) -> (Vec, usize) { + for task in &mut existing { + if engineering_task_requires_review(&task.id) { + task.review_required = true; + } + } + let mut positions = existing + .iter() + .enumerate() + .map(|(index, task)| (task.id.clone(), index)) + .collect::>(); + let mut added = 0; + for task in discovered { + if let Some(index) = positions.get(&task.id).copied() { + let current = &mut existing[index]; + let renewable_alert = task.id.starts_with("dependabot-alert-") + || task.id.starts_with("code-scanning-alert-") + || task.id.starts_with("secret-scanning-alert-"); + let renewable_human_decision = task.id.starts_with("github-pr-merge-") + || task.id.starts_with("github-pr-feedback-"); + let renewable_pr_control = + task.id.starts_with("github-pr-fix-") || task.id.starts_with("github-pr-dedupe-"); + if renewable_alert && current.status == "pending" && task.status == "done" { + current.title = task.title; + current.description = task.description; + current.status = "done".into(); + current.run = None; + current.done_at = task.done_at; + current.stuck_since = None; + } else if current.status == "done" + && (renewable_human_decision + || ((renewable_alert || renewable_pr_control) + && current.description != task.description)) + { + current.title = task.title; + current.description = task.description; + current.status = "pending".into(); + current.run = None; + current.done_at = None; + current.created_at = task.created_at; + added += 1; + } else if (renewable_alert || renewable_pr_control) + && matches!(current.status.as_str(), "pending" | "active") + && current.description != task.description + { + current.title = task.title; + current.description = task.description; + } + current.review_required |= task.review_required; + continue; + } + positions.insert(task.id.clone(), existing.len()); + existing.push(task); + added += 1; + } + (existing, added) +} + +fn engineering_task_requires_review(task_id: &str) -> bool { + task_id.starts_with("dependabot-pr-") + || task_id.starts_with("dependabot-alert-") + || task_id.starts_with("code-scanning-alert-") + || task_id.starts_with("secret-scanning-alert-") + || task_id.starts_with("github-pr-fix-") + || task_id.starts_with("github-pr-dedupe-") + || task_id.starts_with("github-pr-feedback-") +} + +fn append_bounded_tasks( + target: &mut Vec, + known_tasks: &mut BTreeMap, + incoming: Vec, + queued_slots_used: &mut usize, + attempt_cap: usize, +) -> bool { + let mut queue_candidates = Vec::new(); + for task in incoming { + let renewable_alert = task.id.starts_with("dependabot-alert-") + || task.id.starts_with("code-scanning-alert-") + || task.id.starts_with("secret-scanning-alert-"); + match known_tasks.get(&task.id) { + None => { + known_tasks.insert( + task.id.clone(), + (task.status.clone(), task.description.clone()), + ); + queue_candidates.push(task); + } + Some((status, description)) => { + let reopen = + renewable_alert && status == "done" && description != &task.description; + if reopen { + known_tasks.insert( + task.id.clone(), + ("pending".into(), task.description.clone()), + ); + queue_candidates.push(task); + } else if renewable_alert + && matches!(status.as_str(), "pending" | "active") + && description != &task.description + { + known_tasks.insert(task.id.clone(), (status.clone(), task.description.clone())); + target.push(task); + } + } + } + } + let remaining = MAX_ITEMS_PER_SYNC + .saturating_sub(*queued_slots_used) + .min(attempt_cap); + let truncated = queue_candidates.len() > remaining; + queue_candidates.truncate(remaining); + *queued_slots_used += queue_candidates.len(); + target.extend(queue_candidates); + truncated +} + +fn truncate_error(value: impl Into) -> String { + let value = value.into(); + value.chars().take(1000).collect() +} + +#[derive(Debug)] +struct GithubListError { + state: EngineeringSignalSyncState, + detail: String, +} + +struct GithubListResult { + items: Vec, + truncated: bool, +} + +fn next_link(value: &str) -> Option { + value.split(',').find_map(|entry| { + let mut sections = entry.trim().split(';'); + let url = sections.next()?.trim(); + if !sections.any(|section| section.trim() == r#"rel="next""#) { + return None; + } + url.strip_prefix('<')?.strip_suffix('>').map(str::to_string) + }) +} + +async fn github_get_paginated( + client: &reqwest::Client, + token: &str, + initial_url: String, + label: &str, + max_items: usize, +) -> Result, GithubListError> { + let mut url = Some(initial_url); + let mut items = Vec::new(); + let mut pages = 0; + while let Some(current) = url.take() { + pages += 1; + let response = client + .get(¤t) + .bearer_auth(token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|error| GithubListError { + state: EngineeringSignalSyncState::Error, + detail: format!("{label} request failed: {error}"), + })?; + let status = response.status(); + let next = response + .headers() + .get(reqwest::header::LINK) + .and_then(|value| value.to_str().ok()) + .and_then(next_link); + let body = response.text().await.map_err(|error| GithubListError { + state: EngineeringSignalSyncState::Error, + detail: format!("{label} response could not be read: {error}"), + })?; + if !status.is_success() { + return Err(GithubListError { + state: match status.as_u16() { + 403 => EngineeringSignalSyncState::Forbidden, + 404 => EngineeringSignalSyncState::Unavailable, + _ => EngineeringSignalSyncState::Error, + }, + detail: format!("{label} returned HTTP {status}"), + }); + } + let mut page = serde_json::from_str::>(&body).map_err(|error| GithubListError { + state: EngineeringSignalSyncState::Error, + detail: format!("{label} returned invalid JSON: {error}"), + })?; + let remaining = max_items.saturating_sub(items.len()); + if page.len() > remaining { + page.truncate(remaining); + } + items.extend(page); + if next.is_some() && (items.len() >= max_items || pages >= MAX_GITHUB_PAGES) { + return Ok(GithubListResult { + items, + truncated: true, + }); + } + url = next; + } + Ok(GithubListResult { + items, + truncated: false, + }) +} + +#[allow(dead_code)] +async fn list_open_pulls_legacy( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result, String> { + let url = format!( + "https://api.github.com/repos/{repo}/pulls?state=open&per_page={MAX_OPEN_PRS_PER_REPO}" + ); + let response = client + .get(url) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub request for {repo} failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("GitHub response for {repo} could not be read: {e}"))?; + if !status.is_success() { + return Err(truncate_error(format!( + "GitHub returned {status} while listing open pull requests for {repo}: {body}" + ))); + } + serde_json::from_str(&body) + .map_err(|e| format!("GitHub returned invalid pull request data for {repo}: {e}")) +} + +async fn list_open_pulls( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result, GithubListError> { + github_get_paginated( + client, + token, + format!("https://api.github.com/repos/{repo}/pulls?state=open&per_page=100"), + &format!("listing open pull requests for {repo}"), + MAX_OPEN_PRS_PER_REPO, + ) + .await +} + +async fn list_dependabot_alerts( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result, GithubListError> { + github_get_paginated( + client, + token, + format!("https://api.github.com/repos/{repo}/dependabot/alerts?state=open&per_page=100"), + &format!("Dependabot alerts for {repo}"), + MAX_ALERTS_PER_SIGNAL, + ) + .await +} + +async fn list_code_scanning_alerts( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result, GithubListError> { + github_get_paginated( + client, + token, + format!("https://api.github.com/repos/{repo}/code-scanning/alerts?state=open&per_page=100"), + &format!("code scanning alerts for {repo}"), + MAX_ALERTS_PER_SIGNAL, + ) + .await +} + +async fn list_secret_scanning_alerts( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result, GithubListError> { + github_get_paginated( + client, + token, + format!( + "https://api.github.com/repos/{repo}/secret-scanning/alerts?state=open&per_page=100" + ), + &format!("secret scanning alerts for {repo}"), + MAX_ALERTS_PER_SIGNAL, + ) + .await +} + +fn signal_result( + repo: &str, + signal: EngineeringSignal, + result: Result, + truncation_detail: Option, +) -> EngineeringSignalResult { + match result { + Ok(discovered) if truncation_detail.is_some() => EngineeringSignalResult { + repo: repo.to_string(), + signal, + state: EngineeringSignalSyncState::Truncated, + discovered, + detail: truncation_detail.unwrap_or_default(), + }, + Ok(discovered) => EngineeringSignalResult { + repo: repo.to_string(), + signal, + state: EngineeringSignalSyncState::Ok, + discovered, + detail: if discovered == 0 { + "Scanned successfully; no open items.".into() + } else { + format!("Scanned successfully; found {discovered} open item(s).") + }, + }, + Err(error) => EngineeringSignalResult { + repo: repo.to_string(), + signal, + state: error.state, + discovered: 0, + detail: error.detail, + }, + } +} + +async fn github_get_json( + client: &reqwest::Client, + token: &str, + url: &str, +) -> Result { + let response = client + .get(url) + .bearer_auth(token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub request failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("GitHub response could not be read: {e}"))?; + if !status.is_success() { + return Err(truncate_error(format!("GitHub returned {status}: {body}"))); + } + serde_json::from_str(&body).map_err(|e| format!("GitHub returned invalid JSON: {e}")) +} + +fn classify_review_readiness( + pull: &serde_json::Value, + check_runs: &serde_json::Value, + status: &serde_json::Value, +) -> (EngineeringReviewState, String, usize, usize) { + let runs = check_runs + .get("check_runs") + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let statuses = status + .get("statuses") + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let total_count = check_runs + .get("total_count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(runs.len() as u64) as usize; + let total = total_count + statuses.len(); + let passed_runs = runs + .iter() + .filter(|run| { + run.get("status").and_then(serde_json::Value::as_str) == Some("completed") + && matches!( + run.get("conclusion").and_then(serde_json::Value::as_str), + Some("success" | "neutral" | "skipped") + ) + }) + .count(); + let passed_statuses = statuses + .iter() + .filter(|item| item.get("state").and_then(serde_json::Value::as_str) == Some("success")) + .count(); + let passed = passed_runs + passed_statuses; + + if pull.get("draft").and_then(serde_json::Value::as_bool) == Some(true) { + return ( + EngineeringReviewState::Blocked, + "PR is still a draft.".into(), + total, + passed, + ); + } + if pull.get("mergeable").and_then(serde_json::Value::as_bool) == Some(false) { + return ( + EngineeringReviewState::Blocked, + "GitHub reports merge conflicts.".into(), + total, + passed, + ); + } + let mergeable_state = pull + .get("mergeable_state") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + if matches!(mergeable_state, "dirty" | "blocked" | "behind") { + return ( + EngineeringReviewState::Blocked, + format!("Branch state is '{mergeable_state}', not clean and up to date."), + total, + passed, + ); + } + let combined_status = status + .get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("pending"); + if runs.iter().any(|run| { + run.get("status").and_then(serde_json::Value::as_str) == Some("completed") + && !matches!( + run.get("conclusion").and_then(serde_json::Value::as_str), + Some("success" | "neutral" | "skipped") + ) + }) || matches!(combined_status, "failure" | "error") + { + return ( + EngineeringReviewState::CiFailed, + format!("{passed}/{total} GitHub checks passed; at least one check is red."), + total, + passed, + ); + } + if total == 0 { + return ( + EngineeringReviewState::WaitingForCi, + "No GitHub CI/status evidence exists for the head commit yet.".into(), + total, + passed, + ); + } + if total_count > runs.len() + || passed < total + || runs + .iter() + .any(|run| run.get("status").and_then(serde_json::Value::as_str) != Some("completed")) + || (!statuses.is_empty() && combined_status != "success") + || pull.get("mergeable").and_then(serde_json::Value::as_bool) != Some(true) + || mergeable_state != "clean" + { + return ( + EngineeringReviewState::WaitingForCi, + format!("{passed}/{total} GitHub checks passed; waiting for a clean mergeable state."), + total, + passed, + ); + } + ( + EngineeringReviewState::ReadyForReview, + format!("GitHub reports a clean, up-to-date PR with {passed}/{total} checks green."), + total, + passed, + ) +} + +struct ReviewExecution<'a> { + run: &'a str, + work_id: &'a str, + task_status: &'a str, + run_state: Option, + selected_roles: Vec, + delivered_roles: Vec, + artifact_count: Option, +} + +async fn inspect_review_item( + client: &reqwest::Client, + token: &str, + repo: &str, + number: u64, + execution: ReviewExecution<'_>, + observed_at: &str, +) -> Result, String> { + let ReviewExecution { + run, + work_id, + task_status, + run_state, + selected_roles, + delivered_roles, + artifact_count, + } = execution; + let pull = github_get_json( + client, + token, + &format!("https://api.github.com/repos/{repo}/pulls/{number}"), + ) + .await?; + if pull.get("state").and_then(serde_json::Value::as_str) != Some("open") + || pull.get("merged").and_then(serde_json::Value::as_bool) == Some(true) + { + return Ok(None); + } + let head_sha = pull + .get("head") + .and_then(|head| head.get("sha")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("GitHub PR {repo}#{number} has no head SHA"))? + .to_string(); + let check_runs = github_get_json( + client, + token, + &format!("https://api.github.com/repos/{repo}/commits/{head_sha}/check-runs?per_page=100"), + ) + .await?; + let status = github_get_json( + client, + token, + &format!("https://api.github.com/repos/{repo}/commits/{head_sha}/status?per_page=100"), + ) + .await?; + let (state, detail, checks_total, checks_passed) = + classify_review_readiness(&pull, &check_runs, &status); + Ok(Some(EngineeringReviewItem { + repo: repo.to_string(), + pr_number: number, + pr_url: pull + .get("html_url") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + title: pull + .get("title") + .and_then(serde_json::Value::as_str) + .unwrap_or("Pull request") + .to_string(), + run: run.to_string(), + source_id: source_id(repo, number), + work_id: work_id.to_string(), + task_status: task_status.to_string(), + run_state, + selected_roles, + delivered_roles, + artifact_count, + head_sha, + state, + detail, + checks_total, + checks_passed, + observed_at: observed_at.to_string(), + })) +} + +async fn run_execution_summary( + cluster: &Cluster, + namespace: &str, + run: &str, +) -> (Option, Vec, Vec) { + let task = cluster.tasks(namespace).get_opt(run).await.ok().flatten(); + let mut selected = BTreeSet::new(); + let mut delivered = BTreeSet::new(); + let run_state = task + .as_ref() + .and_then(|task| task.status.as_ref()) + .and_then(|status| status.assignment.as_ref()) + .map(|assignment| assignment.state.clone()); + if let Some(events) = task + .as_ref() + .and_then(|task| task.status.as_ref()) + .map(|status| status.assignment_events.as_slice()) + { + for event in events { + let Some(role) = event.child_role.as_ref() else { + continue; + }; + selected.insert(role.clone()); + if event.stage.as_deref() == Some("child_handback") + && event.outcome.as_deref() == Some("success") + && event.state == "Completed" + { + delivered.insert(role.clone()); + } + } + } + ( + run_state, + selected.into_iter().collect(), + delivered.into_iter().collect(), + ) +} + +fn review_followup_task(item: &EngineeringReviewItem, created_at: &str) -> Option { + if !matches!( + item.state, + EngineeringReviewState::CiFailed | EngineeringReviewState::Blocked + ) { + return None; + } + let digest = + Sha256::digest(format!("github-review:{}:{}", item.repo, item.pr_number).as_bytes()); + Some(TeamTaskDto { + id: format!("github-pr-fix-{}", hex::encode(&digest[..10])), + title: format!( + "[PR gate] Resolve or retire {} PR #{} before review", + item.repo, item.pr_number + ), + description: format!( + "GitHub does not consider this PR ready for human review. Before modifying the branch, determine whether the PR is still needed or has been superseded by a merged PR/default-branch change. If it is superseded, do not repair or rebase it: close it when authorized, or report the exact closure recommendation. Only when its objective is still required should you resolve the observed branch/CI state, push the smallest correction, and wait for exact-SHA GitHub checks. Never claim green from local inference and never merge.\n\nPR: {}\nHead SHA: {}\nObserved state: {:?}\nDetail: {}", + item.pr_url, item.head_sha, item.state, item.detail + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }) +} + +fn dedupe_followup_task( + repo: &str, + remediation_id: &str, + pulls: &[&GithubPull], + created_at: &str, +) -> Option { + if pulls.len() < 2 { + return None; + } + let mut ordered = pulls.to_vec(); + ordered.sort_by_key(|pull| pull.number); + let canonical = ordered[0]; + let duplicates = ordered[1..] + .iter() + .map(|pull| format!("#{} {}", pull.number, pull.html_url)) + .collect::>() + .join(", "); + let digest = Sha256::digest(format!("{repo}:{remediation_id}").as_bytes()); + Some(TeamTaskDto { + id: format!("github-pr-dedupe-{}", hex::encode(&digest[..10])), + title: format!( + "[PR dedupe] Keep {repo} PR #{} and retire {} duplicate(s)", + canonical.number, + ordered.len() - 1 + ), + description: format!( + "Multiple open pull requests cover the same canonical remediation. Verify equivalent scope and preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence. Close superseded duplicates, never merge, and report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", + canonical.number, canonical.html_url, duplicates + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }) +} + +async fn collect_review_items( + cluster: &Cluster, + client: &reqwest::Client, + token: &str, + config: &EngineeringSourceConfig, + observed_at: &str, +) -> (Vec, Vec, Vec) { + let backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); + let configured_repos = config + .repos + .iter() + .map(|repo| repo.to_ascii_lowercase()) + .collect::>(); + let mut seen = HashSet::new(); + let mut items = Vec::new(); + let mut followups = Vec::new(); + let mut errors = Vec::new(); + for task in backlog.iter().rev() { + if seen.len() >= MAX_REVIEW_PRS_PER_SYNC { + errors.push(format!( + "review readiness reached the {MAX_REVIEW_PRS_PER_SYNC}-PR sync cap" + )); + break; + } + let Some(run) = task.run.as_deref() else { + continue; + }; + let Some(output) = cluster.read_mission_output(run).await else { + continue; + }; + let (run_state, selected_roles, delivered_roles) = + run_execution_summary(cluster, &config.team_namespace, run).await; + let artifact_count = output + .get("artifactCount") + .and_then(|value| value.parse::().ok()); + let text = output.get("output").map(String::as_str).unwrap_or_default(); + if !crate::routes::tasks::is_real_deliverable( + output.get("status").map(String::as_str), + text, + ) { + continue; + } + for pull in crate::routes::tasks::extract_pull_requests(text) { + let key = format!("{}#{}", pull.repo.to_ascii_lowercase(), pull.number); + if !configured_repos.contains(&pull.repo.to_ascii_lowercase()) || !seen.insert(key) { + continue; + } + match inspect_review_item( + client, + token, + &pull.repo, + pull.number as u64, + ReviewExecution { + run, + work_id: &task.id, + task_status: &task.status, + run_state: run_state.clone(), + selected_roles: selected_roles.clone(), + delivered_roles: delivered_roles.clone(), + artifact_count, + }, + observed_at, + ) + .await + { + Ok(Some(item)) => { + if let Some(task) = review_followup_task(&item, observed_at) { + followups.push(task); + } + items.push(item); + } + Ok(None) => {} + Err(error) => errors.push(format!( + "review readiness for {}#{} failed: {error}", + pull.repo, pull.number + )), + } + } + } + (items, followups, errors) +} + +async fn merge_into_backlog( + cluster: &Cluster, + team: &str, + discovered: Vec, +) -> Result { + let queued = std::sync::atomic::AtomicUsize::new(0); + let name = format!("kars-team-tasks-{team}"); + cluster + .update_configmap_data(&name, &[("kars.azure.com/team-tasks", team)], |data| { + let existing = data + .get("tasks.json") + .map(|raw| read_task_list(raw)) + .unwrap_or_default(); + let (merged, added) = merge_discovered_tasks(existing, discovered.clone()); + queued.store(added, std::sync::atomic::Ordering::Relaxed); + data.insert( + "tasks.json".to_string(), + serde_json::to_string(&merged).unwrap_or_else(|_| "[]".into()), + ); + }) + .await + .map_err(|e| format!("updating the team backlog failed: {e}"))?; + Ok(queued.load(std::sync::atomic::Ordering::Relaxed)) +} + +async fn request_team_run(cluster: &Cluster, namespace: &str, team: &str) -> Result { + let team_object = cluster + .teams(namespace) + .get_opt(team) + .await + .map_err(|error| format!("checking team run state failed: {error}"))? + .ok_or_else(|| "the standing team no longer exists".to_string())?; + if team_object.spec.paused { + return Ok(false); + } + cluster + .teams(namespace) + .patch( + team, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/backlog-run-now": Utc::now().to_rfc3339() + } + } + })), + ) + .await + .map(|_| true) + .map_err(|error| format!("queued work but could not request a team run: {error}")) +} + +async fn ensure_auto_run_for_backlog( + cluster: &Cluster, + config: &EngineeringSourceConfig, +) -> Result { + if !config.enabled || !config.auto_run { + return Ok(false); + } + let has_pending = read_task_list(&cluster.read_team_tasks(&config.team_name).await) + .iter() + .any(|task| task.status == "pending"); + if !has_pending { + return Ok(false); + } + request_team_run(cluster, &config.team_namespace, &config.team_name).await +} + +async fn perform_sync( + cluster: &Cluster, + config: &EngineeringSourceConfig, + mut cursor: EngineeringCursor, + claim_id: &str, +) -> Result { + let expected_connection = connection_config_map_name(&config.owner_sub); + if config.connection_config_map_ref != expected_connection { + return Err("source connection reference does not match its owner".into()); + } + + let team = cluster + .teams(&config.team_namespace) + .get_opt(&config.team_name) + .await + .map_err(|e| format!("reading the standing team failed: {e}"))? + .ok_or_else(|| "the standing team no longer exists".to_string())?; + if team + .annotations() + .get("kars.azure.com/owner-sub") + .is_none_or(|owner| owner != &config.owner_sub) + { + return Err("the engineering source owner no longer owns this team".into()); + } + + let (installation_id, _account, granted_repos) = cluster + .read_github_connection_result(&config.team_namespace, &config.connection_config_map_ref) + .await + .map_err(|e| format!("reading the GitHub connection failed: {e}"))? + .ok_or_else(|| "the owner's GitHub connection is no longer available".to_string())?; + authorize_repo_set(&config.repos, &granted_repos) + .map_err(|e| format!("repository authorization changed: {e}"))?; + + let (app_id, private_key) = cluster + .github_app_creds() + .await + .map_err(|error| format!("GitHub credential authority unavailable: {error}"))? + .ok_or_else(|| "the shared GitHub App is not configured".to_string())?; + let app_jwt = mint_app_jwt(&app_id, &private_key).map_err(|e| e.to_string())?; + let token = installation_token(&app_jwt, &installation_id) + .await + .map_err(|e| e.to_string())?; + + let now = Utc::now().to_rfc3339(); + let mut tasks = Vec::new(); + let mut errors = Vec::new(); + let mut completed_attempts = 0; + let mut signal_results = Vec::new(); + let client = reqwest::Client::new(); + let existing_backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); + let mut known_tasks = existing_backlog + .iter() + .map(|task| { + ( + task.id.clone(), + (task.status.clone(), task.description.clone()), + ) + }) + .collect::>(); + let attempt_count = config + .repos + .len() + .saturating_mul(config.signals.len()) + .max(1); + let attempt_cap = (MAX_ITEMS_PER_SYNC / attempt_count).max(1); + let mut queued_slots_used = 0; + for repo in &config.repos { + let features = repository_features(&client, &token, repo).await; + let open_pull_coverage = if config.signals.contains(&EngineeringSignal::DependabotAlert) { + list_open_pulls(&client, &token, repo) + .await + .map(|pulls| pulls.items) + .unwrap_or_default() + } else { + Vec::new() + }; + let mut dedupe_seen = BTreeSet::new(); + for signal in config.signals.iter().copied() { + let (result, api_truncated, queue_truncated) = match signal { + EngineeringSignal::DependabotPr => { + match list_open_pulls(&client, &token, repo).await { + Ok(pulls) => { + completed_attempts += 1; + if let Some(updated_at) = + pulls.items.iter().map(|pr| pr.updated_at.as_str()).max() + { + cursor + .repository_updated_at + .insert(repo.clone(), updated_at.to_string()); + } + let signal_tasks = pulls + .items + .iter() + .filter(|pr| is_dependabot_pr(pr)) + .map(|pr| backlog_task(repo, pr, &now)) + .collect::>(); + let discovered = signal_tasks.len(); + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), pulls.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + EngineeringSignal::DependabotAlert => { + match list_dependabot_alerts(&client, &token, repo).await { + Ok(alerts) => { + completed_attempts += 1; + let discovered = alerts.items.len(); + let mut signal_tasks = Vec::new(); + for alert in &alerts.items { + let mut task = dependabot_alert_task(repo, alert, &now); + let legacy_ids = known_tasks + .iter() + .filter(|(id, (status, description))| { + id.starts_with("dependabot-alert-") + && status == "pending" + && description_matches_remediation( + description, + repo, + alert.dependency.manifest_path.as_deref(), + &alert.dependency.package.name, + ) + }) + .map(|(id, _)| id.clone()) + .collect::>(); + for legacy_id in legacy_ids { + let retirement = + legacy_alert_retirement(&legacy_id, &task.id, &now); + known_tasks.insert( + legacy_id, + ("done".into(), retirement.description.clone()), + ); + tasks.push(retirement); + } + let covering_pulls = open_pull_coverage + .iter() + .filter(|pull| open_pull_covers_dependabot_alert(pull, alert)) + .collect::>(); + if dedupe_seen.insert(task.id.clone()) + && let Some(dedupe) = + dedupe_followup_task(repo, &task.id, &covering_pulls, &now) + { + tasks.push(dedupe); + } + if let Some(pull) = covering_pulls + .iter() + .min_by_key(|pull| pull.number) + .copied() + { + if known_tasks + .get(&task.id) + .is_some_and(|(status, _)| status == "pending") + { + task.status = "done".into(); + task.done_at = Some(now.clone()); + task.description.push_str(&format!( + "\n\nCovered by existing open PR #{}: {}", + pull.number, pull.html_url + )); + known_tasks.insert( + task.id.clone(), + ("done".into(), task.description.clone()), + ); + tasks.push(task); + } + } else { + signal_tasks.push(task); + } + } + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), alerts.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + EngineeringSignal::CodeScanningAlert => { + match list_code_scanning_alerts(&client, &token, repo).await { + Ok(alerts) => { + completed_attempts += 1; + let signal_tasks = alerts + .items + .iter() + .map(|alert| code_scanning_task(repo, alert, &now)) + .collect::>(); + let discovered = signal_tasks.len(); + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), alerts.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + EngineeringSignal::SecretScanningAlert => { + match list_secret_scanning_alerts(&client, &token, repo).await { + Ok(alerts) => { + completed_attempts += 1; + let signal_tasks = alerts + .items + .iter() + .map(|alert| secret_scanning_task(repo, alert, &now)) + .collect::>(); + let discovered = signal_tasks.len(); + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), alerts.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + }; + let mut truncation_reasons = Vec::new(); + if api_truncated { + let item_limit = if signal == EngineeringSignal::DependabotPr { + MAX_OPEN_PRS_PER_REPO + } else { + MAX_ALERTS_PER_SIGNAL + }; + truncation_reasons.push(format!( + "GitHub returned more than the per-signal {item_limit}-item or {MAX_GITHUB_PAGES}-page scan cap." + )); + } + if queue_truncated { + truncation_reasons.push(format!( + "The sync found more new work than this source's fair {attempt_cap}-item allocation; remaining items will be retried on later polls." + )); + } + let truncation_detail = + (!truncation_reasons.is_empty()).then(|| truncation_reasons.join(" ")); + let (result, expected_unavailable) = match result { + Err(error) => match unavailable_security_product(features.as_ref(), signal, &error) + { + Some(unavailable) => (Err(unavailable), true), + None => (Err(error), false), + }, + Ok(discovered) => (Ok(discovered), false), + }; + if expected_unavailable { + completed_attempts += 1; + } + let signal_status = signal_result(repo, signal, result, truncation_detail); + if signal_status.state != EngineeringSignalSyncState::Ok && !expected_unavailable { + errors.push(format!( + "{} {:?}: {}", + repo, signal_status.signal, signal_status.detail + )); + } + signal_results.push(signal_status); + } + } + + let (review_items, review_followups, review_errors) = + collect_review_items(cluster, &client, &token, config, &now).await; + tasks.extend(review_followups); + errors.extend(review_errors); + let discovered = tasks.len(); + revalidate_claimed_source(cluster, config, claim_id).await?; + let queued = merge_into_backlog(cluster, &config.team_name, tasks).await?; + if let Err(error) = ensure_auto_run_for_backlog(cluster, config).await { + errors.push(error); + } + Ok(SyncOutcome { + cursor, + discovered, + queued, + completed_attempts, + errors, + review_items, + signal_results, + }) +} + +async fn patch_runtime_state( + cluster: &Cluster, + name: &str, + cursor: &EngineeringCursor, + status: &EngineeringSourceStatus, +) -> AppResult<()> { + let data = BTreeMap::from([ + ( + CURSOR_KEY.to_string(), + serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?, + ), + ( + STATUS_KEY.to_string(), + serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?, + ), + ]); + cluster + .patch_engineering_source_data(name, &data) + .await + .map_err(|e| AppError::Upstream(e.to_string())) +} + +async fn finalize_source_claim( + cluster: &Cluster, + name: &str, + claimed_status: &str, + cursor: &EngineeringCursor, + status: &EngineeringSourceStatus, +) -> AppResult<()> { + let cursor = serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?; + let status = serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?; + let completed = cluster + .complete_engineering_source_claim(name, claimed_status, &cursor, &status) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if !completed { + return Err(AppError::Conflict( + "engineering sync lost its claim before completion".into(), + )); + } + Ok(()) +} + +async fn revalidate_claimed_source( + cluster: &Cluster, + config: &EngineeringSourceConfig, + claim_id: &str, +) -> Result<(), String> { + let name = source_config_map_name(&config.team_namespace, &config.team_name); + let source = cluster + .read_engineering_source(&name) + .await + .map_err(|error| format!("re-reading engineering source failed: {error}"))? + .ok_or_else(|| "engineering source was deleted during sync".to_string())?; + let (current_config, _, current_status) = parse_source(&source)?; + if ¤t_config != config + || !sync_claim_active(¤t_status, Utc::now()) + || current_status.sync_claim_id.as_deref() != Some(claim_id) + { + return Err("engineering source changed or lost its sync claim before queueing".into()); + } + Ok(()) +} + +async fn synchronize_source( + cluster: &Cluster, + config: &EngineeringSourceConfig, + _cursor: EngineeringCursor, + _status: EngineeringSourceStatus, +) -> AppResult { + let name = source_config_map_name(&config.team_namespace, &config.team_name); + let current = cluster + .read_engineering_source(&name) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .ok_or_else(|| AppError::Conflict("engineering source no longer exists".into()))?; + let current_data = current + .data + .as_ref() + .ok_or_else(|| AppError::Conflict("engineering source has no data".into()))?; + let expected_config = current_data + .get(CONFIG_KEY) + .cloned() + .ok_or_else(|| AppError::Conflict("engineering source config is missing".into()))?; + let expected_status = current_data + .get(STATUS_KEY) + .cloned() + .unwrap_or_else(|| "{}".into()); + let current_cursor = current_data + .get(CURSOR_KEY) + .map(|value| serde_json::from_str::(value)) + .transpose() + .map_err(|error| { + AppError::Conflict(format!("engineering source cursor is invalid: {error}")) + })? + .unwrap_or_default(); + let mut status = + serde_json::from_str::(&expected_status).map_err(|error| { + AppError::Conflict(format!("engineering source status is invalid: {error}")) + })?; + let stored_config = + serde_json::from_str::(&expected_config).map_err(|error| { + AppError::Conflict(format!("engineering source config is invalid: {error}")) + })?; + if &stored_config != config { + return Err(AppError::Conflict( + "engineering source was reconfigured before sync".into(), + )); + } + if sync_claim_active(&status, Utc::now()) { + return Err(AppError::Conflict( + "another engineering sync still owns the active claim".into(), + )); + } + let claim_id = format!( + "{}-{}", + Utc::now().timestamp_nanos_opt().unwrap_or_default(), + std::process::id() + ); + status.state = EngineeringSyncState::Syncing; + status.sync_claim_id = Some(claim_id.clone()); + status.sync_claim_expires_at = Some((Utc::now() + chrono::Duration::minutes(10)).to_rfc3339()); + status.last_error = None; + status.next_poll_at = Some(next_poll_at(config, Utc::now())); + let claimed_status = + serde_json::to_string(&status).map_err(|e| AppError::Internal(e.into()))?; + let claimed = cluster + .claim_engineering_source(&name, &expected_config, &expected_status, &claimed_status) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if !claimed { + return Err(AppError::Conflict( + "this source was reconfigured or another sync already claimed it".into(), + )); + } + + let completed_at = Utc::now(); + match perform_sync(cluster, config, current_cursor.clone(), &claim_id).await { + Ok(outcome) => { + status.last_sync_at = Some(completed_at.to_rfc3339()); + status.items_discovered = outcome.discovered; + status.items_queued = outcome.queued; + status.total_items_queued = status + .total_items_queued + .saturating_add(outcome.queued as u64); + status.next_poll_at = Some(next_poll_at(config, completed_at)); + status.review_items = outcome.review_items; + status.signal_results = outcome.signal_results; + status.ready_for_review = status + .review_items + .iter() + .filter(|item| item.state == EngineeringReviewState::ReadyForReview) + .count(); + status.waiting_for_ci = status + .review_items + .iter() + .filter(|item| item.state == EngineeringReviewState::WaitingForCi) + .count(); + status.ci_failed = status + .review_items + .iter() + .filter(|item| { + matches!( + item.state, + EngineeringReviewState::CiFailed | EngineeringReviewState::Blocked + ) + }) + .count(); + status.last_error = + (!outcome.errors.is_empty()).then(|| truncate_error(outcome.errors.join("; "))); + status.state = if outcome.errors.is_empty() { + status.last_success_at = Some(completed_at.to_rfc3339()); + EngineeringSyncState::Ok + } else if outcome.completed_attempts > 0 { + EngineeringSyncState::Partial + } else { + EngineeringSyncState::Error + }; + status.sync_claim_id = None; + status.sync_claim_expires_at = None; + finalize_source_claim(cluster, &name, &claimed_status, &outcome.cursor, &status) + .await?; + } + Err(error) => { + status.state = EngineeringSyncState::Error; + status.last_sync_at = Some(completed_at.to_rfc3339()); + status.last_error = Some(truncate_error(error)); + status.items_discovered = 0; + status.items_queued = 0; + status.signal_results = Vec::new(); + status.next_poll_at = Some(next_poll_at(config, completed_at)); + status.sync_claim_id = None; + status.sync_claim_expires_at = None; + finalize_source_claim(cluster, &name, &claimed_status, ¤t_cursor, &status) + .await?; + } + } + Ok(status) +} + +/// `GET /api/namespaces/:ns/teams/:name/engineering-source`. +pub async fn get_source( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let source_name = source_config_map_name(&ns, &name); + let Some(cm) = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + else { + return Ok(Json(to_dto( + false, + None, + EngineeringSourceStatus::default(), + ))); + }; + let (config, _cursor, status) = + parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns || config.team_name != name { + return Err(AppError::NotFound); + } + if !verify_source_owner(&cm, &config, &principal.sub) { + return Ok(Json(to_dto( + false, + None, + EngineeringSourceStatus::default(), + ))); + } + Ok(Json(to_dto(true, Some(&config), status))) +} + +/// `PUT /api/namespaces/:ns/teams/:name/engineering-source`. +pub async fn put_source( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(request): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let connection_ref = connection_config_map_name(&principal.sub); + let (_installation_id, _account, granted_repos) = cluster + .read_github_connection_result(&ns, &connection_ref) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or_else(|| { + AppError::Rejected( + "connect GitHub for your user before configuring engineering intake".into(), + ) + })?; + let (repos, signals) = validate_request(&request, &granted_repos)?; + + let source_name = source_config_map_name(&ns, &name); + let existing = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let (cursor, mut status, previous_config) = if let Some(cm) = existing.as_ref() { + let (config, cursor, status) = + parse_source(cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns || config.team_name != name { + return Err(AppError::NotFound); + } + if sync_claim_active(&status, Utc::now()) { + return Err(AppError::Conflict( + "engineering intake is syncing; retry the configuration change shortly".into(), + )); + } + if verify_source_owner(cm, &config, &principal.sub) { + (cursor, status, Some(config)) + } else { + ( + EngineeringCursor::default(), + EngineeringSourceStatus::default(), + None, + ) + } + } else { + ( + EngineeringCursor::default(), + EngineeringSourceStatus::default(), + None, + ) + }; + + let config = EngineeringSourceConfig { + version: 1, + team_namespace: ns, + team_name: name, + owner_sub: principal.sub, + connection_config_map_ref: connection_ref, + enabled: request.enabled, + auto_run: request.auto_run, + repos, + signals, + poll_interval_seconds: request.poll_interval_seconds, + }; + if config.enabled { + let changed = previous_config.as_ref().is_none_or(|previous| { + !previous.enabled + || previous.repos != config.repos + || previous.signals != config.signals + || previous.auto_run != config.auto_run + || previous.poll_interval_seconds != config.poll_interval_seconds + }); + if changed || status.next_poll_at.is_none() { + status.state = EngineeringSyncState::Idle; + status.next_poll_at = Some( + (Utc::now() + chrono::Duration::seconds(initial_jitter_seconds(&source_name))) + .to_rfc3339(), + ); + } + } else { + status.state = EngineeringSyncState::Disabled; + status.next_poll_at = None; + } + let data = source_data(&config, &cursor, &status)?; + let annotations = source_annotations(&config); + if let Some(current) = existing { + cluster + .replace_engineering_source(current, &annotations, &data) + .await + .map_err(|error| { + if matches!(error, kube::Error::Api(ref response) if response.code == 409) { + AppError::Conflict( + "engineering intake changed concurrently; reload and retry".into(), + ) + } else { + AppError::Upstream(error.to_string()) + } + })?; + } else { + cluster + .create_engineering_source(&source_name, &annotations, &data) + .await + .map_err(|error| { + if matches!(error, kube::Error::Api(ref response) if response.code == 409) { + AppError::Conflict( + "engineering intake was configured concurrently; reload and retry".into(), + ) + } else { + AppError::Upstream(error.to_string()) + } + })?; + } + Ok(Json(to_dto(true, Some(&config), status))) +} + +/// `POST /api/namespaces/:ns/teams/:name/engineering-source/sync`. +pub async fn sync_now( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let source_name = source_config_map_name(&ns, &name); + let cm = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or_else(|| AppError::Rejected("configure engineering intake first".into()))?; + let (config, cursor, status) = + parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns + || config.team_name != name + || !verify_source_owner(&cm, &config, &principal.sub) + { + return Err(AppError::NotFound); + } + if !config.enabled { + return Err(AppError::Rejected( + "enable engineering intake before syncing".into(), + )); + } + let status = synchronize_source(cluster, &config, cursor, status).await?; + Ok(Json(to_dto(true, Some(&config), status))) +} + +/// `DELETE /api/namespaces/:ns/teams/:name/engineering-source`. +pub async fn delete_source( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let source_name = source_config_map_name(&ns, &name); + if let Some(cm) = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + { + let (config, _, status) = + parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns || config.team_name != name { + return Err(AppError::NotFound); + } + if sync_claim_active(&status, Utc::now()) { + return Err(AppError::Conflict( + "engineering intake is syncing; retry disconnect shortly".into(), + )); + } + let resource_version = cm + .metadata + .resource_version + .clone() + .ok_or_else(|| AppError::Conflict("engineering source has no version".into()))?; + + cluster + .delete_engineering_source_if_version(&source_name, resource_version) + .await + .map_err(|error| { + if matches!(error, kube::Error::Api(ref response) if response.code == 409) { + AppError::Conflict( + "engineering intake changed concurrently; reload and retry".into(), + ) + } else { + AppError::Upstream(error.to_string()) + } + })?; + } + Ok(Json(to_dto( + false, + None, + EngineeringSourceStatus::default(), + ))) +} + +/// Turn a human PR decision into durable standing-team work. This preserves the +/// same source → backlog → run chain instead of trying to mutate a retired run. +pub async fn decide_review_item( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(request): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let decision = request.decision.trim(); + if decision != "request_changes" { + return Err(AppError::BadRequest( + "only request_changes is supported; merge remains a human GitHub action until a typed single-use merge grant exists".into(), + )); + } + let comment = request + .comment + .as_deref() + .map(str::trim) + .unwrap_or("") + .chars() + .take(1500) + .collect::(); + if decision == "request_changes" && comment.is_empty() { + return Err(AppError::BadRequest( + "request_changes requires concrete feedback".into(), + )); + } + + let source_name = source_config_map_name(&ns, &name); + let source = cluster + .read_engineering_source(&source_name) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .ok_or_else(|| AppError::Rejected("configure engineering intake first".into()))?; + let (config, _, status) = + parse_source(&source).map_err(|error| AppError::Upstream(error.to_string()))?; + if !verify_source_owner(&source, &config, &principal.sub) + || !config + .repos + .iter() + .any(|repo| repo.eq_ignore_ascii_case(&request.repo)) + { + return Err(AppError::NotFound); + } + let _item = status + .review_items + .iter() + .find(|item| { + item.repo.eq_ignore_ascii_case(&request.repo) + && item.pr_number == request.pr_number + && item.head_sha == request.head_sha + && item.run == request.run + }) + .ok_or_else(|| { + AppError::Conflict( + "the PR changed since this card was rendered; sync before deciding".into(), + ) + })?; + let identity = format!( + "engineering-review:{decision}:{}:{}:{}:{comment}", + request.repo.to_ascii_lowercase(), + request.pr_number, + request.head_sha + ); + let digest = Sha256::digest(identity.as_bytes()); + let task = TeamTaskDto { + id: format!("github-pr-feedback-{}", hex::encode(&digest[..10])), + title: format!( + "[Review feedback] Revise {} PR #{}", + request.repo, request.pr_number + ), + description: format!( + "PR: {}\nREVIEWED SHA: {}\nSOURCE RUN: {}\n\nREQUESTED CHANGES:\n{}\n\nA human reviewed the team's PR and requested changes. Re-open the exact prior evidence, apply only the requested delta, run relevant tests, push a new commit, and wait for GitHub checks. Never merge and never reuse stale green evidence.", + request.pr_url, request.head_sha, request.run, comment + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(Utc::now().to_rfc3339()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }; + let task_id = task.id.clone(); + let queued = merge_into_backlog(cluster, &name, vec![task]) + .await + .map_err(AppError::Upstream)?; + let pending = read_task_list(&cluster.read_team_tasks(&name).await) + .iter() + .any(|task| task.id == task_id && task.status == "pending"); + let run_requested = if pending { + request_team_run(cluster, &ns, &name) + .await + .map_err(AppError::Upstream)? + } else { + false + }; + Ok(Json(serde_json::json!({ + "queued": queued > 0, + "run_requested": run_requested, + "decision": decision, + "team": name, + }))) +} + +/// Start the bounded best-effort source poller. Durable `next_poll_at` values +/// and a stable initial jitter spread GitHub traffic across teams. +pub fn spawn_poller(state: AppState, sweep_interval: Duration) { + if state.cluster().is_none() { + return; + } + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + let mut interval = tokio::time::interval(sweep_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Some(cluster) = state.cluster() else { + continue; + }; + let sources = match cluster + .list_engineering_sources(MAX_SOURCES_PER_SWEEP) + .await + { + Ok(sources) => sources, + Err(error) => { + tracing::error!(error = %error, "engineering intake source listing failed"); + continue; + } + }; + for source in sources { + let source_name = source.name_any(); + let (config, cursor, status) = match parse_source(&source) { + Ok(parsed) => parsed, + Err(error) => { + tracing::error!(source = %source_name, error = %error, "invalid engineering intake source"); + let failed = EngineeringSourceStatus { + state: EngineeringSyncState::Error, + last_sync_at: Some(Utc::now().to_rfc3339()), + last_error: Some(truncate_error(error)), + ..EngineeringSourceStatus::default() + }; + if let Err(patch_error) = patch_runtime_state( + cluster, + &source_name, + &EngineeringCursor::default(), + &failed, + ) + .await + { + tracing::error!(source = %source_name, error = %patch_error, "failed to record engineering intake source error"); + } + continue; + } + }; + if !verify_source_owner(&source, &config, &config.owner_sub) { + let error = "engineering source owner annotations do not match its config"; + tracing::error!(source = %source_name, error, "invalid engineering intake source"); + let failed = EngineeringSourceStatus { + state: EngineeringSyncState::Error, + last_sync_at: Some(Utc::now().to_rfc3339()), + last_error: Some(error.into()), + ..status + }; + if let Err(patch_error) = + patch_runtime_state(cluster, &source_name, &cursor, &failed).await + { + tracing::error!(source = %source_name, error = %patch_error, "failed to record engineering intake ownership error"); + } + continue; + } + if let Err(error) = ensure_auto_run_for_backlog(cluster, &config).await { + tracing::warn!(source = %source_name, team = %config.team_name, %error, "engineering intake could not rearm queued work"); + } + if !config.enabled || !is_due(&status, Utc::now()) { + continue; + } + match synchronize_source(cluster, &config, cursor, status).await { + Ok(updated) => { + if let Some(error) = updated.last_error.as_deref() { + tracing::warn!(source = %source_name, team = %config.team_name, error, "engineering intake sync completed with errors"); + } else { + tracing::info!(source = %source_name, team = %config.team_name, discovered = updated.items_discovered, queued = updated.items_queued, "engineering intake sync complete"); + } + } + Err(error) => { + tracing::error!(source = %source_name, team = %config.team_name, error = %error, "engineering intake sync failed") + } + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pull(login: &str, head: &str, number: u64) -> GithubPull { + GithubPull { + number, + html_url: format!("https://github.com/acme/api/pull/{number}"), + title: "Bump serde from 1.0.1 to 1.0.2".into(), + draft: false, + updated_at: "2026-07-20T12:00:00Z".into(), + user: Some(GithubUser { + login: login.into(), + }), + base: GithubRef { + name: "main".into(), + }, + head: GithubHead { + name: head.into(), + sha: "abc123".into(), + }, + labels: vec![GithubLabel { + name: "dependencies".into(), + }], + } + } + + fn task(id: &str, status: &str) -> TeamTaskDto { + TeamTaskDto { + id: id.into(), + title: id.into(), + description: String::new(), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: false, + status: status.into(), + run: (status == "active").then(|| "run-1".into()), + created_at: Some("2026-07-20T00:00:00Z".into()), + done_at: (status == "done").then(|| "2026-07-20T01:00:00Z".into()), + stuck_since: (status == "active").then(|| "2026-07-20T00:30:00Z".into()), + assignment_nonce: None, + } + } + + #[test] + fn deterministic_ids_are_repo_and_pr_scoped() { + assert_eq!(work_id("Acme/API", 42), work_id("acme/api", 42)); + assert_ne!(work_id("acme/api", 42), work_id("acme/api", 43)); + assert_ne!(work_id("acme/api", 42), work_id("acme/web", 42)); + assert_eq!(source_id("Acme/API", 42), "github:acme/api:pull:42"); + assert_eq!( + source_config_map_name("kars-system", "platform"), + source_config_map_name("kars-system", "platform") + ); + assert_ne!( + source_config_map_name("tenant-a", "platform"), + source_config_map_name("tenant-b", "platform") + ); + assert_eq!( + remediation_work_id("Acme/API", Some("package-lock.json"), "@babel/core"), + remediation_work_id("acme/api", Some("package-lock.json"), "@babel/core") + ); + assert_ne!( + remediation_work_id("acme/api", Some("package-lock.json"), "@babel/core"), + remediation_work_id("acme/api", Some("other/package-lock.json"), "@babel/core") + ); + } + + #[test] + fn alert_tasks_lead_with_authoritative_source_facts() { + let task = alert_backlog_task( + EngineeringSignal::DependabotAlert, + GithubAlertRef { + repo: "pallakatos/kars", + number: 9, + }, + "vite alert".into(), + "Dependabot reported a finding.", + serde_json::json!({ + "manifest_path": "tests/compat/package-lock.json", + "package": "vite", + "vulnerable_version_range": ">= 8.0.0, <= 8.0.15", + "first_patched_version": "8.0.16", + "ghsa_id": "GHSA-v6wh-96g9-6wx3", + }), + None, + "2026-07-23T00:00:00Z", + ); + let prefix = task.description.lines().next().unwrap_or_default(); + assert!(prefix.contains("manifest=tests/compat/package-lock.json")); + assert!(prefix.contains("fixed=8.0.16")); + assert!(prefix.contains("ghsa=GHSA-v6wh-96g9-6wx3")); + assert!(prefix.contains("max2 same target")); + assert!(prefix.contains("search open+merged PRs")); + assert!(prefix.contains("no principal substitution")); + } + + #[test] + fn legacy_remediation_matching_is_exact_and_repo_scoped() { + let description = concat!( + "AUTH SOURCE: manifest=package-lock.json; pkg=react-dom; ghsa=GHSA-a. ", + "Structured: {\"repo\":\"acme/web\",\"details\":{\"manifest_path\":\"package-lock.json\",", + "\"package\":\"react-dom\"}}" + ); + assert!(description_matches_remediation( + description, + "acme/web", + Some("package-lock.json"), + "react-dom", + )); + assert!(!description_matches_remediation( + description, + "acme/api", + Some("package-lock.json"), + "react-dom", + )); + assert!(!description_matches_remediation( + description, + "acme/web", + Some("package-lock.json"), + "react", + )); + assert!(!description_matches_remediation( + description, + "acme/web", + None, + "react-dom", + )); + } + + #[test] + fn dependabot_detection_accepts_bot_login_or_head_prefix() { + assert!(is_dependabot_pr(&pull( + "dependabot[bot]", + "renovate/foo", + 1 + ))); + assert!(is_dependabot_pr(&pull( + "someone", + "dependabot/npm/foo-1.2.3", + 2 + ))); + assert!(!is_dependabot_pr(&pull("renovate[bot]", "renovate/foo", 3))); + } + + #[test] + fn open_pull_covers_same_package_or_advisory() { + let alert = GithubDependabotAlert { + number: 17, + html_url: "https://github.com/acme/api/security/dependabot/17".into(), + dependency: GithubDependabotDependency { + package: GithubPackage { + ecosystem: "npm".into(), + name: "@babel/core".into(), + }, + manifest_path: Some("package-lock.json".into()), + scope: Some("development".into()), + }, + security_advisory: Some(GithubSecurityAdvisory { + ghsa_id: "GHSA-aaaa-bbbb-cccc".into(), + cve_id: None, + summary: "test".into(), + severity: "high".into(), + }), + security_vulnerability: GithubSecurityVulnerability { + vulnerable_version_range: "< 8".into(), + first_patched_version: Some(GithubPatchedVersion { + identifier: "8.0.0".into(), + }), + }, + updated_at: None, + }; + let mut package_pr = pull("agent", "fix-babel-core", 42); + package_pr.title = "chore: bump @babel/core to 8.0.0".into(); + assert!(open_pull_covers_dependabot_alert(&package_pr, &alert)); + let mut advisory_pr = pull("agent", "security-fix", 43); + advisory_pr.title = "fix GHSA-aaaa-bbbb-cccc".into(); + assert!(open_pull_covers_dependabot_alert(&advisory_pr, &alert)); + let unrelated = pull("agent", "fix-vite", 44); + assert!(!open_pull_covers_dependabot_alert(&unrelated, &alert)); + } + + #[test] + fn parses_github_pull_and_builds_structured_task() { + let raw = serde_json::json!({ + "number": 7, + "html_url": "https://github.com/acme/api/pull/7", + "title": "Bump axum", + "draft": true, + "updated_at": "2026-07-20T12:00:00Z", + "user": {"login": "dependabot[bot]"}, + "base": {"ref": "main"}, + "head": {"ref": "dependabot/cargo/axum-1", "sha": "deadbeef"}, + "labels": [{"name": "dependencies"}, {"name": "rust"}] + }); + let parsed: GithubPull = serde_json::from_value(raw).unwrap(); + let task = backlog_task("acme/api", &parsed, "2026-07-20T13:00:00Z"); + assert_eq!(task.status, "pending"); + assert!(task.description.contains("\"head_sha\":\"deadbeef\"")); + assert!(task.description.contains("\"draft\":true")); + assert!(task.description.contains("Never claim CI is green")); + } + + #[test] + fn security_alert_ids_are_stable_and_signal_scoped() { + assert_eq!( + alert_work_id(EngineeringSignal::CodeScanningAlert, "Acme/API", 42), + alert_work_id(EngineeringSignal::CodeScanningAlert, "acme/api", 42) + ); + assert_ne!( + alert_work_id(EngineeringSignal::CodeScanningAlert, "acme/api", 42), + alert_work_id(EngineeringSignal::DependabotAlert, "acme/api", 42) + ); + assert_eq!( + alert_source_id(EngineeringSignal::SecretScanningAlert, "Acme/API", 7), + "github:acme/api:secret-scanning-alert:7" + ); + } + + #[test] + fn code_scanning_alert_builds_actionable_task() { + let alert: GithubCodeScanningAlert = serde_json::from_value(serde_json::json!({ + "number": 12, + "html_url": "https://github.com/acme/api/security/code-scanning/12", + "rule": { + "id": "rust/path-injection", + "name": "Path injection", + "description": "User-controlled path reaches filesystem access", + "severity": "error", + "security_severity_level": "high" + }, + "most_recent_instance": { + "location": {"path": "src/files.rs", "start_line": 44, "end_line": 47} + }, + "updated_at": "2026-07-21T00:00:00Z" + })) + .unwrap(); + let task = code_scanning_task("acme/api", &alert, "2026-07-21T01:00:00Z"); + assert!(task.title.contains("Path injection")); + assert!(task.description.contains("\"severity\":\"high\"")); + assert!(task.description.contains("\"path\":\"src/files.rs\"")); + assert!(task.description.contains("Never claim success")); + } + + #[test] + fn secret_scanning_task_never_persists_secret_value() { + let secret = "ghp_live_secret_value"; + let alert: GithubSecretScanningAlert = serde_json::from_value(serde_json::json!({ + "number": 9, + "html_url": "https://github.com/acme/api/security/secret-scanning/9", + "secret_type": "github_personal_access_token", + "secret_type_display_name": "GitHub Personal Access Token", + "secret": secret, + "resolution": null, + "created_at": "2026-07-21T00:00:00Z", + "updated_at": "2026-07-21T00:00:00Z" + })) + .unwrap(); + let task = secret_scanning_task("acme/api", &alert, "2026-07-21T01:00:00Z"); + assert!(task.description.contains("do not print, persist, or copy")); + assert!(!task.description.contains(secret)); + } + + #[test] + fn private_repo_without_security_products_is_unavailable_not_error() { + let features = GithubRepositoryFeatures { + private: true, + security_and_analysis: None, + }; + let code_error = GithubListError { + state: EngineeringSignalSyncState::Forbidden, + detail: "HTTP 403".into(), + }; + let secret_error = GithubListError { + state: EngineeringSignalSyncState::Unavailable, + detail: "HTTP 404".into(), + }; + for (signal, error) in [ + (EngineeringSignal::CodeScanningAlert, code_error), + (EngineeringSignal::SecretScanningAlert, secret_error), + ] { + let mapped = unavailable_security_product(Some(&features), signal, &error).unwrap(); + assert_eq!(mapped.state, EngineeringSignalSyncState::Unavailable); + assert!(mapped.detail.contains("not enabled or licensed")); + } + } + + #[test] + fn github_link_parser_finds_next_page() { + assert_eq!( + next_link( + r#"; rel="next", ; rel="last""# + ) + .as_deref(), + Some("https://api.github.com/repositories/1/alerts?page=2") + ); + assert_eq!(next_link(""), None); + } + + #[test] + fn dedupe_preserves_existing_active_and_done_tasks() { + let mut active = task("dependabot-pr-active", "active"); + active.assignment_nonce = Some("run-1-assign-7".into()); + let done = task("dependabot-pr-done", "done"); + let (merged, added) = merge_discovered_tasks( + vec![active.clone(), done.clone()], + vec![ + task("dependabot-pr-active", "pending"), + task("dependabot-pr-done", "pending"), + task("dependabot-pr-new", "pending"), + ], + ); + assert_eq!(added, 1); + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].status, "active"); + assert_eq!(merged[0].run, active.run); + assert_eq!(merged[0].assignment_nonce, active.assignment_nonce); + assert!(merged[0].review_required); + assert_eq!(merged[1].status, "done"); + assert_eq!(merged[1].done_at, done.done_at); + assert!(merged[1].review_required); + } + + #[test] + fn changed_open_security_alert_requeues_completed_work() { + let mut completed = task("code-scanning-alert-abc", "done"); + completed.description = "updated_at=old".into(); + let mut rediscovered = task("code-scanning-alert-abc", "pending"); + rediscovered.description = "updated_at=new".into(); + let (merged, queued) = merge_discovered_tasks(vec![completed], vec![rediscovered.clone()]); + assert_eq!(queued, 1); + assert_eq!(merged[0].status, "pending"); + assert_eq!(merged[0].description, rediscovered.description); + assert!(merged[0].run.is_none()); + assert!(merged[0].done_at.is_none()); + + let (unchanged, queued) = merge_discovered_tasks(merged, vec![rediscovered]); + assert_eq!(queued, 0); + assert_eq!(unchanged[0].status, "pending"); + + let mut refreshed = task("code-scanning-alert-abc", "pending"); + refreshed.description = "updated_at=newer".into(); + let (refreshed_tasks, queued) = merge_discovered_tasks(unchanged, vec![refreshed.clone()]); + assert_eq!(queued, 0); + assert_eq!(refreshed_tasks[0].description, refreshed.description); + } + + #[test] + fn changed_pending_alert_flows_through_without_using_queue_capacity() { + let mut existing = task("secret-scanning-alert-abc", "pending"); + existing.description = "updated_at=old".into(); + let mut refreshed = task("secret-scanning-alert-abc", "pending"); + refreshed.description = "updated_at=new".into(); + let mut candidates = Vec::new(); + let mut known = BTreeMap::from([( + existing.id.clone(), + (existing.status.clone(), existing.description.clone()), + )]); + let mut queued_slots = 0; + assert!(!append_bounded_tasks( + &mut candidates, + &mut known, + vec![refreshed.clone()], + &mut queued_slots, + 1, + )); + assert_eq!(queued_slots, 0); + assert_eq!(candidates.len(), 1); + let (merged, queued) = merge_discovered_tasks(vec![existing], candidates); + assert_eq!(queued, 0); + assert_eq!(merged[0].description, refreshed.description); + } + + #[test] + fn changed_active_alert_refreshes_source_facts_without_restarting_run() { + let mut existing = task("dependabot-alert-abc", "active"); + existing.description = "old source facts".into(); + existing.run = Some("run-in-progress".into()); + let mut refreshed = task("dependabot-alert-abc", "pending"); + refreshed.description = + "AUTHORITATIVE SOURCE FACTS: manifest_path=tests/compat/package-lock.json".into(); + let (merged, queued) = merge_discovered_tasks(vec![existing], vec![refreshed.clone()]); + assert_eq!(queued, 0); + assert_eq!(merged[0].status, "active"); + assert_eq!(merged[0].run.as_deref(), Some("run-in-progress")); + assert_eq!(merged[0].description, refreshed.description); + } + + #[test] + fn covered_pending_alert_is_retired_without_touching_active_run() { + let mut pending = task("dependabot-alert-pending", "pending"); + let mut retirement = task("dependabot-alert-pending", "done"); + retirement.description = "Covered by existing open PR #42".into(); + retirement.done_at = Some("2026-07-23T00:00:00Z".into()); + let (merged, queued) = merge_discovered_tasks(vec![pending.clone()], vec![retirement]); + assert_eq!(queued, 0); + assert_eq!(merged[0].status, "done"); + assert!(merged[0].run.is_none()); + + pending.status = "active".into(); + pending.run = Some("run-in-progress".into()); + let mut covered = task("dependabot-alert-pending", "done"); + covered.description = "Covered by existing open PR #42".into(); + let (active, queued) = merge_discovered_tasks(vec![pending], vec![covered]); + assert_eq!(queued, 0); + assert_eq!(active[0].status, "active"); + assert_eq!(active[0].run.as_deref(), Some("run-in-progress")); + } + + #[test] + fn repeated_human_review_decision_requeues_completed_task() { + let completed = task("github-pr-merge-abc", "done"); + let decision = task("github-pr-merge-abc", "pending"); + let (merged, queued) = merge_discovered_tasks(vec![completed], vec![decision]); + assert_eq!(queued, 1); + assert_eq!(merged[0].status, "pending"); + assert!(merged[0].run.is_none()); + assert!(merged[0].done_at.is_none()); + } + + #[test] + fn repo_authorization_and_limits_are_enforced() { + let granted = (0..=MAX_REPOS) + .map(|i| format!("acme/repo-{i}")) + .collect::>(); + let too_many = PutEngineeringSourceRequest { + enabled: true, + auto_run: true, + repos: granted.clone(), + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, + }; + assert!(validate_request(&too_many, &granted).is_err()); + + let unauthorized = PutEngineeringSourceRequest { + enabled: true, + auto_run: true, + repos: vec!["other/private".into()], + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, + }; + assert!(validate_request(&unauthorized, &granted).is_err()); + + let invalid_interval = PutEngineeringSourceRequest { + enabled: true, + auto_run: true, + repos: vec!["acme/repo-0".into()], + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: MIN_POLL_INTERVAL_SECONDS - 1, + }; + assert!(validate_request(&invalid_interval, &granted).is_err()); + } + + #[test] + fn config_cursor_and_status_serialize_round_trip() { + let config = EngineeringSourceConfig { + version: 1, + team_namespace: "kars-system".into(), + team_name: "platform".into(), + owner_sub: "subject-1".into(), + connection_config_map_ref: "kars-github-connection-deadbeef".into(), + enabled: true, + auto_run: true, + repos: vec!["acme/api".into()], + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: 900, + }; + let cursor = EngineeringCursor { + repository_updated_at: BTreeMap::from([( + "acme/api".into(), + "2026-07-20T12:00:00Z".into(), + )]), + }; + let status = EngineeringSourceStatus { + state: EngineeringSyncState::Ok, + last_sync_at: Some("2026-07-20T12:00:00Z".into()), + last_success_at: Some("2026-07-20T12:00:00Z".into()), + last_error: None, + items_discovered: 2, + items_queued: 1, + total_items_queued: 4, + next_poll_at: Some("2026-07-20T12:15:00Z".into()), + ..Default::default() + }; + let data = source_data(&config, &cursor, &status).unwrap(); + let cm = ConfigMap { + data: Some(data), + ..Default::default() + }; + let round_trip = parse_source(&cm).unwrap(); + assert_eq!(round_trip, (config, cursor, status)); + } + + fn clean_pull_status() -> serde_json::Value { + serde_json::json!({ + "state": "open", + "merged": false, + "draft": false, + "mergeable": true, + "mergeable_state": "clean" + }) + } + + #[test] + fn review_readiness_only_flags_green_clean_prs() { + let (state, _, total, passed) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "check_runs": [ + {"status":"completed","conclusion":"success"}, + {"status":"completed","conclusion":"neutral"} + ] + }), + &serde_json::json!({"state":"success","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::ReadyForReview); + assert_eq!((total, passed), (2, 2)); + + let (state, _, _, _) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "check_runs": [{"status":"in_progress","conclusion":null}] + }), + &serde_json::json!({"state":"pending","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::WaitingForCi); + + let (state, _, _, _) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "check_runs": [{"status":"completed","conclusion":"failure"}] + }), + &serde_json::json!({"state":"failure","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::CiFailed); + + let (state, _, _, _) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "total_count": 101, + "check_runs": (0..100).map(|_| serde_json::json!({ + "status":"completed","conclusion":"success" + })).collect::>() + }), + &serde_json::json!({"state":"success","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::WaitingForCi); + } + + #[test] + fn red_pr_creates_deterministic_followup_work() { + let item = EngineeringReviewItem { + repo: "acme/api".into(), + pr_number: 42, + pr_url: "https://github.com/acme/api/pull/42".into(), + title: "Fix dependency".into(), + run: "run-1".into(), + source_id: "github:acme/api:pull:42".into(), + work_id: "dependabot-pr-example".into(), + task_status: "done".into(), + run_state: Some("Completed".into()), + selected_roles: vec!["reviewer".into()], + delivered_roles: vec!["reviewer".into()], + artifact_count: Some(1), + head_sha: "abc123".into(), + state: EngineeringReviewState::CiFailed, + detail: "test failed".into(), + checks_total: 2, + checks_passed: 1, + observed_at: "2026-07-20T12:00:00Z".into(), + }; + let first = review_followup_task(&item, "2026-07-20T12:00:00Z").unwrap(); + let second = review_followup_task(&item, "2026-07-20T13:00:00Z").unwrap(); + assert_eq!(first.id, second.id); + let mut changed_head = item.clone(); + changed_head.head_sha = "def456".into(); + let changed = review_followup_task(&changed_head, "2026-07-20T14:00:00Z").unwrap(); + assert_eq!(first.id, changed.id); + assert_ne!(first.description, changed.description); + assert!(first.description.contains("Never claim green")); + assert!(first.description.contains("superseded")); + assert!(first.description.contains("do not repair or rebase it")); + } + + #[test] + fn duplicate_prs_create_one_canonical_retirement_task() { + let first = pull("agent", "fix-js-yaml", 18); + let second = pull("agent", "fix-js-yaml-again", 24); + let task = dedupe_followup_task( + "acme/api", + "dependency-remediation-abc", + &[&second, &first], + "2026-07-20T12:00:00Z", + ) + .unwrap(); + assert!(task.title.contains("PR #18")); + assert!(task.description.contains("#24")); + assert!(task.description.contains("never merge")); + } +} diff --git a/bridge/bff/src/routes/foundry.rs b/bridge/bff/src/routes/foundry.rs new file mode 100644 index 000000000..140ee7ba6 --- /dev/null +++ b/bridge/bff/src/routes/foundry.rs @@ -0,0 +1,674 @@ +// kars Bridge BFF — operator Foundry onboarding. +// +// The admin connects an Azure AI Foundry project so the cluster can use Foundry +// services (memory store, connections, models). Two auth modes, matching how +// kars authenticates everywhere else: +// • api — a project API key (dev / non-AKS). Stored write-only in +// a Secret and wired into the controller via secretKeyRef. +// • managed-identity — the cluster's workload identity (AKS). No secret; the +// router exchanges an IMDS token for the Foundry +// data-plane audience (https://ai.azure.com) at runtime. +// +// Onboarding patches the `kars-controller` Deployment env (FOUNDRY_PROJECT_ENDPOINT +// etc.), which the controller propagates to every sandbox router. `verify` runs a +// real preflight: for api mode a live authenticated call to the project endpoint; +// for managed-identity mode DNS reachability + a workload-identity-wired check. + +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +fn upstream(e: kube::Error) -> AppError { + AppError::Upstream(e.to_string()) +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +#[derive(Debug, Deserialize)] +pub struct FoundryConnectRequest { + /// The Foundry PROJECT endpoint, e.g. + /// `https://.services.ai.azure.com/api/projects/`. + pub project_endpoint: String, + /// Optional Foundry inference endpoint (Models), e.g. + /// `https://.openai.azure.com/`. + #[serde(default)] + pub inference_endpoint: Option, + /// Optional default memory store id for team knowledge commons. + #[serde(default)] + pub memory_store_id: Option, + /// "api" or "managed-identity". + pub auth: String, + /// The project API key — required (and only used) for `auth = "api"`. + #[serde(default)] + pub api_key: Option, +} + +#[derive(Debug, Serialize)] +pub struct FoundryStatus { + pub connected: bool, + pub project_endpoint: Option, + pub inference_endpoint: Option, + pub memory_store_id: Option, + /// "api", "managed-identity", or null when not connected. Derived from + /// whether a key is wired. + pub auth: Option, + /// True when an API key is wired (the key itself is never returned). + pub has_api_key: bool, +} + +/// Extract the host from an https URL, for DNS checks. +fn host_of(url: &str) -> Option { + let s = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + Some(s.split(['/', ':']).next().unwrap_or(s).to_lowercase()) +} + +async fn resolves(host: &str) -> bool { + tokio::time::timeout( + Duration::from_secs(3), + tokio::net::lookup_host(format!("{host}:443")), + ) + .await + .ok() + .and_then(|r| r.ok()) + .map(|mut it| it.next().is_some()) + .unwrap_or(false) +} + +/// `GET /api/operator/foundry` — the current Foundry connection status. +pub async fn get_foundry(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let (project, inference, store, has_key) = cluster.get_foundry_connection().await; + let connected = project.is_some(); + let auth = if !connected { + None + } else if has_key { + Some("api".to_string()) + } else { + Some("managed-identity".to_string()) + }; + Ok(Json(FoundryStatus { + connected, + project_endpoint: project, + inference_endpoint: inference, + memory_store_id: store, + auth, + has_api_key: has_key, + })) +} + +/// `POST /api/operator/foundry` — onboard / update a Foundry connection. +pub async fn connect_foundry( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let project = req.project_endpoint.trim(); + if !project.starts_with("https://") || host_of(project).is_none() { + return Err(AppError::BadRequest( + "project_endpoint must be an https:// Foundry project URL".into(), + )); + } + if req.auth != "api" + && req.auth != "managed-identity" + && req.auth != "auto" + && req.auth != "identity" + { + return Err(AppError::BadRequest( + "auth must be 'auto', 'managed-identity', or 'api'".into(), + )); + } + let use_key = req.auth == "api"; + + let mut key_secret: Option<(String, String)> = None; + if use_key { + let key = req + .api_key + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + .ok_or_else(|| { + AppError::BadRequest("auth=api requires a Foundry project api_key".into()) + })?; + let secret = "kars-foundry-credentials"; + cluster + .upsert_secret( + "kars-system", + secret, + serde_json::json!({ + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": secret, "namespace": "kars-system", "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}}, + "stringData": {"FOUNDRY_API_KEY": key}, + }), + ) + .await + .map_err(upstream)?; + key_secret = Some((secret.to_string(), "FOUNDRY_API_KEY".to_string())); + } + + let key_ref = key_secret.as_ref().map(|(s, k)| (s.as_str(), k.as_str())); + cluster + .set_foundry_connection( + project, + req.inference_endpoint.as_deref(), + req.memory_store_id.as_deref(), + key_ref, + ) + .await + .map_err(upstream)?; + + Ok(Json(serde_json::json!({ + "connected": true, + "auth": req.auth, + "project_endpoint": project, + "note": if use_key { + "Foundry connected with an API key (wired into the controller via secretKeyRef and propagated to sandbox routers). The controller is rolling to pick it up. Run Verify to confirm access." + } else { + "Foundry connected via the cluster's managed/workload identity — no key stored. Access uses an IMDS token for the https://ai.azure.com audience at runtime. The controller is rolling. Run Verify to confirm." + }, + }))) +} + +/// `DELETE /api/operator/foundry` — disconnect Foundry. +pub async fn disconnect_foundry( + State(state): State, +) -> AppResult> { + let cluster = require_cluster(&state)?; + // Blank the endpoint (the controller treats empty as "unset"). The key + // secret is left in place (harmless, write-only) but the env stops pointing + // to it on the next set. + cluster + .set_foundry_connection("", None, None, None) + .await + .map_err(upstream)?; + // Also remove any `foundry`-tagged Model catalogue entries — a + // disconnected project must not leave models an orchestrator or + // InferencePolicy could still pick (mirrors delete_additional_provider's + // cleanup for every other tag). + sync_foundry_catalog_provider(cluster, "", &[], false) + .await + .map_err(upstream)?; + Ok(Json( + serde_json::json!({ "connected": false, "note": "Foundry disconnected; its models were removed from the catalogue." }), + )) +} + +#[derive(Debug, Serialize)] +pub struct FoundryCheck { + pub label: String, + pub status: String, // "pass" | "warn" | "fail" + pub detail: String, +} + +#[derive(Debug, Serialize, Default)] +pub struct FoundryDiscovered { + /// Model deployments available in the project (data-plane `/deployments`). + pub models: Vec, + /// Connected services (Bing grounding, storage, etc.) — data-plane `/connections`. + pub connections: Vec, + /// Whether the configured memory store id was found among the project's + /// agent memory stores. `None` when no store id is configured or not checked. + pub memory_store_found: Option, +} + +#[derive(Debug, Serialize)] +pub struct FoundryConnection { + pub name: String, + pub category: Option, +} + +#[derive(Debug, Serialize)] +pub struct FoundryVerifyResult { + pub checks: Vec, + pub discovered: FoundryDiscovered, +} + +/// The Foundry data-plane api-version (2025 GA). +const FOUNDRY_API_VERSION: &str = "2025-05-01"; +/// The OAuth2 scope for the Foundry project data-plane. +const FOUNDRY_SCOPE: &str = "https://ai.azure.com/.default"; + +/// Acquire an AAD bearer token for the Foundry data-plane using the same +/// no-Azure-SDK REST paths the router uses, mirroring DefaultAzureCredential's +/// order: (1) AKS **workload identity** (federated token file → AAD exchange), +/// (2) **IMDS** managed identity, (3) the developer's **Azure CLI** login (local +/// dev — a kind cluster has no managed identity). Returns `(token, source)` or +/// `None` when no credential is available. +async fn foundry_bearer_token() -> Option<(String, &'static str)> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .ok()?; + + // (1) Workload identity: federated token file + AAD token endpoint. + if let (Ok(client_id), Ok(tenant), Ok(token_file)) = ( + std::env::var("AZURE_CLIENT_ID"), + std::env::var("AZURE_TENANT_ID"), + std::env::var("AZURE_FEDERATED_TOKEN_FILE"), + ) && let Ok(assertion) = std::fs::read_to_string(&token_file) + { + let authority = std::env::var("AZURE_AUTHORITY_HOST") + .unwrap_or_else(|_| "https://login.microsoftonline.com".into()); + let url = format!( + "{}/{}/oauth2/v2.0/token", + authority.trim_end_matches('/'), + tenant + ); + let form = [ + ("client_id", client_id.as_str()), + ("scope", FOUNDRY_SCOPE), + ("grant_type", "client_credentials"), + ( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ), + ("client_assertion", assertion.trim()), + ]; + if let Ok(resp) = client.post(&url).form(&form).send().await + && let Ok(v) = resp.json::().await + && let Some(t) = v.get("access_token").and_then(|t| t.as_str()) + { + return Some((t.to_string(), "workload identity")); + } + } + + // (2) IMDS managed identity. + let imds = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://ai.azure.com/"; + if let Ok(resp) = client.get(imds).header("Metadata", "true").send().await + && let Ok(v) = resp.json::().await + && let Some(t) = v.get("access_token").and_then(|t| t.as_str()) + { + return Some((t.to_string(), "managed identity")); + } + + // (3) Azure CLI (local dev) — the developer's existing `az login`, mirroring + // DefaultAzureCredential's AzureCliCredential fallback. This only READS an + // existing session (`az account get-access-token`); it never runs `az login`. + // Gated behind an explicit opt-in so the BFF never shells out to `az` + // autonomously — the admin sets KARS_FOUNDRY_ALLOW_AZ_CLI=1 when they want + // dev discovery via their own Azure login. + let az_allowed = matches!( + std::env::var("KARS_FOUNDRY_ALLOW_AZ_CLI").ok().as_deref(), + Some("1") | Some("true") | Some("yes") + ); + if az_allowed + && let Ok(out) = tokio::process::Command::new("az") + .args([ + "account", + "get-access-token", + "--resource", + "https://ai.azure.com", + "--output", + "json", + ]) + .output() + .await + && out.status.success() + && let Ok(v) = serde_json::from_slice::(&out.stdout) + && let Some(t) = v.get("accessToken").and_then(|t| t.as_str()) + { + return Some((t.to_string(), "your Azure CLI login")); + } + None +} + +/// True when the Azure-CLI discovery fallback is explicitly enabled. +fn az_cli_enabled() -> bool { + matches!( + std::env::var("KARS_FOUNDRY_ALLOW_AZ_CLI").ok().as_deref(), + Some("1") | Some("true") | Some("yes") + ) +} + +/// The tag Foundry's discovered models are registered under in the shared +/// `kars-inference-providers` Secret (see `operator::list_additional_providers` +/// / `build_options` in `options.rs`) — this is what makes a Foundry +/// deployment show up in the Model catalogue tagged `foundry`, and lets an +/// InferencePolicy route to it, with NO separate wizard pass. +const INFERENCE_PROVIDERS_SECRET: &str = "kars-inference-providers"; +const INFERENCE_PROVIDERS_NS: &str = "kars-system"; +const FOUNDRY_CREDENTIALS_SECRET: &str = "kars-foundry-credentials"; + +/// Keep the shared Model catalogue's `foundry`-tagged entries in sync with +/// what's ACTUALLY discovered from the connected project — called after a +/// real (successful or empty) discovery in `verify_foundry`, and with an +/// empty model list from `disconnect_foundry`. This is what removes the +/// second, redundant "Azure AI Foundry" wizard pass the operator used to +/// need: connecting + verifying IS the catalogue registration now. +/// +/// When `models` is empty (no deployments discovered, or disconnecting), +/// every `KARS_PROVIDER_FOUNDRY_*` key is removed — mirroring +/// `delete_additional_provider`'s cleanup for any other tag, so a +/// disconnected/model-less Foundry project can never leave stale catalogue +/// entries an orchestrator or InferencePolicy could still pick. +async fn sync_foundry_catalog_provider( + cluster: &crate::kars::cluster::Cluster, + endpoint: &str, + models: &[String], + has_key: bool, +) -> Result<(), kube::Error> { + if models.is_empty() { + return cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + keys.remove("KARS_PROVIDER_FOUNDRY_ENDPOINT"); + keys.remove("KARS_PROVIDER_FOUNDRY_API_KEY"); + keys.remove("KARS_PROVIDER_FOUNDRY_MODELS"); + }) + .await; + } + // For auth=api, mirror the SAME key value already stored in + // kars-foundry-credentials (never re-prompt / re-type it) so the + // additional-provider entry can actually authenticate in dev. For + // managed/workload identity, no key is copied — the router's + // `is_azure_ai_host()` already recognizes `*.services.ai.azure.com` + // project hosts as eligible for an ambient WI/IMDS token, exactly like + // the cluster-default Foundry path. + let api_key = if has_key { + cluster + .read_secret_all("kars-system", FOUNDRY_CREDENTIALS_SECRET) + .await? + .get("FOUNDRY_API_KEY") + .cloned() + } else { + None + }; + let endpoint = endpoint.to_string(); + let models_joined = models.join(","); + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + keys.insert( + "KARS_PROVIDER_FOUNDRY_ENDPOINT".to_string(), + endpoint.clone(), + ); + keys.insert( + "KARS_PROVIDER_FOUNDRY_MODELS".to_string(), + models_joined.clone(), + ); + if let Some(k) = api_key.clone() { + keys.insert("KARS_PROVIDER_FOUNDRY_API_KEY".to_string(), k); + } else { + keys.remove("KARS_PROVIDER_FOUNDRY_API_KEY"); + } + }) + .await +} + +/// `POST /api/operator/foundry/verify` — a real preflight of the connection, +/// including live discovery of the project's models, services, and memory store. +/// A successful discovery ALSO syncs the discovered models into the shared +/// Model catalogue tagged `foundry` (`sync_foundry_catalog_provider`) — no +/// separate wizard pass needed to make a Foundry deployment usable by a +/// mission or team. +pub async fn verify_foundry(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let (project, inference, store, has_key) = cluster.get_foundry_connection().await; + let mut checks: Vec = Vec::new(); + let mut discovered = FoundryDiscovered::default(); + let _ = inference; + + let Some(project) = project else { + checks.push(FoundryCheck { + label: "Foundry is not connected".into(), + status: "fail".into(), + detail: "Onboard a Foundry project first.".into(), + }); + return Ok(Json(FoundryVerifyResult { checks, discovered })); + }; + let project = project.trim_end_matches('/').to_string(); + + // 1. DNS reachability of the project endpoint host. + let host = host_of(&project).unwrap_or_default(); + let dns_ok = !host.is_empty() && resolves(&host).await; + checks.push(FoundryCheck { + label: format!("Project endpoint host {host} resolves"), + status: if dns_ok { "pass" } else { "fail" }.into(), + detail: if dns_ok { + "The Foundry project host resolves in DNS.".into() + } else { + "The project host did not resolve — check the endpoint URL.".into() + }, + }); + + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + // 2. Determine the data-plane credential. Prefer an ambient AAD token + // (workload identity on AKS, IMDS, or the dev az-cli). If none is available + // but an API key is stored (the dev path — the operator connected with a + // key), use it directly: the Foundry project data-plane accepts the + // `api-key` header on /deployments + /connections, so discovery works fully + // in local/dev with just the key (no in-cluster Azure identity needed). + let aad = foundry_bearer_token().await; + let api_key: Option = if aad.is_none() && has_key { + cluster + .read_secret_all("kars-system", "kars-foundry-credentials") + .await + .ok() + .and_then(|k| k.get("FOUNDRY_API_KEY").cloned()) + .filter(|k| !k.trim().is_empty()) + } else { + None + }; + + enum FoundryAuth { + Bearer(String, &'static str), + ApiKey(String), + } + let auth = match (aad, api_key) { + (Some((tok, src)), _) => Some(FoundryAuth::Bearer(tok, src)), + (None, Some(key)) => Some(FoundryAuth::ApiKey(key)), + (None, None) => None, + }; + let apply = |rb: reqwest::RequestBuilder| match &auth { + Some(FoundryAuth::Bearer(t, _)) => rb.bearer_auth(t), + Some(FoundryAuth::ApiKey(k)) => rb.header("api-key", k.as_str()), + None => rb, + }; + + match &auth { + Some(mode) => { + let (label, detail) = match mode { + FoundryAuth::Bearer(_, src) => ( + format!("Authenticated to Foundry via {src}"), + "Obtained an AAD token for the https://ai.azure.com data-plane.".to_string(), + ), + FoundryAuth::ApiKey(_) => ( + "Authenticated to Foundry via API key".to_string(), + "Using the project API key against the data-plane (dev/local).".to_string(), + ), + }; + checks.push(FoundryCheck { + label, + status: "pass".into(), + detail, + }); + // 3a. Model deployments. + let url = format!("{project}/deployments?api-version={FOUNDRY_API_VERSION}"); + match apply(http.get(&url)).send().await { + Ok(r) if r.status().is_success() => { + if let Ok(v) = r.json::().await { + discovered.models = list_names(&v); + } + checks.push(FoundryCheck { + label: format!( + "{} model deployment(s) discovered", + discovered.models.len() + ), + status: "pass".into(), + detail: if discovered.models.is_empty() { + "The project has no model deployments yet.".into() + } else { + discovered.models.join(", ") + }, + }); + } + Ok(r) => checks.push(FoundryCheck { + label: "Model deployments".into(), + status: "warn".into(), + detail: format!( + "Data-plane returned {} — check the identity's access.", + r.status().as_u16() + ), + }), + Err(e) => checks.push(FoundryCheck { + label: "Model deployments".into(), + status: "warn".into(), + detail: format!("Could not query deployments: {e}"), + }), + } + // 3b. Connected services. + let url = format!("{project}/connections?api-version={FOUNDRY_API_VERSION}"); + match apply(http.get(&url)).send().await { + Ok(r) if r.status().is_success() => { + if let Ok(v) = r.json::().await { + discovered.connections = list_connections(&v); + } + checks.push(FoundryCheck { + label: format!("{} connected service(s) discovered", discovered.connections.len()), + status: "pass".into(), + detail: if discovered.connections.is_empty() { "No connections (e.g. Bing grounding, storage) are attached to this project.".into() } else { discovered.connections.iter().map(|c| c.name.clone()).collect::>().join(", ") }, + }); + } + Ok(r) => checks.push(FoundryCheck { + label: "Connected services".into(), + status: "warn".into(), + detail: format!( + "Data-plane returned {} for /connections.", + r.status().as_u16() + ), + }), + Err(e) => checks.push(FoundryCheck { + label: "Connected services".into(), + status: "warn".into(), + detail: format!("Could not query connections: {e}"), + }), + } + // 3c. Memory: memory stores are PER-TEAM, not cluster-wide — each + // team's knowledge-commons maps to its own Foundry memory store, + // configured when the team is set up (p3). Nothing cluster-wide here. + let _ = &store; + + // 3d. Sync the just-discovered models into the shared Model + // catalogue, tagged `foundry` — this IS the catalogue + // registration; no separate wizard pass needed. + match sync_foundry_catalog_provider(cluster, &project, &discovered.models, has_key) + .await + { + Ok(()) => { + if !discovered.models.is_empty() { + checks.push(FoundryCheck { + label: "Model catalogue updated".into(), + status: "pass".into(), + detail: format!( + "{} model(s) tagged `foundry` are now available to missions and teams.", + discovered.models.len() + ), + }); + } + } + Err(e) => checks.push(FoundryCheck { + label: "Model catalogue sync".into(), + status: "warn".into(), + detail: format!("Discovery succeeded but the catalogue update failed: {e}"), + }), + } + } + None => { + let hint = if az_cli_enabled() { + "Couldn't get an AAD token from workload identity, IMDS, or the Azure CLI, and no API key is stored. On AKS, federate a workload identity with 'Azure AI User' on the project; in dev, connect with an API key or run `az login`." + } else { + "No credential available for discovery. In dev/local, connect with an API key (Advanced → Use an API key) — the data-plane accepts it directly. On AKS, use workload identity." + }; + checks.push(FoundryCheck { + label: "No credential available for discovery".into(), + status: "warn".into(), + detail: hint.into(), + }); + } + } + + Ok(Json(FoundryVerifyResult { checks, discovered })) +} + +/// Extract `value[].name` from a Foundry data-plane list response. +fn list_names(v: &serde_json::Value) -> Vec { + v.get("value") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +/// Extract connections (name + category) from a `/connections` list response. +fn list_connections(v: &serde_json::Value) -> Vec { + v.get("value") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| { + let name = x.get("name").and_then(|n| n.as_str())?.to_string(); + let category = x + .get("properties") + .and_then(|p| p.get("category").or_else(|| p.get("connectionType"))) + .and_then(|c| c.as_str()) + .map(String::from) + .or_else(|| x.get("type").and_then(|t| t.as_str()).map(String::from)); + Some(FoundryConnection { name, category }) + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn host_of_extracts_hostname() { + assert_eq!( + host_of("https://r.services.ai.azure.com/api/projects/p").as_deref(), + Some("r.services.ai.azure.com") + ); + assert_eq!( + host_of("https://x.openai.azure.com:443/").as_deref(), + Some("x.openai.azure.com") + ); + assert_eq!(host_of("not a url"), None); + } + + #[test] + fn list_names_parses_deployments() { + let v = json!({"value":[{"name":"gpt-4o"},{"name":"o3-mini"},{"noname":1}]}); + assert_eq!( + list_names(&v), + vec!["gpt-4o".to_string(), "o3-mini".to_string()] + ); + assert!(list_names(&json!({})).is_empty()); + } + + #[test] + fn list_connections_parses_name_and_category() { + let v = json!({"value":[ + {"name":"bing","properties":{"category":"GroundingWithBingSearch"}}, + {"name":"blob","type":"AzureStorageAccount"} + ]}); + let c = list_connections(&v); + assert_eq!(c.len(), 2); + assert_eq!(c[0].name, "bing"); + assert_eq!(c[0].category.as_deref(), Some("GroundingWithBingSearch")); + assert_eq!(c[1].category.as_deref(), Some("AzureStorageAccount")); + } +} diff --git a/bridge/bff/src/routes/github.rs b/bridge/bff/src/routes/github.rs new file mode 100644 index 000000000..682fdb83c --- /dev/null +++ b/bridge/bff/src/routes/github.rs @@ -0,0 +1,564 @@ +// kars Bridge BFF — Connect GitHub (keyless git write, §14). +// +// Per-principal self-service GitHub connection. The Bridge holds the shared kars +// GitHub App; each authenticated principal gets an isolated ConfigMap containing +// only its installation id, account, and repos. Tokens are minted at run time. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::{GitWriteConfig, LocalObjectRef}; +use crate::state::AppState; + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +/// GitHub App metadata the browser needs to render "Connect GitHub". +#[derive(Debug, Serialize)] +pub struct GithubAppDto { + /// Whether the operator has configured the shared kars GitHub App. + pub configured: bool, + /// The App's slug (from the GitHub API), used to build the install URL. + pub slug: Option, + /// `https://github.com/apps//installations/new` — where the user picks + /// repos + installs. `None` when the App isn't configured. + pub install_url: Option, +} + +/// The authenticated principal's connection state. +#[derive(Debug, Serialize)] +pub struct GithubConnectionDto { + pub connected: bool, + pub account: Option, + /// `owner/repo` full names the installation can reach — the repo picker set. + pub repos: Vec, +} + +// ── GitHub App auth helpers ────────────────────────────────────────────────── + +pub(crate) fn mint_app_jwt(app_id: &str, private_key_pem: &str) -> AppResult { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + #[derive(Serialize)] + struct Claims { + iat: i64, + exp: i64, + iss: String, + } + let now = chrono::Utc::now().timestamp(); + let claims = Claims { + iat: now - 60, + exp: now + 540, + iss: app_id.to_string(), + }; + let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes()) + .map_err(|e| AppError::Upstream(format!("invalid GitHub App key: {e}")))?; + encode(&Header::new(Algorithm::RS256), &claims, &key) + .map_err(|e| AppError::Upstream(format!("failed to sign App JWT: {e}"))) +} + +#[allow(dead_code)] +async fn gh_get_legacy(url: &str, bearer: &str) -> AppResult { + let resp = reqwest::Client::new() + .get(url) + .header("Authorization", format!("Bearer {bearer}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .bearer_auth(bearer) + .send() + .await + .map_err(|e| AppError::Upstream(format!("GitHub request failed: {e}")))?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Upstream(format!("GitHub {status}: {body}"))); + } + serde_json::from_str(&body).map_err(|e| AppError::Upstream(format!("bad GitHub JSON: {e}"))) +} + +/// Mint an installation access token (to list the installation's repos). +#[allow(dead_code)] +async fn installation_token_legacy(app_jwt: &str, installation_id: &str) -> AppResult { + let url = format!("https://api.github.com/app/installations/{installation_id}/access_tokens"); + let resp = reqwest::Client::new() + .post(&url) + .header("Authorization", format!("Bearer {app_jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .bearer_auth(app_jwt) + .send() + .await + .map_err(|e| AppError::Upstream(format!("installation token failed: {e}")))?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Upstream(format!( + "installation token {status}: {body}" + ))); + } + + let v: serde_json::Value = + serde_json::from_str(&body).map_err(|e| AppError::Upstream(e.to_string()))?; + v.get("token") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| AppError::Upstream("no token in installation response".into())) +} + +async fn gh_get(url: &str, bearer: &str) -> AppResult { + let resp = reqwest::Client::new() + .get(url) + .bearer_auth(bearer) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| AppError::Upstream(format!("GitHub request failed: {e}")))?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Upstream(format!( + "GitHub request returned {status}" + ))); + } + serde_json::from_str(&body).map_err(|e| AppError::Upstream(format!("bad GitHub JSON: {e}"))) +} + +/// Mint an installation access token (to list the installation's repos). +pub(crate) async fn installation_token(app_jwt: &str, installation_id: &str) -> AppResult { + let url = format!("https://api.github.com/app/installations/{installation_id}/access_tokens"); + let resp = reqwest::Client::new() + .post(&url) + .bearer_auth(app_jwt) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| AppError::Upstream(format!("installation token failed: {e}")))?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Upstream(format!( + "installation token request returned {status}" + ))); + } + let value: serde_json::Value = + serde_json::from_str(&body).map_err(|e| AppError::Upstream(e.to_string()))?; + value + .get("token") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .ok_or_else(|| AppError::Upstream("no token in installation response".into())) +} + +async fn list_installation_repos(app_jwt: &str, installation_id: &str) -> AppResult> { + let token = installation_token(app_jwt, installation_id).await?; + let v = gh_get( + "https://api.github.com/installation/repositories?per_page=100", + &token, + ) + .await?; + Ok(v.get("repositories") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| { + r.get("full_name") + .and_then(|f| f.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default()) +} + +// ── Endpoints ──────────────────────────────────────────────────────────────── + +/// `GET /api/github/app` — is the shared App configured, and where to install it. +pub async fn get_app(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let Some((app_id, key)) = cluster + .github_app_creds() + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + else { + return Ok(Json(GithubAppDto { + configured: false, + slug: None, + install_url: None, + })); + }; + // Fetch the slug from the App itself so the install URL is always correct. + let slug = async { + let jwt = mint_app_jwt(&app_id, &key).ok()?; + let app = gh_get("https://api.github.com/app", &jwt).await.ok()?; + app.get("slug") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) + } + .await; + let install_url = slug + .as_ref() + .map(|s| format!("https://github.com/apps/{s}/installations/new")); + Ok(Json(GithubAppDto { + configured: true, + slug, + install_url, + })) +} + +/// Operator-submitted App credentials. +#[derive(Debug, Deserialize)] +pub struct GithubAppRequest { + pub app_id: String, + pub private_key: String, +} + +/// `PUT /api/operator/github-app` — the operator's self-service setup for the +/// ONE shared kars GitHub App (replaces the manual `kubectl create secret` +/// step). Verifies the submitted App id + key against GitHub's `/app` +/// endpoint BEFORE saving, so a typo'd key surfaces as an immediate, +/// actionable error instead of a silently broken secret. Write-only, like +/// every other credential this Bridge holds: the private key is stored in +/// the `kars-github-app` Secret and never read back into a response. +pub async fn put_app( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let app_id = req.app_id.trim(); + let key = req.private_key.trim(); + if app_id.is_empty() || key.is_empty() { + return Err(AppError::BadRequest( + "app_id and private_key are required".into(), + )); + } + if !app_id.chars().all(|c| c.is_ascii_digit()) { + return Err(AppError::BadRequest( + "app_id must be the numeric App ID shown on the App's settings page".into(), + )); + } + if !key.contains("PRIVATE KEY") { + return Err(AppError::BadRequest( + "private_key doesn't look like a PEM private key (expected a '-----BEGIN ... PRIVATE KEY-----' block) — paste the .pem file GitHub generated, not the App ID or webhook secret".into(), + )); + } + + // Verify against the real GitHub API before persisting anything. A + // malformed PEM is an actionable input error (Rejected — message shown + // verbatim), not an opaque upstream failure. + let jwt = mint_app_jwt(app_id, key).map_err(|e| { + AppError::Rejected(format!( + "That doesn't parse as a valid RSA private key ({e}). Paste the exact contents of the .pem file GitHub generated when you created the App (Settings → Developer settings → GitHub Apps → your App → Generate a private key)." + )) + })?; + let app = gh_get("https://api.github.com/app", &jwt).await.map_err(|e| { + AppError::Rejected(format!( + "GitHub rejected these credentials — double check the App ID and that this is the CURRENT private key (regenerating one on GitHub invalidates the last one): {e}" + )) + })?; + let slug = app.get("slug").and_then(|s| s.as_str()).map(str::to_string); + let name = app.get("name").and_then(|s| s.as_str()).map(str::to_string); + + let body = serde_json::json!({ + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": { "name": "kars-github-app", "namespace": "kars-system", + "labels": {"app.kubernetes.io/managed-by": "kars-bridge"} }, + "stringData": { "GITHUB_APP_ID": app_id, "GITHUB_APP_PRIVATE_KEY": key }, + }); + cluster + .upsert_secret("kars-system", "kars-github-app", body) + .await + .map_err(upstream)?; + + Ok(Json(serde_json::json!({ + "configured": true, + "slug": slug, + "name": name, + "note": "Verified against GitHub and stored write-only — users can now connect their own installation from Workspace → Connections.", + }))) +} + +/// `DELETE /api/operator/github-app` — disconnect the shared App (deletes the +/// `kars-github-app` Secret). Existing per-principal connections (installation +/// ids) are left as-is but become unusable until a new App is configured. +pub async fn delete_app(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + cluster + .delete_secret("kars-system", "kars-github-app") + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ "configured": false }))) +} + +fn upstream(e: kube::Error) -> AppError { + AppError::Upstream(e.to_string()) +} + +pub(crate) fn connection_config_map_name(principal_sub: &str) -> String { + let digest = Sha256::digest(principal_sub.as_bytes()); + format!("kars-github-connection-{}", hex::encode(&digest[..8])) +} + +pub(crate) fn authorize_repo_set( + requested: &[String], + granted: &[String], +) -> AppResult> { + let granted = granted + .iter() + .map(|repo| (repo.trim().to_ascii_lowercase(), repo.trim().to_string())) + .filter(|(key, _)| !key.is_empty()) + .collect::>(); + let mut authorized = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + let mut denied = Vec::new(); + for repo in requested + .iter() + .map(|repo| repo.trim()) + .filter(|repo| !repo.is_empty()) + { + let key = repo.to_ascii_lowercase(); + if !seen.insert(key.clone()) { + continue; + } + match granted.get(&key) { + Some(repo) => authorized.push(repo.clone()), + None => denied.push(repo.to_string()), + } + } + if !denied.is_empty() { + return Err(AppError::Rejected(format!( + "requested repositories are not granted by your GitHub connection: {}", + denied.join(", ") + ))); + } + Ok(authorized) +} + +fn git_write_config(principal_sub: &str, repos: Vec) -> GitWriteConfig { + GitWriteConfig { + connection_config_map_ref: LocalObjectRef { + name: connection_config_map_name(principal_sub), + }, + repos, + } +} + +pub(crate) async fn authorize_git_write( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + principal: &Principal, + requested: Option<&[String]>, +) -> AppResult< + Option<( + GitWriteConfig, + crate::kars::credential_contract::GitHubBinding, + )>, +> { + let Some(requested) = requested else { + return Ok(None); + }; + if requested.iter().all(|repo| repo.trim().is_empty()) { + return Ok(None); + } + let connection_name = connection_config_map_name(&principal.sub); + let (_, _, granted) = cluster + .read_github_connection_result(ns, &connection_name) + .await + .map_err(|error| { + AppError::Upstream(format!("GitHub connection authority unavailable: {error}")) + })? + .ok_or_else(|| { + AppError::Rejected( + "connect GitHub for your user before granting repository access".into(), + ) + })?; + let repos = authorize_repo_set(requested, &granted)?; + if repos.is_empty() { + return Ok(None); + } + let binding = cluster + .github_connection_grant(ns, &principal.sub, repos.clone(), true) + .await + .map_err(|error| { + AppError::Rejected(format!("Keyless GitHub authority unavailable: {error}")) + })?; + Ok(Some((git_write_config(&principal.sub, repos), binding))) +} + +/// `GET /api/namespaces/{ns}/github/connection` — this principal's connection. +pub async fn get_connection( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let connection_name = connection_config_map_name(&principal.sub); + match cluster + .read_github_connection_result(&ns, &connection_name) + .await + .map_err(|error| { + AppError::Upstream(format!("GitHub connection authority unavailable: {error}")) + })? { + Some((_, account, repos)) => Ok(Json(GithubConnectionDto { + connected: true, + account: Some(account), + repos, + })), + None => Ok(Json(GithubConnectionDto { + connected: false, + account: None, + repos: vec![], + })), + } +} + +#[derive(Debug, Deserialize)] +pub struct ConnectRequest { + /// Optional: the GitHub account/org login to bind (when the App has multiple + /// installations). When omitted and there is exactly one installation, that + /// one is used. + #[serde(default)] + pub account: Option, +} + +/// `POST /api/namespaces/{ns}/github/connect` — discover the installation the +/// user just created and store it for this authenticated principal. Localhost-friendly: no +/// webhook/redirect needed — after installing the App, the user clicks Connect +/// and the Bridge finds the installation via the App API. +pub async fn connect( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let (app_id, key) = cluster + .github_app_creds() + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or_else(|| { + AppError::Rejected("the kars GitHub App isn't configured on this cluster".into()) + })?; + let jwt = mint_app_jwt(&app_id, &key)?; + let installs = gh_get( + "https://api.github.com/app/installations?per_page=100", + &jwt, + ) + .await?; + let arr = installs.as_array().cloned().unwrap_or_default(); + if arr.is_empty() { + return Err(AppError::Rejected( + "no installations found — install the kars app on your repos first, then Connect" + .into(), + )); + } + // Pick the installation: by account when given, else the sole one. + let chosen = if let Some(acct) = req.account.as_deref() { + arr.iter().find(|i| { + i.get("account") + .and_then(|a| a.get("login")) + .and_then(|l| l.as_str()) + .map(|l| l.eq_ignore_ascii_case(acct)) + .unwrap_or(false) + }) + } else if arr.len() == 1 { + arr.first() + } else { + return Err(AppError::Rejected( + "multiple GitHub installations exist — specify which account to connect".into(), + )); + }; + let chosen = chosen.ok_or_else(|| AppError::Rejected("no matching installation".into()))?; + let installation_id = chosen + .get("id") + .and_then(|i| i.as_i64()) + .map(|i| i.to_string()) + .ok_or_else(|| AppError::Upstream("installation has no id".into()))?; + let account = chosen + .get("account") + .and_then(|a| a.get("login")) + .and_then(|l| l.as_str()) + .unwrap_or_default() + .to_string(); + let repos = list_installation_repos(&jwt, &installation_id) + .await + .unwrap_or_default(); + let connection_name = connection_config_map_name(&principal.sub); + cluster + .write_github_connection(&ns, &connection_name, &installation_id, &account, &repos) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(GithubConnectionDto { + connected: true, + account: Some(account), + repos, + })) +} + +/// `DELETE /api/namespaces/{ns}/github/connection` — disconnect this principal. +pub async fn disconnect( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let connection_name = connection_config_map_name(&principal.sub); + cluster + .delete_github_connection(&ns, &connection_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(GithubConnectionDto { + connected: false, + account: None, + repos: vec![], + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn different_principals_have_different_non_identifying_names() { + let alice = connection_config_map_name("immutable-alice-subject"); + let bob = connection_config_map_name("immutable-bob-subject"); + assert_ne!(alice, bob); + assert_eq!(alice, connection_config_map_name("immutable-alice-subject")); + assert_eq!(alice.len(), "kars-github-connection-".len() + 16); + assert!(!alice.contains("alice")); + } + + #[test] + fn repo_authorization_isolated_to_the_selected_principal_grant() { + let alice = vec!["org/alice-repo".to_string()]; + let bob = vec!["org/bob-repo".to_string()]; + assert!(authorize_repo_set(&["org/alice-repo".into()], &alice).is_ok()); + assert!(authorize_repo_set(&["org/alice-repo".into()], &bob).is_err()); + } + + #[test] + fn repo_authorization_fails_if_any_requested_repo_is_outside_grant() { + let granted = vec!["org/allowed".to_string()]; + let result = + authorize_repo_set(&["org/allowed".into(), "org/not-allowed".into()], &granted); + assert!(result.is_err()); + } + + #[test] + fn git_write_reference_is_server_derived_from_principal() { + let config = git_write_config("immutable-subject", vec!["org/repo".into()]); + assert_eq!( + config.connection_config_map_ref.name, + connection_config_map_name("immutable-subject") + ); + } +} diff --git a/bridge/bff/src/routes/health.rs b/bridge/bff/src/routes/health.rs new file mode 100644 index 000000000..865a798f8 --- /dev/null +++ b/bridge/bff/src/routes/health.rs @@ -0,0 +1,266 @@ +// Copyright (c) Pal Lakatos-Toth. +// kars Bridge BFF — health & readiness endpoints. + +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use serde::Serialize; + +use crate::state::AppState; + +/// Liveness payload — the process is up and serving. +#[derive(Serialize)] +pub struct Health { + status: &'static str, + service: &'static str, + version: &'static str, +} + +/// `GET /healthz` — liveness. Always 200 while the process serves. +pub async fn healthz() -> Json { + Json(Health { + status: "ok", + service: "kars-bridge-bff", + version: env!("CARGO_PKG_VERSION"), + }) +} + +/// Readiness payload — reports real dependency wiring. +#[derive(Serialize)] +pub struct Readiness { + status: &'static str, + /// Whether every required Kars API is readable in the default namespace. + cluster_configured: bool, +} + +/// `GET /readyz` — readiness. Probes every required Kars API and fails closed. +pub async fn readyz(State(state): State) -> (StatusCode, Json) { + let cluster_configured = match state.cluster() { + Some(c) => match c.ping(state.default_namespace()).await { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, "Bridge readiness requires compatible, accessible Kars APIs"); + false + } + }, + None => false, + }; + let status = if cluster_configured { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + ( + status, + Json(Readiness { + status: if cluster_configured { + "ok" + } else { + "unavailable" + }, + cluster_configured, + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Method, Request, Response}; + use std::sync::{Arc, Mutex}; + use tower::{ServiceExt, service_fn}; + + const REQUIRED_APIS: &[(&str, &str)] = &[ + ("KarsSandbox", "karssandboxes"), + ("KarsTask", "karstasks"), + ("KarsTeam", "karsteams"), + ("KarsProfile", "karsprofiles"), + ("KarsSkill", "karsskills"), + ("KarsApproval", "karsapprovals"), + ("EgressApproval", "egressapprovals"), + ("KarsReceipt", "karsreceipts"), + ("McpServer", "mcpservers"), + ("InferencePolicy", "inferencepolicies"), + ("ToolPolicy", "toolpolicies"), + ("KarsMemory", "karsmemories"), + ("KarsEval", "karsevals"), + ("KarsSREAction", "karssreactions"), + ("KarsCredentialGrant", "karscredentialgrants"), + ]; + + #[derive(Clone, Copy)] + enum Reply { + Healthy, + ApiError { index: usize, code: u16 }, + TransportError, + InvalidJson, + NeverRespond, + SlowResponses, + } + + fn state(reply: Reply, namespace: &str) -> (AppState, Arc>>) { + let paths = Arc::new(Mutex::new(Vec::new())); + let requests = paths.clone(); + let service = service_fn(move |request: Request<_>| { + let requests = requests.clone(); + async move { + assert_eq!(request.method(), Method::GET); + assert!( + request + .uri() + .query() + .unwrap() + .split('&') + .any(|param| param == "limit=1") + ); + let index = { + let mut paths = requests.lock().unwrap(); + let index = paths.len(); + paths.push(request.uri().path().to_string()); + index + }; + if matches!(reply, Reply::SlowResponses) { + tokio::time::sleep(std::time::Duration::from_millis(450)).await; + } + let (code, body) = match reply { + Reply::ApiError { index: at, code } if index == at => ( + code, + serde_json::json!({ + "apiVersion": "v1", "kind": "Status", "status": "Failure", + "reason": "ReadinessTestFailure", "code": code, + "message": "upstream diagnostics must stay server-side" + }) + .to_string(), + ), + Reply::TransportError => { + return Err(std::io::Error::other("connection refused")); + } + Reply::InvalidJson => (200, "not JSON".to_string()), + Reply::NeverRespond => std::future::pending().await, + _ => ( + 200, + serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "List", "metadata": {}, "items": [] + }) + .to_string(), + ), + }; + Ok(Response::builder() + .status(code) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap()) + } + }); + ( + AppState::for_test_client(kube::Client::new(service, namespace), namespace), + paths, + ) + } + + async fn probe(state: AppState, path: &str) -> (StatusCode, serde_json::Value) { + let response = crate::routes::router(state) + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&body).unwrap()) + } + + async fn assert_unavailable(state: AppState) { + let (status, body) = probe(state.clone(), "/readyz").await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + body, + serde_json::json!({"status": "unavailable", "cluster_configured": false}) + ); + assert_eq!(probe(state, "/healthz").await.0, StatusCode::OK); + } + + #[tokio::test] + async fn readiness_requires_all_guardrail_apis_but_not_teams_credentials() { + for namespace in ["kars-system", "bridge-workspace"] { + let (state, paths) = state(Reply::Healthy, namespace); + assert!(state.teams_internal_secret().is_none()); + assert!(state.teams_entra_role_map().is_empty()); + let (status, body) = probe(state, "/readyz").await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + body, + serde_json::json!({"status": "ok", "cluster_configured": true}) + ); + let expected: Vec<_> = REQUIRED_APIS + .iter() + .map(|(_, plural)| { + format!("/apis/kars.azure.com/v1alpha1/namespaces/{namespace}/{plural}") + }) + .collect(); + assert_eq!(*paths.lock().unwrap(), expected); + } + } + + #[tokio::test] + async fn readiness_fails_closed_for_each_missing_required_api() { + for (index, (kind, _)) in REQUIRED_APIS.iter().enumerate() { + let reply = Reply::ApiError { index, code: 404 }; + let (app_state, paths) = state(reply, "kars-system"); + assert_unavailable(app_state).await; + assert_eq!(paths.lock().unwrap().len(), index + 1, "{kind}"); + + let (app_state, _) = state(reply, "kars-system"); + let error = app_state + .cluster() + .unwrap() + .ping("kars-system") + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("kars.azure.com/v1alpha1/{kind}")), + "{error}" + ); + } + } + + #[tokio::test] + async fn readiness_fails_closed_on_auth_rbac_throttling_and_server_errors() { + for code in [401, 403, 429, 500, 503] { + let (state, paths) = state(Reply::ApiError { index: 0, code }, "kars-system"); + assert_unavailable(state).await; + assert_eq!(paths.lock().unwrap().len(), 1); + } + } + + #[tokio::test] + async fn readiness_fails_closed_on_transport_and_decode_errors() { + for reply in [Reply::TransportError, Reply::InvalidJson] { + let (state, paths) = state(reply, "kars-system"); + assert_unavailable(state).await; + assert_eq!(paths.lock().unwrap().len(), 1); + } + } + + #[tokio::test] + async fn readiness_timeout_does_not_block_liveness() { + let (state, _) = state(Reply::NeverRespond, "kars-system"); + tokio::time::timeout(std::time::Duration::from_secs(7), assert_unavailable(state)) + .await + .expect("readiness must finish before the Helm probe's ten-second timeout"); + } + + #[tokio::test] + async fn readiness_budget_is_shared_across_all_required_api_requests() { + let (state, paths) = state(Reply::SlowResponses, "kars-system"); + tokio::time::timeout(std::time::Duration::from_secs(7), assert_unavailable(state)) + .await + .expect("readiness must not reset its timeout for each API"); + let count = paths.lock().unwrap().len(); + assert!(count > 1 && count < REQUIRED_APIS.len()); + } +} diff --git a/bridge/bff/src/routes/insights.rs b/bridge/bff/src/routes/insights.rs new file mode 100644 index 000000000..217a1f1c1 --- /dev/null +++ b/bridge/bff/src/routes/insights.rs @@ -0,0 +1,358 @@ +// kars Bridge BFF — Insights / efficiency metrics API. +// +// HONESTY CONTRACT (design note §24, UX honesty grammar): +// This endpoint returns only facts derivable from live cluster state — mission +// status distribution, tier mix, decision counts, receipt/log sizes. Runtime +// efficiency numbers (token cost, latency split, harness comparison) require a +// real agent run through the inference router, which a local kind cluster +// without an AI Foundry endpoint does not produce. Rather than fabricate zeros +// that imply measurement, we report `runtime_metrics_available: false` and the +// web layer renders the explicit "appears after a real mission runs" state. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::core::DynamicObject; +use serde::Serialize; +use serde_json::Value; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::{principal_can_view_all, require_owned_task}; +use crate::state::AppState; + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} +fn upstream(e: kube::Error) -> AppError { + AppError::Upstream(e.to_string()) +} +fn spec(o: &DynamicObject) -> &Value { + o.data.get("spec").unwrap_or(&Value::Null) +} +fn status(o: &DynamicObject) -> &Value { + o.data.get("status").unwrap_or(&Value::Null) +} + +#[derive(Debug, Serialize, Default)] +pub struct CountPair { + pub label: String, + pub count: i64, +} + +#[derive(Debug, Serialize)] +pub struct InsightsDto { + /// Missions (tasks) grouped by governance phase. + pub missions_by_phase: Vec, + /// Missions grouped by autonomy tier (1..5). + pub missions_by_tier: Vec, + /// Human decisions grouped by outcome. + pub decisions: Vec, + /// Total launched (executing) missions. + pub launched: i64, + /// Governance receipts issued. + pub receipts_issued: i64, + /// Size of the hash-chained inclusion log (entries). + pub inclusion_log_size: i64, + /// Delegated children rejected for amplifying authority (a safety signal). + pub amplification_rejections: i64, + /// Whether runtime efficiency metrics (token cost, latency) are available. + /// False on a cluster with no real inference runs — drives the honest + /// "needs a real run" empty state instead of fabricated zeros. + pub runtime_metrics_available: bool, + /// Human-readable reason runtime metrics are unavailable, when they are. + pub runtime_metrics_note: Option, +} + +async fn gather_insights( + cluster: &crate::kars::cluster::Cluster, + owner_subject: Option<&str>, +) -> AppResult { + let mut tasks = cluster.list_kind_all("KarsTask").await.map_err(upstream)?; + let mut approvals = cluster + .list_kind_all("KarsApproval") + .await + .map_err(upstream)?; + let mut receipts = cluster + .list_kind_all("KarsReceipt") + .await + .map_err(upstream)?; + if let Some(owner) = owner_subject { + let owns = |object: &DynamicObject| { + object + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get("kars.azure.com/owner-sub")) + .is_some_and(|subject| subject == owner) + }; + tasks.retain(owns); + approvals.retain(owns); + let owned_tasks: std::collections::BTreeSet = tasks + .iter() + .filter_map(|task| task.metadata.name.clone()) + .collect(); + receipts.retain(|receipt| { + spec(receipt) + .get("taskRef") + .and_then(|reference| reference.get("name")) + .and_then(|name| name.as_str()) + .is_some_and(|name| owned_tasks.contains(name)) + }); + } + + // Missions by phase. + let mut phase_counts: std::collections::BTreeMap = Default::default(); + let mut tier_counts: std::collections::BTreeMap = Default::default(); + let mut launched = 0i64; + let mut amplification_rejections = 0i64; + for t in &tasks { + let phase = status(t) + .get("phase") + .and_then(|p| p.as_str()) + .unwrap_or("Pending") + .to_string(); + *phase_counts.entry(phase).or_default() += 1; + if let Some(tier) = spec(t) + .get("envelope") + .and_then(|e| e.get("tier")) + .and_then(|x| x.as_i64()) + { + *tier_counts.entry(tier).or_default() += 1; + } + let exec = status(t) + .get("executionPhase") + .and_then(|p| p.as_str()) + .unwrap_or("Idle"); + if exec != "Idle" { + launched += 1; + } + // A degraded child whose Ready condition cites amplification is a + // safety win worth surfacing. + if let Some(conds) = status(t).get("conditions").and_then(|c| c.as_array()) { + for c in conds { + let msg = c.get("message").and_then(|m| m.as_str()).unwrap_or(""); + if msg.to_ascii_lowercase().contains("amplif") { + amplification_rejections += 1; + } + } + } + } + + // Decisions by outcome. + let mut decision_counts: std::collections::BTreeMap = Default::default(); + for a in &approvals { + let phase = status(a) + .get("phase") + .and_then(|p| p.as_str()) + .unwrap_or("Pending") + .to_string(); + *decision_counts.entry(phase).or_default() += 1; + } + + let log = cluster + .receipt_log() + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + let inclusion_log_size = if owner_subject.is_some() { + let visible: std::collections::BTreeSet = receipts + .iter() + .filter_map(|receipt| { + Some(format!( + "{}/{}", + receipt.metadata.namespace.as_ref()?, + receipt.metadata.name.as_ref()? + )) + }) + .collect(); + log.entries + .iter() + .filter(|entry| visible.contains(&entry.receipt)) + .count() as i64 + } else { + log.entries.len() as i64 + }; + + let to_pairs = |m: std::collections::BTreeMap| -> Vec { + m.into_iter() + .map(|(label, count)| CountPair { label, count }) + .collect() + }; + + // Runtime metrics ARE surfaced now: the efficiency frontier derives real + // per-run token cost (from mission-output) and latency/cache/tool-fail (from + // the execution trace). Report availability honestly from the data — true + // once at least one delivered run carries real token telemetry. + let outputs = cluster.list_mission_output_evidence().await; + let runtime_available = outputs.iter().any(|record| { + record + .data + .get("totalTokens") + .and_then(|t| t.parse::().ok()) + .is_some_and(|t| t > 0) + }); + + Ok(InsightsDto { + missions_by_phase: to_pairs(phase_counts), + // Always emit all five autonomy tiers (1..=5), zero-filled, so the + // "Autonomy mix" chart shows the full ladder and a missing tier reads as + // "none granted" rather than silently vanishing from the axis. + missions_by_tier: (1..=5) + .map(|t| CountPair { + label: format!("Tier {t}"), + count: tier_counts.get(&t).copied().unwrap_or(0), + }) + .collect(), + decisions: to_pairs(decision_counts), + launched, + receipts_issued: receipts.len() as i64, + inclusion_log_size, + amplification_rejections, + runtime_metrics_available: runtime_available, + runtime_metrics_note: if runtime_available { + None + } else { + Some( + "Token cost and latency appear here once a mission runs and delivers — nothing is faked before then.".to_string(), + ) + }, + }) +} + +/// `GET /api/insights` — fleet-wide efficiency + governance insights. +pub async fn get_insights( + State(state): State, + Extension(principal): Extension, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let owner = (!principal_can_view_all(&principal)).then_some(principal.sub.as_str()); + Ok(Json(gather_insights(cluster, owner).await?)) +} + +// ─── Per-mission scorecard ─────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ScorecardDto { + pub task: String, + pub namespace: String, + pub tier: Option, + pub launched: bool, + pub execution_phase: Option, + /// Budget ceiling (tokens) from the envelope, when set. + pub token_budget: Option, + /// Number of human decisions recorded for this mission. + pub decisions_recorded: i64, + pub approvals_granted: i64, + pub approvals_denied: i64, + /// Whether a signed receipt exists for this mission. + pub receipt_issued: bool, + /// Real tokens consumed by the latest captured mission run, when one exists. + pub run_total_tokens: Option, + pub run_prompt_tokens: Option, + pub run_completion_tokens: Option, + pub run_model: Option, + /// Runtime efficiency availability (see InsightsDto). + pub runtime_metrics_available: bool, + pub runtime_metrics_note: Option, +} + +/// `GET /api/namespaces/:ns/tasks/:name/scorecard` — per-mission efficiency. +pub async fn get_scorecard( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_task(cluster, &ns, &name, &principal).await?; + let task = cluster + .get_kind(&ns, "KarsTask", &name) + .await + .map_err(upstream)? + .ok_or(AppError::NotFound)?; + + let approvals = cluster + .list_kind(&ns, "KarsApproval") + .await + .map_err(upstream)?; + let mut granted = 0i64; + let mut denied = 0i64; + let mut recorded = 0i64; + for a in &approvals { + let refs = spec(a) + .get("taskRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + == Some(name.as_str()); + if !refs { + continue; + } + recorded += 1; + match status(a).get("phase").and_then(|p| p.as_str()) { + Some("Approved") => granted += 1, + Some("Denied") => denied += 1, + _ => {} + } + } + + let receipt_issued = cluster + .get_kind(&ns, "KarsReceipt", &name) + .await + .map_err(upstream)? + .is_some(); + + let exec_phase = status(&task) + .get("executionPhase") + .and_then(|p| p.as_str()) + .map(|x| x.to_string()); + + // Real per-mission token telemetry from the latest captured run, if any. + let run = cluster.read_mission_output(&name).await; + let run_total_tokens = run + .as_ref() + .and_then(|d| d.get("totalTokens")) + .and_then(|v| v.parse().ok()); + let run_prompt_tokens = run + .as_ref() + .and_then(|d| d.get("promptTokens")) + .and_then(|v| v.parse().ok()); + let run_completion_tokens = run + .as_ref() + .and_then(|d| d.get("completionTokens")) + .and_then(|v| v.parse().ok()); + let run_model = run.as_ref().and_then(|d| d.get("model").cloned()); + let has_run = run_total_tokens.is_some(); + + Ok(Json(ScorecardDto { + task: name, + namespace: ns, + tier: spec(&task) + .get("envelope") + .and_then(|e| e.get("tier")) + .and_then(|x| x.as_i64()), + launched: exec_phase.as_deref().is_some_and(|p| p != "Idle"), + execution_phase: exec_phase, + token_budget: spec(&task) + .get("envelope") + .and_then(|e| e.get("budget")) + .and_then(|b| b.get("tokens")) + .and_then(|x| x.as_i64()), + decisions_recorded: recorded, + approvals_granted: granted, + approvals_denied: denied, + receipt_issued, + run_total_tokens, + run_prompt_tokens, + run_completion_tokens, + run_model, + // A real run gives us real token numbers; latency/streaming telemetry is + // still a named next step, so we surface tokens honestly and say what's + // not yet wired. + runtime_metrics_available: has_run, + runtime_metrics_note: if has_run { + None + } else { + Some( + "Token burn and latency for this mission aren't surfaced until it runs — click 'Run mission' to capture a real governed run with real token cost.".to_string(), + ) + }, + })) +} diff --git a/bridge/bff/src/routes/mod.rs b/bridge/bff/src/routes/mod.rs new file mode 100644 index 000000000..8a7f03194 --- /dev/null +++ b/bridge/bff/src/routes/mod.rs @@ -0,0 +1,441 @@ +// Copyright (c) Pal Lakatos-Toth. +// kars Bridge BFF — route module aggregation. + +pub mod approvals; +pub mod artifacts; +pub mod budgets; +pub mod channels; +pub mod compose; +pub mod credential_review; +pub mod digests; +pub mod efficiency; +pub mod engineering; +pub mod foundry; +pub mod github; +pub mod health; +pub mod insights; +pub mod operator; +pub mod options; +mod ownership; +pub mod receipts; +pub mod retention; +pub mod review; +pub mod run; +pub mod sre_actions; +pub mod system; +pub mod tasks; +pub mod teams; +pub mod teams_internal; +pub mod telemetry; +pub mod validate; + +use axum::Router; +use axum::routing::{delete, get, post, put}; + +use crate::error::AppError; +use crate::state::AppState; + +/// Build the application router with shared state. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/healthz", get(health::healthz)) + .route("/readyz", get(health::readyz)) + .route("/api/system", get(system::get_system)) + .route("/api/options", get(options::get_options)) + .route("/api/agents", get(tasks::list_agents)) + .route("/api/agents/fleet", get(tasks::fleet_telemetry)) + .route("/api/artifacts", get(artifacts::list_artifacts)) + .route("/api/digests", get(digests::list_digests)) + .route("/api/efficiency", get(efficiency::get_efficiency)) + .route("/api/insights", get(insights::get_insights)) + .route( + "/api/namespaces/{ns}/tasks", + get(tasks::list_tasks).post(tasks::create_task), + ) + .route( + "/api/namespaces/{ns}/teams", + get(teams::list_teams).post(teams::create_team), + ) + .route( + "/api/namespaces/{ns}/teams/{name}", + get(teams::get_team) + .patch(teams::update_team) + .delete(teams::delete_team), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/commons", + get(teams::get_team_commons), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/runs/{run}/archive", + get(teams::get_archived_run), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/promote", + post(teams::promote_team), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/run", + post(teams::run_team), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/runs/{run}/halt", + post(teams::halt_team_run), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/tasks", + get(teams::list_team_tasks).post(teams::add_team_task), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/tasks/{task_id}", + delete(teams::delete_team_task), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/tasks/{task_id}/review", + post(teams::review_team_task), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/engineering-source", + get(engineering::get_source) + .put(engineering::put_source) + .delete(engineering::delete_source), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/engineering-source/sync", + post(engineering::sync_now), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/engineering-review", + post(engineering::decide_review_item), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/channels", + get(teams::get_team_channels).post(teams::set_team_channel), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/channels/{channel}", + delete(teams::delete_team_channel), + ) + .route( + "/api/namespaces/{ns}/channels", + get(channels::get_channels).post(channels::set_channel), + ) + .route( + "/api/namespaces/{ns}/channels/{channel}", + delete(channels::delete_channel), + ) + .route( + "/api/namespaces/{ns}/teams/{name}/ledger", + get(teams::get_team_ledger), + ) + .route( + "/api/namespaces/{ns}/validate", + post(validate::validate_package), + ) + .route("/api/namespaces/{ns}/compose", post(compose::compose)) + .route( + "/api/namespaces/{ns}/propose-loop", + post(compose::propose_loop), + ) + .route( + "/api/namespaces/{ns}/compose-team", + post(compose::compose_team), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}", + get(tasks::get_task).delete(tasks::delete_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/artifact/{file}", + get(tasks::download_artifact), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/receipt", + get(receipts::get_receipt), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/receipt/verify", + post(receipts::verify_receipt), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/compliance", + get(receipts::compliance_pack), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/scorecard", + get(insights::get_scorecard), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/troubleshoot", + get(tasks::troubleshoot_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/launch", + post(tasks::launch_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/budget", + post(tasks::increase_task_budget), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/run", + post(run::run_mission), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/replicate", + post(tasks::replicate_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/promote", + post(tasks::promote_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/halt", + post(tasks::halt_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/review", + get(review::get_review).post(review::post_review), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/validate", + post(validate::validate_task), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/approvals", + get(approvals::list_task_approvals), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/egress", + post(tasks::request_egress), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/egress/learned", + get(tasks::get_learned_egress), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/egress-mode", + post(tasks::set_egress_mode), + ) + .route( + "/api/namespaces/{ns}/tasks/{name}/stream", + get(telemetry::stream_mission), + ) + .route( + "/api/namespaces/{ns}/approvals", + get(approvals::list_approvals), + ) + .route( + "/api/namespaces/{ns}/approvals/{name}/decision", + post(approvals::decide_approval), + ) + // Connect GitHub: shared App metadata + isolated per-principal connection. + .route("/api/github/app", get(github::get_app)) + .route( + "/api/operator/github-app", + put(github::put_app).delete(github::delete_app), + ) + .route( + "/api/namespaces/{ns}/github/connection", + get(github::get_connection).delete(github::disconnect), + ) + .route("/api/namespaces/{ns}/github/connect", post(github::connect)) + // Operator Console surfaces (read-only projections of live CRDs). + .route("/api/operator/sandboxes", get(operator::list_sandboxes)) + .route("/api/operator/capacity", get(operator::capacity)) + .route( + "/api/operator/evals", + get(operator::list_evals).post(operator::create_eval), + ) + .route( + "/api/operator/evals/{name}/report", + get(operator::eval_report), + ) + .route( + "/api/operator/mcpservers", + get(operator::list_mcpservers).put(operator::put_mcpserver), + ) + .route( + "/api/operator/mcpservers/{name}", + delete(operator::delete_mcpserver), + ) + .route( + "/api/operator/mcp-profiles", + get(operator::list_mcp_profiles).put(operator::put_mcp_profile), + ) + .route( + "/api/operator/mcp-profiles/{name}", + delete(operator::delete_mcp_profile), + ) + .route( + "/api/operator/toolpolicies", + get(operator::list_toolpolicies).put(operator::put_toolpolicy), + ) + .route( + "/api/operator/toolpolicies/{name}", + delete(operator::delete_toolpolicy), + ) + .route( + "/api/operator/inferencepolicies", + get(operator::list_inferencepolicies).put(operator::create_inferencepolicy), + ) + .route( + "/api/operator/inferencepolicies/{name}", + axum::routing::patch(operator::patch_inferencepolicy) + .delete(operator::delete_inferencepolicy), + ) + .route("/api/operator/inference-budgets", get(budgets::get_budgets)) + .route( + "/api/operator/inference-budgets/cluster", + put(budgets::set_cluster_budget), + ) + .route( + "/api/operator/inference-budgets/workspaces/{ns}", + put(budgets::set_workspace_budget), + ) + .route( + "/api/operator/inference-budgets/users/{user}", + put(budgets::set_user_budget), + ) + .route( + "/api/operator/retention-policy", + get(retention::get_retention_policy).put(retention::set_retention_policy), + ) + .route("/api/operator/egress", get(operator::list_egress)) + .route( + "/api/operator/egress/{name}", + delete(operator::delete_egress), + ) + .route("/api/operator/diagnostics", get(operator::get_diagnostics)) + .route( + "/api/operator/orchestrator", + get(operator::get_orchestrator), + ) + .route( + "/api/operator/sre-actions", + get(sre_actions::list_sre_actions), + ) + .route( + "/api/operator/sre-actions/{ns}/{name}/decision", + post(sre_actions::decide_sre_action), + ) + .route( + "/api/operator/integrations", + get(operator::get_integrations), + ) + .route( + "/api/operator/datapath-witness", + get(operator::datapath_witness), + ) + .route( + "/api/operator/skills", + get(operator::list_skills).put(operator::put_skill), + ) + .route( + "/api/operator/skills/{name}", + delete(operator::delete_skill), + ) + .route( + "/api/operator/skills/{name}/approve", + post(operator::approve_skill), + ) + .route( + "/api/operator/skills/{name}/revoke", + post(operator::revoke_skill), + ) + // User-side skills: submit a package (lands PENDING) + list to see review status. + .route( + "/api/skills", + get(operator::list_skills).post(operator::submit_skill), + ) + .route( + "/api/operator/profiles", + get(operator::list_profiles).put(operator::put_profile), + ) + .route( + "/api/operator/profiles/{name}", + delete(operator::delete_profile), + ) + .route("/api/operator/credentials", post(operator::put_credential)) + .route( + "/api/operator/credentials/review", + post(credential_review::review), + ) + .route("/api/operator/providers", post(operator::put_provider)) + .route( + "/api/operator/providers/discover", + post(operator::discover_models), + ) + .route( + "/api/operator/providers/copilot/login/start", + post(operator::copilot_login_start), + ) + .route( + "/api/operator/providers/copilot/login/poll", + post(operator::copilot_login_poll), + ) + .route( + "/api/operator/providers/additional", + get(operator::list_additional_providers).put(operator::put_additional_provider), + ) + .route( + "/api/operator/providers/additional/{tag}", + delete(operator::delete_additional_provider), + ) + .route( + "/api/operator/providers/additional/{tag}/promote", + post(operator::promote_additional_provider), + ) + .route( + "/api/operator/models/default", + post(operator::set_default_model), + ) + .route( + "/api/operator/local-inference/status", + get(operator::local_inference_status), + ) + .route( + "/api/operator/local-inference/catalog", + get(operator::local_inference_catalog), + ) + .route( + "/api/operator/local-inference/deployments", + get(operator::list_local_model_deployments) + .post(operator::create_local_model_deployment), + ) + .route( + "/api/operator/local-inference/deployments/{name}/status", + get(operator::local_deployment_live_status), + ) + .route( + "/api/operator/local-inference/deployments/{name}", + delete(operator::delete_local_model_deployment), + ) + .route( + "/api/operator/foundry", + get(foundry::get_foundry) + .post(foundry::connect_foundry) + .delete(foundry::disconnect_foundry), + ) + .route( + "/api/operator/foundry/verify", + post(foundry::verify_foundry), + ) + .route("/api/operator/audit", get(operator::get_audit)) + // Internal Teams gateway decision endpoint (not browser-facing). + .route( + "/api/internal/teams/decision", + post(teams_internal::teams_decision), + ) + .route( + "/api/internal/teams/command", + post(teams_internal::teams_command), + ) + .fallback(not_found) + .with_state(state) +} + +/// Structured 404 for any unmatched route, using the shared error envelope. +async fn not_found() -> AppError { + AppError::NotFound +} diff --git a/bridge/bff/src/routes/operator.rs b/bridge/bff/src/routes/operator.rs new file mode 100644 index 000000000..723d10779 --- /dev/null +++ b/bridge/bff/src/routes/operator.rs @@ -0,0 +1,4497 @@ +// kars Bridge BFF — Operator Console API. +// +// The operator surface reads the same CRDs as the user Workspace but projects +// them into resource/policy/audit language. Every field here is read from live +// cluster state via the dynamic API (no typed consumer per CRD) and projected +// into a stable browser DTO. Absent CRDs surface as empty lists, never errors — +// the honesty grammar (empty vs not-wired) lives in the web layer. + +use axum::Json; +use axum::extract::{Extension, State}; +use kube::core::DynamicObject; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +fn upstream(e: kube::Error) -> AppError { + AppError::Upstream(e.to_string()) +} + +// ─── helpers over DynamicObject ────────────────────────────────────────────── + +fn name_of(o: &DynamicObject) -> String { + o.metadata.name.clone().unwrap_or_default() +} +fn ns_of(o: &DynamicObject) -> String { + o.metadata.namespace.clone().unwrap_or_default() +} +fn created_of(o: &DynamicObject) -> Option { + o.metadata + .creation_timestamp + .as_ref() + .map(|t| t.0.to_rfc3339()) +} +/// Whether this sandbox is owned by a KarsTask — true for every mission/team- +/// run sandbox, false for a standing sandbox with no task behind it (e.g. the +/// Bridge's own orchestrator sandbox). Such a sandbox never gets a +/// mission-output ConfigMap, so it must be excluded from the "executing" test +/// below (it would otherwise look permanently "not yet delivered"). +fn has_task_owner(o: &DynamicObject) -> bool { + o.metadata + .owner_references + .as_ref() + .is_some_and(|refs| refs.iter().any(|r| r.kind == "KarsTask")) +} +fn spec(o: &DynamicObject) -> &Value { + o.data.get("spec").unwrap_or(&Value::Null) +} +fn status(o: &DynamicObject) -> &Value { + o.data.get("status").unwrap_or(&Value::Null) +} +fn s(v: &Value, key: &str) -> Option { + v.get(key).and_then(|x| x.as_str()).map(|x| x.to_string()) +} +fn label(o: &DynamicObject, key: &str) -> Option { + o.metadata.labels.as_ref().and_then(|l| l.get(key).cloned()) +} +fn annotation(o: &DynamicObject, key: &str) -> Option { + o.metadata + .annotations + .as_ref() + .and_then(|a| a.get(key).cloned()) +} + +// Skill-admission annotation keys — the operator trust gate (§ skills workflow): +// a user-uploaded skill is only usable once an operator has scanned + approved +// it, which LOCKS the approval to the exact version digest at approval time. +// Any later change to the skill breaks the lock and returns it to review. +const ANN_REVIEW: &str = "kars.azure.com/skill-review"; +const ANN_LOCKED_DIGEST: &str = "kars.azure.com/skill-locked-digest"; +const ANN_APPROVED_BY: &str = "kars.azure.com/skill-approved-by"; +const ANN_APPROVED_AT: &str = "kars.azure.com/skill-approved-at"; + +// ─── Sandbox fleet ─────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct SandboxDto { + pub name: String, + pub namespace: String, + pub runtime_namespace: Option, + pub phase: Option, + pub runtime: Option, + pub isolation: Option, + /// The governing ToolPolicy (AGT capability bounds) this agent runs under — + /// registry inventory: "which policy governs this agent". From + /// spec.governance.toolPolicyRef. + pub tool_policy: Option, + /// The InferencePolicy binding its model route + token budget. From + /// spec.inferenceRef. + pub inference_policy: Option, + /// Whether AGT governance is enabled (fails closed on an empty policy set). + pub governed: bool, + /// Owning standing team (label), if any — the "who owns this" registry column. + pub team: Option, + /// Parent sandbox name when this is a spawned sub-agent (label-derived). + pub parent: Option, + pub message: Option, + pub created: Option, + /// For a Running sandbox: whether its run has produced ANY real activity + /// (model rounds / tool calls). `Some(false)` = the pod is Running but idle + /// — e.g. a chat-gateway harness waiting for input, or a hung run. Surfaced + /// so a green "Running" never masks a stalled agent (audit f23). `None` when + /// the sandbox isn't Running (the signal doesn't apply). + pub working: Option, + /// Whether this sandbox is CURRENTLY executing a task (Running AND no + /// terminal mission-output yet) — the same "live" test the Workspace's + /// Active-agents page uses. Distinct from `working` above: a sandbox can + /// have `working: true` (it did real work) and still be `executing: false` + /// (it already delivered and is simply lingering before teardown/ + /// retention) — the exact case that made "Sandboxes: 2 running" and + /// "Active agents: 0 working" look contradictory when they're both true. + pub executing: Option, + pub cpu_millicores: Option, + pub memory_bytes: Option, + /// Standard K8s conditions, surfaced for the troubleshooting table. + pub conditions: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ConditionDto { + pub type_: String, + pub status: String, + pub reason: Option, + pub message: Option, +} + +fn conditions_of(o: &DynamicObject) -> Vec { + status(o) + .get("conditions") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .map(|c| ConditionDto { + type_: s(c, "type").unwrap_or_default(), + status: s(c, "status").unwrap_or_default(), + reason: s(c, "reason"), + message: s(c, "message"), + }) + .collect() + }) + .unwrap_or_default() +} + +fn to_sandbox(o: &DynamicObject) -> SandboxDto { + let sp = spec(o); + SandboxDto { + name: name_of(o), + namespace: ns_of(o), + runtime_namespace: s(status(o), "namespace"), + phase: s(status(o), "phase"), + runtime: sp + .get("runtime") + .and_then(|r| r.get("kind")) + .and_then(|k| k.as_str()) + .map(|x| x.to_string()), + isolation: sp + .get("sandbox") + .and_then(|sb| sb.get("isolation")) + .and_then(|i| i.as_str()) + .map(|x| x.to_string()), + tool_policy: sp + .get("governance") + .and_then(|g| g.get("toolPolicyRef")) + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|x| x.to_string()), + inference_policy: sp + .get("inferenceRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|x| x.to_string()), + governed: sp + .get("governance") + .and_then(|g| g.get("enabled")) + .and_then(|e| e.as_bool()) + .unwrap_or(false), + team: label(o, "kars.azure.com/team"), + parent: label(o, "kars.azure.com/parent").or_else(|| s(sp, "parentSandbox")), + message: s(status(o), "message"), + created: created_of(o), + working: None, + executing: None, + cpu_millicores: None, + memory_bytes: None, + conditions: conditions_of(o), + } +} + +fn cpu_millicores(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(value) = raw.strip_suffix('n') { + return value.parse::().ok().map(|value| value / 1_000_000.0); + } + if let Some(value) = raw.strip_suffix('u') { + return value.parse::().ok().map(|value| value / 1_000.0); + } + if let Some(value) = raw.strip_suffix('m') { + return value.parse::().ok(); + } + raw.parse::().ok().map(|value| value * 1_000.0) +} + +fn memory_bytes(raw: &str) -> Option { + let raw = raw.trim(); + for (suffix, multiplier) in [ + ("Ki", 1_024_f64), + ("Mi", 1_048_576_f64), + ("Gi", 1_073_741_824_f64), + ("Ti", 1_099_511_627_776_f64), + ("K", 1_000_f64), + ("M", 1_000_000_f64), + ("G", 1_000_000_000_f64), + ] { + if let Some(value) = raw.strip_suffix(suffix) { + return value + .parse::() + .ok() + .map(|value| (value * multiplier) as u64); + } + } + raw.parse::().ok() +} + +fn metric_usage(metric: &DynamicObject) -> (f64, u64) { + metric + .data + .get("containers") + .and_then(Value::as_array) + .map(|containers| { + containers + .iter() + .fold((0.0, 0_u64), |(cpu, memory), container| { + let usage = container.get("usage").unwrap_or(&Value::Null); + ( + cpu + usage + .get("cpu") + .and_then(Value::as_str) + .and_then(cpu_millicores) + .unwrap_or(0.0), + memory + + usage + .get("memory") + .and_then(Value::as_str) + .and_then(memory_bytes) + .unwrap_or(0), + ) + }) + }) + .unwrap_or((0.0, 0)) +} + +fn inherit_sandbox_context(sandboxes: &mut [SandboxDto]) { + let by_name: HashMap<(String, String), usize> = sandboxes + .iter() + .enumerate() + .map(|(index, sandbox)| ((sandbox.namespace.clone(), sandbox.name.clone()), index)) + .collect(); + let resolved: Vec<(Option, Option)> = sandboxes + .iter() + .enumerate() + .map(|(index, sandbox)| { + let mut team = sandbox.team.clone(); + let mut executing = sandbox.executing; + let mut cursor = index; + let mut visited = vec![false; sandboxes.len()]; + visited[cursor] = true; + + while let Some(parent) = sandboxes[cursor].parent.as_ref() { + let parent_key = (sandboxes[cursor].namespace.clone(), parent.clone()); + let Some(parent_index) = by_name.get(&parent_key).copied() else { + break; + }; + if visited[parent_index] { + break; + } + visited[parent_index] = true; + let parent = &sandboxes[parent_index]; + if team.is_none() { + team = parent.team.clone(); + } + if parent.executing.is_some() { + executing = parent.executing; + } + cursor = parent_index; + } + (team, executing) + }) + .collect(); + + for (sandbox, (team, executing)) in sandboxes.iter_mut().zip(resolved) { + sandbox.team = team; + if sandbox.parent.is_some() { + let observed_working = + sandbox.phase.as_deref() == Some("Running") && sandbox.working == Some(true); + sandbox.executing = + executing.map(|parent_executing| parent_executing && observed_working); + } + } +} + +#[derive(Debug, Serialize)] +pub struct NodeCapacityDto { + pub name: String, + pub cpu_usage_millicores: Option, + pub cpu_allocatable_millicores: Option, + pub memory_usage_bytes: Option, + pub memory_allocatable_bytes: Option, + pub cpu_percent: Option, + pub memory_percent: Option, +} + +#[derive(Debug, Serialize)] +pub struct CapacityDto { + pub metrics_available: bool, + pub metrics_error: Option, + pub team_max_concurrent_runs: usize, + pub global_active_runs_limit: usize, + pub active_team_runs: usize, + pub pod_metrics_available: bool, + pub pod_metrics_error: Option, + pub nodes: Vec, +} + +/// `GET /api/operator/sandboxes` — the fleet, across all namespaces. +pub async fn list_sandboxes(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("KarsSandbox") + .await + .map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_sandbox).collect(); + let task_teams: HashMap<(String, String), String> = cluster + .list_kind_all("KarsTask") + .await + .unwrap_or_default() + .into_iter() + .filter_map(|task| { + label(&task, "kars.azure.com/team").map(|team| ((ns_of(&task), name_of(&task)), team)) + }) + .collect(); + let pod_metrics = cluster + .list_metrics_all("PodMetrics", "pods") + .await + .unwrap_or_default(); + let usage: HashMap<(String, String), (f64, u64)> = pod_metrics + .iter() + .map(|metric| ((ns_of(metric), name_of(metric)), metric_usage(metric))) + .collect(); + // Stall signal (audit f23): for each Running sandbox, check whether its run + // has produced any real activity. A Running-but-empty sandbox is idle or + // hung (a chat-gateway harness waiting for input, or a stalled loop) — the + // operator must be able to tell that apart from a green "Running". + for (o, d) in items.iter().zip(dtos.iter_mut()) { + if d.team.is_none() + && let Some(task_name) = label(o, "kars.azure.com/karstask") + { + d.team = task_teams.get(&(d.namespace.clone(), task_name)).cloned(); + } + if let Some(runtime_namespace) = d.runtime_namespace.as_deref() { + let (cpu, memory) = usage + .iter() + .filter(|((namespace, pod), _)| { + namespace == runtime_namespace && pod.starts_with(&d.name) + }) + .fold((0.0, 0_u64), |(cpu, memory), (_, usage)| { + (cpu + usage.0, memory + usage.1) + }); + if cpu > 0.0 || memory > 0 { + d.cpu_millicores = Some(cpu); + d.memory_bytes = Some(memory); + } + } + if d.phase.as_deref() == Some("Running") { + let persisted_activity = cluster + .read_mission_trace(&d.name) + .await + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .map(|v| !v.is_empty()) + .unwrap_or(false); + let has_activity = + persisted_activity || !cluster.sandbox_live_trace(&d.name).await.is_empty(); + d.working = Some(has_activity); + // "Executing right now" — the same test the Workspace's Active + // agents page uses (live iff Running AND no terminal mission-output + // yet). A sandbox that already delivered still shows `working: true` + // (it DID real work) but `executing: false` (nothing left to do, + // just lingering before teardown/retention) — this is what makes + // "Sandboxes: N running" and "Active agents: 0 working" both + // correct at once instead of reading as a contradiction. Only + // applies to a task-owned sandbox — a standing sandbox with no + // KarsTask (e.g. the Bridge's own orchestrator) never gets a + // mission-output ConfigMap, so it would otherwise look permanently + // "not yet delivered" and inflate this count. + if has_task_owner(o) { + let delivered = cluster.read_mission_output(&d.name).await.is_some(); + d.executing = Some(!delivered); + } + } + } + inherit_sandbox_context(&mut dtos); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +pub async fn capacity(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let nodes = cluster.list_nodes().await.map_err(upstream)?; + let metrics = cluster.list_metrics_all("NodeMetrics", "nodes").await; + let (metrics_api_available, metrics_api_error, metric_map) = match metrics { + Ok(items) => ( + true, + None, + items + .into_iter() + .map(|metric| { + let usage = metric.data.get("usage").cloned().unwrap_or(Value::Null); + ( + name_of(&metric), + ( + usage + .get("cpu") + .and_then(Value::as_str) + .and_then(cpu_millicores), + usage + .get("memory") + .and_then(Value::as_str) + .and_then(memory_bytes), + ), + ) + }) + .collect::>(), + ), + Err(error) => (false, Some(error.to_string()), HashMap::new()), + }; + let node_count = nodes.len(); + let covered_nodes = nodes + .iter() + .filter(|node| { + node.metadata + .name + .as_ref() + .is_some_and(|name| metric_map.contains_key(name)) + }) + .count(); + let metrics_available = metrics_api_available && node_count > 0 && covered_nodes == node_count; + let metrics_error = if !metrics_api_available { + metrics_api_error + } else if node_count == 0 { + Some("the cluster reported no nodes".into()) + } else if covered_nodes != node_count { + Some(format!( + "node metrics coverage is partial ({covered_nodes}/{node_count})" + )) + } else { + None + }; + let nodes = nodes + .into_iter() + .map(|node| { + let name = node.metadata.name.unwrap_or_default(); + let allocatable = node.status.and_then(|status| status.allocatable); + let cpu_allocatable = allocatable + .as_ref() + .and_then(|values| values.get("cpu")) + .and_then(|value| cpu_millicores(&value.0)); + let memory_allocatable = allocatable + .as_ref() + .and_then(|values| values.get("memory")) + .and_then(|value| memory_bytes(&value.0)); + let (cpu_usage, memory_usage) = metric_map.get(&name).cloned().unwrap_or((None, None)); + NodeCapacityDto { + name, + cpu_usage_millicores: cpu_usage, + cpu_allocatable_millicores: cpu_allocatable, + memory_usage_bytes: memory_usage, + memory_allocatable_bytes: memory_allocatable, + cpu_percent: cpu_usage + .zip(cpu_allocatable) + .filter(|(_, allocatable)| *allocatable > 0.0) + .map(|(usage, allocatable)| usage / allocatable * 100.0), + memory_percent: memory_usage + .zip(memory_allocatable) + .filter(|(_, allocatable)| *allocatable > 0) + .map(|(usage, allocatable)| usage as f64 / allocatable as f64 * 100.0), + } + }) + .collect(); + let team_max_concurrent_runs = cluster + .controller_env_value("KARS_TEAM_MAX_CONCURRENT_RUNS") + .await + .and_then(|value| value.parse().ok()) + .unwrap_or(2); + let global_active_runs_limit = cluster + .controller_env_value("KARS_TEAM_GLOBAL_ACTIVE_RUNS_LIMIT") + .await + .and_then(|value| value.parse().ok()) + .unwrap_or(6); + let active_team_runs = cluster + .list_kind_all("KarsTask") + .await + .unwrap_or_default() + .iter() + .filter(|task| { + let annotations = task.metadata.annotations.as_ref(); + let taskforce = annotations + .and_then(|values| values.get("kars.azure.com/team-role")) + .is_some_and(|role| role == "taskforce"); + let launched = task + .data + .pointer("/spec/execution/launch") + .and_then(Value::as_bool) + .unwrap_or(false); + if !taskforce || !launched { + return false; + } + let requested = + annotations.and_then(|values| values.get("kars.azure.com/run-requested")); + let completed = + annotations.and_then(|values| values.get("kars.azure.com/run-completed")); + let delivery_pending = requested.is_some() && requested != completed; + let assignment_active = task + .data + .pointer("/status/assignment/state") + .and_then(Value::as_str) + .is_some_and(|state| matches!(state, "Assigned" | "Running")); + let execution_active = task + .data + .pointer("/status/executionPhase") + .and_then(Value::as_str) + .is_some_and(|phase| matches!(phase, "Launching" | "Running")); + delivery_pending || assignment_active || execution_active + }) + .count(); + let (pod_metrics_available, pod_metrics_error) = + match cluster.list_metrics_all("PodMetrics", "pods").await { + Ok(items) if !items.is_empty() => (true, None), + Ok(_) => ( + false, + Some("the metrics API returned no pod samples".into()), + ), + Err(error) => (false, Some(error.to_string())), + }; + Ok(Json(CapacityDto { + metrics_available, + metrics_error, + team_max_concurrent_runs, + global_active_runs_limit, + active_team_runs, + pod_metrics_available, + pod_metrics_error, + nodes, + })) +} + +// ─── KarsEval — safety/quality lifecycle (conformance evals) ───────────────── + +#[derive(Debug, Serialize)] +pub struct EvalResultDto { + pub total: i64, + pub passed: i64, + pub failed: i64, + pub errored: i64, + pub corpus_name: Option, + pub corpus_digest: Option, + pub completed_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct EvalDto { + pub name: String, + pub namespace: String, + pub display_name: Option, + /// The sandbox this eval targets (spec.targetSandboxRef). + pub target_sandbox: Option, + /// The corpus replayed — `builtin:` or an OCI ref. + pub corpus: Option, + /// Reconcile phase (Ready / Degraded / Pending). + pub phase: Option, + /// Optional cron schedule (recurring eval), when set. + pub schedule: Option, + pub last_run_at: Option, + /// The most recent verdict (pass/fail counts), when a run completed. + pub last_result: Option, + pub created: Option, +} + +fn to_eval_result(v: &Value) -> Option { + if !v.is_object() { + return None; + } + Some(EvalResultDto { + total: v.get("total").and_then(|x| x.as_i64()).unwrap_or(0), + passed: v.get("passed").and_then(|x| x.as_i64()).unwrap_or(0), + failed: v.get("failed").and_then(|x| x.as_i64()).unwrap_or(0), + errored: v.get("errored").and_then(|x| x.as_i64()).unwrap_or(0), + corpus_name: s(v, "corpusName"), + corpus_digest: s(v, "corpusDigest"), + completed_at: s(v, "completedAt"), + }) +} + +fn to_eval(o: &DynamicObject) -> EvalDto { + let sp = spec(o); + let st = status(o); + let corpus = sp.get("corpus").and_then(|c| { + c.get("builtin") + .and_then(|b| b.as_str()) + .map(|b| format!("builtin:{b}")) + .or_else(|| { + c.get("bundleRef") + .and_then(|r| r.get("repository")) + .and_then(|x| x.as_str()) + .map(|x| x.to_string()) + }) + }); + EvalDto { + name: name_of(o), + namespace: ns_of(o), + display_name: s(sp, "displayName"), + target_sandbox: sp + .get("targetSandboxRef") + .and_then(|r| r.get("name")) + .and_then(|x| x.as_str()) + .map(|x| x.to_string()), + corpus, + phase: s(st, "phase"), + schedule: s(sp, "schedule"), + last_run_at: s(st, "lastRunAt"), + last_result: st.get("lastResult").and_then(to_eval_result), + created: created_of(o), + } +} + +/// `GET /api/operator/evals` — the KarsEval safety/quality lifecycle: every +/// conformance eval, which sandbox it targets, and its latest real verdict +/// (pass/fail against the replayed corpus). Honest empty when none exist. +pub async fn list_evals(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_kind_all("KarsEval").await.map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_eval).collect(); + dtos.sort_by(|a, b| b.last_run_at.cmp(&a.last_run_at).then(a.name.cmp(&b.name))); + Ok(Json(dtos)) +} + +/// Operator request to configure + launch a safety eval. +#[derive(Debug, serde::Deserialize)] +pub struct CreateEvalRequest { + /// The sandbox to evaluate (spec.targetSandboxRef). + pub target_sandbox: String, + /// Builtin corpus name, e.g. `jailbreak-baseline` (spec.corpus.builtin). + pub corpus: String, + /// Optional cron schedule for a recurring eval; one-shot when omitted. + pub schedule: Option, + /// Runner image override. On a dev cluster this must be the locally-loaded + /// `kars-conformance-runner:dev`; in prod the controller default applies. + pub runner_image: Option, + /// Human label. + pub display_name: Option, + /// Run immediately (stamp the run-now annotation). Default true. + pub run_now: Option, +} + +/// `POST /api/operator/evals` — configure and (by default) launch a safety eval +/// against a sandbox. Operator-only surface; the controller spawns the runner +/// Job that replays the corpus and records the real verdict. +pub async fn create_eval( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let sandbox = req.target_sandbox.trim(); + let corpus = req.corpus.trim(); + if sandbox.is_empty() || corpus.is_empty() { + return Err(AppError::BadRequest( + "target_sandbox and corpus are required".into(), + )); + } + // Deterministic, readable name so re-running the same eval updates in place. + let name = format!("{sandbox}-{}", corpus.replace([':', '_', '/'], "-")); + let mut spec = serde_json::json!({ + "targetSandboxRef": { "name": sandbox }, + "corpus": { "builtin": corpus }, + }); + if let Some(img) = req + .runner_image + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + spec["runnerImage"] = serde_json::json!(img); + } + if let Some(sch) = req + .schedule + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + spec["schedule"] = serde_json::json!(sch); + } + if let Some(dn) = req + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + spec["displayName"] = serde_json::json!(dn); + } + let mut annotations = serde_json::Map::new(); + if req.run_now.unwrap_or(true) { + annotations.insert("kars.azure.com/run-now".into(), serde_json::json!("true")); + } + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsEval", + "metadata": { "name": name, "annotations": annotations }, + "spec": spec.clone(), + }); + cluster + .apply_kind("kars-system", "KarsEval", body, true) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ "created": true, "name": name }))) +} + +/// A single eval case with what it tests and its latest verdict. +#[derive(Debug, Serialize)] +pub struct EvalCaseDto { + pub id: String, + pub tags: Vec, + /// Plain-language summary of the adversarial probe this case sends. + pub probe: Option, + /// The expected decision (what a safe agent SHOULD do), e.g. "Blocked". + pub expected: Option, + /// What the router ACTUALLY decided on the last run, when known. + pub actual: Option, + /// The actual decision's reason (e.g. why it was blocked/allowed) — surfaces + /// WHY a case failed (e.g. blocked by a transport error, not content safety). + pub actual_reason: Option, + /// Latest verdict: true=passed, false=failed, None=not yet run OR errored. + pub pass: Option, + /// True when the case could NOT be evaluated (target unreachable / transport + /// error). Distinct from a policy failure — inconclusive, shown amber. + pub errored: bool, +} + +#[derive(Debug, Serialize)] +pub struct EvalReportDto { + pub name: String, + pub corpus: Option, + pub total: usize, + pub passed: usize, + pub failed: usize, + /// Cases the runner could not evaluate (target unreachable). Inconclusive, + /// not counted as failures — surfaced so the UI never conflates "couldn't + /// reach the sandbox" with "the sandbox let a jailbreak through". + pub errored: usize, + pub completed_at: Option, + /// Whether the controller captured PER-CASE verdicts for the last run. False + /// for runs that predate per-case reporting (only counts survive) — the UI + /// then shows the baseline cases without verdicts and invites a re-run. + pub per_case_available: bool, + pub cases: Vec, +} + +/// `GET /api/operator/evals/{name}/report` — the DETAILED eval report: every case +/// in the corpus (what it probes, the expected decision) merged with the latest +/// per-case verdict (pass/fail, and what the router actually did). Sourced from +/// the corpus ConfigMap (definitions) + the report ConfigMap (verdicts) the +/// controller persists — real, never fabricated. Empty verdicts until a run. +pub async fn eval_report( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + // Corpus definitions (what each case tests). + let corpus_raw = cluster + .configmap_data(&format!("karseval-{name}-corpus")) + .await + .and_then(|d| d.get("corpus.json").cloned()); + // Per-case verdicts from the last run (may be absent before first run). + let report_raw = cluster + .configmap_data(&format!("karseval-{name}-report")) + .await + .and_then(|d| d.get("report.json").cloned()); + + // Index verdicts by case id. + let report_json: Option = report_raw + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + let per_case_available = report_json.is_some(); + let mut verdicts: std::collections::BTreeMap = Default::default(); + let mut completed_at = None; + let (mut total, mut passed, mut failed, mut errored) = (0usize, 0usize, 0usize, 0usize); + if let Some(r) = &report_json { + completed_at = r + .get("completedAt") + .and_then(|v| v.as_str()) + .map(String::from); + total = r.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + passed = r.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + failed = r.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + errored = r.get("errored").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + if let Some(arr) = r.get("results").and_then(|v| v.as_array()) { + for c in arr { + if let Some(id) = c.get("caseId").and_then(|v| v.as_str()) { + verdicts.insert(id.to_string(), c.clone()); + } + } + } + } + // Fall back to the KarsEval's own status counts when no per-case report exists + // (an older run) so the detail's totals never contradict the summary card. + if !per_case_available + && let Ok(items) = cluster.list_kind_all("KarsEval").await + && let Some(ev) = items.iter().find(|o| name_of(o) == name) + { + if let Some(lr) = status(ev).get("lastResult") { + total = lr.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + passed = lr.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + failed = lr.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + errored = lr.get("errored").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + } + completed_at = s(status(ev), "lastRunAt"); + } + + let corpus_json: Option = corpus_raw + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + let corpus_name = corpus_json + .as_ref() + .and_then(|c| c.get("name")) + .and_then(|v| v.as_str()) + .map(String::from); + + let mut cases: Vec = Vec::new(); + if let Some(arr) = corpus_json + .as_ref() + .and_then(|c| c.get("cases")) + .and_then(|v| v.as_array()) + { + for case in arr { + let id = case + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tags = case + .get("tags") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|t| t.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let expected = case + .get("expect") + .and_then(|e| e.get("decision")) + .and_then(|v| v.as_str()) + .map(String::from); + // Summarise the probe: the last user message in the scenario. + let probe = case + .get("scenario") + .and_then(|s| s.get("messages")) + .and_then(|m| m.as_array()) + .and_then(|arr| { + arr.iter() + .rev() + .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user")) + }) + .and_then(|m| m.get("content").and_then(|c| c.as_str())) + .map(|s| s.chars().take(160).collect::()); + let v = verdicts.get(&id); + let pass = v.and_then(|c| c.get("pass")).and_then(|p| p.as_bool()); + let errored = v + .and_then(|c| c.get("errored")) + .and_then(|e| e.as_bool()) + .unwrap_or(false); + let actual = v + .and_then(|c| c.get("actual")) + .and_then(|a| a.get("decision")) + .and_then(|d| d.as_str()) + .map(String::from); + let actual_reason = v + .and_then(|c| c.get("actual")) + .and_then(|a| a.get("reason")) + .and_then(|d| d.as_str()) + .map(|s| s.chars().take(240).collect::()); + cases.push(EvalCaseDto { + id, + tags, + probe, + expected, + actual, + actual_reason, + pass, + errored, + }); + } + } + + Ok(Json(EvalReportDto { + name, + corpus: corpus_name, + total, + passed, + failed, + errored, + completed_at, + per_case_available, + cases, + })) +} + +// ─── MCP servers (connected services) ──────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct McpServerDto { + pub name: String, + pub namespace: String, + pub url: Option, + pub phase: Option, + pub mode: Option, + pub endpoint: Option, + pub workload_ref: Option, + pub discovered_tools: Vec, + pub tool_schema_digest: Option, + pub production: Option, + pub allowed_tools: Vec, + pub created: Option, + /// Raw `spec` for Edit-form prefill. + pub spec: serde_json::Value, +} + +fn to_mcp(o: &DynamicObject) -> McpServerDto { + let sp = spec(o); + let st = status(o); + let allowed_tools = sp + .get("allowedTools") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + McpServerDto { + name: name_of(o), + namespace: ns_of(o), + url: s(st, "endpoint").or_else(|| s(sp, "url")), + phase: s(st, "phase"), + mode: s(st, "mode"), + endpoint: s(st, "endpoint"), + workload_ref: s(st, "workloadRef"), + discovered_tools: st + .get("discoveredTools") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + tool_schema_digest: s(st, "toolSchemaDigest"), + production: sp.get("productionMode").and_then(|p| p.as_bool()), + allowed_tools, + created: created_of(o), + spec: sp.clone(), + } +} + +/// `GET /api/operator/mcpservers` — registered MCP servers (connected services). +pub async fn list_mcpservers(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_kind_all("McpServer").await.map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_mcp).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +// ─── Tool policies ─────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ToolPolicyDto { + pub name: String, + pub namespace: String, + pub phase: Option, + pub version_hash: Option, + /// What this policy applies to (sandbox/tool scope), in plain terms. + pub applies_to: Option, + /// Whether the policy carries an AGT governance profile (the rule set that + /// allows/denies/rate-limits capabilities). + pub has_governance_profile: bool, + /// Allowed tool / MCP identifiers, when expressed as a flat list. + pub allowed: Vec, + pub created: Option, + /// The raw `spec` object, so the console can prefill the Edit form with the + /// exact current spec (edit = re-apply with changed fields via SSA). + pub spec: serde_json::Value, +} + +fn to_toolpolicy(o: &DynamicObject) -> ToolPolicyDto { + let sp = spec(o); + let mut allowed: Vec = Vec::new(); + if let Some(arr) = sp.get("allow").and_then(|a| a.as_array()) { + allowed.extend(arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string()))); + } + if let Some(arr) = sp.get("tools").and_then(|a| a.as_array()) { + allowed.extend(arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string()))); + } + // The real ToolPolicy scopes via `appliesTo` (sandbox labels + tool glob) + // and governs capabilities through an embedded AGT profile. Project that + // into a plain summary rather than an empty list. + let applies_to = sp.get("appliesTo").map(|a| { + let tool = a.get("tool").and_then(|t| t.as_str()).unwrap_or("*"); + // Render the FULL sandbox selector, not just the well-known sandbox + // label, so a policy scoped by other labels isn't misreported as "*". + let labels = a + .get("sandboxMatchLabels") + .and_then(|l| l.as_object()) + .map(|m| { + m.iter() + .map(|(k, v)| format!("{}={}", k, v.as_str().unwrap_or(""))) + .collect::>() + .join(", ") + }) + .filter(|s| !s.is_empty()); + match labels { + Some(sel) => format!("sandbox [{sel}] · tools {tool}"), + None => format!("all sandboxes · tools {tool}"), + } + }); + let has_governance_profile = sp.get("agtProfile").is_some(); + ToolPolicyDto { + name: name_of(o), + namespace: ns_of(o), + phase: s(status(o), "phase"), + version_hash: s(status(o), "versionHash"), + applies_to, + has_governance_profile, + allowed, + created: created_of(o), + spec: sp.clone(), + } +} + +/// `GET /api/operator/toolpolicies` — tool/MCP authorization policies. +pub async fn list_toolpolicies( + State(state): State, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("ToolPolicy") + .await + .map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_toolpolicy).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +// ─── Inference policies ────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct InferencePolicyDto { + pub name: String, + pub namespace: String, + pub phase: Option, + pub version_hash: Option, + pub sandbox: Option, + pub daily_token_budget: Option, + pub content_safety: bool, + pub created: Option, + /// The raw spec, so the console's visual editor can pre-fill an edit + /// (name/sandbox/tokens/content-safety/model-preference) instead of a + /// hand-authored JSON blob. + pub spec: Value, +} + +fn to_inferencepolicy(o: &DynamicObject) -> InferencePolicyDto { + let sp = spec(o); + InferencePolicyDto { + name: name_of(o), + namespace: ns_of(o), + phase: s(status(o), "phase"), + version_hash: s(status(o), "versionHash"), + sandbox: sp + .get("appliesTo") + .and_then(|a| a.get("sandboxName")) + .and_then(|x| x.as_str()) + .map(|x| x.to_string()), + daily_token_budget: sp + .get("tokenBudget") + .and_then(|t| t.get("dailyTokens")) + .and_then(|x| x.as_i64()), + // Content safety is enforced when the floor actually sets a severity + // threshold or requires Prompt Shields — an empty `contentSafety: {}` + // object is not protection, so don't report it as enabled. + content_safety: sp + .get("contentSafety") + .map(|cs| { + ["hate", "selfHarm", "sexual", "violence"] + .iter() + .any(|k| cs.get(*k).and_then(|v| v.as_str()).is_some()) + || cs.get("requirePromptShields").and_then(|v| v.as_bool()) == Some(true) + }) + .unwrap_or(false), + created: created_of(o), + spec: sp.clone(), + } +} + +/// `GET /api/operator/inferencepolicies` — inference governance policies. +pub async fn list_inferencepolicies( + State(state): State, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("InferencePolicy") + .await + .map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_inferencepolicy).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +/// `POST /api/operator/inferencepolicies` — create (or Server-Side-Apply edit) a +/// standalone InferencePolicy the operator authors directly (e.g. a policy +/// scoped to a selector with a token budget + content-safety floor). The +/// per-sandbox `-inference` policies remain controller-generated; this is +/// the "I should be able to create inference policies" capability. +pub async fn create_inferencepolicy( + State(state): State, + Json(req): Json, +) -> AppResult> { + apply_governance(require_cluster(&state)?, "InferencePolicy", req).await +} + +#[derive(serde::Deserialize)] +pub struct PatchInferenceBudgetRequest { + /// New daily token cap for this policy (0 clears the cap). + pub daily_tokens: i64, +} + +/// `PATCH /api/operator/inferencepolicies/{name}` — edit a policy's daily token +/// budget in place (a merge patch on `spec.tokenBudget.dailyTokens`). For a +/// controller-generated policy the durable source is the mission's envelope +/// budget, so the reconciler may re-derive it; for an operator-authored policy +/// the edit sticks. +pub async fn patch_inferencepolicy( + State(state): State, + axum::extract::Path(name): axum::extract::Path, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let budget = if req.daily_tokens > 0 { + serde_json::json!({ "dailyTokens": req.daily_tokens }) + } else { + serde_json::Value::Null + }; + let patch = serde_json::json!({ "spec": { "tokenBudget": budget } }); + cluster + .merge_patch_kind("kars-system", "InferencePolicy", &name, patch) + .await + .map_err(apply_err)?; + Ok(Json(serde_json::json!({ "patched": true, "name": name }))) +} + +/// `DELETE /api/operator/inferencepolicies/{name}` — remove an operator-authored +/// policy. (A controller-generated one will be recreated by the reconciler.) +pub async fn delete_inferencepolicy( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + delete_governance(require_cluster(&state)?, "InferencePolicy", &name, None).await +} + +// ─── Egress (allowlists + temporary approvals) ─────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct EgressApprovalDto { + pub name: String, + pub namespace: String, + pub sandbox: Option, + pub phase: Option, + pub reason: Option, + pub hosts: Vec, + pub expires_at: Option, + pub created: Option, +} + +fn to_egress_approval(o: &DynamicObject) -> EgressApprovalDto { + let sp = spec(o); + let hosts = sp + .get("hosts") + .and_then(|h| h.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host").and_then(|x| x.as_str())?; + let port = e.get("port").and_then(|x| x.as_i64()); + Some(match port { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + EgressApprovalDto { + name: name_of(o), + namespace: ns_of(o), + sandbox: s(sp, "sandbox"), + phase: s(status(o), "phase"), + reason: s(sp, "reason"), + hosts, + expires_at: s(status(o), "expiresAt"), + created: created_of(o), + } +} + +/// `GET /api/operator/egress` — temporary egress approvals across the fleet. +pub async fn list_egress(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("EgressApproval") + .await + .map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_egress_approval).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +// ─── Audit: receipts + inclusion log + checkpoint ──────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ReceiptSummaryDto { + pub name: String, + pub namespace: String, + pub task: Option, + pub envelope_digest: Option, + pub key_id: Option, + pub inclusion_seq: Option, + pub created: Option, + /// At-a-glance verdict from the receipt's claim matrix (`spec.claims`, which + /// the CRD already carries) — `verified` (all required non-regulatory claims + /// PASS), `failed` (any FAIL), `partial` (required evidence incomplete), or + /// `none` (no claims). Regulatory maturity is advisory and shown in detail. + pub verdict: String, +} + +/// Reduce a receipt's `(class, status)` claim pairs to an overall verdict. +/// Any FAIL/ERROR ⇒ "failed". Otherwise the badge reflects the CRYPTOGRAPHIC +/// claims (integrity + conformance + completeness) — the "regulatory" claim and +/// any "OMITTED" status are advisory V0-maturity disclosures that must NOT block +/// a "verified" verdict (else every receipt reads "partial" forever). `class` +/// is expected lowercased, `status` uppercased. +fn receipt_verdict(claims: &[(String, String)]) -> &'static str { + if claims.is_empty() { + return "none"; + } + if claims.iter().any(|(_, s)| s == "FAIL" || s == "ERROR") { + return "failed"; + } + let core: Vec<&(String, String)> = claims + .iter() + .filter(|(class, status)| class != "regulatory" && status != "OMITTED") + .collect(); + if !core.is_empty() && core.iter().all(|(_, s)| s == "PASS" || s == "OK") { + "verified" + } else { + "partial" + } +} + +fn to_receipt_summary(o: &DynamicObject) -> ReceiptSummaryDto { + let sp = spec(o); + // The regulatory claim is a V0 maturity dimension — it is ALWAYS "PARTIAL" + // or "OMITTED" until an external KMS/transparency anchor lands (a named V1 + // follow-up), and "OMITTED" is an honest disclosure, not a verification + // failure. Treating either as blocking meant NO receipt could ever read + // "Verified" (every one showed "Partial"), making the verdict useless. So + // the badge reflects the CRYPTOGRAPHIC claims (integrity + conformance + + // completeness); the regulatory/omitted maturity is still shown in detail. + let claims: Vec<(String, String)> = sp + .get("claims") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| { + let status = c + .get("status") + .and_then(|s| s.as_str())? + .to_ascii_uppercase(); + let class = c + .get("class") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + Some((class, status)) + }) + .collect() + }) + .unwrap_or_default(); + let verdict = receipt_verdict(&claims).to_string(); + ReceiptSummaryDto { + name: name_of(o), + namespace: ns_of(o), + task: sp + .get("taskRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|x| x.to_string()), + envelope_digest: s(sp, "envelopeDigest"), + key_id: s(sp, "keyId"), + inclusion_seq: status(o).get("inclusionSeq").and_then(|x| x.as_i64()), + created: created_of(o), + verdict, + } +} + +#[derive(Debug, Serialize)] +pub struct AuditDto { + pub receipts: Vec, + pub inclusion_log_size: i64, + pub checkpoint: Option, + /// Real cryptographic integrity verdict — the whole hash chain recomputed + /// and the signed checkpoint verified against the published anchor. Drives + /// the audit banner so it reflects verification, not field presence. + pub integrity: crate::routes::receipts::LogIntegrity, +} + +#[derive(Debug, Serialize)] +pub struct CheckpointSummaryDto { + pub tree_size: i64, + pub root_hash: String, + pub key_id: String, + pub published_at: Option, +} + +/// `GET /api/operator/audit` — the audit substrate: every governance receipt, +/// the inclusion-log size, and the signed checkpoint (signed tree head). +pub async fn get_audit(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("KarsReceipt") + .await + .map_err(upstream)?; + let mut receipts: Vec = items.iter().map(to_receipt_summary).collect(); + receipts.sort_by_key(|a| a.inclusion_seq); + + let log = cluster + .receipt_log() + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + let inclusion_log_size = log.entries.len() as i64; + + let checkpoint = log.checkpoint.as_ref().and_then(|d| { + let tree_size = d.get("treeSize")?.parse::().ok()?; + Some(CheckpointSummaryDto { + tree_size, + root_hash: d.get("rootHash").cloned().unwrap_or_default(), + key_id: d.get("keyId").cloned().unwrap_or_default(), + published_at: d.get("publishedAt").cloned(), + }) + }); + + Ok(Json(AuditDto { + receipts, + inclusion_log_size, + checkpoint, + integrity: crate::routes::receipts::verify_log_integrity(&log), + })) +} + +// ─── Skills & Profiles (predefined building blocks for customers) ──────────── + +#[derive(Debug, Serialize)] +pub struct SkillDto { + pub name: String, + pub namespace: String, + pub version: Option, + pub summary: Option, + pub bounding_policy: Option, + pub phase: Option, + pub version_digest: Option, + pub attestation_verified: Option, + // ── Operator trust gate ────────────────────────────────────────────────── + /// Admission verdict: "approved" once an operator has signed off, else the + /// skill is treated as pending review. + pub review: String, + /// The version digest the approval is locked to (from status at approval). + pub locked_digest: Option, + pub approved_by: Option, + pub approved_at: Option, + /// True when approved AND the locked digest still matches the current + /// version digest — i.e. usable by users. False if never approved or the + /// skill changed since approval (lock broken → back to review). + pub usable: bool, + /// Raw `spec` for Edit-form prefill. + pub spec: serde_json::Value, +} + +fn to_skill(o: &DynamicObject) -> SkillDto { + let sp = spec(o); + let version_digest = s(status(o), "versionDigest"); + let review = annotation(o, ANN_REVIEW).unwrap_or_else(|| "pending".into()); + let locked_digest = annotation(o, ANN_LOCKED_DIGEST); + // Usable only when explicitly approved and the lock still matches the live + // digest. When the skill has no digest yet (not scanned), it can't be usable. + let usable = review == "approved" && locked_digest.is_some() && locked_digest == version_digest; + SkillDto { + name: name_of(o), + namespace: ns_of(o), + version: s(sp, "version"), + summary: s(sp, "summary"), + bounding_policy: s(sp, "boundingPolicy"), + phase: s(status(o), "phase"), + version_digest, + attestation_verified: status(o) + .get("attestationVerified") + .and_then(|v| v.as_bool()), + review, + locked_digest, + approved_by: annotation(o, ANN_APPROVED_BY), + approved_at: annotation(o, ANN_APPROVED_AT), + usable, + spec: sp.clone(), + } +} + +#[derive(Debug, Serialize, serde::Deserialize, Clone)] +pub struct McpProfileDto { + pub name: String, + #[serde(default)] + pub summary: Option, + /// Names of the operator-vetted McpServers this profile bundles. + pub servers: Vec, +} + +/// `GET /api/operator/mcp-profiles` — the operator-curated MCP bundles users +/// can pick from (a named, vetted set of McpServers, so users compose from +/// approved groupings rather than assembling servers one by one). +pub async fn list_mcp_profiles( + State(state): State, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let raw = cluster.read_mcp_profiles().await; + let profiles: Vec = serde_json::from_str(&raw).unwrap_or_default(); + Ok(Json(profiles)) +} + +/// `PUT /api/operator/mcp-profiles` — upsert a profile by name. Validates that +/// every referenced server is a real McpServer on the cluster, so a profile can +/// never bundle a non-existent (unvetted) server. +pub async fn put_mcp_profile( + State(state): State, + Json(req): Json, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + if req.name.trim().is_empty() { + return Err(AppError::BadRequest("profile name is required".into())); + } + // Real McpServers on the cluster — the vetted universe a profile may draw from. + let known: std::collections::BTreeSet = cluster + .list_kind_all("McpServer") + .await + .map_err(upstream)? + .iter() + .map(name_of) + .collect(); + for s in &req.servers { + if !known.contains(s) { + return Err(AppError::BadRequest(format!( + "server '{s}' is not a registered McpServer — vet it first" + ))); + } + } + let raw = cluster.read_mcp_profiles().await; + let mut profiles: Vec = serde_json::from_str(&raw).unwrap_or_default(); + profiles.retain(|p| p.name != req.name); + profiles.push(req); + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + let json = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".into()); + cluster + .write_mcp_profiles(&json) + .await + .map_err(AppError::Internal)?; + Ok(Json(profiles)) +} + +/// `DELETE /api/operator/mcp-profiles/:name` — remove a profile. +pub async fn delete_mcp_profile( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let raw = cluster.read_mcp_profiles().await; + let mut profiles: Vec = serde_json::from_str(&raw).unwrap_or_default(); + profiles.retain(|p| p.name != name); + let json = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".into()); + cluster + .write_mcp_profiles(&json) + .await + .map_err(AppError::Internal)?; + Ok(Json(profiles)) +} + +pub async fn list_skills(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_kind_all("KarsSkill").await.map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_skill).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +/// Annotation recording who uploaded a user-submitted skill (provenance for the +/// operator reviewing it). +const ANN_UPLOADED_BY: &str = "kars.azure.com/skill-uploaded-by"; +const ANN_UPLOADED_BY_SUB: &str = "kars.azure.com/skill-uploaded-by-sub"; + +#[derive(serde::Deserialize)] +pub struct SubmitSkillRequest { + /// DNS-1123 object name (kebab-case). + pub name: String, + pub display_name: Option, + pub version: String, + pub summary: String, + /// The bounding tool policy — must be one the operator already vetted; it + /// caps what the skill's recipe can do. Users pick from the approved set. + pub bounding_policy: String, + pub recipe: Option, + #[serde(default)] + pub mcp_servers: Vec, + /// The skill PACKAGE files — flat filenames (SKILL.md + scripts). Stored as + /// the `karsskill-` ConfigMap and mounted into a granting sandbox. + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct SkillFile { + /// Flat filename (no path separators) — e.g. `SKILL.md`, `triage.sh`. + pub name: String, + pub content: String, +} + +/// `POST /api/skills` — USER skill submission. A team member uploads a skill +/// package; it lands as a `KarsSkill` that starts life PENDING REVIEW (never +/// usable until an operator scans + approves it). This is the user side of the +/// trust gate: users propose capability, operators vet + sign, then it's +/// grantable. The BFF never marks a user-submitted skill approved. +pub async fn submit_skill( + State(state): State, + Extension(principal): Extension, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let name = req.name.trim(); + if !is_dns1123_label(name) { + return Err(AppError::BadRequest( + "skill name must be a DNS-1123 label (lowercase letters, digits, hyphens)".into(), + )); + } + if req.version.trim().is_empty() { + return Err(AppError::BadRequest("version is required".into())); + } + if req.summary.trim().len() < 8 { + return Err(AppError::BadRequest("a real summary is required".into())); + } + if req.bounding_policy.trim().is_empty() { + return Err(AppError::BadRequest( + "a bounding tool policy is required — it caps what the skill may do".into(), + )); + } + let ns = "kars-system".to_string(); + let mut spec = serde_json::json!({ + "version": req.version.trim(), + "summary": req.summary.trim(), + "boundingPolicy": req.bounding_policy.trim(), + }); + if let Some(dn) = req.display_name.as_ref().filter(|s| !s.trim().is_empty()) { + spec["displayName"] = serde_json::json!(dn.trim()); + } + if let Some(r) = req.recipe.as_ref().filter(|s| !s.trim().is_empty()) { + spec["recipe"] = serde_json::json!(r.trim()); + } + if !req.mcp_servers.is_empty() { + spec["mcpServers"] = serde_json::json!(req.mcp_servers); + } + // Validate + collect the package files. Standard Agent Skills use + // subdirectories (scripts/, references/, assets/) referenced relatively from + // SKILL.md. ConfigMap keys can't contain '/', so we accept relative paths + // here and path-encode '/'→'__' only when writing the ConfigMap; the sandbox + // entrypoint decodes them back on mount so the on-disk tree matches exactly. + // A real skill package is at least a SKILL.md at the root. + let mut files: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for f in &req.files { + let fname = f.name.trim(); + let bad_segments = fname.split('/').any(|seg| { + seg.is_empty() + || !seg + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }); + if fname.is_empty() + || fname.starts_with('/') + || fname.ends_with('/') + || fname.contains("..") + || fname.contains("__") // reserved as the CM path separator + || fname.len() > 253 + || bad_segments + { + return Err(AppError::BadRequest(format!( + "invalid skill file path '{fname}': use relative paths (letters, digits, . _ - and / for subdirs); no '..', no '__', no leading/trailing '/'" + ))); + } + files.insert(fname.to_string(), f.content.clone()); + } + if !files.is_empty() { + // A package the agent can actually USE must carry a SKILL.md — OpenClaw + // auto-discovers `/SKILL.md` and reads its frontmatter `description` + // to know when to invoke the skill. Without it the files are dead weight. + let skill_md = files.get("SKILL.md"); + match skill_md { + None => { + return Err(AppError::BadRequest( + "a skill package must include a SKILL.md — the agent discovers the skill from it".into(), + )); + } + Some(md) if !md.contains("description:") => { + return Err(AppError::BadRequest( + "SKILL.md must have YAML frontmatter with a `description:` — that's how the agent knows when to use the skill".into(), + )); + } + _ => {} + } + spec["package"] = serde_json::json!(true); + spec["files"] = serde_json::json!(files.keys().cloned().collect::>()); + use sha2::{Digest, Sha256}; + let configmap_data: std::collections::BTreeMap = files + .iter() + .map(|(path, content)| (path.replace('/', "__"), content.clone())) + .collect(); + let canonical = serde_json::to_vec(&configmap_data) + .map_err(|e| AppError::Internal(anyhow::Error::new(e)))?; + spec["packageDigest"] = serde_json::json!(format!( + "sha256:{}", + hex::encode(Sha256::digest(&canonical)) + )); + } + let uploader = principal.name; + let uploader_sub = principal.sub; + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSkill", + "metadata": { + "name": name, + "namespace": ns, + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + // Explicitly PENDING — the operator trust gate must approve it before + // it is usable. Never set review=approved on the user path. + "annotations": { + ANN_REVIEW: "pending", + ANN_UPLOADED_BY: uploader, + ANN_UPLOADED_BY_SUB: uploader_sub, + }, + }, + "spec": spec, + }); + let applied = cluster + .apply_kind(&ns, "KarsSkill", body, false) + .await + .map_err(apply_err)?; + // Persist the package files as the karsskill- ConfigMap so the + // controller can mount them into a granting sandbox. ConfigMap keys can't + // contain '/', so subdirectory paths are encoded '/'→'__'; the sandbox + // entrypoint decodes them back to the real tree on mount. + if !files.is_empty() { + let cm_files: std::collections::BTreeMap = files + .iter() + .map(|(path, content)| (path.replace('/', "__"), content.clone())) + .collect(); + let package_digest = spec + .get("packageDigest") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("package digest missing")))?; + cluster + .write_skill_package(name, &cm_files, package_digest) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } + Ok(Json(to_skill(&applied))) +} + +/// Locate a skill by name across namespaces, returning `(namespace, object)`. +async fn find_skill( + cluster: &crate::kars::cluster::Cluster, + name: &str, +) -> AppResult<(String, DynamicObject)> { + let items = cluster.list_kind_all("KarsSkill").await.map_err(upstream)?; + items + .into_iter() + .find(|o| name_of(o) == name) + .map(|o| (ns_of(&o), o)) + .ok_or(AppError::NotFound) +} + +/// `POST /api/operator/skills/:name/approve` — the operator admission gate. +/// Records the operator's approval and LOCKS it to the skill's current version +/// digest, after which users can assign the skill. Requires the skill to have +/// been scanned (a version digest present) and its attestation to have verified +/// — an operator can't approve a skill the controller hasn't validated. Any +/// later change to the skill breaks the lock and returns it to review. +pub async fn approve_skill( + State(state): State, + Extension(principal): Extension, + axum::extract::Path(name): axum::extract::Path, + Json(_req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let (ns, obj) = find_skill(cluster, &name).await?; + let uploader_subject = obj + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(ANN_UPLOADED_BY_SUB)) + .cloned(); + if uploader_subject.as_deref() == Some(principal.sub.as_str()) { + return Err(AppError::Forbidden( + "skill submitter cannot approve their own package".into(), + )); + } + let generation = obj.metadata.generation.unwrap_or_default(); + let observed_generation = status(&obj) + .get("observedGeneration") + .and_then(|v| v.as_i64()) + .unwrap_or_default(); + if observed_generation != generation { + return Err(AppError::Conflict( + "skill changed after its last controller scan; wait for the current generation".into(), + )); + } + let digest = s(status(&obj), "versionDigest").ok_or_else(|| { + AppError::BadRequest( + "skill has not been scanned yet (no version digest) — the controller must validate it before approval".into(), + ) + })?; + // Honest gate: don't let an operator approve a skill whose attestation the + // controller could not verify. + if status(&obj) + .get("attestationVerified") + .and_then(|v| v.as_bool()) + == Some(false) + { + return Err(AppError::BadRequest( + "skill attestation did not verify — cannot approve until the scan passes".into(), + )); + } + let by = principal.name; + let now = chrono::Utc::now().to_rfc3339(); + let updated = cluster + .annotate_kind( + &ns, + "KarsSkill", + &name, + &[ + (ANN_REVIEW, Some("approved".into())), + (ANN_LOCKED_DIGEST, Some(digest)), + (ANN_APPROVED_BY, Some(by)), + (ANN_APPROVED_AT, Some(now)), + ], + ) + .await + .map_err(upstream)?; + Ok(Json(to_skill(&updated))) +} + +/// `POST /api/operator/skills/:name/revoke` — withdraw approval, returning the +/// skill to review (users immediately stop seeing it). +pub async fn revoke_skill( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let (ns, _) = find_skill(cluster, &name).await?; + let updated = cluster + .annotate_kind( + &ns, + "KarsSkill", + &name, + &[ + (ANN_REVIEW, Some("pending".into())), + (ANN_LOCKED_DIGEST, None), + (ANN_APPROVED_BY, None), + (ANN_APPROVED_AT, None), + ], + ) + .await + .map_err(upstream)?; + Ok(Json(to_skill(&updated))) +} + +#[derive(Debug, serde::Deserialize)] +pub struct ApproveSkillRequest {} + +#[derive(Debug, Serialize)] +pub struct ProfileRoleDto { + pub name: String, + pub system_prompt: Option, + pub skills: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ProfileDto { + pub name: String, + pub namespace: String, + pub domain: Option, + pub phase: Option, + pub template_digest: Option, + // Instantiation fields — so the team composer can prefill a whole team from + // a profile (the profile is a vetted org template, not a dead-end record). + pub display_name: Option, + pub charter_template: Option, + pub tier: Option, + pub tool_policy: Option, + pub knowledge_commons: Option, + pub roles: Vec, + /// Raw spec for Edit-form prefill. + pub spec: serde_json::Value, +} + +fn to_profile(o: &DynamicObject) -> ProfileDto { + let sp = spec(o); + let roles = sp + .get("roles") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| { + let name = r.get("name")?.as_str()?.to_string(); + Some(ProfileRoleDto { + name, + system_prompt: r + .get("systemPrompt") + .and_then(|s| s.as_str()) + .map(String::from), + skills: r + .get("skills") + .and_then(|s| s.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + }) + }) + .collect() + }) + .unwrap_or_default(); + ProfileDto { + name: name_of(o), + namespace: ns_of(o), + domain: s(sp, "domain"), + phase: s(status(o), "phase"), + template_digest: s(status(o), "templateDigest"), + display_name: s(sp, "displayName"), + charter_template: s(sp, "charterTemplate"), + tier: sp + .get("defaultEnvelope") + .and_then(|e| e.get("tier")) + .and_then(|t| t.as_i64()) + .map(|t| t as i32), + tool_policy: s(sp, "toolPolicy"), + knowledge_commons: s(sp, "knowledgeCommons"), + roles, + spec: sp.clone(), + } +} + +pub async fn list_profiles(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("KarsProfile") + .await + .map_err(upstream)?; + let mut dtos: Vec = items.iter().map(to_profile).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +/// `PUT /api/operator/profiles` — author/edit a `KarsProfile` (SSA). +pub async fn put_profile( + State(state): State, + Json(req): Json, +) -> AppResult> { + apply_governance(require_cluster(&state)?, "KarsProfile", req).await +} + +/// `DELETE /api/operator/profiles/:name` — remove a `KarsProfile`. +pub async fn delete_profile( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + delete_governance(require_cluster(&state)?, "KarsProfile", &name, None).await +} + +// ─── Credentials (secure repo/system access for agents) ────────────────────── + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialRequest { + /// The agent/team this credential is for (becomes `-credentials`). + pub target: String, + pub kind: String, + pub namespace: String, + #[serde(default)] + pub target_uid: Option, + /// The env var name the agent reads (e.g. GITHUB_TOKEN, BRAVE_API_KEY). + pub key: String, + /// The secret value. Stored only in the K8s Secret; never read back. + pub value: String, + #[serde(default)] + pub review: Option, +} + +/// A DNS-1123 label (lowercase alphanumeric + hyphens, must start/end +/// alphanumeric, ≤63 chars) — the constraint on the `kars-` namespace +/// derived below, so an invalid target is rejected before it reaches the API +/// server as an opaque 422. +pub(super) fn is_dns1123_label(s: &str) -> bool { + !s.is_empty() + && s.len() <= 63 + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !s.starts_with('-') + && !s.ends_with('-') +} + +/// A POSIX-ish environment variable name: letters/digits/underscore, not +/// starting with a digit. Agents read the credential under this name. +pub(super) fn is_env_key(s: &str) -> bool { + let mut chars = s.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +pub(super) fn credential_write_error(error: kube::Error) -> AppError { + match error { + kube::Error::Api(status) if status.code == 409 => AppError::Conflict( + "Credential authority changed or a source already exists. Refresh credential metadata and review before resubmitting; a source write may already be stored. No automatic retry or rollback was attempted.".into(), + ), + kube::Error::Api(status) => { + AppError::Upstream(format!("Credential write: Kubernetes status {}", status.code)) + } + _ => AppError::Upstream("Credential write transport or serialization failed".into()), + } +} + +/// `POST /api/operator/credentials` — write a governed workspace source and +/// bind its actual UID to the reviewed target. Values are write-only. +/// A binding conflict may follow a committed source write; report 409 without +/// retrying the transaction or deleting a source whose delivery is uncertain. +pub async fn put_credential( + State(state): State, + principal: Option>, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let target = req.target.trim(); + let key = req.key.trim(); + if target.is_empty() || key.is_empty() || req.value.is_empty() { + return Err(AppError::BadRequest( + "target, key and value are required".into(), + )); + } + // Validate client-supplied names client-side of the API server, so the + // failure is an actionable 400 rather than an opaque Kubernetes 422. + if !is_dns1123_label(target) { + return Err(AppError::BadRequest( + "target must be a DNS-1123 label (lowercase letters, digits, hyphens; not starting/ending with a hyphen; ≤63 chars)".into(), + )); + } + if !is_env_key(key) { + return Err(AppError::BadRequest( + "key must be a valid environment variable name (letters, digits, underscore; not starting with a digit)".into(), + )); + } + if !is_dns1123_label(&req.namespace) + || !["KarsSandbox", "KarsTask", "KarsTeam"].contains(&req.kind.as_str()) + { + return Err(AppError::BadRequest("An explicit workspace namespace and KarsSandbox/KarsTask/KarsTeam target kind are required".into())); + } + if req.review.is_some() { + let principal = principal.ok_or_else(|| { + AppError::Forbidden( + "A verified operator is required for reviewed credential writes".into(), + ) + })?; + return super::credential_review::write(&state, &principal.0, req).await; + } + Ok(Json( + cluster + .write_agent_credentials( + &req.namespace, + &req.kind, + target, + req.target_uid.as_deref(), + std::collections::BTreeMap::from([(key.to_string(), req.value)]), + Vec::new(), + ) + .await + .map_err(credential_write_error)?, + )) +} + +// ─── Provider onboarding (model providers for missions + envelope gen) ─────── + +#[derive(Debug, serde::Deserialize)] +pub struct ProviderRequest { + /// "github-models" | "azure-openai" | "foundry". + pub kind: String, + /// Auth mode: "api" (key), "workload" (workload identity), "agentid". + pub auth: String, + pub endpoint: Option, + /// Comma-separated deployment ids to expose in the catalog. + pub models: String, + /// Optional key when auth=api; stored write-only in kars-system. + pub key: Option, +} + +/// `POST /api/operator/providers` — onboard a model provider. Sets the catalog +/// the controller serves, records the endpoint, and (for api auth) stores the +/// key as a write-only secret. Workload/agentid auth store no secret — the +/// controller authenticates via its identity. The catalog feeds both mission +/// models and envelope generation. Patches the controller deployment env. +pub async fn put_provider( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + // GUARD: this endpoint only ever wires FOUNDRY_ENDPOINT + AZURE_OPENAI_API_KEY + // onto the controller (set_controller_catalog below). GitHub Copilot needs a + // COPILOT_GITHUB_TOKEN (exchanged for a Copilot JWT by the router's copilot_auth + // path) and GitHub Models needs its catalog endpoint recognized by the router's + // is_github_models() host check — neither is wired by this route. Silently + // "succeeding" here would tell the operator the cluster default changed when it + // did not. Reject until real backend wiring exists; both kinds work correctly + // today via the "additional provider" flow (POST .../providers/additional), + // which does propagate a real per-provider tag + credential to every sandbox. + if req.kind == "github-copilot" || req.kind == "github-models" { + return Err(AppError::BadRequest(format!( + "{} can't be set as the cluster's default provider from this form yet \ + (it only wires an Azure-style endpoint/key). Add it as an additional \ + provider instead — every sandbox can already route to it per-request \ + via an InferencePolicy model preference.", + if req.kind == "github-copilot" { + "GitHub Copilot" + } else { + "GitHub Models" + } + ))); + } + let models: Vec<&str> = req + .models + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + if models.is_empty() { + return Err(AppError::BadRequest( + "at least one model deployment is required".into(), + )); + } + let mut key_secret: Option<(String, String)> = None; + if req.auth == "api" { + if let Some(k) = req.key.as_deref().filter(|k| !k.trim().is_empty()) { + let secret = format!("kars-provider-{}", req.kind); + cluster.upsert_secret("kars-system", &secret, serde_json::json!({ + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": secret, "namespace": "kars-system", "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}}, + "stringData": {"API_KEY": k}, + })).await.map_err(upstream)?; + key_secret = Some((secret, "API_KEY".to_string())); + } else { + return Err(AppError::BadRequest( + "auth=api requires a provider API key".into(), + )); + } + } + let key_ref = key_secret.as_ref().map(|(s, k)| (s.as_str(), k.as_str())); + cluster + .set_controller_catalog(&models.join(","), req.endpoint.as_deref(), key_ref) + .await + .map_err(upstream)?; + Ok(Json( + serde_json::json!({"onboarded": true, "kind": req.kind, "auth": req.auth, "models": models, + "note": if key_secret.is_some() { + "Catalog updated and the API key wired into the controller via secretKeyRef (AZURE_OPENAI_API_KEY), which the controller propagates to sandbox pods. The controller is rolling to pick it up." + } else { + "Catalog updated; the controller is rolling. workload/agentid auth use the controller's own identity — no key stored." + }}), + )) +} + +/// One discoverable model, browser-facing. +#[derive(Debug, Serialize)] +pub struct DiscoveredModelDto { + /// The exact id to feed back into `ProviderRequest.models` (e.g. `openai/gpt-4o`). + pub id: String, + /// Human label, when richer than the id (e.g. "OpenAI GPT-4o"). + pub label: Option, + /// True for a highlighted/pre-selected pick. For GitHub Copilot this is + /// every model in Copilot's own `powerful` picker category (the flagship + /// tier), derived LIVE from the `/models` endpoint — not a hand-picked id + /// that goes stale. Absent/false for GitHub Models / Azure OpenAI, which + /// have no "best pick" signal. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recommended: bool, + /// Short human detail (e.g. "Anthropic · 1.0M ctx · powerful"), when the + /// provider exposes it (GitHub Copilot's live catalog does). Shown in the + /// Model catalogue so a model isn't just an opaque id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// The same editor/integration headers the router's `copilot_auth` sends, so +/// the seat sees a consistent client identity across discovery and inference. +const COPILOT_EDITOR_VERSION: &str = "vscode/1.107.0"; +const COPILOT_INTEGRATION_ID: &str = "vscode-chat"; +/// Public OAuth client id for the GitHub Copilot device-flow integration — the +/// SAME id the CLI's `copilotDeviceLogin` uses (cli/src/github-copilot.ts). A +/// token minted through this flow is authorized for the `copilot_internal/v2/ +/// token` exchange, unlike a stock `gh auth login` token (which 404s there). +const COPILOT_OAUTH_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; + +/// `POST /api/operator/providers/copilot/login/start` — begin the GitHub +/// device-flow OAuth so the operator can sign in to Copilot properly (no +/// hand-pasted token). Returns the user code + verification URL to show, and +/// the device code the client polls with. +pub async fn copilot_login_start() -> AppResult> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .post("https://github.com/login/device/code") + .header("Accept", "application/json") + .header("User-Agent", "kars-bridge") + .json(&serde_json::json!({ "client_id": COPILOT_OAUTH_CLIENT_ID, "scope": "read:user" })) + .send() + .await + .map_err(|e| AppError::Upstream(format!("device-code request failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "GitHub device-code returned {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad device-code JSON: {e}")))?; + Ok(Json(serde_json::json!({ + "device_code": body.get("device_code").and_then(|v| v.as_str()).unwrap_or_default(), + "user_code": body.get("user_code").and_then(|v| v.as_str()).unwrap_or_default(), + "verification_uri": body.get("verification_uri").and_then(|v| v.as_str()).unwrap_or("https://github.com/login/device"), + "interval": body.get("interval").and_then(|v| v.as_u64()).unwrap_or(5), + "expires_in": body.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(900), + }))) +} + +#[derive(Debug, serde::Deserialize)] +pub struct CopilotLoginPollRequest { + pub device_code: String, +} + +/// `POST /api/operator/providers/copilot/login/poll` — poll the device flow. +/// While the user hasn't approved yet, returns `{status:"pending"}`. On +/// approval it: (1) verifies the minted token is Copilot-entitled, (2) stores +/// it server-side as the Copilot provider credential (COPILOT_GITHUB_TOKEN in +/// the shared providers secret) — the token NEVER returns to the browser, +/// (3) busts the live-catalog cache, and (4) returns the seat's live model +/// list so the wizard can show it immediately. +pub async fn copilot_login_poll( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .header("User-Agent", "kars-bridge") + .json(&serde_json::json!({ + "client_id": COPILOT_OAUTH_CLIENT_ID, + "device_code": req.device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + })) + .send() + .await + .map_err(|e| AppError::Upstream(format!("device poll failed: {e}")))?; + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad poll JSON: {e}")))?; + + if let Some(token) = body + .get("access_token") + .and_then(|v| v.as_str()) + .filter(|t| !t.is_empty()) + { + // Verify the seat is genuinely Copilot-entitled before storing. + copilot_jwt(token).await?; + // Store server-side as the Copilot provider credential (never returned + // to the browser). Also refresh the controller's default credential so + // a cluster whose default IS Copilot starts working immediately. + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + keys.insert("COPILOT_GITHUB_TOKEN".to_string(), token.to_string()); + }) + .await + .map_err(upstream)?; + // Fresh token → invalidate any cached catalog for the old one. + invalidate_copilot_catalog_cache(); + let models = copilot_catalog_cached(token).await; + return Ok(Json(serde_json::json!({ + "status": "authorized", + "models": models.iter().map(|(id, rec, detail)| serde_json::json!({"id": id, "recommended": rec, "detail": detail})).collect::>(), + }))); + } + + match body.get("error").and_then(|v| v.as_str()) { + Some("authorization_pending") | Some("slow_down") => { + Ok(Json(serde_json::json!({ "status": "pending" }))) + } + Some("expired_token") => Err(AppError::Rejected( + "The sign-in code expired before it was approved. Start again.".into(), + )), + Some("access_denied") => Err(AppError::Rejected( + "Sign-in was cancelled on GitHub.".into(), + )), + Some(other) => Err(AppError::Upstream(format!( + "GitHub device flow error: {other}" + ))), + None => Ok(Json(serde_json::json!({ "status": "pending" }))), + } +} + +/// Exchange a GitHub OAuth token / PAT for a short-lived Copilot JWT — the +/// exact same endpoint (and `chat_enabled` eligibility semantics) the CLI's +/// `checkCopilotEligibility` and the router's `copilot_auth` use. A 200 with a +/// token and `chat_enabled != false` means the router will actually be able to +/// serve inference for this seat, not merely that the token parses. Returns +/// the JWT so the caller can immediately query the live `/models` catalog with +/// it (no second exchange). +pub(crate) async fn copilot_jwt(gh_token: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .get("https://api.github.com/copilot_internal/v2/token") + .header("Authorization", format!("token {gh_token}")) + .header("Accept", "application/json") + .header("User-Agent", "kars-bridge") + .send() + .await + .map_err(|e| AppError::Upstream(format!("Copilot eligibility check failed: {e}")))?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN + { + return Err(AppError::Rejected( + "This GitHub token isn't entitled to Copilot. Enable Copilot at https://github.com/settings/copilot, or use a token from an account with an active seat.".into(), + )); + } + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "Copilot token endpoint returned {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad Copilot token response: {e}")))?; + if body.get("chat_enabled").and_then(|c| c.as_bool()) == Some(false) { + return Err(AppError::Rejected( + "Copilot subscription is active but Chat is disabled. Enable it at https://github.com/settings/copilot/features.".into(), + )); + } + body.get("token") + .and_then(|t| t.as_str()) + .map(str::to_string) + .ok_or_else(|| AppError::Upstream("Copilot token endpoint returned no token".into())) +} + +/// Parse GitHub Copilot's live `/models` response into the browser DTO. Pure +/// (no I/O) so it's unit-testable against a captured sample. Surfaces ONLY the +/// models a seat can actually reason with: +/// • `capabilities.type == "chat"` — excludes embeddings. +/// • `model_picker_enabled == true` — Copilot's own "show in picker" flag; +/// drops legacy/hidden aliases (gpt-4o, gpt-3.5-turbo, dated snapshots). +/// • policy absent, OR `policy.state == "enabled"` — a gated preview the +/// seat hasn't opted into is not usable, so it's hidden. +/// Ordering: Copilot's picker category (powerful → versatile → lightweight), +/// then context window desc, then id — so the flagship tier leads. Every +/// `powerful`-category model is marked `recommended` (pre-checked in the +/// wizard). This is entirely live: a new flagship (gpt-5.7, opus-4.9, …) +/// appears and is categorised by GitHub, with no code change here. +pub(crate) fn parse_copilot_models(body: &serde_json::Value) -> Vec { + fn category_rank(cat: &str) -> u8 { + match cat { + "powerful" => 0, + "versatile" => 1, + "lightweight" => 2, + _ => 3, + } + } + let mut rows: Vec<(u8, u64, String, DiscoveredModelDto)> = Vec::new(); + let Some(data) = body.get("data").and_then(|d| d.as_array()) else { + return Vec::new(); + }; + for m in data { + let caps = m.get("capabilities"); + let is_chat = caps.and_then(|c| c.get("type")).and_then(|t| t.as_str()) == Some("chat"); + let picker = m + .get("model_picker_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // policy absent => generally available; present => must be "enabled". + let policy_ok = match m.get("policy") { + None => true, + Some(p) => p.get("state").and_then(|s| s.as_str()) == Some("enabled"), + }; + if !(is_chat && picker && policy_ok) { + continue; + } + let Some(id) = m + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + else { + continue; + }; + let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); + let vendor = m.get("vendor").and_then(|v| v.as_str()).unwrap_or(""); + let category = m + .get("model_picker_category") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ctx = caps + .and_then(|c| c.get("limits")) + .and_then(|l| l.get("max_context_window_tokens")) + .and_then(|v| v.as_u64()); + let ctx_label = ctx + .map(|c| { + if c >= 1_000_000 { + format!("{:.1}M ctx", c as f64 / 1_000_000.0) + } else { + format!("{}k ctx", c / 1000) + } + }) + .unwrap_or_default(); + let label = [vendor, &ctx_label, category] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(" · "); + let detail = if label.is_empty() { + None + } else { + Some(label.clone()) + }; + rows.push(( + category_rank(category), + ctx.unwrap_or(0), + id.to_string(), + DiscoveredModelDto { + id: id.to_string(), + label: (name != id || !label.is_empty()).then(|| { + if label.is_empty() { + name.to_string() + } else { + format!("{name} — {label}") + } + }), + recommended: category == "powerful", + detail, + }, + )); + } + // Sort: powerful first, then largest context, then id desc (newer version + // numbers tend to sort higher) — purely presentational. + rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(b.2.cmp(&a.2))); + rows.into_iter().map(|(_, _, _, dto)| dto).collect() +} + +/// Fetch the live Copilot model catalog for a seat, given its exchanged JWT. +pub(crate) async fn fetch_copilot_models(jwt: &str) -> Result, AppError> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .get("https://api.githubcopilot.com/models") + .header("Authorization", format!("Bearer {jwt}")) + .header("Editor-Version", COPILOT_EDITOR_VERSION) + .header("Copilot-Integration-Id", COPILOT_INTEGRATION_ID) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| AppError::Upstream(format!("Copilot /models request failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "Copilot /models returned {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad Copilot /models JSON: {e}")))?; + Ok(parse_copilot_models(&body)) +} + +/// A short-TTL cache for the live Copilot catalog so `build_options` (hit on +/// every Configuration page load AND every orchestrator compose) doesn't do a +/// token-exchange + /models round-trip each time. Keyed by the token so a +/// changed seat re-fetches; 5-minute freshness is plenty for a model list. +type CopilotCatalog = Vec<(String, bool, Option)>; +type CachedCopilotCatalog = (String, std::time::Instant, CopilotCatalog); +static COPILOT_CATALOG_CACHE: std::sync::Mutex> = + std::sync::Mutex::new(None); + +/// Drop the cached Copilot catalog — call after a fresh sign-in so the next +/// `build_options` re-fetches against the new token immediately. +pub(crate) fn invalidate_copilot_catalog_cache() { + *COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()) = None; +} + +/// Live (cached) Copilot model catalog for a seat token: `(deployment_id, +/// recommended, detail)` for every model the seat can actually use. Best-effort +/// — on any auth/network failure it returns the last good cache if still +/// present, else empty, so a transient Copilot outage never blanks the catalogue. +pub(crate) async fn copilot_catalog_cached(gh_token: &str) -> Vec<(String, bool, Option)> { + const TTL: std::time::Duration = std::time::Duration::from_secs(300); + { + let guard = COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()); + if let Some((tok, at, models)) = guard.as_ref() + && tok == gh_token + && at.elapsed() < TTL + { + return models.clone(); + } + } + let fetched = async { + let jwt = copilot_jwt(gh_token).await.ok()?; + let models = fetch_copilot_models(&jwt).await.ok()?; + Some( + models + .into_iter() + .map(|m| (m.id, m.recommended, m.detail)) + .collect::>(), + ) + } + .await; + match fetched { + Some(models) => { + let mut guard = COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()); + *guard = Some(( + gh_token.to_string(), + std::time::Instant::now(), + models.clone(), + )); + models + } + None => { + // Fetch failed — reuse a still-present cache entry (even if stale) + // rather than blanking the catalogue on a transient hiccup. + let guard = COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()); + guard + .as_ref() + .filter(|(tok, _, _)| tok == gh_token) + .map(|(_, _, m)| m.clone()) + .unwrap_or_default() + } + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct DiscoverModelsRequest { + /// "github-models" | "azure-openai" | "github-copilot". (Foundry already + /// discovers models via the existing /api/operator/foundry/verify.) + pub kind: String, + pub endpoint: Option, + pub key: Option, +} + +/// `POST /api/operator/providers/discover` — real, live model discovery so the +/// operator never hand-types a deployment id. GitHub Models queries the public +/// catalog (no auth). Azure OpenAI queries the data-plane `/openai/deployments` +/// endpoint using the operator-supplied endpoint + key (a live round-trip, so a +/// wrong key/endpoint surfaces as an immediate, actionable error). GitHub +/// Copilot exchanges the supplied token for a Copilot JWT (verifying the seat + +/// Chat entitlement live) and then queries the seat's LIVE `/models` catalog — +/// so the picker always reflects the models GitHub currently serves this seat +/// (gpt-5.6, claude-opus-4.8, gemini-3.1-pro, …), never a hand-maintained list +/// that goes stale. +pub async fn discover_models( + Json(req): Json, +) -> AppResult>> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + match req.kind.as_str() { + "github-models" => { + let resp = client + .get("https://models.github.ai/catalog/models") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| { + AppError::Upstream(format!("GitHub Models catalog request failed: {e}")) + })?; + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "GitHub Models catalog returned {}", + resp.status() + ))); + } + let body: Vec = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad catalog JSON: {e}")))?; + let models = body + .iter() + .filter_map(|m| { + let id = m.get("id").and_then(|v| v.as_str())?.to_string(); + let name = m.get("name").and_then(|v| v.as_str()).map(str::to_string); + Some(DiscoveredModelDto { + id, + label: name, + recommended: false, + detail: None, + }) + }) + .collect(); + Ok(Json(models)) + } + "azure-openai" => { + let endpoint = req + .endpoint + .as_deref() + .map(|e| e.trim().trim_end_matches('/')) + .filter(|e| !e.is_empty()) + .ok_or_else(|| { + AppError::BadRequest( + "endpoint is required to discover Azure OpenAI deployments".into(), + ) + })?; + let key = req + .key + .as_deref() + .filter(|k| !k.trim().is_empty()) + .ok_or_else(|| AppError::BadRequest( + "an API key is required to discover deployments (workload/agentid auth can't be exercised from the browser — enter deployment ids manually, or discover once with a temporary key)".into(), + ))?; + let url = format!("{endpoint}/openai/deployments?api-version=2023-05-15"); + let resp = client + .get(&url) + .header("api-key", key) + .send() + .await + .map_err(|e| AppError::Upstream(format!("Azure OpenAI request failed: {e}")))?; + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Rejected(format!( + "Azure OpenAI rejected the discovery request ({status}) — check the endpoint and key: {body_text}" + ))); + } + let body: serde_json::Value = serde_json::from_str(&body_text) + .map_err(|e| AppError::Upstream(format!("bad deployments JSON: {e}")))?; + let models = body + .get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|d| { + let id = d.get("id").and_then(|v| v.as_str())?.to_string(); + let base = d + .get("model") + .and_then(|v| v.as_str()) + .map(|m| format!("deployment of {m}")); + Some(DiscoveredModelDto { + id, + label: base, + recommended: false, + detail: None, + }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(Json(models)) + } + "github-copilot" => { + let token = req + .key + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + .ok_or_else(|| AppError::BadRequest( + "a GitHub token (OAuth token or PAT with Copilot access) is required to verify the seat before showing the model catalog".into(), + ))?; + let jwt = copilot_jwt(token).await?; + let models = fetch_copilot_models(&jwt).await?; + Ok(Json(models)) + } + other => Err(AppError::BadRequest(format!( + "unknown provider kind '{other}' for discovery" + ))), + } +} + +// ─── Multi-provider inference (§ inference-provider-wizard) ───────────────── +// +// The single "Inference provider" flow above (`put_provider`) sets the ONE +// default provider every mission inherits. This section manages ADDITIONAL +// providers that can be configured *at the same time* — e.g. GitHub Copilot +// as the default, Azure AI Foundry also connected — so an InferencePolicy's +// `modelPreference.primary.provider` can route a specific sandbox's calls to +// whichever one actually serves the model it needs (a sub-agent on gpt-4.1 +// via Foundry, a principal on opus-4.8 via Copilot, in the SAME cluster). +// +// Storage: the `kars-inference-providers` Secret in `kars-system`. Its KEYS +// are the literal env var names `inference-router::config::Config::from_env` +// already parses generically (`KARS_PROVIDER__ENDPOINT` + optional +// `_API_KEY`/`_TOKEN`, or the well-known `COPILOT_GITHUB_TOKEN` for the +// GitHub Copilot special case) — no router-side change needed to support a +// provider added here. The controller mirrors this ONE secret into every +// sandbox's own namespace (the same mechanism already used for +// `kars-github-app`), and every sandbox's router picks whichever provider a +// request's InferencePolicy names — never all-or-nothing, never guessed from +// what's merely present in the env. +const INFERENCE_PROVIDERS_SECRET: &str = "kars-inference-providers"; +const INFERENCE_PROVIDERS_NS: &str = "kars-system"; + +/// One additional provider, as surfaced to the operator (never the key/token +/// itself — `has_key` only tells you whether one is stored). +#[derive(Debug, Serialize)] +pub struct AdditionalProviderDto { + pub tag: String, + pub endpoint: Option, + pub has_key: bool, + /// Deployment ids the operator declared this provider serves — these + /// feed the shared model catalog (`GET /api/options`), tagged with this + /// provider, so InferencePolicy's model picker can offer them. + pub models: Vec, +} + +/// `GET /api/operator/providers/additional` — list every additional provider +/// configured on this cluster (beyond the single default from `put_provider`). +pub async fn list_additional_providers( + State(state): State, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + let mut providers: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for key in keys.keys() { + if let Some(tag_part) = key + .strip_prefix("KARS_PROVIDER_") + .and_then(|r| r.strip_suffix("_ENDPOINT")) + { + let tag = tag_part.to_ascii_lowercase().replace('_', "-"); + providers + .entry(tag.clone()) + .or_insert(AdditionalProviderDto { + tag, + endpoint: None, + has_key: false, + models: Vec::new(), + }); + } + } + for (tag, dto) in providers.iter_mut() { + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + dto.endpoint = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) + .cloned(); + dto.has_key = keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) + || keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_TOKEN")); + dto.models = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) + .map(|m| { + m.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + } + // GitHub Copilot is a special case (well-known endpoint, no + // KARS_PROVIDER_*_ENDPOINT needed — see resolve_provider in the router). + if keys.contains_key("COPILOT_GITHUB_TOKEN") { + providers.insert( + "github-copilot".to_string(), + AdditionalProviderDto { + tag: "github-copilot".to_string(), + endpoint: Some("https://api.githubcopilot.com".to_string()), + has_key: true, + models: keys + .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") + .map(|m| { + m.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + }, + ); + } + Ok(Json(providers.into_values().collect())) +} + +#[derive(Debug, serde::Deserialize)] +pub struct AdditionalProviderRequest { + /// Lowercase, hyphenated tag (e.g. "foundry", "github-models"). The + /// reserved tag "github-copilot" only needs `api_key` (its endpoint is + /// the well-known Copilot API and is never user-editable). + pub tag: String, + pub endpoint: Option, + /// Dev-mode direct key/token (e.g. a GitHub Models PAT, or a second + /// Azure OpenAI resource's key). Optional for providers that authenticate + /// via Workload Identity in production (Foundry/Azure OpenAI need no key + /// at all on AKS — see `inference-router::auth::WorkloadIdentityAuth`). + pub api_key: Option, + /// Comma-separated deployment ids this provider serves — feeds the + /// shared model catalog (`GET /api/options`), tagged with this provider, + /// so InferencePolicy's model picker can offer "this model via THIS + /// provider" without any change to that editor. + pub models: Option, +} + +/// `PUT /api/operator/providers/additional` — add or update one additional +/// provider. Read-modify-write against the shared Secret so configuring one +/// provider never disturbs another already stored there. +pub async fn put_additional_provider( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let tag = req.tag.trim().to_ascii_lowercase(); + if !is_dns1123_label(&tag) { + return Err(AppError::BadRequest( + "tag must be lowercase letters, digits, hyphens (e.g. \"foundry\", \"github-models\")" + .into(), + )); + } + let is_copilot = tag == "github-copilot"; + if !is_copilot { + let endpoint = req + .endpoint + .as_deref() + .map(str::trim) + .filter(|e| !e.is_empty()) + .ok_or_else(|| AppError::BadRequest("endpoint is required for this provider".into()))?; + if !endpoint.starts_with("https://") && !endpoint.starts_with("http://") { + return Err(AppError::BadRequest("endpoint must be a URL".into())); + } + } + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let key_val = req + .api_key + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + .map(str::to_string); + let models: Vec<&str> = req + .models + .as_deref() + .unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if models.is_empty() { + return Err(AppError::BadRequest( + "at least one model deployment id is required (comma-separated) so InferencePolicy can offer it".into(), + )); + } + let models_joined = models.join(","); + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + if is_copilot { + if let Some(k) = key_val.clone() { + keys.insert("COPILOT_GITHUB_TOKEN".to_string(), k); + } + keys.insert( + "KARS_PROVIDER_GITHUB_COPILOT_MODELS".to_string(), + models_joined.clone(), + ); + } else { + if let Some(endpoint) = req + .endpoint + .as_deref() + .map(str::trim) + .filter(|e| !e.is_empty()) + { + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_ENDPOINT"), + endpoint.to_string(), + ); + } + if let Some(k) = key_val.clone() { + keys.insert(format!("KARS_PROVIDER_{tag_upper}_API_KEY"), k); + } + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_MODELS"), + models_joined.clone(), + ); + } + }) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ + "configured": true, + "tag": tag, + "note": "Every sandbox's router now has this provider available. Which one a given request actually uses is decided per-sandbox by its InferencePolicy.modelPreference — this alone doesn't make it the default." + }))) +} + +/// `DELETE /api/operator/providers/additional/:tag` — remove one additional +/// provider's keys from the shared Secret (leaves other providers intact). +pub async fn delete_additional_provider( + State(state): State, + axum::extract::Path(tag): axum::extract::Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let tag = tag.trim().to_ascii_lowercase(); + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let is_copilot = tag == "github-copilot"; + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + if is_copilot { + keys.remove("COPILOT_GITHUB_TOKEN"); + keys.remove("KARS_PROVIDER_GITHUB_COPILOT_MODELS"); + } else { + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_TOKEN")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_MODELS")); + } + }) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({"removed": true, "tag": tag}))) +} + +/// `POST /api/operator/providers/additional/:tag/promote` — make an already- +/// connected additional provider the cluster's DEFAULT (patches the +/// controller's own env — every mission that leaves its model unset inherits +/// this). Reads the tag's endpoint/key/models straight from +/// `kars-inference-providers` server-side (never exposed to the browser) and +/// re-points the SAME secret+key via `secretKeyRef` — no key duplication. +/// +/// `github-copilot` is rejected: it authenticates via `COPILOT_GITHUB_TOKEN` +/// exchanged for a short-lived Copilot JWT, a completely different mechanism +/// than the endpoint+key shape every other provider here uses — the same +/// reason `put_provider` already refuses to set it as default from the other +/// form (see that handler's comment). Every other tag (Foundry, Azure OpenAI, +/// Custom, GitHub Models, and a local in-cluster model) is a plain +/// endpoint(+optional key), which is exactly what `set_controller_catalog` +/// wires — so promoting any of THOSE genuinely works. +pub async fn promote_additional_provider( + State(state): State, + axum::extract::Path(tag): axum::extract::Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let tag = tag.trim().to_ascii_lowercase(); + // GitHub Copilot IS promotable now — the wizard's device sign-in stores a + // Copilot-authorized token, which `set_copilot_as_default` wires onto the + // controller (KARS_PROVIDER + COPILOT_GITHUB_TOKEN), unlike the endpoint+key + // shape every other provider uses. + if tag == "github-copilot" { + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + if !keys.contains_key("COPILOT_GITHUB_TOKEN") { + return Err(AppError::BadRequest( + "Sign in to GitHub Copilot first (Connect a provider → GitHub Copilot) — then it can be set as the cluster default.".into(), + )); + } + let models = keys + .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") + .cloned() + .unwrap_or_default(); + let models = if models.trim().is_empty() { + // No explicit selection stored — fall back to the live catalog so + // the default catalogue isn't empty. + copilot_catalog_cached(keys.get("COPILOT_GITHUB_TOKEN").unwrap()) + .await + .into_iter() + .map(|(id, _, _)| id) + .collect::>() + .join(",") + } else { + models + }; + cluster + .set_copilot_as_default(&models) + .await + .map_err(upstream)?; + return Ok(Json(serde_json::json!({ + "promoted": true, + "tag": tag, + "note": "GitHub Copilot is now the cluster default; the controller is rolling to pick it up. Every mission that leaves its model unset now inherits it." + }))); + } + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + let endpoint = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) + .cloned() + .ok_or_else(|| AppError::BadRequest(format!("no connected provider tagged {tag:?} with an endpoint (GitHub Models has a well-known endpoint but no explicit one is stored, so it can't be promoted this way either)")))?; + let models = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) + .cloned() + .unwrap_or_default(); + if models.trim().is_empty() { + return Err(AppError::BadRequest(format!( + "{tag} has no declared models to promote" + ))); + } + let key_ref = if keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) { + Some(( + INFERENCE_PROVIDERS_SECRET, + format!("KARS_PROVIDER_{tag_upper}_API_KEY"), + )) + } else { + None + }; + cluster + .set_controller_catalog( + &models, + Some(&endpoint), + key_ref.as_ref().map(|(s, k)| (*s, k.as_str())), + ) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ + "promoted": true, + "tag": tag, + "note": "Cluster default updated; the controller is rolling to pick it up. Every mission that leaves its model unset now inherits this provider." + }))) +} + +#[derive(Debug, serde::Deserialize)] +pub struct SetDefaultModelRequest { + pub deployment: String, + /// The provider tag that serves this model, as shown in the catalogue + /// (e.g. "github-copilot", "foundry", "local-llama-3-2-1b-instruct"). + pub provider: String, +} + +/// `POST /api/operator/models/default` — make one specific MODEL the cluster +/// default (what the Model catalogue's "Set as default" does). Promotes the +/// model's provider AND pins that model as the default (moved to the front of +/// the catalog, which `set_*_default` treats as KARS_TASK_DEFAULT_MODEL). +pub async fn set_default_model( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let deployment = req.deployment.trim().to_string(); + let provider = req.provider.trim().to_ascii_lowercase(); + if deployment.is_empty() { + return Err(AppError::BadRequest( + "a model deployment id is required".into(), + )); + } + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + + // Reorder a comma list so `deployment` is first (becomes the default), + // deduped; ensures the chosen model is present even if it wasn't listed. + let reorder = |csv: &str| -> String { + let mut out = vec![deployment.clone()]; + for m in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if m != deployment { + out.push(m.to_string()); + } + } + out.join(",") + }; + + if provider == "github-copilot" { + if !keys.contains_key("COPILOT_GITHUB_TOKEN") { + return Err(AppError::BadRequest( + "Sign in to GitHub Copilot first.".into(), + )); + } + let existing = keys + .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") + .cloned() + .unwrap_or_default(); + let models = if existing.trim().is_empty() { + // fall back to the live catalog so the catalog isn't just one model + let mut live = copilot_catalog_cached(keys.get("COPILOT_GITHUB_TOKEN").unwrap()) + .await + .into_iter() + .map(|(id, _, _)| id) + .collect::>() + .join(","); + if live.trim().is_empty() { + live = deployment.clone(); + } + reorder(&live) + } else { + reorder(&existing) + }; + cluster + .set_copilot_as_default(&models) + .await + .map_err(upstream)?; + return Ok(Json( + serde_json::json!({"ok": true, "default": deployment, "provider": provider}), + )); + } + + // Endpoint-based providers (foundry, azure-openai, custom, local-*): promote + // via set_controller_catalog with the chosen model first. + let tag_upper = provider.to_ascii_uppercase().replace('-', "_"); + let endpoint = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) + .cloned() + .ok_or_else(|| AppError::BadRequest(format!( + "no connected provider {provider:?} with an endpoint serves {deployment:?} — connect it first" + )))?; + let existing = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) + .cloned() + .unwrap_or_default(); + let models = reorder(&existing); + let key_ref = if keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) { + Some(( + INFERENCE_PROVIDERS_SECRET, + format!("KARS_PROVIDER_{tag_upper}_API_KEY"), + )) + } else { + None + }; + cluster + .set_controller_catalog( + &models, + Some(&endpoint), + key_ref.as_ref().map(|(s, k)| (*s, k.as_str())), + ) + .await + .map_err(upstream)?; + Ok(Json( + serde_json::json!({"ok": true, "default": deployment, "provider": provider}), + )) +} +// +// The Bridge is the author of the envelope's governance objects, not just a +// reader. Authoring uses Server-Side Apply (`cluster.apply_kind`) — the +// Kubernetes-native declarative upsert — so the SAME endpoint creates a new CRD +// and edits an existing one (re-apply with changed spec). The security boundary +// is RBAC on the Bridge ServiceAccount plus the CRD's admission/CEL validation; +// a rejected write surfaces the API server's own message via `AppError::Rejected`. + +/// Apply-a-governance-CRD request. `spec` is the kind's raw `spec` object so the +/// operator can author every field; `force` opts into taking field ownership on +/// a 409 conflict (default: surface the conflict instead of clobbering). +#[derive(Debug, serde::Deserialize)] +pub struct ApplyCrdRequest { + pub name: String, + #[serde(default)] + pub namespace: Option, + pub spec: serde_json::Value, + #[serde(default)] + pub force: bool, +} + +/// Map a CRD-apply kube error to a client-safe AppError: admission/validation +/// (400/422) and field-ownership conflicts (409) are surfaced verbatim (safe — +/// they are the API server's own messages), RBAC denials (403) are made +/// actionable, everything else is an opaque upstream error. +fn apply_err(e: kube::Error) -> AppError { + if let kube::Error::Api(ae) = &e { + match ae.code { + 400 | 422 => return AppError::Rejected(ae.message.clone()), + 409 => { + return AppError::Rejected(format!( + "field-ownership conflict: {} — another manager owns a field this apply sets; re-apply with force:true to take ownership", + ae.message + )); + } + 403 => { + return AppError::Rejected(format!( + "forbidden: {} — the Bridge ServiceAccount lacks RBAC to write this resource", + ae.message + )); + } + // Server-Side Apply surfaces schema-validation failures (an unknown + // or misspelled spec field) as a 500 whose message IS actionable and + // safe — e.g. "failed to create typed patch object (…): .spec.allow: + // field not declared in schema". Without this, an operator authoring + // a bad field gets an opaque "upstream dependency failed" instead of + // the field to fix. Surface it as a rejection with the real message. + 500 if ae.message.contains("field not declared in schema") + || ae.message.contains("failed to create typed patch object") + || ae.message.contains("unknown field") => + { + return AppError::Rejected(ae.message.clone()); + } + _ => {} + } + } + AppError::Upstream(e.to_string()) +} + +/// Shared apply path for the three governance kinds. Targets `kars-system` by +/// default (where the controller reads them). +async fn apply_governance( + cluster: &crate::kars::cluster::Cluster, + kind: &str, + req: ApplyCrdRequest, +) -> AppResult> { + let name = req.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + if !req.spec.is_object() { + return Err(AppError::BadRequest("spec must be a JSON object".into())); + } + let ns = req + .namespace + .as_deref() + .unwrap_or("kars-system") + .to_string(); + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": kind, + "metadata": { + "name": name, + "namespace": ns, + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + }, + "spec": req.spec, + }); + let applied = cluster + .apply_kind(&ns, kind, body, req.force) + .await + .map_err(apply_err)?; + Ok(Json(serde_json::json!({ + "applied": true, + "kind": kind, + "name": name_of(&applied), + "namespace": ns, + "note": "Server-Side Apply (field manager kars-bridge): created on first apply, edited on re-apply." + }))) +} + +/// `PUT /api/operator/toolpolicies` — author/edit a `ToolPolicy` (SSA). +pub async fn put_toolpolicy( + State(state): State, + Json(req): Json, +) -> AppResult> { + apply_governance(require_cluster(&state)?, "ToolPolicy", req).await +} + +/// `PUT /api/operator/mcpservers` — author/edit an `McpServer` (SSA). +pub async fn put_mcpserver( + State(state): State, + Json(req): Json, +) -> AppResult> { + apply_governance(require_cluster(&state)?, "McpServer", req).await +} + +/// `PUT /api/operator/skills` — author/edit a `KarsSkill` (SSA). +pub async fn put_skill( + State(state): State, + Json(req): Json, +) -> AppResult> { + apply_governance(require_cluster(&state)?, "KarsSkill", req).await +} + +/// Map a delete kube error: 404 → NotFound, 403 → actionable RBAC message, +/// everything else opaque upstream. +fn delete_err(e: kube::Error) -> AppError { + if let kube::Error::Api(ae) = &e { + match ae.code { + 404 => return AppError::NotFound, + 403 => { + return AppError::Rejected(format!( + "forbidden: {} — the Bridge ServiceAccount lacks RBAC to delete this resource", + ae.message + )); + } + _ => {} + } + } + AppError::Upstream(e.to_string()) +} + +/// Shared delete path for a governance kind. Targets `kars-system` by default. +async fn delete_governance( + cluster: &crate::kars::cluster::Cluster, + kind: &str, + name: &str, + namespace: Option<&str>, +) -> AppResult> { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + let ns = namespace.unwrap_or("kars-system"); + cluster + .delete_kind(ns, kind, name) + .await + .map_err(delete_err)?; + Ok(Json(serde_json::json!({ + "deleted": true, + "kind": kind, + "name": name, + "namespace": ns, + "note": "Deleted with foreground propagation — the controller's finalizers revoke downstream state before removal." + }))) +} + +/// `DELETE /api/operator/toolpolicies/:name` — remove a `ToolPolicy`. +pub async fn delete_toolpolicy( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + delete_governance(require_cluster(&state)?, "ToolPolicy", &name, None).await +} + +/// `DELETE /api/operator/mcpservers/:name` — remove an `McpServer`. +pub async fn delete_mcpserver( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + delete_governance(require_cluster(&state)?, "McpServer", &name, None).await +} + +/// `DELETE /api/operator/skills/:name` — remove a `KarsSkill`. +pub async fn delete_skill( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + delete_governance(require_cluster(&state)?, "KarsSkill", &name, None).await +} + +/// `DELETE /api/operator/egress/:name` — revoke a temporary `EgressApproval`. +/// The EgressApproval model is create-to-grant / delete-to-revoke, so deleting +/// the object is the authoritative revoke action (the controller reconciles the +/// sandbox allowlist back to its signed baseline on removal). +pub async fn delete_egress( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + delete_governance(require_cluster(&state)?, "EgressApproval", &name, None).await +} + +// ─── datapath-completeness witness (optional eBPF) ─────────────────────────── +// +// An independent, kernel-level attestation of what sandboxes ACTUALLY send on +// the network, cross-checked against the controller-declared egress allowlist. +// Produced out-of-band by the optional Inspektor Gadget witness +// (deploy/ebpf-witness/) and published to the `kars-datapath-witness` ConfigMap +// in kars-system. The Bridge only READS that ConfigMap — no eBPF/gadget +// dependency here. Absent ConfigMap => witness not enabled (honest empty), never +// an error. + +#[derive(Serialize, Deserialize, Default)] +pub struct DatapathWitnessSandbox { + pub namespace: String, + pub sandbox: String, + #[serde(default)] + pub declared_hosts: Vec, + #[serde(default)] + pub observed_dns: Vec, + #[serde(default)] + pub observed_connects: u64, + #[serde(default)] + pub beyond_declared: Vec, + #[serde(default)] + pub unused_declared: Vec, + pub verdict: String, +} + +#[derive(Serialize)] +pub struct DatapathWitnessDto { + /// True once the optional eBPF witness is installed and has published a + /// verdict. False => not enabled (the web layer shows enable instructions). + pub enabled: bool, + pub generated_at: Option, + pub window_seconds: Option, + pub sandboxes: Vec, + /// How to turn the witness on — surfaced verbatim in the not-enabled state. + pub install_hint: String, +} + +#[derive(Deserialize)] +struct WitnessDoc { + generated_at: Option, + window_seconds: Option, + #[serde(default)] + sandboxes: Vec, +} + +pub async fn datapath_witness( + State(state): State, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let hint = "Enable the optional eBPF datapath witness on the cluster: \ + KARS_EBPF_WITNESS=1 deploy/ebpf-witness/install.sh --continuous" + .to_string(); + + let not_enabled = || DatapathWitnessDto { + enabled: false, + generated_at: None, + window_seconds: None, + sandboxes: Vec::new(), + install_hint: hint.clone(), + }; + + let Some(body) = cluster + .configmap_data("kars-datapath-witness") + .await + .and_then(|d| d.get("witness.json").cloned()) + else { + return Ok(Json(not_enabled())); + }; + + match serde_json::from_str::(&body) { + Ok(doc) => Ok(Json(DatapathWitnessDto { + enabled: true, + generated_at: doc.generated_at, + window_seconds: doc.window_seconds, + sandboxes: doc.sandboxes, + install_hint: hint, + })), + // Malformed payload is treated as not-enabled rather than a hard error — + // the console must never 500 on optional-feature data. + Err(_) => Ok(Json(not_enabled())), + } +} + +// ─── Diagnostics: live "what's actually broken right now" scan ──────────────── +// The Troubleshooting page's real job: not a wiring/roadmap checklist, but the +// concrete problems an operator must act on — pods that won't start, containers +// crash-looping or stuck pulling an image, sandboxes the controller marked +// Degraded/Failed, and agents that came up but never went Ready. Every issue is +// read from live pod/CRD status and carries a plain remedy hint. + +#[derive(Debug, Serialize)] +pub struct DiagnosticIssue { + /// "critical" (blocks the workload) or "warning" (degraded but running). + pub severity: String, + /// Short machine-ish kind, e.g. "ImagePullBackOff", "CrashLoopBackOff", + /// "PodPending", "NotReady", "SandboxDegraded", "HighRestarts". + pub kind: String, + /// The affected object, `namespace/name`. + pub subject: String, + /// The raw reason/phase from the cluster. + pub reason: String, + /// Human detail (container message / status message) when available. + pub detail: Option, + /// A concrete next step for the operator. + pub remedy: String, +} + +#[derive(Debug, Serialize)] +pub struct DiagnosticsDto { + pub issues: Vec, + pub scanned_pods: usize, + pub scanned_sandboxes: usize, + /// True when the scan found nothing wrong — the honest "all clear". + pub healthy: bool, +} + +/// `GET /api/operator/diagnostics` — the live problem scan behind Troubleshooting. +pub async fn get_diagnostics(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + let mut issues: Vec = Vec::new(); + + // ── Pods: the ground truth for "won't start / not healthy". ────────────── + let pods = cluster.all_pods().await; + let scanned_pods = pods.len(); + for p in &pods { + let ns = p.metadata.namespace.as_deref().unwrap_or("").to_string(); + let name = p.metadata.name.as_deref().unwrap_or("").to_string(); + let subject = format!("{ns}/{name}"); + let status = p.status.as_ref(); + let phase = status.and_then(|s| s.phase.as_deref()).unwrap_or(""); + let age_secs = status + .and_then(|s| s.start_time.as_ref()) + .map(|t| (chrono::Utc::now() - t.0).num_seconds().max(0)) + .unwrap_or(0); + + // Container-level waiting reasons (image pull, crashloop, config error). + let mut container_flagged = false; + if let Some(cs) = status.and_then(|s| s.container_statuses.as_ref()) { + for c in cs { + if let Some(w) = c.state.as_ref().and_then(|st| st.waiting.as_ref()) { + let reason = w.reason.clone().unwrap_or_default(); + let bad = matches!( + reason.as_str(), + "ImagePullBackOff" + | "ErrImagePull" + | "CrashLoopBackOff" + | "CreateContainerConfigError" + | "CreateContainerError" + | "InvalidImageName" + | "RunContainerError" + ); + if bad { + container_flagged = true; + let remedy = match reason.as_str() { + "ImagePullBackOff" | "ErrImagePull" | "InvalidImageName" => { + "Image can't be pulled — check the image tag exists in the registry and the node has pull access." + } + "CrashLoopBackOff" | "RunContainerError" => { + "Container keeps exiting — check its logs (kubectl logs) for the crash cause." + } + _ => { + "Container config is invalid — check the ConfigMap/Secret mounts and env for this container." + } + }; + issues.push(DiagnosticIssue { + severity: "critical".into(), + kind: reason.clone(), + subject: format!("{subject} · {}", c.name), + reason, + detail: w.message.clone(), + remedy: remedy.into(), + }); + } + } + // A container restarting many times is a warning even if currently up. + if c.restart_count >= 5 { + issues.push(DiagnosticIssue { + severity: "warning".into(), + kind: "HighRestarts".into(), + subject: format!("{subject} · {}", c.name), + reason: format!("{} restarts", c.restart_count), + detail: None, + remedy: + "Container is unstable — inspect its logs for the recurring failure." + .into(), + }); + } + } + } + + // Pod stuck Pending (unschedulable / image / volume) for > 60s. + if phase == "Pending" && age_secs > 60 && !container_flagged { + let msg = status + .and_then(|s| s.conditions.as_ref()) + .and_then(|c| c.iter().find(|cond| cond.status == "False")) + .and_then(|c| c.message.clone()); + issues.push(DiagnosticIssue { + severity: "critical".into(), + kind: "PodPending".into(), + subject: subject.clone(), + reason: "Pending".into(), + detail: msg, + remedy: "Pod can't be scheduled — check node capacity, taints, or unbound volumes (kubectl describe pod)." + .into(), + }); + } + + // Running but not all containers Ready for > 120s (probes failing). + if phase == "Running" + && age_secs > 120 + && !container_flagged + && let Some(cs) = status.and_then(|s| s.container_statuses.as_ref()) + { + let total = cs.len(); + let ready = cs.iter().filter(|s| s.ready).count(); + if total > 0 && ready < total { + issues.push(DiagnosticIssue { + severity: "warning".into(), + kind: "NotReady".into(), + subject: subject.clone(), + reason: format!("{ready}/{total} containers ready"), + detail: None, + remedy: "A container is up but failing its readiness probe — check the probe and the container's logs." + .into(), + }); + } + } + } + + // ── Sandboxes the controller itself flagged Degraded/Failed. ───────────── + let sandboxes = cluster + .list_kind_all("KarsSandbox") + .await + .unwrap_or_default(); + let scanned_sandboxes = sandboxes.len(); + for sb in &sandboxes { + let phase = sb + .data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .unwrap_or(""); + if matches!(phase, "Degraded" | "Failed") { + let name = sb.metadata.name.as_deref().unwrap_or("").to_string(); + let msg = sb + .data + .get("status") + .and_then(|s| s.get("message")) + .and_then(|m| m.as_str()) + .map(String::from); + issues.push(DiagnosticIssue { + severity: if phase == "Failed" { "critical" } else { "warning" }.into(), + kind: "SandboxDegraded".into(), + subject: format!("kars-system/{name}"), + reason: phase.to_string(), + detail: msg, + remedy: "The controller couldn't fully reconcile this sandbox — check the controller logs and the sandbox's referenced policies/secrets." + .into(), + }); + } + // Run-level stall detection (audit f24): the pod-level scan is blind to a + // run whose sandbox is "Running" but whose run has FAILED/timed out. A + // mission-output recorded with status=error is a definitive run failure + // the operator must see even though the pod looks healthy. + if phase == "Running" { + let name = sb.metadata.name.as_deref().unwrap_or("").to_string(); + if let Some(out) = cluster.read_mission_output(&name).await + && out.get("status").map(|s| s.as_str()) == Some("error") + { + let detail = out + .get("output") + .cloned() + .filter(|s| !s.is_empty()) + .or_else(|| out.get("error").cloned()); + issues.push(DiagnosticIssue { + severity: "warning".into(), + kind: "RunFailed".into(), + subject: format!("kars-system/{name}"), + reason: "run reported an error while the sandbox is still Running".into(), + detail, + remedy: "The agent's run did not complete (often a slow/absent agent or a chat-gateway harness that never executed the loop). Check the mission's Run tab, or re-run with the OpenClaw harness for autonomous missions." + .into(), + }); + } + } + } + + // Critical first, then warnings; stable within a severity. + issues.sort_by(|a, b| { + let rank = |s: &str| if s == "critical" { 0 } else { 1 }; + rank(&a.severity).cmp(&rank(&b.severity)) + }); + + let healthy = issues.is_empty(); + Ok(Json(DiagnosticsDto { + issues, + scanned_pods, + scanned_sandboxes, + healthy, + })) +} + +// ─── Orchestrator health + the compose failover path ───────────────────────── +// The Bridge composer ("intent → package") runs its own inference. It prefers a +// DIRECT endpoint (BRIDGE_ORCHESTRATOR_* — scales for many teams) and otherwise +// routes through the standing `bridge-orchestrator` sandbox's router. This +// surfaces which path is live, the orchestrator sandbox's health, and — when the +// sandbox path is under strain — recommends configuring the direct endpoint +// (the "switch to inference-based orchestration under load" lever). + +#[derive(Debug, Serialize)] +pub struct OrchestratorDto { + /// Active compose inference path: "direct" (endpoint configured) or + /// "sandbox" (routing through the orchestrator sandbox router), or "none". + pub mode: String, + /// Whether a direct BRIDGE_ORCHESTRATOR endpoint triple is configured. + pub direct_configured: bool, + /// Whether the standing orchestrator sandbox exists. + pub sandbox_present: bool, + /// The orchestrator sandbox phase (Running/Degraded/…), when present. + pub sandbox_phase: Option, + /// Ready/total containers of the orchestrator pod, restarts, waiting reason. + pub sandbox_ready: Option, + pub sandbox_restarts: Option, + pub sandbox_waiting_reason: Option, + /// How many Running sandbox routers the composer can fall back through. + pub router_candidates: usize, + /// True when the operator should configure the direct endpoint (sandbox path + /// is the only option and it's unhealthy or capacity is thin). + pub recommend_direct: bool, + /// Plain-language recommendation. + pub note: String, +} + +/// `GET /api/operator/orchestrator` — orchestrator health + compose failover path. +pub async fn get_orchestrator(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + + let direct_configured = [ + "BRIDGE_ORCHESTRATOR_ENDPOINT", + "BRIDGE_ORCHESTRATOR_TOKEN", + "BRIDGE_ORCHESTRATOR_MODEL", + ] + .iter() + .all(|k| { + std::env::var(k) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + }); + + // Orchestrator sandbox presence + health. + let sandboxes = cluster + .list_kind_all("KarsSandbox") + .await + .unwrap_or_default(); + let orch = sandboxes.iter().find(|sb| { + sb.metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/orchestrator")) + .map(String::as_str) + == Some("true") + }); + let sandbox_present = orch.is_some(); + let sandbox_phase = orch.and_then(|o| { + o.data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(String::from) + }); + let health = if let Some(o) = orch { + let name = o.metadata.name.clone().unwrap_or_default(); + cluster.sandbox_pod_health(&name).await + } else { + None + }; + let (sandbox_ready, sandbox_restarts, sandbox_waiting_reason) = match &health { + Some(h) => ( + Some(format!("{}/{}", h.ready_containers, h.total_containers)), + Some(h.restarts), + h.waiting_reason.clone(), + ), + None => (None, None, None), + }; + + let router_candidates = cluster.running_sandbox_candidates().await.len(); + + let sandbox_healthy = sandbox_phase.as_deref() == Some("Running") + && health + .as_ref() + .map(|h| h.ready_containers == h.total_containers && h.total_containers > 0) + .unwrap_or(false); + + let mode = if direct_configured { + "direct" + } else if sandbox_present && router_candidates > 0 { + "sandbox" + } else { + "none" + } + .to_string(); + + // Recommend the direct endpoint when we're on the sandbox path and it's the + // only option while being unhealthy or thin on router capacity. + let recommend_direct = !direct_configured && !sandbox_healthy; + + let note = if direct_configured { + "Composing via the direct inference endpoint — scales independently of any sandbox." + .to_string() + } else if !sandbox_present { + "No orchestrator sandbox and no direct endpoint — the composer can't run. Set BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} or let the Bridge provision the orchestrator sandbox.".to_string() + } else if recommend_direct { + "The orchestrator sandbox is present but not healthy enough to compose reliably. Repair it or configure BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} for a direct inference path.".to_string() + } else if router_candidates <= 1 { + "Composing through the healthy orchestrator sandbox router. One router is sufficient for serial composition; configure BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} only when you need independent capacity for many concurrent compose requests.".to_string() + } else { + "Composing via the orchestrator sandbox router — healthy. For many concurrent teams, a direct BRIDGE_ORCHESTRATOR endpoint scales better.".to_string() + }; + + Ok(Json(OrchestratorDto { + mode, + direct_configured, + sandbox_present, + sandbox_phase, + sandbox_ready, + sandbox_restarts, + sandbox_waiting_reason, + router_candidates, + recommend_direct, + note, + })) +} + +// ─── Integrations: kars-SRE agent + Headlamp plugin ────────────────────────── +// kars ships a real Headlamp plugin (tools/headlamp-plugin — /kars/sre and +// /kars/* views) and a real SRE agent (deploy/helm/kars/templates/sre.yaml, +// gated on sre.enabled; `kars sre install`). This surfaces whether each is +// active, deep-links into the existing plugin views, and gives the exact +// activation for what isn't enabled — rather than pretending to integrate. + +#[derive(Debug, Serialize)] +pub struct IntegrationsDto { + /// kars-SRE agent. + pub sre_present: bool, + pub sre_phase: Option, + pub sre_ready: Option, + /// The `kars sre install` activation command when SRE isn't enabled. + pub sre_activate_cmd: String, + /// Headlamp dashboard + kars plugin. + pub headlamp_deployed: bool, + pub headlamp_url: Option, + /// Deep-link paths into the kars Headlamp plugin (appended to headlamp_url). + pub headlamp_paths: Vec, + /// How to install the plugin when Headlamp is present but the URL is unset. + pub headlamp_install_hint: String, +} + +#[derive(Debug, Serialize)] +pub struct HeadlampLink { + pub label: String, + pub path: String, +} + +/// `GET /api/operator/integrations` — kars-SRE + Headlamp status & deep-links. +pub async fn get_integrations(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + + // SRE agent: the `sre` KarsSandbox (deploy/helm/kars/templates/sre.yaml). + let sandboxes = cluster + .list_kind_all("KarsSandbox") + .await + .unwrap_or_default(); + let sre = sandboxes + .iter() + .find(|sb| sb.metadata.name.as_deref() == Some("sre")); + let sre_present = sre.is_some(); + let sre_phase = sre.and_then(|o| { + o.data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(String::from) + }); + let sre_ready = if sre_present { + cluster + .sandbox_pod_health("sre") + .await + .map(|h| format!("{}/{}", h.ready_containers, h.total_containers)) + } else { + None + }; + + // Headlamp: the `headlamp` Deployment in the `headlamp` namespace. + let headlamp_deployed = cluster.deployment_exists("headlamp", "headlamp").await; + + Ok(Json(IntegrationsDto { + sre_present, + sre_phase, + sre_ready, + sre_activate_cmd: "kars sre install # helm upgrade --reuse-values --set sre.enabled=true".into(), + headlamp_deployed, + headlamp_url: std::env::var("BRIDGE_HEADLAMP_URL").ok().filter(|u| !u.trim().is_empty()), + headlamp_paths: vec![ + HeadlampLink { label: "SRE console".into(), path: "/kars/sre".into() }, + HeadlampLink { label: "Sandboxes".into(), path: "/kars/karssandboxes".into() }, + HeadlampLink { label: "Agent mesh".into(), path: "/kars/mesh".into() }, + ], + headlamp_install_hint: "Build tools/headlamp-plugin (npm run build), kubectl cp dist into the headlamp pod at /headlamp/plugins/kars, then set BRIDGE_HEADLAMP_URL.".into(), + })) +} + +// ─── Local (in-cluster) inference — AI Runway ModelDeployment ──────────────── +// See docs/local-inference.md (kars core). kars does NOT install or manage +// AI Runway/KAITO — an operator installs both once via their own real +// helm/kubectl commands, exactly like the GitHub App or Azure AI Foundry +// connection. This surface only detects presence and manages `ModelDeployment` +// objects on top, in the Bridge's own `kars-local-inference` namespace. + +#[derive(Debug, Serialize)] +pub struct LocalInferenceStatusDto { + /// Whether AI Runway's `modeldeployments.airunway.ai` CRD is present — + /// i.e. whether an operator has installed it (see docs/local-inference.md). + pub available: bool, + /// Real, live-scanned count of nodes advertising `nvidia.com/gpu` + /// capacity — never a hardcoded guess. Zero means only CPU-tier models + /// can be offered. + pub gpu_node_count: u32, + /// Distinct GPU product names found via the NFD/GPU-feature-discovery + /// `nvidia.com/gpu.product` node label, when present. + pub gpu_products: Vec, +} + +/// `GET /api/operator/local-inference/status` — detect whether the cluster +/// can host an in-cluster model, and whether it has GPU capacity for the +/// larger tier. Never installs anything. +pub async fn local_inference_status( + State(state): State, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let available = cluster.local_inference_available().await; + let gpu = cluster.gpu_node_summary().await.unwrap_or_default(); + Ok(Json(LocalInferenceStatusDto { + available, + gpu_node_count: gpu.gpu_node_count, + gpu_products: gpu.gpu_products, + })) +} + +/// One curated, vetted model the wizard can offer without the operator +/// hand-typing a HuggingFace id or an AIKit image reference. Real values +/// verified live against AI Runway v0.7.0 + KAITO workspace chart 0.11.0 — +/// see docs/local-inference.md. +#[derive(Debug, Serialize, Clone)] +pub struct CuratedLocalModelDto { + pub id: String, + pub label: String, + pub tier: String, // "cpu" | "gpu" + pub params: String, +} + +/// `GET /api/operator/local-inference/catalog` — the curated list + tier +/// availability (a GPU entry is still LISTED when no GPU node exists, so the +/// wizard can show it disabled with a clear reason, rather than silently +/// hiding an option and confusing an operator who just hasn't added GPU +/// nodes yet). +pub async fn local_inference_catalog() -> Json> { + Json(vec![ + CuratedLocalModelDto { + id: "llama-3.2-1b-instruct".into(), + label: "Llama 3.2 (1B, CPU)".into(), + tier: "cpu".into(), + params: "1B".into(), + }, + CuratedLocalModelDto { + id: "llama-3.2-3b-instruct".into(), + label: "Llama 3.2 (3B, CPU)".into(), + tier: "cpu".into(), + params: "3B".into(), + }, + CuratedLocalModelDto { + id: "gemma-2-2b-instruct".into(), + label: "Gemma 2 (2B, CPU)".into(), + tier: "cpu".into(), + params: "2B".into(), + }, + CuratedLocalModelDto { + id: "microsoft/Phi-4-mini-instruct".into(), + label: "Phi-4-mini (GPU)".into(), + tier: "gpu".into(), + params: "3.8B".into(), + }, + CuratedLocalModelDto { + id: "meta-llama/Llama-3.1-8B-Instruct".into(), + label: "Llama 3.1 (8B, GPU)".into(), + tier: "gpu".into(), + params: "8B".into(), + }, + CuratedLocalModelDto { + id: "mistralai/Mistral-7B-Instruct-v0.3".into(), + label: "Mistral (7B, GPU)".into(), + tier: "gpu".into(), + params: "7B".into(), + }, + ]) +} + +/// The AIKit CPU image for each curated CPU-tier model id — the `llamacpp` +/// engine needs an explicit pre-built image (there is no live HF→GGUF +/// resolution path), so this is the one place that mapping has to be +/// hardcoded. Free-text/advanced deployments must supply their own image. +fn aikit_image_for(model_id: &str) -> Option<&'static str> { + match model_id { + "llama-3.2-1b-instruct" => Some("ghcr.io/kaito-project/aikit/llama3.2:1b"), + "llama-3.2-3b-instruct" => Some("ghcr.io/kaito-project/aikit/llama3.2:3b"), + "gemma-2-2b-instruct" => Some("ghcr.io/kaito-project/aikit/gemma2:2b"), + _ => None, + } +} + +#[derive(Debug, Serialize)] +pub struct LocalModelDeploymentDto { + pub name: String, + pub namespace: String, + pub managed: bool, + pub model_id: Option, + pub engine: Option, + pub provider: Option, + pub phase: Option, + pub message: Option, + pub endpoint: Option, + pub created_at: Option, +} + +fn project_model_deployment(o: &DynamicObject) -> LocalModelDeploymentDto { + let name = name_of(o); + let namespace = ns_of(o); + let managed = namespace == crate::kars::cluster::LOCAL_INFERENCE_NAMESPACE + && label(o, "app.kubernetes.io/managed-by").as_deref() == Some("kars-bridge"); + let spec = o.data.get("spec"); + let status = o.data.get("status"); + let model_id = spec + .and_then(|s| s.get("model")) + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(String::from); + let engine = status + .and_then(|s| s.get("engine")) + .and_then(|e| e.get("type")) + .and_then(Value::as_str) + .map(String::from); + let provider = status + .and_then(|s| s.get("provider")) + .and_then(|p| p.get("name")) + .and_then(Value::as_str) + .map(String::from); + let phase = status + .and_then(|s| s.get("phase")) + .and_then(Value::as_str) + .map(String::from); + let message = status + .and_then(|s| s.get("message")) + .and_then(Value::as_str) + .map(String::from); + // AI Runway publishes the routable Service in status when available. Fall + // back to the ModelDeployment name and port 80 for older controller builds. + let endpoint = if phase.as_deref() == Some("Running") { + let service = status + .and_then(|s| s.get("endpoint")) + .and_then(|e| e.get("service")) + .and_then(Value::as_str) + .unwrap_or(&name); + let port = status + .and_then(|s| s.get("endpoint")) + .and_then(|e| e.get("port")) + .and_then(Value::as_u64) + .unwrap_or(80); + Some(format!( + "http://{service}.{namespace}.svc.cluster.local:{port}" + )) + } else { + None + }; + LocalModelDeploymentDto { + name, + namespace, + managed, + model_id, + engine, + provider, + phase, + message, + endpoint, + created_at: created_of(o), + } +} + +/// `GET /api/operator/local-inference/deployments` — every ModelDeployment +/// the Bridge manages, with live status. +/// `GET /api/operator/local-inference/deployments` — every ModelDeployment +/// the Bridge manages, with live status. As a side effect, auto-registers +/// any newly-`Running` deployment as a normal additional inference provider +/// (tag `local-`) — reusing the exact multi-provider mechanism proven +/// this session, so no router changes are needed: every sandbox's router +/// already knows how to dial an arbitrary custom OpenAI-compatible endpoint +/// once it's in `kars-inference-providers`. Idempotent (a re-list of an +/// already-wired deployment is a no-op re-write of the same values). +pub async fn list_local_model_deployments( + State(state): State, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_model_deployments().await.map_err(upstream)?; + let dtos: Vec = items.iter().map(project_model_deployment).collect(); + for d in &dtos { + if d.managed + && d.phase.as_deref() == Some("Running") + && let (Some(endpoint), Some(model_id)) = (&d.endpoint, &d.model_id) + { + auto_wire_local_provider(cluster, &d.name, endpoint, model_id).await; + } + } + Ok(Json(dtos)) +} + +/// Register a Running local ModelDeployment's Service as an additional +/// inference provider tagged `local-`, no API key (in-cluster, +/// unauthenticated). Best-effort: a write failure here degrades to "the +/// model runs but isn't yet selectable from an InferencePolicy" rather than +/// failing the status poll the wizard depends on. +async fn auto_wire_local_provider( + cluster: &crate::kars::cluster::Cluster, + name: &str, + endpoint: &str, + model_id: &str, +) { + let tag_upper = format!("LOCAL_{}", name.to_ascii_uppercase().replace('-', "_")); + let endpoint = endpoint.to_string(); + let model_id = model_id.to_string(); + if let Err(e) = cluster + .mutate_secret_keys( + INFERENCE_PROVIDERS_NS, + INFERENCE_PROVIDERS_SECRET, + move |keys| { + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_ENDPOINT"), + endpoint.clone(), + ); + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_MODELS"), + model_id.clone(), + ); + }, + ) + .await + { + tracing::warn!(deployment = name, error = %e, "failed to auto-wire local model as an inference provider"); + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateLocalModelDeploymentRequest { + /// DNS-label name for this deployment (becomes the Service name kars + /// wires into the inference-providers secret). + pub name: String, + /// A curated id (see `local_inference_catalog`) or, for the advanced + /// free-text path, any HuggingFace model id. + pub model_id: String, + /// "cpu" or "gpu" — selects the engine/provider shape. Advanced/free-text + /// requests must pick "cpu" (with an explicit `image`) or "gpu". + pub tier: String, + /// Required for tier=cpu when `model_id` isn't one of the curated ids + /// (the llamacpp engine needs a pre-built AIKit/GGUF image — there's no + /// live HF→GGUF resolution path). + #[serde(default)] + pub image: Option, + /// GPU count for tier=gpu. Default 1. + #[serde(default)] + pub gpu_count: Option, +} + +/// `POST /api/operator/local-inference/deployments` — create (or update, via +/// SSA) a `ModelDeployment`. Rejects tier=cpu requests with no resolvable +/// image rather than creating a ModelDeployment doomed to fail validation +/// with an opaque upstream error. +pub async fn create_local_model_deployment( + State(state): State, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + if !is_dns1123_label(&req.name) { + return Err(AppError::BadRequest( + "name must be lowercase letters, digits, hyphens".into(), + )); + } + let spec = match req.tier.as_str() { + "cpu" => { + let image = req.image.as_deref().filter(|i| !i.trim().is_empty()) + .or_else(|| aikit_image_for(&req.model_id)) + .ok_or_else(|| AppError::BadRequest( + "a CPU deployment needs a pre-built AIKit image — pick a curated model or supply spec.image for an advanced/free-text one".into(), + ))?; + serde_json::json!({ + "model": {"id": req.model_id}, + "engine": {"type": "llamacpp"}, + "image": image, + }) + } + "gpu" => { + serde_json::json!({ + "model": {"id": req.model_id}, + "resources": {"gpu": {"count": req.gpu_count.unwrap_or(1), "type": "nvidia.com/gpu"}}, + }) + } + other => { + return Err(AppError::BadRequest(format!( + "tier must be \"cpu\" or \"gpu\", got {other:?}" + ))); + } + }; + let obj = cluster + .apply_model_deployment(&req.name, spec) + .await + .map_err(upstream)?; + Ok(Json(project_model_deployment(&obj))) +} + +/// `GET /api/operator/local-inference/deployments/:name/status` — rich LIVE +/// status for the deploy progress tracker: a milestone-derived percentage, +/// real pod/container state, and the actual Kubernetes event stream (image +/// pull, scheduling, container start/fail) for this deployment's pods. +pub async fn local_deployment_live_status( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let status = cluster + .local_deployment_live_status(&name) + .await + .map_err(upstream)?; + Ok(Json(status)) +} + +/// `DELETE /api/operator/local-inference/deployments/:name` — undeploy a +/// local model. The Bridge also removes it from the connected-providers list +/// if it had been auto-wired (see `auto_wire_local_provider` in routes/run.rs +/// or the corresponding poll path) — callers should not assume the +/// InferencePolicy-facing tag disappears atomically with the CR. +pub async fn delete_local_model_deployment( + State(state): State, + axum::extract::Path(name): axum::extract::Path, +) -> AppResult> { + let cluster = require_cluster(&state)?; + cluster + .delete_model_deployment(&name) + .await + .map_err(upstream)?; + // Best-effort: also drop it from the additional-providers secret if it + // was auto-wired. Not fatal if it wasn't (e.g. deleted before Ready). + let tag = format!("local-{name}"); + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let _ = cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_MODELS")); + }) + .await; + Ok(Json(serde_json::json!({"deleted": true, "name": name}))) +} + +#[cfg(test)] +mod tests { + use super::{ + SandboxDto, apply_err, inherit_sandbox_context, is_dns1123_label, is_env_key, + parse_copilot_models, project_model_deployment, receipt_verdict, + }; + use crate::error::AppError; + use kube::core::DynamicObject; + use serde_json::json; + + #[test] + fn projects_discovered_airunway_model_in_its_actual_namespace() { + let object: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "airunway.ai/v1alpha1", + "kind": "ModelDeployment", + "metadata": { + "name": "gpt-oss-120b", + "namespace": "default" + }, + "spec": { + "model": {"id": "openai/gpt-oss-120b"} + }, + "status": { + "phase": "Running", + "endpoint": {"service": "gpt-oss-120b", "port": 80}, + "engine": {"type": "vllm"}, + "provider": {"name": "kaito"} + } + })) + .expect("valid dynamic object"); + + let projected = project_model_deployment(&object); + + assert_eq!(projected.namespace, "default"); + assert!(!projected.managed); + assert_eq!( + projected.endpoint.as_deref(), + Some("http://gpt-oss-120b.default.svc.cluster.local:80") + ); + assert_eq!(projected.model_id.as_deref(), Some("openai/gpt-oss-120b")); + } + + fn sandbox( + name: &str, + parent: Option<&str>, + team: Option<&str>, + executing: Option, + ) -> SandboxDto { + SandboxDto { + name: name.to_string(), + namespace: "kars-system".to_string(), + runtime_namespace: None, + phase: Some("Running".to_string()), + runtime: None, + isolation: None, + tool_policy: None, + inference_policy: None, + governed: true, + team: team.map(str::to_string), + parent: parent.map(str::to_string), + message: None, + created: None, + working: Some(false), + executing, + cpu_millicores: None, + memory_bytes: None, + conditions: Vec::new(), + } + } + + #[test] + fn nested_subagents_inherit_root_team_and_execution() { + let mut sandboxes = vec![ + sandbox("lead", None, Some("maintenance"), Some(true)), + sandbox("specialist", Some("lead"), None, None), + sandbox("worker", Some("specialist"), None, None), + ]; + sandboxes[1].working = Some(true); + sandboxes[2].working = Some(true); + + inherit_sandbox_context(&mut sandboxes); + + assert_eq!(sandboxes[0].team.as_deref(), Some("maintenance")); + assert_eq!(sandboxes[0].executing, Some(true)); + assert_eq!(sandboxes[0].working, Some(false)); + for sandbox in &sandboxes[1..] { + assert_eq!(sandbox.team.as_deref(), Some("maintenance")); + assert_eq!(sandbox.executing, Some(true)); + assert_eq!(sandbox.working, Some(true)); + } + } + + #[test] + fn sandbox_context_does_not_cross_namespaces() { + let mut first_lead = sandbox("lead", None, Some("team-a"), Some(true)); + first_lead.namespace = "namespace-a".into(); + let mut first_child = sandbox("worker", Some("lead"), None, None); + first_child.namespace = "namespace-a".into(); + let mut second_lead = sandbox("lead", None, Some("team-b"), Some(false)); + second_lead.namespace = "namespace-b".into(); + let mut second_child = sandbox("worker", Some("lead"), None, None); + second_child.namespace = "namespace-b".into(); + let mut sandboxes = vec![first_lead, first_child, second_lead, second_child]; + + inherit_sandbox_context(&mut sandboxes); + + assert_eq!(sandboxes[1].team.as_deref(), Some("team-a")); + assert_eq!(sandboxes[1].executing, Some(false)); + assert_eq!(sandboxes[3].team.as_deref(), Some("team-b")); + assert_eq!(sandboxes[3].executing, Some(false)); + } + + #[test] + fn parse_copilot_models_filters_and_categorises() { + // A trimmed but faithful sample of the real /models response shape + // (captured live 2026-07): a flagship chat model, a versatile one, an + // embeddings model (must be dropped), a legacy non-picker chat model + // (must be dropped), and a gated preview the seat hasn't enabled + // (must be dropped). + let body = json!({"data": [ + { + "id": "claude-opus-4.8", "name": "Claude Opus 4.8", "vendor": "Anthropic", + "model_picker_enabled": true, "model_picker_category": "powerful", + "policy": {"state": "enabled"}, + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 1_000_000}} + }, + { + "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "vendor": "OpenAI", + "model_picker_enabled": true, "model_picker_category": "versatile", + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 1_050_000}} + }, + { + "id": "text-embedding-3-small", "name": "Embedding V3 small", "vendor": "Azure OpenAI", + "model_picker_enabled": false, "capabilities": {"type": "embeddings"} + }, + { + "id": "gpt-4o", "name": "GPT-4o", "vendor": "Azure OpenAI", + "model_picker_enabled": false, + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 128_000}} + }, + { + "id": "some-preview", "name": "Gated Preview", "vendor": "OpenAI", + "model_picker_enabled": true, "model_picker_category": "powerful", + "policy": {"state": "unconfigured"}, + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 200_000}} + } + ]}); + let out = parse_copilot_models(&body); + let ids: Vec<&str> = out.iter().map(|m| m.id.as_str()).collect(); + // Only the two enabled, picker-enabled chat models survive — embeddings, + // the legacy non-picker gpt-4o, and the un-enabled preview are dropped. + assert_eq!(ids, vec!["claude-opus-4.8", "gpt-5.6-terra"]); + // powerful sorts before versatile. + assert!(out[0].recommended, "powerful model must be recommended"); + assert!( + !out[1].recommended, + "versatile model must not be recommended" + ); + // Label carries the human name + context. + assert!(out[0].label.as_deref().unwrap().contains("Claude Opus 4.8")); + assert!(out[0].label.as_deref().unwrap().contains("1.0M ctx")); + } + + #[test] + fn parse_copilot_models_empty_on_missing_data() { + assert!(parse_copilot_models(&json!({})).is_empty()); + assert!(parse_copilot_models(&json!({"data": []})).is_empty()); + } + + fn claim(class: &str, status: &str) -> (String, String) { + (class.to_string(), status.to_string()) + } + + #[test] + fn receipt_verdict_regulatory_and_omitted_are_advisory() { + // The real V0 shape: crypto claims PASS, regulatory OMITTED. Must be + // "verified" (regression: it used to read "partial" for every receipt). + let v0 = vec![ + claim("integrity", "PASS"), + claim("conformance", "PASS"), + claim("completeness", "PASS"), + claim("regulatory", "OMITTED"), + ]; + assert_eq!(receipt_verdict(&v0), "verified"); + + // Regulatory PARTIAL is likewise advisory. + let v0b = vec![ + claim("integrity", "PASS"), + claim("conformance", "PASS"), + claim("completeness", "PASS"), + claim("regulatory", "PARTIAL"), + ]; + assert_eq!(receipt_verdict(&v0b), "verified"); + + // A genuinely partial CORE claim (completeness) is still "partial". + let partial = vec![ + claim("integrity", "PASS"), + claim("conformance", "PASS"), + claim("completeness", "PARTIAL"), + claim("regulatory", "OMITTED"), + ]; + assert_eq!(receipt_verdict(&partial), "partial"); + + // Any FAIL/ERROR anywhere is "failed". + let failed = vec![claim("integrity", "FAIL"), claim("conformance", "PASS")]; + assert_eq!(receipt_verdict(&failed), "failed"); + + // No claims ⇒ "none". + assert_eq!(receipt_verdict(&[]), "none"); + } + + fn api_err(code: u16, message: &str) -> kube::Error { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: message.into(), + reason: "".into(), + code, + }) + } + + #[test] + fn apply_err_surfaces_ssa_schema_failure() { + // A Server-Side Apply schema rejection arrives as a 500 with an + // actionable message — it must become a Rejected (422) carrying that + // message, NOT an opaque Upstream (502). + let e = api_err( + 500, + "failed to create typed patch object (kars-system/qa; kars.azure.com/v1alpha1, Kind=ToolPolicy): .spec.allow: field not declared in schema", + ); + match apply_err(e) { + AppError::Rejected(m) => assert!(m.contains("field not declared in schema")), + other => panic!("expected Rejected, got {other:?}"), + } + } + + #[test] + fn apply_err_keeps_opaque_500_opaque() { + // A generic 500 with no actionable schema message stays Upstream. + match apply_err(api_err(500, "etcdserver: request timed out")) { + AppError::Upstream(_) => {} + other => panic!("expected Upstream, got {other:?}"), + } + } + + #[test] + fn apply_err_maps_validation_and_rbac() { + assert!(matches!( + apply_err(api_err(422, "bad")), + AppError::Rejected(_) + )); + assert!(matches!( + apply_err(api_err(403, "no")), + AppError::Rejected(_) + )); + assert!(matches!( + apply_err(api_err(409, "conflict")), + AppError::Rejected(_) + )); + } + + #[test] + fn dns1123_label_rules() { + assert!(is_dns1123_label("repo-watch")); + assert!(is_dns1123_label("a")); + assert!(is_dns1123_label("team1")); + assert!(!is_dns1123_label("")); // empty + assert!(!is_dns1123_label("-lead")); // leading hyphen + assert!(!is_dns1123_label("lead-")); // trailing hyphen + assert!(!is_dns1123_label("Repo")); // uppercase + assert!(!is_dns1123_label("a_b")); // underscore + assert!(!is_dns1123_label(&"x".repeat(64))); // too long + } + + #[test] + fn env_key_rules() { + assert!(is_env_key("GITHUB_TOKEN")); + assert!(is_env_key("_x")); + assert!(is_env_key("BRAVE_API_KEY")); + assert!(!is_env_key("")); // empty + assert!(!is_env_key("1TOKEN")); // leading digit + assert!(!is_env_key("MY-KEY")); // hyphen + assert!(!is_env_key("MY KEY")); // space + } + + #[test] + fn witness_doc_parses_real_aggregator_payload() { + // The exact shape the aggregator publishes into kars-datapath-witness. + let body = r#"{ + "generated_at": "2026-07-02T13:51:32Z", + "window_seconds": 15, + "gadget": "inspektor-gadget", + "sandboxes": [ + {"namespace":"kars-demo","sandbox":"demo", + "declared_hosts":["api.github.com"], + "observed_dns":["api.github.com","example.com"], + "observed_connects":4, + "beyond_declared":["example.com"], + "unused_declared":[], + "verdict":"BEYOND-DECLARED"} + ] + }"#; + let doc: super::WitnessDoc = serde_json::from_str(body).expect("parse"); + assert_eq!(doc.generated_at.as_deref(), Some("2026-07-02T13:51:32Z")); + assert_eq!(doc.window_seconds, Some(15)); + assert_eq!(doc.sandboxes.len(), 1); + let s = &doc.sandboxes[0]; + assert_eq!(s.sandbox, "demo"); + assert_eq!(s.verdict, "BEYOND-DECLARED"); + assert_eq!(s.beyond_declared, vec!["example.com"]); + assert_eq!(s.observed_connects, 4); + } + + #[test] + fn witness_sandbox_tolerates_missing_optional_arrays() { + // Defaults must hold so a partial payload never fails deserialization. + let s: super::DatapathWitnessSandbox = + serde_json::from_str(r#"{"namespace":"n","sandbox":"x","verdict":"LEARN"}"#) + .expect("parse"); + assert_eq!(s.verdict, "LEARN"); + assert!(s.declared_hosts.is_empty()); + assert!(s.observed_dns.is_empty()); + assert_eq!(s.observed_connects, 0); + } +} diff --git a/bridge/bff/src/routes/options.rs b/bridge/bff/src/routes/options.rs new file mode 100644 index 000000000..5e8eb25ef --- /dev/null +++ b/bridge/bff/src/routes/options.rs @@ -0,0 +1,1599 @@ +// kars Bridge BFF — launch-package options. +// +// The editable launch package (§20 of the design note) must be composed from +// **real cluster facts**, never a hard-coded menu. This endpoint enumerates the +// composable substrate a task-giver can pick from: the models this cluster is +// configured to serve, the agent runtimes the controller can materialize, the +// isolation levels, and the existing `ToolPolicy` / `McpServer` / `KarsMemory` +// objects the blueprint composes by reference. Absent CRDs surface as empty +// lists (the web layer renders the honesty grammar), never as errors. + +use axum::Json; +use axum::extract::State; +use kube::core::DynamicObject; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} +fn upstream(e: kube::Error) -> AppError { + AppError::Upstream(e.to_string()) +} +fn name_of(o: &DynamicObject) -> String { + o.metadata.name.clone().unwrap_or_default() +} +fn ns_of(o: &DynamicObject) -> String { + o.metadata.namespace.clone().unwrap_or_default() +} + +/// A model the agent can reason with — a provider tag + deployment name, the +/// exact pair that lands on `InferencePolicy.spec.modelPreference.primary`. +#[derive(Debug, Serialize)] +pub struct ModelOption { + pub provider: String, + pub deployment: String, + /// True for the controller's configured default (used when a package leaves + /// the model unset) so the UI can pre-select and label it. + pub is_default: bool, + /// Short human detail (e.g. "Anthropic · 1.0M ctx · powerful") when the + /// provider exposes it — GitHub Copilot's live catalog does. `None` for + /// providers with no metadata. Shown in the Model catalogue. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// An agent runtime/harness the controller can materialize. `wired` reflects +/// whether the substrate has a real, end-to-end adapter today; `status` adds +/// the honest validation tier (`validated` = exercised end-to-end on kars +/// clusters; `available` = adapter wired, less battle-tested; `unavailable` = +/// declared but no adapter yet). `note` is a one-line human explanation. +#[derive(Debug, Serialize)] +pub struct RuntimeOption { + pub kind: String, + pub label: String, + pub wired: bool, + pub status: String, + pub note: String, +} + +/// The inference provider this cluster inherits from its setup (GitHub Copilot, +/// GitHub Models, or Azure AI Foundry) — surfaced so the UI states it as fact +/// rather than pretending the Bridge picks it. +#[derive(Debug, Serialize)] +pub struct ProviderInfo { + pub id: String, + pub label: String, + pub note: String, +} + +/// A composable CRD the blueprint references by name (tool policy, MCP server, +/// shared memory), projected to just what the picker needs. +#[derive(Debug, Clone, Serialize)] +pub struct RefOption { + pub name: String, + pub namespace: String, + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub discovered_tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_schema_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compiled_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readiness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recipe: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_digest: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub qualified_routes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct IsolationOption { + pub value: String, + pub label: String, + pub note: String, +} + +/// The full composable palette for the launch package. +#[derive(Debug, Serialize)] +pub struct Options { + pub models: Vec, + /// The controller default deployment, surfaced explicitly so the UI can say + /// "leave unset → uses " honestly. + pub default_model: Option, + /// The inherited inference provider for this cluster (None when unreadable). + pub provider: Option, + pub runtimes: Vec, + pub isolation: Vec, + pub tool_policies: Vec, + pub mcp_servers: Vec, + /// Operator-curated MCP profiles — named vetted bundles of McpServers users + /// can add as a set (the operator vets the grouping once; users pick it). + pub mcp_profiles: Vec, + pub memories: Vec, + /// Attested capability bundles (KarsSkill) a team role can acquire, so the + /// team composer can offer a real skill picker instead of teaching a concept + /// with no control behind it. + pub skills: Vec, +} + +/// A named, operator-vetted bundle of McpServers offered to users as a set. +#[derive(Debug, Serialize, Clone)] +pub struct McpProfileOption { + pub name: String, + pub summary: Option, + pub servers: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct QualifiedRoute { + runtime: String, + provider: String, + deployment: String, + #[serde(default)] + capabilities: Vec, + #[serde(default = "default_qualified_parallelism")] + max_parallel: i32, + #[serde(default)] + min_total_tokens: Option, + #[serde(default)] + resource: Option, + evidence: QualificationEvidence, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct QualifiedResource { + kind: String, + name: String, + #[serde(default)] + backend: Option, + #[serde(default)] + schema_digest: Option, + #[serde(default)] + version_digest: Option, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct QualificationEvidence { + task: String, + run_id: String, + digest: String, +} + +#[derive(Debug, Clone)] +struct QualifiedResourceSelection { + kind: String, + name: String, + backend: Option, + schema_digest: Option, + version_digest: Option, +} + +fn default_qualified_parallelism() -> i32 { + 1 +} + +fn string_at(value: &serde_json::Value, pointers: &[&str]) -> Option { + pointers.iter().find_map(|pointer| { + value + .pointer(pointer) + .and_then(|entry| entry.as_str()) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + }) +} + +fn bool_at(value: &serde_json::Value, pointers: &[&str]) -> Option { + pointers + .iter() + .find_map(|pointer| value.pointer(pointer).and_then(serde_json::Value::as_bool)) +} + +fn string_array_at(value: &serde_json::Value, pointers: &[&str]) -> Vec { + pointers + .iter() + .find_map(|pointer| { + value.pointer(pointer).and_then(|entry| { + entry.as_array().map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect::>() + }) + }) + }) + .unwrap_or_default() +} + +fn status_observes_current_generation(resource: &DynamicObject) -> bool { + resource + .data + .pointer("/status/observedGeneration") + .and_then(serde_json::Value::as_i64) + == resource.metadata.generation +} + +fn readiness_summary(resource: &DynamicObject) -> Option { + let phase = string_at(&resource.data, &["/status/phase"]); + let ready = resource + .data + .pointer("/status/conditions") + .and_then(serde_json::Value::as_array) + .and_then(|conditions| { + conditions.iter().find_map(|condition| { + (condition.get("type").and_then(serde_json::Value::as_str) == Some("Ready")).then( + || { + let status = condition + .get("status") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let message = condition + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(str::to_string); + (status, message) + }, + ) + }) + }); + let observed = status_observes_current_generation(resource); + match (phase, ready) { + (Some(phase), Some((Some(status), Some(message)))) => Some(format!( + "{}{}{}", + phase, + if observed { "" } else { " (stale generation)" }, + if status == "True" { + String::new() + } else { + format!(" — {message}") + } + )), + (Some(phase), Some((Some(status), None))) => Some(format!( + "{}{}{}", + phase, + if observed { "" } else { " (stale generation)" }, + if status == "True" { + String::new() + } else { + format!(" — Ready={status}") + } + )), + (Some(phase), _) => Some(format!( + "{}{}", + phase, + if observed { "" } else { " (stale generation)" } + )), + (None, Some((Some(status), Some(message)))) => Some(format!("Ready={status} — {message}")), + (None, Some((Some(status), None))) => Some(format!("Ready={status}")), + _ => None, + } +} + +fn route_matches( + route: &QualifiedRoute, + runtime: &str, + provider: &str, + deployment: &str, + max_parallel: i32, + total_tokens: Option, +) -> bool { + route.runtime.eq_ignore_ascii_case(runtime) + && route.provider == provider + && route.deployment == deployment + && max_parallel <= route.max_parallel + && route + .min_total_tokens + .is_none_or(|minimum| total_tokens.is_none_or(|tokens| tokens >= minimum)) +} + +fn evidence_complete(route: &QualifiedRoute) -> bool { + !route.evidence.task.trim().is_empty() + && !route.evidence.run_id.trim().is_empty() + && route.evidence.digest.starts_with("sha256:") +} + +fn resource_capability(kind: &str) -> Option<&'static str> { + match kind.to_ascii_lowercase().as_str() { + "mcp" => Some("mcp"), + "memory" => Some("memory"), + _ => None, + } +} + +fn resource_requires_backend(kind: &str) -> bool { + kind.eq_ignore_ascii_case("memory") +} + +fn resource_requires_schema_digest(kind: &str) -> bool { + kind.eq_ignore_ascii_case("mcp") || kind.eq_ignore_ascii_case("memory") +} + +fn resource_requires_version_digest(kind: &str) -> bool { + kind.eq_ignore_ascii_case("skill") +} + +fn resource_matches( + required: &QualifiedResourceSelection, + recorded: &QualifiedResource, + capabilities: &std::collections::BTreeSet, +) -> bool { + if !recorded.kind.eq_ignore_ascii_case(&required.kind) + || !recorded.name.eq_ignore_ascii_case(&required.name) + { + return false; + } + if let Some(capability) = resource_capability(&required.kind) + && !capabilities.contains(capability) + { + return false; + } + if resource_requires_backend(&required.kind) && required.backend.is_none() { + return false; + } + if resource_requires_schema_digest(&required.kind) && required.schema_digest.is_none() { + return false; + } + if resource_requires_version_digest(&required.kind) && required.version_digest.is_none() { + return false; + } + if let Some(backend) = required.backend.as_deref() + && recorded.backend.as_deref() != Some(backend) + { + return false; + } + if let Some(schema_digest) = required.schema_digest.as_deref() + && recorded.schema_digest.as_deref() != Some(schema_digest) + { + return false; + } + if let Some(version_digest) = required.version_digest.as_deref() + && recorded.version_digest.as_deref() != Some(version_digest) + { + return false; + } + true +} + +fn qualification_records(raw: &str) -> Result, String> { + serde_json::from_str::>(raw) + .map_err(|error| format!("BRIDGE_QUALIFICATION_RECORDS_JSON is invalid: {error}")) +} + +fn qualification_records_from_env() -> Result, String> { + let raw = std::env::var("BRIDGE_QUALIFICATION_RECORDS_JSON") + .map_err(|_| "BRIDGE_QUALIFICATION_RECORDS_JSON is not configured".to_string())?; + let mut records = qualification_records(&raw)?; + if let Ok(additional) = std::env::var("BRIDGE_ADDITIONAL_QUALIFICATION_RECORDS_JSON") + && !additional.trim().is_empty() + { + records.extend(qualification_records(&additional).map_err(|error| { + error.replace( + "BRIDGE_QUALIFICATION_RECORDS_JSON", + "BRIDGE_ADDITIONAL_QUALIFICATION_RECORDS_JSON", + ) + })?); + } + Ok(records) +} + +fn qualification_records_raw_from_env() -> Result { + serde_json::to_string(&qualification_records_from_env()?) + .map_err(|error| format!("qualification records could not be serialized: {error}")) +} + +pub(crate) fn route_label(runtime: &str, provider: &str, deployment: &str) -> String { + format!("{runtime} · {provider}::{deployment}") +} + +fn resource_qualification_routes_in( + raw: &str, + resource: &QualifiedResourceSelection, +) -> Result, String> { + let mut labels = qualification_records(raw)? + .into_iter() + .filter(evidence_complete) + .filter_map(|route| { + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::>(); + route + .resource + .as_ref() + .filter(|recorded| resource_matches(resource, recorded, &capabilities)) + .map(|_| route_label(&route.runtime, &route.provider, &route.deployment)) + }) + .collect::>(); + labels.sort(); + labels.dedup(); + Ok(labels) +} + +fn resource_is_qualified_in( + raw: &str, + runtime: &str, + provider: &str, + deployment: &str, + resource: &QualifiedResourceSelection, +) -> Result { + Ok(qualification_records(raw)?.into_iter().any(|route| { + if !evidence_complete(&route) + || !route_matches(&route, runtime, provider, deployment, 1, None) + { + return false; + } + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::>(); + route + .resource + .as_ref() + .is_some_and(|recorded| resource_matches(resource, recorded, &capabilities)) + })) +} + +fn resource_qualification_routes( + resource: &QualifiedResourceSelection, +) -> Result, String> { + let raw = qualification_records_raw_from_env()?; + resource_qualification_routes_in(&raw, resource) +} + +fn resource_is_qualified( + runtime: &str, + provider: &str, + deployment: &str, + resource: &QualifiedResourceSelection, +) -> Result { + let raw = qualification_records_raw_from_env()?; + resource_is_qualified_in(&raw, runtime, provider, deployment, resource) +} + +fn mcp_resource_selection(option: &RefOption) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "mcp".into(), + name: option.name.clone(), + backend: None, + schema_digest: option.tool_schema_digest.clone(), + version_digest: None, + } +} + +fn memory_resource_selection(option: &RefOption) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "memory".into(), + name: option.name.clone(), + backend: option.backend.clone(), + schema_digest: option.compiled_digest.clone(), + version_digest: None, + } +} + +fn skill_resource_selection(option: &RefOption) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "skill".into(), + name: option.name.clone(), + backend: None, + schema_digest: None, + version_digest: option.version_digest.clone(), + } +} + +fn channel_resource_selection(channel: &str) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "channel".into(), + name: channel.to_ascii_lowercase(), + backend: None, + schema_digest: None, + version_digest: None, + } +} + +pub(crate) fn mcp_server_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + option: &RefOption, +) -> Result { + resource_is_qualified( + runtime, + provider, + deployment, + &mcp_resource_selection(option), + ) +} + +pub(crate) fn memory_binding_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + option: &RefOption, +) -> Result { + resource_is_qualified( + runtime, + provider, + deployment, + &memory_resource_selection(option), + ) +} + +pub(crate) fn skill_version_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + option: &RefOption, +) -> Result { + resource_is_qualified( + runtime, + provider, + deployment, + &skill_resource_selection(option), + ) +} + +pub(crate) fn channel_adapter_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + channel: &str, +) -> Result { + resource_is_qualified( + runtime, + provider, + deployment, + &channel_resource_selection(channel), + ) +} + +pub(crate) fn resource_qualification_summary(options: &Options) -> Result { + let mut lines: Vec = Vec::new(); + for server in &options.mcp_servers { + let routes = resource_qualification_routes(&mcp_resource_selection(server))?; + lines.push(format!( + " - MCP \"{}\"{}{}{}", + server.name, + server + .tool_schema_digest + .as_deref() + .map(|digest| format!(" schema_digest={digest}")) + .unwrap_or_else(|| " schema_digest=missing".into()), + if server.discovered_tools.is_empty() { + String::new() + } else { + format!(" tools=[{}]", server.discovered_tools.join(", ")) + }, + if routes.is_empty() { + " qualified_on=(none)".into() + } else { + format!(" qualified_on=[{}]", routes.join("; ")) + } + )); + } + for memory in &options.memories { + let routes = resource_qualification_routes(&memory_resource_selection(memory))?; + lines.push(format!( + " - MEMORY \"{}\"{}{}{}{}", + memory.name, + memory + .backend + .as_deref() + .map(|backend| format!(" backend={backend}")) + .unwrap_or_else(|| " backend=missing".into()), + memory + .compiled_digest + .as_deref() + .map(|digest| format!(" compiled_digest={digest}")) + .unwrap_or_else(|| " compiled_digest=missing".into()), + memory + .readiness + .as_deref() + .map(|readiness| format!(" readiness={readiness}")) + .unwrap_or_default(), + if routes.is_empty() { + " qualified_on=(none)".into() + } else { + format!(" qualified_on=[{}]", routes.join("; ")) + } + )); + } + for skill in &options.skills { + let routes = resource_qualification_routes(&skill_resource_selection(skill))?; + lines.push(format!( + " - SKILL \"{}\"{}{}{}{}", + skill.name, + skill + .version + .as_deref() + .map(|version| format!(" version={version}")) + .unwrap_or_default(), + skill + .version_digest + .as_deref() + .map(|digest| format!(" version_digest={digest}")) + .unwrap_or_else(|| " version_digest=missing".into()), + skill + .recipe + .as_deref() + .map(|recipe| format!(" recipe={}", recipe.chars().take(180).collect::())) + .unwrap_or_default(), + if routes.is_empty() { + " qualified_on=(none)".into() + } else { + format!(" qualified_on=[{}]", routes.join("; ")) + } + )); + } + if lines.is_empty() { + Ok(" (no MCP, memory, or approved-skill resources are available)".into()) + } else { + Ok(lines.join("\n")) + } +} + +pub(crate) fn mcp_server_option(resource: &DynamicObject) -> RefOption { + let mode = string_at(&resource.data, &["/status/mode"]).or_else(|| { + bool_at(&resource.data, &["/spec/managed"]) + .map(|managed| if managed { "Managed" } else { "External" }.to_string()) + }); + let discovered_tools = string_array_at(&resource.data, &["/status/discoveredTools"]); + let tool_schema_digest = + string_at(&resource.data, &["/status/toolSchemaDigest"]).or_else(|| { + let signature = serde_json::json!({ + "mode": mode.clone(), + "endpoint": string_at( + &resource.data, + &["/status/endpoint", "/spec/url", "/spec/endpoint"], + ), + "allowed_tools": string_array_at(&resource.data, &["/spec/allowedTools"]), + "discovered_tools": discovered_tools.clone(), + }); + serde_json::to_vec(&signature) + .ok() + .map(|bytes| format!("sha256:{:x}", Sha256::digest(bytes))) + }); + let mut option = RefOption { + name: name_of(resource), + namespace: ns_of(resource), + summary: string_at( + &resource.data, + &["/status/endpoint", "/spec/url", "/spec/endpoint"], + ), + mode, + discovered_tools, + tool_schema_digest, + compiled_digest: None, + backend: None, + readiness: readiness_summary(resource), + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }; + option.qualified_routes = + resource_qualification_routes(&mcp_resource_selection(&option)).unwrap_or_default(); + option +} + +pub(crate) fn memory_option(resource: &DynamicObject) -> RefOption { + let mut option = RefOption { + name: name_of(resource), + namespace: ns_of(resource), + summary: string_at( + &resource.data, + &["/spec/displayName", "/spec/storeName", "/status/storeName"], + ), + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: None, + compiled_digest: string_at( + &resource.data, + &[ + "/status/compiledDigest", + "/status/compiled/digest", + "/status/resolvedDigest", + "/status/specDigest", + ], + ), + backend: string_at( + &resource.data, + &[ + "/status/backend", + "/spec/backend", + "/status/binding/backend", + "/spec/binding/backend", + "/status/provider", + "/spec/provider", + ], + ) + .or_else(|| Some("foundry".into())), + readiness: readiness_summary(resource), + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }; + option.qualified_routes = + resource_qualification_routes(&memory_resource_selection(&option)).unwrap_or_default(); + option +} + +pub(crate) fn skill_option(resource: &DynamicObject) -> RefOption { + let mut option = RefOption { + name: name_of(resource), + namespace: ns_of(resource), + summary: string_at(&resource.data, &["/spec/summary"]), + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: None, + compiled_digest: None, + backend: None, + readiness: string_at(&resource.data, &["/status/phase"]), + version: string_at(&resource.data, &["/spec/version"]), + recipe: string_at(&resource.data, &["/spec/recipe"]), + version_digest: string_at(&resource.data, &["/status/versionDigest"]), + qualified_routes: Vec::new(), + }; + option.qualified_routes = + resource_qualification_routes(&skill_resource_selection(&option)).unwrap_or_default(); + option +} + +pub(crate) fn qualification_constraints_summary() -> Result { + let routes = qualification_records_from_env()?; + if routes.is_empty() { + return Ok(" (no qualified execution routes are retained)".into()); + } + Ok(routes + .into_iter() + .map(|route| { + format!( + " - {} · capabilities=[{}] · max_parallel={} · min_total_tokens={}{}", + route_label(&route.runtime, &route.provider, &route.deployment), + route.capabilities.join(","), + route.max_parallel, + route + .min_total_tokens + .map(|tokens| tokens.to_string()) + .unwrap_or_else(|| "none".into()), + route + .resource + .map(|resource| { + format!( + " · resource={}::{}{}{}{}", + resource.kind, + resource.name, + resource + .backend + .as_deref() + .map(|backend| format!(" backend={backend}")) + .unwrap_or_default(), + resource + .schema_digest + .as_deref() + .map(|digest| format!(" schema_digest={digest}")) + .unwrap_or_default(), + resource + .version_digest + .as_deref() + .map(|digest| format!(" version_digest={digest}")) + .unwrap_or_default(), + ) + }) + .unwrap_or_default(), + ) + }) + .collect::>() + .join("\n")) +} + +pub(crate) fn route_minimum_tokens( + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, +) -> Result, String> { + let routes = qualification_records_from_env()?; + let matching = routes.into_iter().filter(|route| { + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::>(); + route_matches(route, runtime, provider, deployment, max_parallel, None) + && required_capabilities.is_subset(&capabilities) + }); + let mut minimum: Option = None; + for route in matching { + let Some(tokens) = route.min_total_tokens else { + return Ok(None); + }; + minimum = Some(minimum.map_or(tokens, |current| current.min(tokens))); + } + Ok(minimum) +} + +pub(crate) fn route_qualification( + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, + total_tokens: Option, +) -> Result { + let raw = qualification_records_raw_from_env()?; + route_is_qualified_in( + &raw, + runtime, + provider, + deployment, + required_capabilities, + max_parallel, + total_tokens, + ) +} + +pub(crate) fn route_qualification_gap( + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, + total_tokens: Option, +) -> Result, String> { + let raw = qualification_records_raw_from_env()?; + route_qualification_gap_in( + &raw, + runtime, + provider, + deployment, + required_capabilities, + max_parallel, + total_tokens, + ) +} + +fn route_qualification_gap_in( + raw: &str, + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, + total_tokens: Option, +) -> Result, String> { + let routes = qualification_records(raw)?; + let mut gaps = routes + .into_iter() + .filter_map(|route| { + if !evidence_complete(&route) + || !route_matches( + &route, + runtime, + provider, + deployment, + max_parallel, + total_tokens, + ) + { + return None; + } + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::>(); + Some( + required_capabilities + .difference(&capabilities) + .cloned() + .collect::>(), + ) + }) + .collect::>(); + gaps.sort_by(|left, right| { + left.len() + .cmp(&right.len()) + .then_with(|| left.iter().cmp(right.iter())) + }); + Ok(gaps + .into_iter() + .next() + .unwrap_or_else(|| required_capabilities.clone())) +} + +fn route_is_qualified_in( + raw: &str, + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, + total_tokens: Option, +) -> Result { + let routes = qualification_records(raw)?; + Ok(routes.into_iter().any(|route| { + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::>(); + route_matches( + &route, + runtime, + provider, + deployment, + max_parallel, + total_tokens, + ) && evidence_complete(&route) + && required_capabilities.is_subset(&capabilities) + })) +} + +/// Infer a provider tag from a deployment string when none is recorded — a +/// `github-models`-style `openai/` carries its vendor in the prefix. A +/// bare deployment name (the Copilot/Foundry form) carries no vendor, so we tag +/// it with the cluster's ACTUAL inherited provider (`github-copilot`, +/// `github-models`, or a Foundry/Azure provider) rather than guessing +/// `azure-openai`. This is what makes a composed mission/team stamp the real +/// provider the router serves — never a misleading default. +pub(crate) fn provider_for( + deployment: &str, + recorded: Option<&str>, + cluster_default: Option<&str>, +) -> String { + if let Some(p) = recorded { + return p.to_string(); + } + match deployment.split_once('/') { + Some(("openai", _)) => "github-models".to_string(), + Some((vendor, _)) => vendor.to_string(), + None => cluster_default.unwrap_or("azure-openai").to_string(), + } +} + +/// `GET /api/options` — the composable launch-package palette, from live state. +pub async fn get_options(State(state): State) -> AppResult> { + let cluster = require_cluster(&state)?; + Ok(Json(build_options(cluster).await?)) +} + +/// Build the real composable building blocks from live cluster state. Shared by +/// the `/api/options` route and the orchestrator (`/compose`), so the LLM only +/// ever proposes models, tool policies, MCP servers, isolation levels, and +/// memory stores that genuinely exist on this cluster. +pub async fn build_options(cluster: &crate::kars::cluster::Cluster) -> AppResult { + // Models: the controller-configured default + catalog, deduped against any + // distinct models already pinned on existing InferencePolicies (real, + // in-use facts). Order: default first, then catalog, then discovered. + let (default_model, catalog) = cluster.controller_models().await; + // The cluster's inherited inference provider — the authoritative tag for any + // catalog model that doesn't carry its own vendor prefix. Fetched up front + // so every offered model is stamped with the provider the router actually + // serves (e.g. `github-copilot`), not a neutral guess. + let provider = cluster + .controller_provider() + .await + .map(|(id, label, note)| ProviderInfo { id, label, note }); + let cluster_provider_id: Option = provider.as_ref().map(|p| p.id.clone()); + // The operator's declared, served set — the only models we trust enough to + // offer. Discovered InferencePolicy models are surfaced ONLY if they are + // also in this set, so a stale policy pinning an unserved model (e.g. a + // decommissioned deployment) can't leak a broken option into the picker. + let catalog_set: std::collections::BTreeSet = catalog + .iter() + .cloned() + .chain(default_model.clone()) + .collect(); + let mut models: Vec = Vec::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + // Detail blurbs (deployment → "vendor · ctx · category") for models whose + // provider exposes them; applied in a post-pass so push_model stays simple. + let mut details: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let cpid = cluster_provider_id.clone(); + let mut push_model = |deployment: String, provider: Option<&str>, is_default: bool| { + if deployment.is_empty() || !seen.insert(deployment.clone()) { + return; + } + let provider = provider_for(&deployment, provider, cpid.as_deref()); + models.push(ModelOption { + provider, + deployment, + is_default, + detail: None, + }); + }; + if let Some(def) = default_model.clone() { + push_model(def, None, true); + } + for dep in catalog { + push_model(dep, None, false); + } + // Live GitHub Copilot catalog — when Copilot is the cluster's default + // provider, surface the seat's ACTUAL served models (gpt-5.6, opus-4.8, + // gemini-3.1-pro, …) instead of only the static KARS_MODEL_CATALOG. This + // is the SAME set the wizard's Copilot discovery shows, so the Model + // catalogue, the orchestrator's menu, and the manual-override picker all + // reflect what Copilot really serves — refreshing itself as GitHub adds + // models. Cached (5-min TTL); a transient Copilot failure leaves the + // static catalog intact (best-effort, never blanks the list). + if cluster_provider_id.as_deref() == Some("github-copilot") + && let Some(token) = cluster.controller_copilot_token().await + { + for (dep, _recommended, detail) in + crate::routes::operator::copilot_catalog_cached(&token).await + { + if let Some(d) = detail { + details.entry(dep.clone()).or_insert(d); + } + push_model(dep, Some("github-copilot"), false); + } + } + for ip in cluster + .list_kind_all("InferencePolicy") + .await + .map_err(upstream)? + { + let primary = ip + .data + .get("spec") + .and_then(|s| s.get("modelPreference")) + .and_then(|m| m.get("primary")); + if let Some(p) = primary { + let dep = p.get("deployment").and_then(|d| d.as_str()).unwrap_or(""); + // Only surface a discovered model if the operator's catalog declares + // it — never an arbitrary (possibly unserved) pinned deployment. + if !catalog_set.contains(dep) { + continue; + } + let prov = p.get("provider").and_then(|d| d.as_str()); + push_model(dep.to_string(), prov, false); + } + } + + // Additional providers (§ inference-provider-wizard): every model the + // operator explicitly declared when connecting a provider beyond the + // single default — e.g. a Foundry deployment or a GitHub Models id — + // tagged with ITS OWN provider, not the cluster default. These are + // operator-declared (trusted) the same way the default catalog is, so + // they don't need the catalog_set gate above. This is what lets + // InferencePolicy's model picker offer "gpt-4.1 via Foundry" alongside + // "opus-4.8 via GitHub Copilot" with no change to that editor — it + // already keys options by `provider::deployment`. + let provider_keys = cluster + .read_secret_all("kars-system", "kars-inference-providers") + .await + .map_err(upstream)?; + let mut declared_models: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (key, value) in &provider_keys { + let Some(tag_part) = key + .strip_prefix("KARS_PROVIDER_") + .and_then(|r| r.strip_suffix("_MODELS")) + else { + continue; + }; + let tag = tag_part.to_ascii_lowercase().replace('_', "-"); + declared_models.insert( + tag, + value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + ); + } + for (tag, deployments) in declared_models { + for dep in deployments { + push_model(dep, Some(tag.as_str()), false); + } + } + + // Apply detail blurbs (only some providers expose them). + if !details.is_empty() { + for m in models.iter_mut() { + if m.detail.is_none() + && let Some(d) = details.get(&m.deployment) + { + m.detail = Some(d.clone()); + } + } + } + + // Runtimes: the controller wires adapters for several harnesses, but a + // harness is only RUNNABLE here when its container image is configured and + // the controller has registry credentials that cover the image. + // `wired` now means "can start a pod here" — so the UI never lets a user (or + // the orchestrator) pick a harness that would ErrImagePull. `status`: + // ready = runnable on this cluster + // needs_image = adapter exists, but no image configured here + // unavailable = no adapter at all (SemanticKernel) + let runnable = cluster.runnable_runtimes().await; + let mk = |kind: &str, label: &str, ready_note: &str| { + let is_runnable = runnable.contains(kind); + RuntimeOption { + kind: kind.into(), + label: label.into(), + wired: is_runnable, + status: if is_runnable { + "ready".into() + } else { + "needs_image".into() + }, + note: if is_runnable { + ready_note.into() + } else { + "Adapter wired, but its runtime image or registry pull credential is unavailable on this cluster.".into() + }, + } + }; + let runtimes = vec![ + mk( + "OpenClaw", + "OpenClaw", + "Autonomous — the default kars harness, exercised end-to-end (full mesh + spawn). Runs missions and standing teams.", + ), + mk( + "Hermes", + "Hermes (Nous Research)", + "Autonomous — executes a delivered objective in-process and replies (plugins, 20+ channels, native MCP). Verified end-to-end.", + ), + mk( + "Anthropic", + "Anthropic Claude Agent SDK", + "Adapter only (pins the governed router) — you supply the agent logic. Not a turnkey autonomous harness; auto-corrected to OpenClaw for missions/teams.", + ), + mk( + "OpenAIAgents", + "OpenAI Agents SDK", + "Adapter only (routes through the inference sidecar) — you supply the agent logic. Not turnkey autonomous; auto-corrected to OpenClaw for missions/teams.", + ), + mk( + "MicrosoftAgentFramework", + "Microsoft Agent Framework", + "Adapter only (MAF Python, first-party AGT integration) — you supply the agent logic. Not turnkey autonomous; auto-corrected to OpenClaw.", + ), + mk( + "LangGraph", + "LangGraph", + "Adapter only (Python + TypeScript, pins the router) — you supply the graph. Not turnkey autonomous; auto-corrected to OpenClaw.", + ), + mk( + "PydanticAi", + "Pydantic-AI", + "Adapter only (provider-agnostic, pins the router at bootstrap) — you supply the agent. Not turnkey autonomous; auto-corrected to OpenClaw.", + ), + mk( + "BYO", + "Bring-your-own runtime", + "Autonomous by contract — any image honoring the BYO contract (UID 1000, router at 127.0.0.1:8443, consumes the objective + delivers).", + ), + RuntimeOption { + kind: "SemanticKernel".into(), + label: "Semantic Kernel".into(), + wired: false, + status: "unavailable".into(), + note: "Declared on the substrate but no adapter is wired yet.".into(), + }, + ]; + + let isolation = vec![ + IsolationOption { + value: "standard".into(), + label: "Standard".into(), + note: "Namespaced sandbox, default-deny egress, seccomp.".into(), + }, + IsolationOption { + value: "enhanced".into(), + label: "Enhanced".into(), + note: "Hardened profile for sensitive work.".into(), + }, + IsolationOption { + value: "confidential".into(), + label: "Confidential".into(), + note: "Confidential compute (CVM) where the node pool supports it.".into(), + }, + ]; + + let tool_policies = cluster + .list_kind_all("ToolPolicy") + .await + .map_err(upstream)? + .iter() + .map(|o| RefOption { + name: name_of(o), + namespace: ns_of(o), + summary: o + .data + .get("spec") + .and_then(|s| s.get("appliesTo")) + .and_then(|a| a.get("tool")) + .and_then(|t| t.as_str()) + .map(|t| format!("tools {t}")), + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: None, + compiled_digest: None, + backend: None, + readiness: None, + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }) + .collect(); + + let mut mcp_servers: Vec = cluster + .list_kind_all("McpServer") + .await + .map_err(upstream)? + .iter() + .map(mcp_server_option) + .collect(); + // Collapse duplicate registrations in the SAME namespace that point at the + // same endpoint URL. Identical endpoints in different namespaces are + // distinct workspace grants and must survive namespace filtering. + // the audit saw two identical Playwright servers offered side by side, which + // is confusing and invites a redundant grant. Keep the first per URL; servers + // with no URL are always kept (nothing to compare on). + { + let mut seen_urls: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); + mcp_servers.retain(|s| match s.summary.as_deref() { + Some(url) if !url.is_empty() => { + seen_urls.insert((s.namespace.clone(), url.to_string())) + } + _ => true, + }); + } + + let memories = cluster + .list_kind_all("KarsMemory") + .await + .map_err(upstream)? + .iter() + .map(memory_option) + .collect(); + + let skills = cluster + .list_kind_all("KarsSkill") + .await + .map_err(upstream)? + .iter() + // Operator trust gate: users may only assign skills an operator has + // approved AND locked to the skill's current version digest. A pending, + // never-approved, or changed-since-approval skill is withheld until + // (re)approved — the same rule the operator console enforces. + .filter(|o| { + let review = o + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/skill-review")) + .map(String::as_str); + let locked = o + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/skill-locked-digest")); + let digest = o + .data + .get("status") + .and_then(|s| s.get("versionDigest")) + .and_then(|d| d.as_str()); + review == Some("approved") && locked.is_some() && locked.map(String::as_str) == digest + }) + .map(skill_option) + .collect(); + + // Operator-curated MCP profiles (vetted bundles). Only surface servers that + // still exist on the cluster, so a deleted McpServer can't linger in a bundle. + let known_servers: std::collections::BTreeSet = + mcp_servers.iter().map(|r| r.name.clone()).collect(); + let mcp_profiles: Vec = { + let raw = cluster.read_mcp_profiles().await; + let parsed: Vec = + serde_json::from_str(&raw).unwrap_or_default(); + parsed + .into_iter() + .map(|p| McpProfileOption { + name: p.name, + summary: p.summary, + servers: p + .servers + .into_iter() + .filter(|s| known_servers.contains(s)) + .collect(), + }) + .collect() + }; + + Ok(Options { + models, + default_model, + provider, + runtimes, + isolation, + tool_policies, + mcp_servers, + mcp_profiles, + memories, + skills, + }) +} + +#[cfg(test)] +mod qualification_tests { + use super::{ + QualifiedResourceSelection, channel_resource_selection, mcp_resource_selection, + resource_is_qualified_in, resource_qualification_routes_in, route_is_qualified_in, + route_qualification_gap_in, + }; + use std::collections::BTreeSet; + + #[test] + fn qualification_requires_route_capabilities_constraints_and_evidence() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["delegation","filesystem-read","shell","network","artifacts","telemetry"], + "max_parallel":1, + "min_total_tokens":128144, + "evidence":{ + "task":"openclaw-proof", + "run_id":"run-1", + "digest":"sha256:abc" + } + + } + ]"#; + let required = ["delegation", "shell", "telemetry"] + .into_iter() + .map(str::to_string) + .collect::>(); + assert!( + route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + Some(128_144) + ) + .expect("valid routes") + ); + assert!( + route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + None + ) + .expect("an uncapped route is not below the retained minimum") + ); + let unsupported = ["delegation", "memory"] + .into_iter() + .map(str::to_string) + .collect::>(); + assert!( + !route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &unsupported, + 1, + Some(128_144) + ) + .expect("valid routes") + ); + assert!( + !route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 2, + Some(128_144) + ) + .expect("valid routes") + ); + assert!( + !route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + Some(100_000) + ) + .expect("valid routes") + ); + assert!(route_is_qualified_in("{", "OpenClaw", "x", "y", &required, 1, None).is_err()); + } + + #[test] + fn qualification_gap_reports_only_capabilities_missing_from_closest_record() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["delegation","web-search","network","artifacts","telemetry"], + "max_parallel":1, + "min_total_tokens":300000, + "evidence":{"task":"research-proof","run_id":"run-1","digest":"sha256:abc"} + } + ]"#; + let required = [ + "artifacts", + "delegation", + "mcp", + "network", + "telemetry", + "web-search", + ] + .into_iter() + .map(str::to_string) + .collect::>(); + assert_eq!( + route_qualification_gap_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + Some(300000), + ) + .expect("gap"), + ["mcp".to_string()].into_iter().collect() + ); + } + + #[test] + fn resource_qualification_requires_matching_current_digest_and_ignores_generic_routes() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["mcp","network","telemetry"], + "max_parallel":1, + "evidence":{"task":"generic-proof","run_id":"run-1","digest":"sha256:generic"} + }, + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["mcp","network","telemetry"], + "max_parallel":1, + "resource":{"kind":"mcp","name":"playwright","schema_digest":"sha256:tools-v1"}, + "evidence":{"task":"mcp-proof","run_id":"run-2","digest":"sha256:mcp"} + } + ]"#; + let selection = QualifiedResourceSelection { + kind: "mcp".into(), + name: "playwright".into(), + backend: None, + schema_digest: Some("sha256:tools-v1".into()), + version_digest: None, + }; + assert!( + resource_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &selection, + ) + .expect("resource qualification") + ); + let mismatched = QualifiedResourceSelection { + schema_digest: Some("sha256:tools-v2".into()), + ..selection.clone() + }; + assert!( + !resource_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &mismatched, + ) + .expect("resource qualification") + ); + let missing_digest = QualifiedResourceSelection { + schema_digest: None, + ..selection + }; + assert!( + !resource_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &missing_digest, + ) + .expect("resource qualification") + ); + } + + #[test] + fn resource_route_summary_lists_only_matching_resource_records() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["skill","telemetry"], + "max_parallel":1, + "resource":{"kind":"channel","name":"telegram"}, + "evidence":{"task":"channel-proof","run_id":"run-1","digest":"sha256:chan"} + }, + { + "runtime":"Hermes", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["mcp","telemetry"], + "max_parallel":1, + "resource":{"kind":"mcp","name":"playwright","schema_digest":"sha256:tools-v1"}, + "evidence":{"task":"mcp-proof","run_id":"run-2","digest":"sha256:mcp"} + } + ]"#; + let selection = channel_resource_selection("telegram"); + assert_eq!( + resource_qualification_routes_in(routes, &selection).expect("summary"), + vec!["OpenClaw · local-inference::gpt-oss-120b".to_string()] + ); + let mcp = mcp_resource_selection(&super::RefOption { + name: "playwright".into(), + namespace: "demo".into(), + summary: None, + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: Some("sha256:tools-v1".into()), + compiled_digest: None, + backend: None, + readiness: None, + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }); + assert_eq!( + resource_qualification_routes_in(routes, &mcp).expect("summary"), + vec!["Hermes · local-inference::gpt-oss-120b".to_string()] + ); + } +} diff --git a/bridge/bff/src/routes/ownership.rs b/bridge/bff/src/routes/ownership.rs new file mode 100644 index 000000000..c3f6a30a9 --- /dev/null +++ b/bridge/bff/src/routes/ownership.rs @@ -0,0 +1,187 @@ +use kube::ResourceExt; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::cluster::Cluster; +use crate::kars::task::KarsTask; + +pub(crate) fn task_is_owned_by(task: &KarsTask, principal: &Principal) -> bool { + task.annotations() + .get("kars.azure.com/owner-sub") + .is_some_and(|subject| subject == &principal.sub) +} + +pub(crate) fn output_is_owned_by( + output: &std::collections::BTreeMap, + principal: &Principal, +) -> bool { + output + .get("ownerSub") + .is_some_and(|subject| subject == &principal.sub) +} + +pub(crate) fn principal_can_view_all(principal: &Principal) -> bool { + principal + .roles + .iter() + .any(|role| matches!(role.as_str(), "admin" | "operator")) +} + +pub(crate) fn principal_can_audit_all(principal: &Principal) -> bool { + principal_can_view_all(principal) || principal.roles.iter().any(|role| role == "auditor") +} + +pub(crate) async fn require_owned_task( + cluster: &Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult { + let task = cluster + .tasks(ns) + .get_opt(name) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .ok_or(AppError::NotFound)?; + if !task_is_owned_by(&task, principal) { + return Err(AppError::NotFound); + } + Ok(task) +} + +/// Authorize access to retained task evidence after the task CR itself has been +/// garbage-collected. A live task's owner annotation always wins; only a missing +/// task may fall back to the controller-persisted output owner. +pub(crate) async fn require_owned_task_or_output( + cluster: &Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult> { + if let Some(task) = cluster + .tasks(ns) + .get_opt(name) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + { + if !task_is_owned_by(&task, principal) { + return Err(AppError::NotFound); + } + return Ok(Some(task)); + } + + let output = cluster + .read_mission_output(name) + .await + .ok_or(AppError::NotFound)?; + if !output_is_owned_by(&output, principal) { + return Err(AppError::NotFound); + } + Ok(None) +} + +pub(crate) async fn require_task_evidence_access( + cluster: &Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult> { + if principal_can_audit_all(principal) { + return Ok(None); + } + require_owned_task_or_output(cluster, ns, name, principal).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kars::task::{KarsTaskSpec, TaskEnvelope}; + + fn principal(sub: &str, roles: &[&str]) -> Principal { + Principal { + sub: sub.to_string(), + name: format!("{sub}@example.test"), + roles: roles.iter().map(|role| role.to_string()).collect(), + } + } + + fn task(name: &str) -> KarsTask { + KarsTask::new( + name, + KarsTaskSpec { + objective: "test objective".into(), + envelope: TaskEnvelope { + tier: 1, + budget: None, + tool_policy_ref: None, + egress_allowlist_ref: None, + delegation_depth: 0, + authority_ceiling: 1, + }, + parent_ref: None, + execution: None, + blueprint: None, + display_name: None, + retention_ttl_seconds: None, + }, + ) + } + + #[test] + fn ownership_requires_exact_immutable_subject() { + let mut owned_task = task("owned"); + owned_task + .metadata + .annotations + .get_or_insert_with(Default::default) + .insert("kars.azure.com/owner-sub".into(), "subject-a".into()); + + assert!(task_is_owned_by( + &owned_task, + &principal("subject-a", &["user"]), + )); + assert!(!task_is_owned_by( + &owned_task, + &principal("subject-b", &["user"]), + )); + assert!(!task_is_owned_by( + &task("legacy"), + &principal("subject-a", &["user"]), + )); + } + + #[test] + fn retained_output_uses_persisted_owner() { + let output = + std::collections::BTreeMap::from([("ownerSub".to_string(), "subject-a".to_string())]); + assert!(output_is_owned_by( + &output, + &principal("subject-a", &["user"]), + )); + assert!(!output_is_owned_by( + &output, + &principal("subject-b", &["user"]), + )); + } + + #[test] + fn only_operator_or_admin_can_request_cluster_aggregates() { + assert!(!principal_can_view_all(&principal("user", &["user"]))); + assert!(principal_can_view_all(&principal( + "operator", + &["operator"] + ))); + assert!(principal_can_view_all(&principal("admin", &["admin"]))); + } + + #[test] + fn audit_evidence_is_visible_to_auditors_and_operators() { + assert!(!principal_can_audit_all(&principal("user", &["user"]))); + assert!(principal_can_audit_all(&principal("auditor", &["auditor"]))); + assert!(principal_can_audit_all(&principal( + "operator", + &["operator"] + ))); + assert!(principal_can_audit_all(&principal("admin", &["admin"]))); + } +} diff --git a/bridge/bff/src/routes/receipts.rs b/bridge/bff/src/routes/receipts.rs new file mode 100644 index 000000000..28c14e329 --- /dev/null +++ b/bridge/bff/src/routes/receipts.rs @@ -0,0 +1,973 @@ +// kars Bridge BFF — Governance Receipt API. +// +// Read endpoints project the signed predicate, never unsigned claim echoes. +// Cryptographic validity is a separate operation: the verification endpoint +// and `kars receipt verify` check the controller-published trust anchor, +// signature, exact subject binding, inclusion log and signed checkpoint. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde::Serialize; +use serde_json::Value; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::receipt::KarsReceipt; +use crate::kars::receipt_log::{ReceiptLog, chain_entry_hash}; +use crate::routes::ownership::require_task_evidence_access; +use crate::state::AppState; + +mod statement; + +/// A DSSE signature line, browser-facing. +#[derive(Debug, Serialize)] +pub struct SignatureDto { + pub keyid: String, + pub sig: String, +} + +/// One claim-class assertion. +#[derive(Debug, Serialize)] +pub struct ClaimDto { + pub class: String, + pub status: String, + pub detail: String, +} + +/// Browser-facing Governance Receipt. +#[derive(Debug, Serialize)] +pub struct ReceiptDetailDto { + pub name: String, + pub namespace: String, + pub task: String, + pub envelope_digest: String, + pub predicate_type: String, + pub scheme: String, + pub key_id: String, + pub payload_type: String, + pub signatures: Vec, + pub claims: Vec, + /// The decoded in-toto Statement (the signed payload), for the evidence + /// view. This is exactly the bytes the signature covers. + pub statement: Option, + /// Issuance time (unsigned echo), if the controller stamped it. + pub issued_at: Option, + /// Inclusion-log sequence number (cross-receipt tamper-evidence chain). + pub inclusion_seq: Option, + /// Inclusion-log entry hash. + pub inclusion_entry_hash: Option, + pub inclusion_state: Option, + pub inclusion_error: Option, + pub log_segment: Option, + pub checkpoint_tree_size: Option, + pub witnessed: Option, + /// The log's signed checkpoint (signed tree head), when published. + pub checkpoint: Option, + /// The exact command an auditor runs to verify independently. + pub verify_command: String, +} + +/// A compact view of the inclusion log's signed checkpoint. +#[derive(Debug, Serialize)] +pub struct CheckpointDto { + pub tree_size: i64, + pub root_hash: String, + pub key_id: String, + pub published_at: Option, +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +fn to_detail(ns: &str, r: &KarsReceipt) -> AppResult { + use kube::ResourceExt; + let name = r.name_any(); + let spec = &r.spec; + let decoded = statement::decode(spec, ns, &name) + .map_err(|error| AppError::Upstream(format!("Invalid receipt: {error}")))?; + Ok(ReceiptDetailDto { + name: name.clone(), + namespace: ns.to_string(), + task: spec.task_ref.name.clone(), + envelope_digest: spec.envelope_digest.clone(), + predicate_type: spec.predicate_type.clone(), + scheme: spec.scheme.clone(), + key_id: spec.key_id.clone(), + payload_type: spec.dsse.payload_type.clone(), + signatures: spec + .dsse + .signatures + .iter() + .map(|s| SignatureDto { + keyid: s.keyid.clone(), + sig: s.sig.clone(), + }) + .collect(), + claims: decoded + .claims + .iter() + .map(|c| ClaimDto { + class: c.class.clone(), + status: c.status.clone(), + detail: c.detail.clone(), + }) + .collect(), + statement: Some(decoded.statement), + issued_at: r.status.as_ref().and_then(|s| s.issued_at.clone()), + inclusion_seq: r.status.as_ref().and_then(|s| s.inclusion_seq), + inclusion_entry_hash: r + .status + .as_ref() + .and_then(|s| s.inclusion_entry_hash.clone()), + inclusion_state: r.status.as_ref().and_then(|s| s.inclusion_state.clone()), + inclusion_error: r.status.as_ref().and_then(|s| s.inclusion_error.clone()), + log_segment: r.status.as_ref().and_then(|s| s.log_segment.clone()), + checkpoint_tree_size: r.status.as_ref().and_then(|s| s.checkpoint_tree_size), + witnessed: r.status.as_ref().and_then(|s| s.witnessed), + checkpoint: None, + verify_command: format!("kars receipt verify {name} -n {ns}"), + }) +} + +/// `GET /api/namespaces/:ns/tasks/:name/receipt` — the Governance Receipt for a +/// task, or 404 if the task has none (e.g. it is Degraded, so no receipt was +/// emitted). A receipt's name matches its task's name. +pub async fn get_receipt( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_task_evidence_access(cluster, &ns, &name, &principal).await?; + let api = cluster.receipts(&ns); + match api.get_opt(&name).await.map_err(|e| { + if let kube::Error::Api(resp) = &e + && (400..500).contains(&resp.code) + { + return AppError::Rejected(resp.message.clone()); + } + AppError::Upstream(e.to_string()) + })? { + Some(r) => { + let mut dto = to_detail(&ns, &r)?; + // Attach the cluster-global signed checkpoint, if published. + let log = cluster + .receipt_log() + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + if let Some(data) = log.checkpoint.as_ref() + && let (Some(size), Some(root)) = (data.get("treeSize"), data.get("rootHash")) + && let Ok(tree_size) = size.parse::() + { + dto.checkpoint = Some(CheckpointDto { + tree_size, + root_hash: root.clone(), + key_id: data.get("keyId").cloned().unwrap_or_default(), + published_at: data.get("publishedAt").cloned(), + }); + } + Ok(Json(dto)) + } + None => Err(AppError::NotFound), + } +} + +/// One mapped compliance control — a receipt claim expressed as an external +/// regulatory obligation, with the signed receipt as its evidence. +#[derive(Debug, Serialize)] +pub struct ComplianceControlDto { + /// Stable control identifier, e.g. `EU-AI-Act-Art-12`. + pub control_id: String, + /// The framework this control belongs to. + pub framework: String, + /// Human reference, e.g. `Article 12 — Record-keeping`. + pub reference: String, + /// The receipt claim class that backs this control. + pub receipt_class: String, + /// The claim's status verbatim (PASS / PARTIAL / …) — never upgraded. + pub status: String, + /// The signed claim detail, carried as the control's evidence. + pub evidence: String, + /// True for the `regulatory` claim class: a named V0 architectural + /// limitation (external transparency anchor is a V1 item), so it reads + /// PARTIAL on every receipt this product issues, not just this task. + /// Mirrors `VerifyCheck.advisory` (BUG-4) and `AuditReceiptRow`'s + /// `isAdvisoryClaim` — same claim, same treatment, now carried + /// through to the compliance pack instead of silently disagreeing + /// with the receipt row's own "Verified" verdict. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub advisory: bool, +} + +/// A compliance evidence pack derived from a single mission's signed Governance +/// Receipt. Every control is backed by a real, signed receipt claim; the pack +/// carries the envelope digest, signature scheme, transparency-log inclusion, +/// and the exact verify command so an auditor can independently confirm it. +#[derive(Debug, Serialize)] +pub struct CompliancePackDto { + pub task: String, + pub namespace: String, + pub generated_at: String, + pub predicate_type: String, + pub envelope_digest: String, + pub signature_scheme: String, + pub key_id: String, + pub inclusion_seq: Option, + pub issued_at: Option, + pub verify_command: String, + pub controls: Vec, + /// Count of controls whose backing claim is PASS. + pub satisfied: usize, + /// Count of controls that are non-PASS AND not `advisory` — a real gap + /// worth an operator's attention, not a known V0 limitation. + pub partial: usize, + /// Count of controls marked `advisory` (the `regulatory` claim class) — + /// shown separately so the pack doesn't contradict the receipt row's own + /// "Verified" verdict, which already excludes this claim class. + #[serde(default, skip_serializing_if = "is_zero")] + pub advisory: usize, +} + +fn is_zero(n: &usize) -> bool { + *n == 0 +} + +/// Map a receipt claim class to the external regulatory controls it evidences. +/// The mapping is static and conservative: it names the obligation each signed +/// claim speaks to, and inherits the claim's status verbatim — a PARTIAL claim +/// never becomes a satisfied control. This is first-party audit data expressed +/// in the auditor's framework, not a self-assessed compliance grade. +fn controls_for_claim(class: &str, status: &str, detail: &str) -> Vec { + let refs: &[(&str, &str, &str)] = match class { + // Tamper-evident signed logs of the governed run. + "integrity" => &[ + ( + "EU-AI-Act-Art-12", + "EU AI Act", + "Article 12 — Record-keeping (automatic logging)", + ), + ( + "NIST-AI-RMF-MEASURE-2.7", + "NIST AI RMF", + "MEASURE 2.7 — Traceability & tamper-evidence", + ), + ], + // The trust envelope was validated; delegation authority is bounded. + "conformance" => &[ + ( + "EU-AI-Act-Art-9", + "EU AI Act", + "Article 9 — Risk-management system", + ), + ( + "NIST-AI-RMF-MAP-1", + "NIST AI RMF", + "MAP 1 — Context & authority established", + ), + ], + // Completeness-floor controls (admission, egress, seccomp) were enforced. + "completeness" => &[ + ( + "EU-AI-Act-Art-9-controls", + "EU AI Act", + "Article 9 — Risk controls in operation", + ), + ( + "EU-AI-Act-Art-14", + "EU AI Act", + "Article 14 — Human oversight", + ), + ( + "NIST-AI-RMF-MANAGE-2", + "NIST AI RMF", + "MANAGE 2 — Controls operational & monitored", + ), + ], + // Signing + independent transparency witness / accountability posture. + "regulatory" => &[ + ( + "EU-AI-Act-Art-13", + "EU AI Act", + "Article 13 — Transparency to deployers", + ), + ( + "NIST-AI-RMF-GOVERN-4", + "NIST AI RMF", + "GOVERN 4 — Accountability & documentation", + ), + ], + _ => &[], + }; + refs.iter() + .map(|(id, fw, reference)| ComplianceControlDto { + control_id: (*id).to_string(), + framework: (*fw).to_string(), + reference: (*reference).to_string(), + receipt_class: class.to_string(), + status: status.to_string(), + evidence: detail.to_string(), + advisory: class.eq_ignore_ascii_case("regulatory"), + }) + .collect() +} + +/// `GET /api/namespaces/:ns/tasks/:name/compliance` — a compliance evidence pack +/// generated from the mission's signed Governance Receipt. No competitor ships +/// this from first-party audit data: the receipt claims are mapped to EU AI Act +/// and NIST AI RMF controls, each backed by the signed envelope digest + +/// transparency-log inclusion, with the verify command for independent proof. +pub async fn compliance_pack( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_task_evidence_access(cluster, &ns, &name, &principal).await?; + let api = cluster.receipts(&ns); + let Some(r) = api + .get_opt(&name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + else { + return Err(AppError::NotFound); + }; + let dto = to_detail(&ns, &r)?; + let mut controls: Vec = Vec::new(); + for c in &dto.claims { + controls.extend(controls_for_claim(&c.class, &c.status, &c.detail)); + } + let satisfied = controls + .iter() + .filter(|c| c.status.eq_ignore_ascii_case("PASS")) + .count(); + let advisory = controls.iter().filter(|c| c.advisory).count(); + let partial = controls.len() - satisfied - advisory; + Ok(Json(CompliancePackDto { + task: dto.task, + namespace: ns, + generated_at: chrono::Utc::now().to_rfc3339(), + predicate_type: dto.predicate_type, + envelope_digest: dto.envelope_digest, + signature_scheme: dto.scheme, + key_id: dto.key_id, + inclusion_seq: dto.inclusion_seq, + issued_at: dto.issued_at, + verify_command: dto.verify_command, + controls, + satisfied, + partial, + advisory, + })) +} + +/// The outcome of an in-browser cryptographic verification — performed +/// server-side by the BFF against the controller's out-of-band public-key +/// anchor, so an auditor gets a real verdict — and the underlying evidence — +/// without installing the CLI. +#[derive(Debug, Serialize)] +pub struct VerifyResult { + /// True only when every independent check passed. + pub verified: bool, + /// Ordered, human-readable checks with their pass/fail outcome. + pub checks: Vec, + /// The actual artifacts behind the verdict — what an auditor inspects. + pub evidence: Evidence, +} + +#[derive(Debug, Serialize)] +pub struct VerifyCheck { + pub name: String, + pub passed: bool, + pub detail: String, + /// BUG-4: a check that is DISPLAYED but not cryptographically re-verified + /// here (e.g. the independent witness whose public key isn't published in + /// V0). Rendered as an advisory "shown, not verified" state — never a green + /// ✓ — so an auditor is not misled into thinking all items were checked. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub advisory: bool, + /// The value the proof expected (e.g. a recorded hash), when applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// The value the BFF independently computed, shown so the auditor sees the + /// two match (or don't) rather than trusting a green tick. + #[serde(skip_serializing_if = "Option::is_none")] + pub computed: Option, +} + +/// The evidence artifacts an auditor inspects — the signed payload, the +/// signature material, and the full inclusion proof. Everything here is the +/// real bytes the verdict was computed over. +#[derive(Debug, Serialize, Default)] +pub struct Evidence { + /// The exact in-toto Statement the signature covers (the signed payload). + pub signed_statement: Option, + /// Base64 Ed25519 signature over the DSSE PAE of the statement. + pub signature_b64: Option, + pub scheme: Option, + /// The out-of-band trust anchor the signature was checked against. + pub anchor_key_id: Option, + pub anchor_public_key_b64: Option, + /// The receipt's position in the hash-chained inclusion log + the proof. + pub inclusion: Option, + /// The signed checkpoint (signed tree head) + independent witness. + pub checkpoint: Option, +} + +#[derive(Debug, Serialize)] +pub struct InclusionEvidence { + pub seq: i64, + pub receipt: String, + pub payload_sha256: String, + pub prev_hash: String, + pub entry_hash: String, + /// The entry-hash the BFF recomputed from (seq | receipt | payloadSha | prev). + pub recomputed_entry_hash: String, + /// The head the chain links to — equals the checkpoint root when intact. + pub chain_head: String, + /// Whether the whole chain (genesis → head) recomputes consistently. + pub chain_consistent: bool, + pub tree_size: usize, +} + +#[derive(Debug, Serialize)] +pub struct CheckpointEvidence { + pub tree_size: i64, + pub root_hash: String, + /// The exact signed-note bytes the checkpoint signature covers. + pub signed_note: String, + pub signature_b64: String, + pub signature_valid: bool, + /// An independent transparency witness co-signs the same root with a + /// SEPARATE key — evidence the log isn't forked. Its public key isn't + /// published in V0, so we surface its identity + co-signature honestly. + pub witness_key_id: Option, + pub witness_signature_b64: Option, +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex(&Sha256::digest(bytes)) +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(out, "{b:02x}"); + } + out +} + +/// A whole-log integrity verdict — the page-level answer to "is this audit log +/// actually tamper-evident?". Unlike a per-receipt proof, this recomputes the +/// ENTIRE hash chain and verifies the signed checkpoint, so the auditor banner +/// reflects real cryptographic verification, never mere field presence. +#[derive(Debug, Default, serde::Serialize)] +pub struct LogIntegrity { + /// The chain recomputes consistently genesis→head: contiguous `seq`, + /// prev-hash linkage, and every entry hash recomputes. False if empty/broken. + pub chain_consistent: bool, + /// Number of entries in the inclusion log. + pub tree_size: i64, + /// A signed checkpoint exists, its Ed25519 signature verifies against the + /// published out-of-band anchor, AND it commits to the current chain head. + pub checkpoint_verified: bool, + /// An independent transparency-witness co-signature is PRESENT. Shown, not + /// re-verified in V0 (the witness public key isn't published), so it is + /// advisory — never counted toward `checkpoint_verified`. + pub witness_present: bool, + /// Whether the anchor the checkpoint signature was verified against is pinned + /// OUT-OF-BAND (a BFF-configured key id / public key from a trust boundary + /// distinct from the log). When false, the anchor is the in-cluster + /// `kars-receipt-pubkey` — the SAME trust domain as the log — so a party that + /// can rewrite the log could also rewrite the anchor. The verdict is then + /// "consistent + signed by the cluster's published anchor", NOT absolute + /// tamper-evidence; the banner must not overclaim. + pub anchor_pinned: bool, +} + +/// Recompute the ENTIRE `kars-receipt-log` hash chain and verify the signed +/// checkpoint against the published anchor key. Used by the audit page so its +/// integrity verdict is a real verification, not a `inclusion_seq != null` proxy. +pub(crate) fn verify_log_integrity(log: &ReceiptLog) -> LogIntegrity { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let mut out = LogIntegrity::default(); + let chain = &log.entries; + if chain.is_empty() { + return out; + } + out.tree_size = chain.len() as i64; + + // Recompute the whole chain: contiguous seq, prev-hash linkage, entry hashes. + let mut chain_consistent = !chain.is_empty(); + let mut prev = "genesis".to_string(); + for (i, e) in chain.iter().enumerate() { + if e.seq != i as i64 + || e.prev_hash != prev + || chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash) != e.entry_hash + { + chain_consistent = false; + break; + } + prev = e.entry_hash.clone(); + } + out.chain_consistent = chain_consistent; + let chain_head = chain + .last() + .map(|e| e.entry_hash.clone()) + .unwrap_or_else(|| "genesis".into()); + + // Verify the signed checkpoint (Ed25519 over the canonical note) against the + // anchor key, and that it commits to the current chain head. + if let Some(cp) = log.checkpoint.as_ref() { + let cp_tree = cp + .get("treeSize") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); + let cp_sig = cp.get("signature").cloned().unwrap_or_default(); + let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); + let anchor = log.anchor(); + + // OUT-OF-BAND PINNING. The in-cluster anchor lives in the same trust + // domain as the log, so on its own it can't prove tamper-evidence + // against an insider who can rewrite both. When the operator pins the + // anchor out-of-band (BRIDGE_RECEIPT_ANCHOR_KEY_ID / _PUBKEY), require + // the in-cluster anchor to match it — and only then is the verdict + // absolute. Without a pin, the anchor is trusted-on-read and the banner + // reflects the weaker, honest claim. + let pin_key_id = std::env::var("BRIDGE_RECEIPT_ANCHOR_KEY_ID").ok(); + let pin_pubkey = std::env::var("BRIDGE_RECEIPT_ANCHOR_PUBKEY").ok(); + let anchor_matches_pin = match (&anchor, pin_key_id.as_deref(), pin_pubkey.as_deref()) { + (Some((kid, pub_b64, _)), pk_id, pk_pub) => { + let id_ok = pk_id.is_none_or(|w| w == kid); + let pub_ok = pk_pub.is_none_or(|w| w.trim() == pub_b64.trim()); + (pk_id.is_some() || pk_pub.is_some()) && id_ok && pub_ok + } + _ => false, + }; + out.anchor_pinned = anchor_matches_pin; + + // If a pin is configured but the in-cluster anchor does NOT match it, + // the anchor is untrusted — do not honor any signature made with it. + let pin_configured = pin_key_id.is_some() || pin_pubkey.is_some(); + let anchor_trusted = !pin_configured || anchor_matches_pin; + + let cp_sig_ok = anchor_trusted + && anchor + .as_ref() + .and_then(|(_, pub_b64, _)| BASE64.decode(pub_b64.as_bytes()).ok()) + .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .and_then(|pk| VerifyingKey::from_bytes(&pk).ok()) + .map(|vk| { + BASE64 + .decode(cp_sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| { + vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) + .is_ok() + }) + .unwrap_or(false) + }) + .unwrap_or(false); + out.checkpoint_verified = + chain_consistent && cp_sig_ok && cp_root == chain_head && cp_tree == out.tree_size; + } + + // Independent witness co-signature — present-or-not only (advisory in V0). + out.witness_present = log + .witness + .as_ref() + .and_then(|w| w.get("witnessSignature").cloned()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + out +} + +/// DSSE Pre-Authentication Encoding — byte-for-byte the same framing the +/// controller signs (`controller/src/providers/signing.rs::pae`): +/// `"DSSEv1" SP len(type) SP type SP len(body) SP body`. +fn pae(payload_type: &str, body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload_type.len() + body.len() + 32); + out.extend_from_slice(b"DSSEv1 "); + out.extend_from_slice(payload_type.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload_type.as_bytes()); + out.push(b' '); + out.extend_from_slice(body.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(body); + out +} + +/// `POST /api/namespaces/:ns/tasks/:name/receipt/verify` — independently verify +/// a Governance Receipt's DSSE/Ed25519 signature against the controller's +/// published public-key anchor (`kars-receipt-pubkey` ConfigMap). This is the +/// same trust root `kars receipt verify` uses; performing it here lets an +/// auditor get a real cryptographic verdict in the browser. The BFF never +/// trusts a key embedded in the receipt — only the out-of-band anchor. +pub async fn verify_receipt( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + use base64::engine::general_purpose::STANDARD as B64; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let cluster = require_cluster(&state)?; + require_task_evidence_access(cluster, &ns, &name, &principal).await?; + let receipt = cluster + .receipts(&ns) + .get_opt(&name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or(AppError::NotFound)?; + let spec = &receipt.spec; + + let mut checks: Vec = Vec::new(); + let mut evidence = Evidence::default(); + let push = |checks: &mut Vec, + name: &str, + passed: bool, + detail: String, + expected: Option, + computed: Option| { + checks.push(VerifyCheck { + name: name.to_string(), + passed, + detail, + advisory: false, + expected, + computed, + }); + passed + }; + + // Decode the signed statement once — it IS the evidence the auditor reads. + let decoded = match statement::decode(spec, &ns, &name) { + Ok(decoded) => decoded, + Err(error) => { + push( + &mut checks, + "Signed statement binding", + false, + error, + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + } + }; + let payload_raw = decoded.payload; + evidence.signed_statement = Some(decoded.statement); + evidence.scheme = Some(spec.scheme.clone()); + evidence.signature_b64 = spec.dsse.signatures.first().map(|s| s.sig.clone()); + + // 1) Trust anchor present (out-of-band published public key). + let log = match cluster.receipt_log().await { + Ok(log) => log, + Err(error) => { + push( + &mut checks, + "Inclusion log", + false, + error.to_string(), + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + } + }; + let anchor = log.anchor(); + let Some((anchor_key_id, anchor_pub_b64, anchor_scheme)) = anchor else { + push( + &mut checks, + "Trust anchor", + false, + "No published public-key anchor (kars-receipt-pubkey) found — cannot verify.".into(), + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + }; + evidence.anchor_key_id = Some(anchor_key_id.clone()); + evidence.anchor_public_key_b64 = Some(anchor_pub_b64.clone()); + push(&mut checks, "Trust anchor", true, + "An out-of-band public key is published by the controller; the signature is checked against THIS key, never one carried in the receipt.".into(), + None, None); + + // 2) Receipt key id matches the anchor. + let key_match = spec.key_id == anchor_key_id; + push( + &mut checks, + "Signing key identity", + key_match, + if key_match { + "The receipt's key fingerprint matches the published anchor.".into() + } else { + "The receipt's signing key does NOT match the trusted anchor.".into() + }, + Some(anchor_key_id.clone()), + Some(spec.key_id.clone()), + ); + + // 3) Scheme is the expected DSSE/Ed25519. + let scheme_ok = spec.scheme == statement::SCHEME && spec.scheme == anchor_scheme; + push( + &mut checks, + "Signature scheme", + scheme_ok, + format!("Scheme: {}.", spec.scheme), + Some(anchor_scheme.clone()), + Some(spec.scheme.clone()), + ); + + // 4) Ed25519 signature verifies over the DSSE PAE of the exact payload. + let mut sig_ok = false; + let pub_bytes = B64 + .decode(anchor_pub_b64.as_bytes()) + .ok() + .and_then(|b| <[u8; 32]>::try_from(b).ok()); + if let (Some(pk), false) = (pub_bytes, payload_raw.is_empty()) + && let Ok(vk) = VerifyingKey::from_bytes(&pk) + { + let message = pae(&spec.dsse.payload_type, &payload_raw); + let valid_signature = spec.dsse.signatures.iter().find(|s| { + s.keyid == anchor_key_id + && B64 + .decode(s.sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| vk.verify(&message, &Signature::from_bytes(&sb)).is_ok()) + .unwrap_or(false) + }); + sig_ok = valid_signature.is_some(); + if let Some(signature) = valid_signature { + evidence.signature_b64 = Some(signature.sig.clone()); + } + } + push( + &mut checks, + "Cryptographic signature", + sig_ok, + if sig_ok { + "The Ed25519 signature verifies over the DSSE pre-authentication encoding of the statement below — so the payload is authentic and has not been altered by a single byte.".into() + } else { + "Ed25519 signature did NOT verify — the payload may have been altered.".into() + }, + None, + None, + ); + + // 5) The signed payload binds the trust-envelope digest the receipt claims. + let envelope_bound = evidence + .signed_statement + .as_ref() + .is_some_and(|value| statement::binds_subject(value, spec, &ns, &name)); + push( + &mut checks, + "Trust-envelope binding", + envelope_bound, + if envelope_bound { + "The signed statement contains the exact trust-envelope digest the receipt declares — the signature can't be lifted onto a different envelope.".into() + } else { + "The signed payload does not reference the declared envelope digest.".into() + }, + Some(spec.envelope_digest.clone()), + None, + ); + + // 6) FULL inclusion proof — fetch the hash-chained log + signed checkpoint, + // recompute this receipt's entry, confirm the chain links to the head, + // and verify the checkpoint signature. No CLI, no hand-waving. + let mut inclusion_ok = true; + let receipt_log_ref = format!("{ns}/{name}"); + let loaded_chain = &log.entries; + if !loaded_chain.is_empty() { + let chain = loaded_chain; + let tree_size = chain.len(); + // The receipt's own recorded position. + let seq = receipt.status.as_ref().and_then(|s| s.inclusion_seq); + let entry = seq.and_then(|q| { + chain + .iter() + .find(|e| e.seq == q && e.receipt == receipt_log_ref) + }); + + // (a) payloadSha256 of the entry equals sha256 of the signed payload. + let computed_payload_sha = sha256_hex(&payload_raw); + let payload_sha_ok = entry + .map(|e| e.payload_sha256 == computed_payload_sha) + .unwrap_or(false); + + // (b) entryHash recomputes from (seq | receipt | payloadSha | prev). + let recomputed_entry = + entry.map(|e| chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash)); + let entry_hash_ok = entry + .zip(recomputed_entry.as_ref()) + .map(|(e, r)| &e.entry_hash == r) + .unwrap_or(false); + + // (c) the WHOLE chain recomputes consistently (contiguous seq, + // prev-hash linkage, recomputed entry hashes) up to the head. + let mut chain_consistent = true; + let mut prev = "genesis".to_string(); + for (i, e) in chain.iter().enumerate() { + if e.seq != i as i64 + || e.prev_hash != prev + || chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash) + != e.entry_hash + { + chain_consistent = false; + break; + } + prev = e.entry_hash.clone(); + } + let chain_head = chain + .last() + .map(|e| e.entry_hash.clone()) + .unwrap_or_else(|| "genesis".into()); + + if let Some(e) = entry { + evidence.inclusion = Some(InclusionEvidence { + seq: e.seq, + receipt: e.receipt.clone(), + payload_sha256: e.payload_sha256.clone(), + prev_hash: e.prev_hash.clone(), + entry_hash: e.entry_hash.clone(), + recomputed_entry_hash: recomputed_entry.clone().unwrap_or_default(), + chain_head: chain_head.clone(), + chain_consistent, + tree_size, + }); + } + + inclusion_ok = payload_sha_ok && entry_hash_ok && chain_consistent; + push(&mut checks, "Payload digest in log", payload_sha_ok, + "The inclusion-log entry records a SHA-256 of the signed payload — recomputing it matches, so this exact receipt is the one logged.".into(), + entry.map(|e| e.payload_sha256.clone()), Some(computed_payload_sha)); + push(&mut checks, "Inclusion-log entry hash", entry_hash_ok, + "The entry hash recomputes from (seq | receipt | payload-digest | previous-hash) — binding this receipt to its exact position in the chain.".into(), + entry.map(|e| e.entry_hash.clone()), recomputed_entry); + push( + &mut checks, + "Chain integrity", + chain_consistent, + format!( + "All {tree_size} entries recompute and link genesis → head with no gap — removing or altering any one would break the chain." + ), + None, + Some(chain_head.clone()), + ); + + // (d) the signed checkpoint commits to this head, and its Ed25519 + // signature verifies with the anchor key. + if let Some(cp) = log.checkpoint.as_ref() { + let cp_tree = cp + .get("treeSize") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); + let cp_sig = cp.get("signature").cloned().unwrap_or_default(); + let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); + let cp_sig_ok = pub_bytes + .and_then(|pk| VerifyingKey::from_bytes(&pk).ok()) + .map(|vk| { + B64.decode(cp_sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| { + vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) + .is_ok() + }) + .unwrap_or(false) + }) + .unwrap_or(false); + let root_matches = cp_root == chain_head && cp_tree == tree_size as i64; + + let witness = log.witness.as_ref(); + evidence.checkpoint = Some(CheckpointEvidence { + tree_size: cp_tree, + root_hash: cp_root.clone(), + signed_note: note.clone(), + signature_b64: cp_sig.clone(), + signature_valid: cp_sig_ok, + witness_key_id: witness + .as_ref() + .and_then(|w| w.get("witnessKeyId").cloned()), + witness_signature_b64: witness + .as_ref() + .and_then(|w| w.get("witnessSignature").cloned()), + }); + + inclusion_ok = inclusion_ok && cp_sig_ok && root_matches; + push(&mut checks, "Signed checkpoint", cp_sig_ok && root_matches, + "A signed tree head commits to the chain head, and its Ed25519 signature verifies with the anchor key — pinning the whole log to a value an auditor can re-check.".into(), + Some(cp_root.clone()), Some(chain_head.clone())); + if let Some(w) = witness.as_ref().and_then(|w| w.get("witnessKeyId")) { + // BUG-4: the witness co-signature is DISPLAYED, not re-verified + // (its public key isn't published in V0). Mark it advisory so the + // UI shows "shown, not verified" instead of a deceptive ✓, and show + // the witness key itself (not the anchor key, which guaranteed a + // spurious recorded≠recomputed mismatch). + checks.push(VerifyCheck { + name: "Independent witness".to_string(), + passed: false, + advisory: true, + detail: "A separate transparency-witness key co-signs the same tree head — evidence the log isn't forked behind your back. Its public key isn't published in V0, so this is shown, not re-verified here.".into(), + expected: Some(w.clone()), + computed: None, + }); + } else { + inclusion_ok = false; + push( + &mut checks, + "Signed checkpoint", + false, + "No signed checkpoint is available for the inclusion log.".into(), + None, + None, + ); + } + } else { + inclusion_ok = false; + push( + &mut checks, + "Signed checkpoint", + false, + "No signed checkpoint is available for the inclusion log.".into(), + None, + None, + ); + } + } + if evidence.inclusion.is_none() { + let detail = "This receipt isn't yet recorded in the inclusion log (it may not have run / been chained).".into(); + push(&mut checks, "Inclusion proof", false, detail, None, None); + inclusion_ok = false; + } + + let verified = key_match && scheme_ok && sig_ok && envelope_bound && inclusion_ok; + Ok(Json(VerifyResult { + verified, + checks, + evidence, + })) +} diff --git a/bridge/bff/src/routes/receipts/statement.rs b/bridge/bff/src/routes/receipts/statement.rs new file mode 100644 index 000000000..bd2638ffb --- /dev/null +++ b/bridge/bff/src/routes/receipts/statement.rs @@ -0,0 +1,214 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::Value; + +use crate::kars::receipt::{KarsReceiptSpec, ReceiptClaim}; + +pub(super) const SCHEME: &str = "DSSEv1+ed25519"; +const PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; +const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1"; +const PREDICATE_TYPE: &str = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; + +pub(super) struct BoundStatement { + pub payload: Vec, + pub statement: Value, + pub claims: Vec, +} + +pub(super) fn binds_subject( + statement: &Value, + spec: &KarsReceiptSpec, + namespace: &str, + task_name: &str, +) -> bool { + let Some(digest) = spec + .envelope_digest + .strip_prefix("sha256:") + .filter(|value| { + matches!(value.len(), 32 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + else { + return false; + }; + let Some(subjects) = statement.get("subject").and_then(Value::as_array) else { + return false; + }; + let expected_name = format!("{namespace}/{task_name}"); + spec.task_ref.name == task_name + && subjects.len() == 1 + && subjects[0].get("name").and_then(Value::as_str) == Some(expected_name.as_str()) + && subjects[0] + .pointer("/digest/sha256") + .and_then(Value::as_str) + == Some(digest) +} + +/// Validate the signed data's structure and binding, not its signature. +/// Signature and inclusion verification remain separate mandatory steps. +pub(super) fn decode( + spec: &KarsReceiptSpec, + namespace: &str, + task_name: &str, +) -> Result { + if spec.scheme != SCHEME || spec.dsse.payload_type != PAYLOAD_TYPE { + return Err("unsupported receipt signing scheme or payload type".into()); + } + if spec.task_ref.name != task_name || spec.predicate_type != PREDICATE_TYPE { + return Err("receipt task reference or predicate type does not match its request".into()); + } + let payload = STANDARD + .decode(&spec.dsse.payload) + .map_err(|_| "receipt payload is not valid base64")?; + let statement: Value = + serde_json::from_slice(&payload).map_err(|_| "receipt payload is not valid JSON")?; + if statement.get("_type").and_then(Value::as_str) != Some(STATEMENT_TYPE) + || statement.get("predicateType").and_then(Value::as_str) != Some(PREDICATE_TYPE) + { + return Err("signed statement has an unsupported type or predicate".into()); + } + if !binds_subject(&statement, spec, namespace, task_name) { + return Err("signed subject does not bind this exact namespace, task and envelope".into()); + } + let claims: Vec = serde_json::from_value( + statement + .pointer("/predicate/claims") + .cloned() + .ok_or("signed predicate has no claim matrix")?, + ) + .map_err(|_| "signed claim matrix is malformed")?; + if !spec.claims.is_empty() + && (spec.claims.len() != claims.len() + || spec.claims.iter().zip(&claims).any(|(echo, signed)| { + echo.class != signed.class + || echo.status != signed.status + || echo.detail != signed.detail + })) + { + return Err("unsigned receipt claims contradict the signed predicate".into()); + } + Ok(BoundStatement { + payload, + statement, + claims, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kars::receipt::{DsseEnvelope, DsseSignature}; + use crate::kars::task::LocalObjectRef; + use ed25519_dalek::{Signer, SigningKey, Verifier}; + use serde_json::json; + + fn receipt() -> (KarsReceiptSpec, SigningKey) { + let key = SigningKey::from_bytes(&[42; 32]); + let claims = vec![ReceiptClaim { + class: "completeness".into(), + status: "PARTIAL".into(), + detail: "Not all controls were independently observed.".into(), + }]; + let payload = serde_json::to_vec(&json!({ + "_type": STATEMENT_TYPE, + "predicateType": PREDICATE_TYPE, + "subject": [{ + "name": "tenant/task", + "digest": {"sha256": "0123456789abcdef0123456789abcdef"} + }], + "predicate": {"claims": claims} + })) + .unwrap(); + let signature = key.sign(&super::super::pae(PAYLOAD_TYPE, &payload)); + ( + KarsReceiptSpec { + task_ref: LocalObjectRef { + name: "task".into(), + }, + envelope_digest: "sha256:0123456789abcdef0123456789abcdef".into(), + predicate_type: PREDICATE_TYPE.into(), + scheme: SCHEME.into(), + key_id: "test-key".into(), + dsse: DsseEnvelope { + payload: STANDARD.encode(payload), + payload_type: PAYLOAD_TYPE.into(), + signatures: vec![DsseSignature { + keyid: "test-key".into(), + sig: STANDARD.encode(signature.to_bytes()), + }], + }, + claims, + }, + key, + ) + } + + #[test] + fn valid_signed_claims_are_the_projection_source() { + let (mut spec, _) = receipt(); + assert_eq!( + decode(&spec, "tenant", "task").unwrap().claims[0].status, + "PARTIAL" + ); + spec.claims.clear(); + assert_eq!( + decode(&spec, "tenant", "task").unwrap().claims[0].status, + "PARTIAL" + ); + } + + #[test] + fn a_valid_signature_does_not_authenticate_a_forged_unsigned_pass() { + let (mut spec, key) = receipt(); + spec.claims[0].status = "PASS".into(); + let payload = STANDARD.decode(&spec.dsse.payload).unwrap(); + let signature = STANDARD.decode(&spec.dsse.signatures[0].sig).unwrap(); + key.verifying_key() + .verify( + &super::super::pae(PAYLOAD_TYPE, &payload), + &ed25519_dalek::Signature::from_slice(&signature).unwrap(), + ) + .unwrap(); + assert!(decode(&spec, "tenant", "task").is_err()); + } + + #[test] + fn an_envelope_digest_appearing_in_unrelated_text_is_not_a_binding() { + let (mut spec, _) = receipt(); + let mut payload: Value = + serde_json::from_slice(&STANDARD.decode(&spec.dsse.payload).unwrap()).unwrap(); + payload["subject"][0]["digest"]["sha256"] = json!("wrong"); + payload["predicate"]["note"] = json!(spec.envelope_digest); + spec.dsse.payload = STANDARD.encode(serde_json::to_vec(&payload).unwrap()); + assert!(decode(&spec, "tenant", "task").is_err()); + } + + #[test] + fn rejects_cross_namespace_and_cross_task_replays() { + let (mut spec, _) = receipt(); + assert!(decode(&spec, "other-tenant", "task").is_err()); + assert!(decode(&spec, "tenant", "other-task").is_err()); + spec.task_ref.name = "other-task".into(); + assert!(decode(&spec, "tenant", "other-task").is_err()); + } + + #[test] + fn rejects_empty_digests_and_unsupported_envelope_types() { + let (mut spec, _) = receipt(); + spec.envelope_digest.clear(); + assert!(decode(&spec, "tenant", "task").is_err()); + let (mut spec, _) = receipt(); + spec.dsse.payload_type = "text/plain".into(); + assert!(decode(&spec, "tenant", "task").is_err()); + let (mut spec, _) = receipt(); + spec.scheme = "DSSEv1-unknown".into(); + assert!(decode(&spec, "tenant", "task").is_err()); + } + + #[test] + fn rejects_malformed_payloads_instead_of_using_unsigned_claims() { + let (mut spec, _) = receipt(); + for payload in ["not base64", "e30="] { + spec.dsse.payload = payload.into(); + assert!(decode(&spec, "tenant", "task").is_err()); + } + } +} diff --git a/bridge/bff/src/routes/retention.rs b/bridge/bff/src/routes/retention.rs new file mode 100644 index 000000000..101ab54f7 --- /dev/null +++ b/bridge/bff/src/routes/retention.rs @@ -0,0 +1,114 @@ +// kars Bridge — cluster-wide mission/team-run retention policy. +// +// Kars intentionally keeps mission/team-run records (KarsTask CRs) after +// delivery — they're the audit trail (deliverable, receipt, activity) a human +// reviews. Only the SANDBOX (live compute) auto-tears-down once delivery is +// terminal. Left unmanaged, the CR records accumulate forever (missions list +// grows unbounded, and on a small/kind cluster the CR count itself becomes +// noise). This mirrors Kubernetes' `Job.spec.ttlSecondsAfterFinished`: once a +// task's deliverable landed (`status.deliveredAt` stamped), the controller's +// retention reconciler deletes it once the effective TTL elapses. +// +// Effective TTL per task = that task's own `spec.retentionTtlSeconds` +// override (set at creation), else this cluster-wide default. `0`/absent +// means "never auto-delete" — the safe, backward-compatible default. The +// principal + roster members of a standing team are NEVER auto-deleted +// (the controller pins them to `0` unconditionally) — only individual +// missions and team RUN records are eligible. + +use axum::{Json, extract::State}; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::kars::cluster::Cluster; +use crate::state::AppState; + +fn require_cluster(state: &AppState) -> AppResult<&Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +#[derive(Debug, Serialize)] +pub struct RetentionPolicyDto { + /// Effective cluster-wide default, in seconds. `0` = disabled (never + /// auto-delete unless a mission/team sets its own override). + pub default_ttl_seconds: i64, + /// Human-readable summary of what's currently in effect, for the console. + pub summary: String, +} + +fn summarize(ttl: i64) -> String { + if ttl <= 0 { + "Disabled — delivered missions and team runs are kept until a human deletes them (the default).".to_string() + } else { + let hours = ttl as f64 / 3600.0; + if hours >= 1.0 && (ttl % 3600) == 0 { + format!( + "Delivered missions and team runs auto-delete {hours:.0}h after their deliverable lands (unless a mission overrides its own retention)." + ) + } else { + format!( + "Delivered missions and team runs auto-delete {ttl}s after their deliverable lands (unless a mission overrides its own retention)." + ) + } + } +} + +/// `GET /api/operator/retention-policy`. +pub async fn get_retention_policy( + State(state): State, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let ttl = cluster.read_retention_policy().await; + Ok(Json(RetentionPolicyDto { + default_ttl_seconds: ttl, + summary: summarize(ttl), + })) +} + +#[derive(Debug, Deserialize)] +pub struct SetRetentionPolicyRequest { + /// Seconds; `0` disables cluster-wide auto-delete. Must be `>= 0`. + pub default_ttl_seconds: i64, +} + +/// `PUT /api/operator/retention-policy` — admin-only (gated at the web-proxy +/// layer, same pattern as inference budgets). +pub async fn set_retention_policy( + State(state): State, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + if body.default_ttl_seconds < 0 { + return Err(AppError::BadRequest( + "default_ttl_seconds must be >= 0".into(), + )); + } + cluster + .write_retention_policy(body.default_ttl_seconds) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(RetentionPolicyDto { + default_ttl_seconds: body.default_ttl_seconds, + summary: summarize(body.default_ttl_seconds), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_summary_is_honest() { + assert!(summarize(0).contains("Disabled")); + } + + #[test] + fn hour_aligned_ttl_reads_in_hours() { + assert!(summarize(86400).contains("24h")); + } + + #[test] + fn non_aligned_ttl_reads_in_seconds() { + assert!(summarize(90).contains("90s")); + } +} diff --git a/bridge/bff/src/routes/review.rs b/bridge/bff/src/routes/review.rs new file mode 100644 index 000000000..b69a85401 --- /dev/null +++ b/bridge/bff/src/routes/review.rs @@ -0,0 +1,277 @@ +// kars Bridge BFF — artifact review loop (design note §16). +// +// A deliverable isn't done until a human accepts it. This surface turns the +// passive artifact view into a governed review loop: a reviewer approves or +// requests changes, and **request-changes re-drives the producing task on the +// delta** — the agent re-runs against the original objective plus the reviewer's +// feedback, producing a new revision. Every review is recorded with provenance +// (reviewer, decision, comment, time) and the revision lineage is preserved. + +use std::collections::BTreeMap; + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::{require_owned_task, require_owned_task_or_output}; +use crate::routes::tasks::require_cluster; +use crate::state::AppState; + +/// One recorded review decision (provenance-tracked). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ReviewEntry { + pub decision: String, + pub comment: Option, + pub reviewer: String, + pub decided_at: String, + /// The revision this review applied to (0 = the first deliverable). + pub revision: i64, + /// Whether the reviewer identity came from the verified Bridge principal. + #[serde(default)] + pub attested: bool, + /// Exact execution evidence this decision reviewed. + #[serde(default)] + pub assignment_nonce: Option, +} + +/// Browser-facing review state for a task's deliverable. +#[derive(Debug, Serialize)] +pub struct ReviewStateDto { + /// `none` | `approved` | `changes_requested`. + pub status: String, + /// The current revision number (incremented on each request-changes). + pub revision: i64, + /// The full review history, newest first (revision lineage). + pub history: Vec, + /// True when a re-drive is in flight (a revision was requested but the new + /// run hasn't landed yet). + pub redrive_pending: bool, + /// Exact execution evidence represented by the current review status. + pub assignment_nonce: Option, +} + +/// Request body for a review decision. +#[derive(Debug, Deserialize)] +pub struct ReviewRequest { + /// `approve` | `request_changes`. + pub decision: String, + pub comment: Option, + /// Exact execution the reviewer saw. + pub assignment_nonce: String, +} + +fn parse_history(data: &BTreeMap) -> Vec { + data.get("history") + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_default() +} + +/// `GET /api/namespaces/:ns/tasks/:name/review` — the deliverable's review state. +pub async fn get_review( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_task_or_output(cluster, &ns, &name, &principal).await?; + let data = cluster.read_review(&name).await.unwrap_or_default(); + let mut history = parse_history(&data); + history.sort_by(|a, b| b.decided_at.cmp(&a.decided_at)); + let status = data + .get("status") + .cloned() + .unwrap_or_else(|| "none".to_string()); + let revision = data + .get("revision") + .and_then(|r| r.parse::().ok()) + .unwrap_or(0); + let mut redrive_pending = data + .get("redrivePending") + .map(|v| v == "true") + .unwrap_or(false); + let assignment_nonce = data.get("assignmentNonce").cloned(); + + // A pending re-drive clears once a fresh deliverable has landed (the output + // finishedAt is newer than the most recent request-changes decision). + if redrive_pending { + let last_request = history + .iter() + .find(|e| e.decision == "request_changes") + .map(|e| e.decided_at.clone()); + let finished_at = cluster + .configmap_data(&format!("kars-mission-output-{name}")) + .await + .and_then(|d| d.get("finishedAt").cloned()); + if let (Some(req), Some(fin)) = (last_request, finished_at) + && fin > req + { + redrive_pending = false; + let mut updated = data.clone(); + updated.insert("redrivePending".into(), "false".into()); + // Best-effort auto-clear: persist the cleared flag, but don't fail + // the read if it doesn't stick (the next GET simply re-clears). Log + // it rather than silently discarding, so a persistent write failure + // is visible instead of looping invisibly. + if let Err(e) = cluster.write_review(&name, updated).await { + tracing::warn!(task = %name, "failed to persist auto-cleared redrivePending: {e}"); + } + } + } + Ok(Json(ReviewStateDto { + status, + revision, + history, + redrive_pending, + assignment_nonce, + })) +} + +/// `POST /api/namespaces/:ns/tasks/:name/review` — record a review decision. +/// `request_changes` re-drives the producing task on the reviewer's delta. +pub async fn post_review( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let decision = body.decision.trim(); + if decision != "approve" && decision != "request_changes" { + return Err(AppError::BadRequest( + "decision must be 'approve' or 'request_changes'".into(), + )); + } + if decision == "request_changes" + && body + .comment + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + return Err(AppError::BadRequest( + "request_changes requires a comment describing what to change".into(), + )); + } + + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let reviewed_assignment = cluster + .configmap_data(&format!("kars-mission-output-{name}")) + .await + .and_then(|output| output.get("assignmentNonce").cloned()) + .unwrap_or_else(|| name.clone()); + if body.assignment_nonce != reviewed_assignment { + return Err(AppError::Conflict( + "the deliverable changed since it was displayed; reload before reviewing".into(), + )); + } + + // Read prior review state, distinguishing a genuine cluster-read error from + // "no review yet". Silently defaulting on error would erase the entire + // review history on the next write (a transient blip = permanent data loss). + let data = cluster + .configmap_data_result(&format!("kars-mission-review-{name}")) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .unwrap_or_default(); + let mut history = parse_history(&data); + let revision = data + .get("revision") + .and_then(|r| r.parse::().ok()) + .unwrap_or(0); + // Preserve the original objective once, so revisions compose from it rather + // than compounding directives across rounds. + let original = data + .get("originalObjective") + .cloned() + .unwrap_or_else(|| task.spec.objective.clone()); + + let now = chrono::Utc::now().to_rfc3339(); + let reviewer = principal.name; + let entry = ReviewEntry { + decision: decision.to_string(), + comment: body.comment.clone(), + reviewer: reviewer.clone(), + decided_at: now.clone(), + revision, + attested: true, + assignment_nonce: Some(reviewed_assignment.clone()), + }; + + let (status, new_revision, redrive_pending) = if decision == "approve" { + ("approved".to_string(), revision, false) + } else { + // Compose the revision objective from the original + the previous + // deliverable + the reviewer's requested changes, then re-drive the + // producing task on the delta. Including the prior deliverable lets the + // agent actually REVISE (keep what was right, change what was flagged) + // rather than blindly re-run. + let comment = body.comment.clone().unwrap_or_default(); + let prior_output = cluster + .configmap_data(&format!("kars-mission-output-{name}")) + .await + .and_then(|d| d.get("output").cloned()) + .map(|o| o.chars().take(4000).collect::()) + .unwrap_or_default(); + let prior_block = if prior_output.trim().is_empty() { + String::new() + } else { + format!( + "\n\nYour previous deliverable (revision {revision}) was:\n---\n{prior_output}\n---" + ) + }; + let revised = format!( + "{original}{prior_block}\n\nA reviewer reviewed it and requested changes:\n{comment}\n\n\ + Produce a revised deliverable that addresses this feedback. Keep what was already \ + correct; change only what the feedback asks for.", + ); + cluster + .redrive_with_revision(&ns, &name, &revised) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + ("changes_requested".to_string(), revision + 1, true) + }; + + // Persist under optimistic concurrency: append THIS decision to whatever + // history is currently stored (re-read inside the CAS), so two concurrent + // reviews can't silently drop each other's decision — the whole point of an + // audit trail. status/revision reflect this request; originalObjective is + // preserved-once. + let cm_name = format!("kars-mission-review-{name}"); + let entry_for_write = entry.clone(); + cluster + .update_configmap_data( + &cm_name, + &[("kars.azure.com/mission-review", name.as_str())], + |d| { + let mut hist = parse_history(d); + hist.push(entry_for_write.clone()); + d.insert( + "history".into(), + serde_json::to_string(&hist).unwrap_or_else(|_| "[]".into()), + ); + d.insert("status".into(), status.clone()); + d.insert("revision".into(), new_revision.to_string()); + d.entry("originalObjective".into()) + .or_insert_with(|| original.clone()); + d.insert("redrivePending".into(), redrive_pending.to_string()); + d.insert("assignmentNonce".into(), reviewed_assignment.clone()); + }, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + + // Response: reflect this request's decision on top of the pre-read history + // (the durable record is the CAS-written one above). + history.push(entry); + history.sort_by(|a, b| b.decided_at.cmp(&a.decided_at)); + Ok(Json(ReviewStateDto { + status, + revision: new_revision, + history, + redrive_pending, + assignment_nonce: Some(reviewed_assignment), + })) +} diff --git a/bridge/bff/src/routes/run.rs b/bridge/bff/src/routes/run.rs new file mode 100644 index 000000000..dddf30203 --- /dev/null +++ b/bridge/bff/src/routes/run.rs @@ -0,0 +1,369 @@ +// kars Bridge BFF — mission run (drive a real governed model run, capture it). +// +// This makes a "mission" actually DO something instead of a sandbox sitting +// idle. When a task is launched and its sandbox is Running, this drives a REAL +// model call through the sandbox's secure inference router and captures the +// real assistant output as a durable deliverable + the real token usage as +// telemetry, persisted to a ConfigMap so the result survives. +// +// HARNESS-NEUTRAL BY CONSTRUCTION: the per-pod inference router (:8443) is the +// identical seam EVERY runtime adapter (OpenClaw, Hermes, MAF, …) routes its +// model calls through (design note §2). We drive *that*, not a runtime-specific +// gateway — so this run path is agnostic across harnesses, not OpenClaw-bound. +// +// HONESTY BOUNDARY — what this is and isn't. This endpoint drives a real, +// governed run (content-safety + budget enforced, no key in the agent) and +// captures real output + real tokens. The PRIMARY path is the full autonomous +// agent LOOP (tools + sub-agent delegation), driven HARNESS-AGNOSTICALLY over +// the AGT mesh: `request_mesh_run` stamps the run-requested annotation, a mesh +// peer (the controller's `mesh_peer::task_delivery`) discovers the agent and +// delivers the objective into its native loop, and we await the deliverable. +// The SINGLE-TURN router path below is only a FALLBACK, used when the mesh +// round-trip is unavailable (the controller isn't the mesh-peer leader, the +// relay is down, or the request times out); it is one model turn (no tools / +// sub-agents) and is marked `source: single_turn` so the UI can say so. +// Driving the OpenClaw gateway directly was deliberately rejected as +// harness-specific. + +use std::collections::BTreeMap; + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::Serialize; +use serde_json::json; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::KarsTask; +use crate::routes::ownership::require_owned_task; +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct RunResult { + pub ok: bool, + /// The agent's produced output (the deliverable text). + pub output: Option, + /// Real token usage from the router response. + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, + /// The model that actually served the run. + pub model: Option, + /// When the run completed (RFC3339). + pub finished_at: String, + /// Honest error detail when ok=false. + pub error: Option, +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +/// `POST /api/namespaces/:ns/tasks/:name/run` — drive a real mission run. +pub async fn run_mission( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let now = chrono::Utc::now().to_rfc3339(); + + // The task must be launched with a Running sandbox to drive a run. + let task: KarsTask = require_owned_task(cluster, &ns, &name, &principal).await?; + + // Aggregate inference-budget gate (cluster + workspace + user): a run consumes + // inference tokens, so a strict/over-buffer budget at any tier blocks it. The + // creator is read from the task's stamped annotation. + let created_by = task + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/created-by").cloned()) + .unwrap_or_else(|| "unattributed".into()); + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; + + let sandbox = task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()); + let Some(sandbox) = sandbox else { + return Ok(Json(RunResult { + ok: false, + output: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + model: None, + finished_at: now, + error: Some( + "This mission isn't launched — launch it first so its agent sandbox is running." + .into(), + ), + })); + }; + + let Some(pod) = cluster.running_pod_for_sandbox(&sandbox).await else { + return Ok(Json(RunResult { + ok: false, + output: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + model: None, + finished_at: now, + error: Some( + "The mission's agent sandbox isn't Running yet. Wait for it to come up, then run." + .into(), + ), + })); + }; + + // Build the run prompt from the task's real objective + composed + // instructions (the system prompt the controller materialized). + let objective = task.spec.objective.clone(); + let instructions = task + .spec + .blueprint + .as_ref() + .and_then(|b| b.instructions.clone()); + let model = match task + .spec + .blueprint + .as_ref() + .and_then(|b| b.model.as_ref().map(|m| m.deployment.clone())) + { + Some(m) => m, + // No model pinned on the blueprint → use the cluster's ACTUAL configured + // default (read from the controller), never a hardcoded guess. This is + // the same value missions inherit, so the run telemetry reports the real + // model. Falls back to the controller's stock default only if the + // cluster is unreadable. + None => cluster + .controller_models() + .await + .0 + .unwrap_or_else(|| "gpt-4o-mini".to_string()), + }; + + let mut messages = Vec::new(); + if let Some(sys) = instructions { + messages.push(json!({ "role": "system", "content": sys })); + } + messages.push(json!({ "role": "user", "content": objective })); + + let body = json!({ + "model": model, + "messages": messages, + "max_tokens": 800, + }); + + let sandbox_ns = format!("kars-{sandbox}"); + + // ── Mesh-driven agent run (primary path) ────────────────────────────── + // Drive the agent's *native loop* (tools + sub-agent delegation) by asking + // the core controller — a live mesh peer — to deliver the objective over + // the mesh and capture the reply. This is the harness-neutral path: the + // Bridge stamps a declarative run-request annotation and reads the durable + // deliverable the controller writes back. The full agent loop runs inside + // the sandbox, governed by the AGT `task:execute` policy. Falls back to a + // single-turn router run below if the controller can't complete the mesh + // round-trip in time (e.g. it isn't the mesh leader, or the agent isn't + // discoverable yet). + if let Ok(nonce) = cluster.request_mesh_run(&ns, &name).await { + use crate::kars::cluster::MeshRunOutcome; + match cluster + .await_mesh_run(&ns, &name, &nonce, std::time::Duration::from_secs(200)) + .await + { + MeshRunOutcome::Completed(out) => { + let output = out.get("output").cloned(); + let status_ok = out.get("status").map(|s| s == "ok").unwrap_or(false); + let finished_at = out + .get("finishedAt") + .cloned() + .unwrap_or_else(|| now.clone()); + let served_model = out.get("model").cloned().unwrap_or_else(|| model.clone()); + // The controller records real token usage on the mesh deliverable — + // parse it instead of reporting null, so the efficiency scorecard and + // receipt telemetry reflect the PRIMARY execution path, not zeros. + let parse_tok = |k: &str| out.get(k).and_then(|v| v.parse::().ok()); + // The mesh deliverable is the agent loop's real reply. `status: ok` + // means the controller got a `task_response`; the body may still be + // an agent-side error (surfaced verbatim, never masked). + return Ok(Json(RunResult { + ok: status_ok && output.as_deref().map(|s| !s.is_empty()).unwrap_or(false), + output, + prompt_tokens: parse_tok("promptTokens"), + completion_tokens: parse_tok("completionTokens"), + total_tokens: parse_tok("totalTokens"), + model: Some(served_model), + finished_at, + error: None, + })); + } + MeshRunOutcome::InProgress => { + // The mesh peer acknowledged and the agent loop is STILL running + // (it can run for many minutes). Do NOT single-turn — the + // controller will write the real deliverable when it finishes, + // and the UI's live refresh + Activity stream surface it landing. + // Racing it with a single-turn write here is the double-write bug. + tracing::info!(task = %name, "mesh run in progress past the sync window — returning in-progress (no single-turn)"); + return Ok(Json(RunResult { + ok: true, + output: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + model: Some(model.clone()), + finished_at: now, + error: None, + })); + } + MeshRunOutcome::NeverProcessed => { + // The request may be waiting behind authority cleanup, admission, + // or agent registration. Preserve the nonce and report pending; + // clearing it here silently cancels healthy gated work and races a + // late controller claim. A one-shot mission must stay on its real + // harness path rather than degrade into an unrelated single turn. + tracing::info!( + task = %name, + "mesh run not acknowledged inside the sync window — preserving pending request" + ); + return Ok(Json(RunResult { + ok: true, + output: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + model: Some(model.clone()), + finished_at: now, + error: None, + })); + } + } + } + + // ── Single-turn router run (fallback) ───────────────────────────────── + // Drive a real, governed model run through the sandbox's inference router + // (:8443) via the pods/proxy subresource. The router is the trusted in-pod + // path and requires no caller auth, so pods/proxy can reach it. This is a + // single model turn (no agent tools/delegation) — used only when the mesh + // path above is unavailable, so the button always produces a real result. + let raw = match cluster.router_chat(&sandbox_ns, &pod, &body).await { + Ok(t) => t, + Err(e) => { + return Ok(Json(run_failed( + now, + model, + format!("inference failed: {e}"), + ))); + } + }; + + // Parse the OpenAI-shape response from the router. + let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or(json!({})); + let output = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + let usage = parsed.get("usage"); + let prompt_tokens = usage + .and_then(|u| u.get("prompt_tokens")) + .and_then(|v| v.as_i64()); + let completion_tokens = usage + .and_then(|u| u.get("completion_tokens")) + .and_then(|v| v.as_i64()); + let total_tokens = usage + .and_then(|u| u.get("total_tokens")) + .and_then(|v| v.as_i64()); + let served_model = parsed + .get("model") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .unwrap_or(model); + + let ok = output.is_some(); + if !ok { + // The router answered but not with a completion (e.g. a safety block or + // an upstream error) — surface the raw payload honestly, truncated. + let detail = raw.chars().take(400).collect::(); + return Ok(Json(RunResult { + ok: false, + output: None, + prompt_tokens, + completion_tokens, + total_tokens, + model: Some(served_model), + finished_at: now, + error: Some(format!("The router returned no completion. Raw: {detail}")), + })); + } + + // Persist the deliverable + telemetry as a durable artifact record. + let mut data: BTreeMap = BTreeMap::new(); + data.insert("output".into(), output.clone().unwrap_or_default()); + data.insert("objective".into(), task.spec.objective.clone()); + data.insert("model".into(), served_model.clone()); + data.insert("finishedAt".into(), now.clone()); + // Mark this as a SINGLE-TURN completion so the UI can be honest that it is + // one model turn (no tools / sub-agents) — the mesh agent loop was + // unavailable. The mesh path leaves this unset (⇒ full loop, the default). + data.insert("source".into(), "single_turn".into()); + if let Some(t) = total_tokens { + data.insert("totalTokens".into(), t.to_string()); + } + if let Some(t) = prompt_tokens { + data.insert("promptTokens".into(), t.to_string()); + } + if let Some(t) = completion_tokens { + data.insert("completionTokens".into(), t.to_string()); + } + // Persist the deliverable + telemetry as a durable artifact record. If this + // fails the deliverable would vanish on the next page load, so report the + // failure honestly instead of returning a green "ok" for work that wasn't + // saved — the user can re-run rather than believe a phantom result landed. + if let Err(e) = cluster.write_mission_output(&name, data).await { + tracing::warn!(task = %name, "failed to persist mission output: {e}"); + return Ok(Json(RunResult { + ok: false, + output, + prompt_tokens, + completion_tokens, + total_tokens, + model: Some(served_model), + finished_at: now, + error: Some(format!( + "The model produced a result but the Bridge could not save it ({e}). Please re-run." + )), + })); + } + + Ok(Json(RunResult { + ok: true, + output, + prompt_tokens, + completion_tokens, + total_tokens, + model: Some(served_model), + finished_at: now, + error: None, + })) +} + +/// Build a failed `RunResult` with an honest error message. +fn run_failed(finished_at: String, model: String, error: String) -> RunResult { + RunResult { + ok: false, + output: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + model: Some(model), + finished_at, + error: Some(error), + } +} diff --git a/bridge/bff/src/routes/sre_actions.rs b/bridge/bff/src/routes/sre_actions.rs new file mode 100644 index 000000000..79f2e831d --- /dev/null +++ b/bridge/bff/src/routes/sre_actions.rs @@ -0,0 +1,156 @@ +// kars Bridge BFF — the kars-sre self-remediation approval surface. +// +// Backs the operator "SRE Actions" console page: the kars-sre agent +// diagnoses a workload incident and proposes ONE typed remediation +// (`KarsSREAction`, Pending). An operator approves or rejects; the BFF only +// ever patches `spec.approval` — the controller is the sole executor (it +// mints a narrowly-scoped one-shot token, applies the action, tears the +// binding down, and records the outcome in `status`). + +use axum::Json; +use axum::extract::{Path, State}; +use kube::api::{ListParams, Patch, PatchParams}; +use kube::{Resource, ResourceExt}; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::kars::sre_action::{KarsSREAction, SreApprovalSpec}; +use crate::state::AppState; + +fn map_kube_err(e: kube::Error) -> AppError { + if let kube::Error::Api(resp) = &e + && (400..500).contains(&resp.code) + { + return AppError::Rejected(resp.message.clone()); + } + AppError::Upstream(e.to_string()) +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +/// Browser-facing SRE action proposal shape. +#[derive(Debug, Serialize)] +pub struct SreActionDto { + pub name: String, + pub namespace: String, + pub action_type: String, + pub target_namespace: Option, + pub target_name: Option, + pub params: serde_json::Value, + pub rationale: Option, + pub diagnosis: Option, + pub approval_state: String, + pub approval_note: Option, + pub phase: String, + pub applied_at: Option, + pub ttl_minutes: Option, + pub created_at: Option, + /// Whether a human can still act on this (only a Pending proposal). + pub actionable: bool, +} + +fn to_dto(a: &KarsSREAction) -> SreActionDto { + let status = a.status.clone().unwrap_or_default(); + let phase = status.phase.unwrap_or_else(|| "Proposed".to_string()); + let approval_state = a.spec.approval.state.clone(); + let actionable = approval_state == "Pending"; + let target_namespace = a + .spec + .action + .params + .get("namespace") + .and_then(|v| v.as_str()) + .map(str::to_string); + let target_name = a + .spec + .action + .params + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string); + SreActionDto { + name: a.name_any(), + namespace: a.namespace().unwrap_or_default(), + action_type: a.spec.action.kind.clone(), + target_namespace, + target_name, + params: serde_json::to_value(&a.spec.action.params).unwrap_or_default(), + rationale: a.spec.rationale.clone(), + diagnosis: a.spec.diagnosis.clone(), + approval_state, + approval_note: a.spec.approval.note.clone(), + phase, + applied_at: status.applied_at, + ttl_minutes: a.spec.ttl_minutes, + created_at: a + .meta() + .creation_timestamp + .as_ref() + .map(|t| t.0.to_rfc3339()), + actionable, + } +} + +/// `GET /api/operator/sre-actions` — every SRE remediation proposal, +/// cluster-wide, pending-first then most-recently-created. +pub async fn list_sre_actions(State(state): State) -> AppResult>> { + let cluster = require_cluster(&state)?; + let api = cluster.sre_actions_all(); + let list = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + let mut dtos: Vec = list.items.iter().map(to_dto).collect(); + dtos.sort_by(|a, b| { + b.actionable + .cmp(&a.actionable) + .then_with(|| b.created_at.cmp(&a.created_at)) + }); + Ok(Json(dtos)) +} + +/// Decision request from the operator console. +#[derive(Debug, Deserialize)] +pub struct SreDecisionRequest { + /// `approve` or `reject`. + pub verdict: String, + pub note: Option, +} + +/// `POST /api/operator/sre-actions/:ns/:name/decision` — record the +/// operator's approve/reject decision by patching `spec.approval`. The +/// controller is the sole executor of the remediation itself. +pub async fn decide_sre_action( + State(state): State, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + let state_value = match req.verdict.as_str() { + "approve" => "Approved", + "reject" => "Rejected", + other => { + return Err(AppError::Rejected(format!( + "verdict must be 'approve' or 'reject', got '{other}'" + ))); + } + }; + let cluster = require_cluster(&state)?; + let api = cluster.sre_actions(&ns); + + let approval = SreApprovalSpec { + state: state_value.to_string(), + note: req.note.filter(|n| !n.trim().is_empty()), + }; + let patch = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSREAction", + "spec": { "approval": approval }, + }); + let patched = api + .patch(&name, &PatchParams::default(), &Patch::Merge(&patch)) + .await + .map_err(map_kube_err)?; + Ok(Json(to_dto(&patched))) +} diff --git a/bridge/bff/src/routes/system.rs b/bridge/bff/src/routes/system.rs new file mode 100644 index 000000000..781e854b5 --- /dev/null +++ b/bridge/bff/src/routes/system.rs @@ -0,0 +1,382 @@ +// kars Bridge BFF — system / wiring introspection. +// +// Delivery Constraint #5 (honest wiring visibility): the product must never +// let an un-wired gap hide behind a finished-looking screen. This endpoint +// reports the true, cluster-read status of every stage of the governed-agent +// pipeline — task → envelope → digest → delegation → sandbox → agent → +// telemetry → receipt — labelling each `live`, `partial`, or `not_wired`. +// +// Where a fact can be read from the cluster (CRD installed? how many tasks / +// sandboxes?), it is read, never asserted. Where a capability is simply not +// built yet, that is stated plainly rather than implied to exist. + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; +use crate::kars::task::KarsTask; +use crate::state::AppState; +use kube::api::{Api, ListParams}; + +/// Wiring status of a single pipeline stage. +#[derive(Debug, Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum WiringStatus { + /// Fully implemented and exercised end-to-end. + Live, + /// Partially wired — works to a point, with a stated limitation. + Partial, + /// Not yet implemented; named so the gap is visible, not hidden. + NotWired, +} + +#[derive(Debug, Serialize)] +pub struct PipelineStage { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub status: WiringStatus, + /// Honest, specific note — what works, what doesn't, and why. + pub detail: String, +} + +#[derive(Debug, Serialize, Default)] +pub struct SystemCounts { + pub tasks: usize, + pub ready_tasks: usize, + pub degraded_tasks: usize, + pub digested_tasks: usize, + /// Sandboxes in the namespace; `null` when the CRD is not installed. + pub sandboxes: Option, + /// Sandboxes actually reporting Running/Ready (agent serving). + pub running_sandboxes: usize, + /// Governance receipts issued. + pub receipts: usize, + /// Human approvals (steering decisions) recorded. + pub approvals: usize, + /// Hash-chained receipt inclusion-log size. + pub inclusion_log_size: usize, + /// Whether a signed checkpoint (signed tree head) is published. + pub checkpoint_published: bool, + /// Captured mission deliverables (review-loop + content-addressed). + pub deliverables: usize, + /// Whether an independent transparency witness co-signs the log head. + pub transparency_witnessed: bool, + /// Per-mission token/trace records captured (live telemetry substrate). + pub trace_records: usize, +} + +#[derive(Debug, Serialize)] +pub struct SystemStatus { + pub namespace: String, + pub controller_reachable: bool, + pub crds: Vec, + pub counts: SystemCounts, + pub pipeline: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CrdStatus { + pub name: &'static str, + pub installed: bool, +} + +const TRACKED_CRDS: &[&str] = &[ + "karstasks.kars.azure.com", + "karssandboxes.kars.azure.com", + "inferencepolicies.kars.azure.com", + "toolpolicies.kars.azure.com", + "egressapprovals.kars.azure.com", +]; + +/// `GET /api/system` — the honest wiring map. +pub async fn get_system(State(state): State) -> AppResult> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let ns = state.default_namespace().to_string(); + + // Read what we can from the cluster. + let tasks_api: Api = cluster.tasks(&ns); + let task_list = tasks_api.list(&ListParams::default()).await; + let controller_reachable = task_list.is_ok(); + + let mut counts = SystemCounts::default(); + if let Ok(list) = &task_list { + counts.tasks = list.items.len(); + for t in &list.items { + let phase = t + .status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_default(); + match phase.as_str() { + "Ready" => counts.ready_tasks += 1, + "Degraded" => counts.degraded_tasks += 1, + _ => {} + } + if t.status + .as_ref() + .and_then(|s| s.envelope_digest.as_ref()) + .is_some() + { + counts.digested_tasks += 1; + } + } + } + + let sandbox_crd = cluster.crd_installed("karssandboxes.kars.azure.com").await; + counts.sandboxes = if sandbox_crd { + cluster.count_kind(&ns, "KarsSandbox").await + } else { + None + }; + + // Real audit + steering + execution facts (read, never asserted). Count + // only *task-owned* running sandboxes — a standalone `kars dev` sandbox + // (e.g. localkarstest) is not a Bridge mission and must not inflate the + // agent-execution stage. Task-materialized sandboxes carry the + // `kars.azure.com/task` label. + if let Ok(sandboxes) = cluster.list_kind_all("KarsSandbox").await { + counts.running_sandboxes = sandboxes + .iter() + .filter(|s| { + let task_owned = s + .metadata + .labels + .as_ref() + .map(|l| l.contains_key("kars.azure.com/task")) + .unwrap_or(false) + || s.metadata + .owner_references + .as_ref() + .map(|o| o.iter().any(|r| r.kind == "KarsTask")) + .unwrap_or(false); + let running = s + .data + .get("status") + .and_then(|st| st.get("phase")) + .and_then(|p| p.as_str()) + .map(|p| p == "Running" || p == "Ready") + .unwrap_or(false); + task_owned && running + }) + .count(); + } + counts.receipts = cluster + .list_kind_all("KarsReceipt") + .await + .map(|v| v.len()) + .unwrap_or(0); + counts.approvals = cluster + .list_kind_all("KarsApproval") + .await + .map(|v| v.len()) + .unwrap_or(0); + let log = cluster + .receipt_log() + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + counts.inclusion_log_size = log.entries.len(); + counts.checkpoint_published = log.checkpoint.is_some(); + // Real deliverables (review-loop + content-addressed) and live-telemetry + // substrate (per-mission token/trace records). These drive the artifacts + + // telemetry stage statuses with cluster-read truth, not hardcoded claims. + counts.deliverables = cluster.list_mission_output_evidence().await.len(); + counts.trace_records = cluster.count_trace_records().await; + counts.transparency_witnessed = log + .witness + .as_ref() + .and_then(|d| d.get("witnessKeyId").cloned()) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + let mut crds = Vec::with_capacity(TRACKED_CRDS.len()); + for name in TRACKED_CRDS { + crds.push(CrdStatus { + name, + installed: cluster.crd_installed(name).await, + }); + } + + let pipeline = build_pipeline(&counts); + + Ok(Json(SystemStatus { + namespace: ns, + controller_reachable, + crds, + counts, + pipeline, + })) +} + +/// Build the pipeline status. A stage is `Live` only when there is concrete +/// cluster evidence it has actually run; a stage that is wired but not yet +/// exercised is `Partial` (stated plainly), never asserted `Live` on faith. +fn build_pipeline(counts: &SystemCounts) -> Vec { + let sandbox_count = counts.sandboxes.unwrap_or(0); + // Live once there is at least one governed task; until then the capability + // is wired but unexercised. + let live_if = |cond: bool| { + if cond { + WiringStatus::Live + } else { + WiringStatus::Partial + } + }; + vec![ + PipelineStage { + id: "intake", + name: "Task intake", + description: "Create a governed unit of work.", + status: live_if(counts.tasks > 0), + detail: if counts.tasks > 0 { + format!( + "{} governed task(s) present in this namespace (created via the product or the `kars`/kubectl surface — both land as the same CRD).", + counts.tasks + ) + } else { + "Wired and ready — no governed task exists in this namespace yet. Create one from the product or the `kars`/kubectl surface to exercise this stage.".to_string() + }, + }, + PipelineStage { + id: "envelope", + name: "Envelope validation & digest", + description: "Validate the trust envelope and stamp a verifiable digest.", + status: live_if(counts.digested_tasks > 0), + detail: if counts.digested_tasks > 0 { + format!( + "{}/{} task(s) validated and digested by the controller.", + counts.digested_tasks, counts.tasks + ) + } else { + "Wired — the controller stamps a verifiable envelope digest on each task; no task has been digested yet in this namespace.".to_string() + }, + }, + PipelineStage { + id: "delegation", + name: "Capability-attenuating delegation", + description: "Verify child roles attenuate their parent; reject amplification.", + // This guard runs whenever tasks are reconciled; treat it as proven + // once there are tasks to reconcile, otherwise wired-but-unexercised. + status: live_if(counts.tasks > 0), + detail: + "Child authority (tier, tool policy, and the egress the sandbox actually enforces) is verified to be a strict subset of the parent; amplifying delegations are rejected with no digest, and a child of a not-ready parent waits rather than running." + .to_string(), + }, + PipelineStage { + id: "validation", + name: "Pre-flight validation (§20)", + description: "Prove the package is launch-ready before any agent starts.", + status: WiringStatus::Live, + detail: + "Before launch, the package is validated against the live cluster: the trust envelope (autonomy tier + token budget), the referenced tool policy / connected services / shared memory, the model the cluster serves, egress-host resolution, and capability-readiness probes that the connected MCP servers are reachable + usable before dispatch. Failures are itemised with a specific reason. A live in-sandbox RBAC-delegation probe is the remaining deepening.".to_string(), + }, + PipelineStage { + id: "sandbox", + name: "Sandbox materialization", + description: "Materialize a governed KarsSandbox (pod) from the task.", + status: live_if(sandbox_count > 0), + detail: if sandbox_count > 0 { + format!( + "Launching a task materializes an envelope-bounded KarsSandbox + InferencePolicy (owned by the task). {sandbox_count} sandbox(es) currently in the namespace." + ) + } else { + "Wired — launching a task materializes an envelope-bounded KarsSandbox + InferencePolicy owned by the task. No sandbox is materialized in this namespace right now.".to_string() + }, + }, + PipelineStage { + id: "agent", + name: "Agent execution", + description: "Run the OpenClaw agent through the secure inference router.", + status: if counts.running_sandboxes > 0 + || counts.deliverables > 0 + || counts.trace_records > 0 + { + WiringStatus::Live + } else { + WiringStatus::Partial + }, + detail: if counts.running_sandboxes > 0 { + format!( + "{} sandbox(es) running and serving through the inference router. Inference uses the controller's configured model provider (GitHub Models / Azure OpenAI / Foundry).", + counts.running_sandboxes + ) + } else if counts.deliverables > 0 || counts.trace_records > 0 { + format!( + "The agent execution path is proven by {} retained deliverable(s) and {} router trace record(s). No sandbox is running right now because these workloads are on-demand and completed sandboxes are retired or left idle.", + counts.deliverables, counts.trace_records + ) + } else { + "Sandboxes materialize and reconcile; performing inference needs a model provider configured on the controller (GitHub Models, Azure OpenAI, or Foundry). With none, the pod spawns and degrades honestly at the inference step.".to_string() + }, + }, + PipelineStage { + id: "steering", + name: "Steering & decisions (HITL)", + description: "Human-in-the-loop steering: approve, deny, raise/lower tier.", + status: live_if(counts.approvals > 0), + detail: if counts.approvals > 0 { + format!( + "The steering inbox is live: {} approval(s) recorded; each decision is bound to the task envelope and routed to the owning OIDC subject. Operator-only authority requests remain available in the Console approval plane.", + counts.approvals + ) + } else { + "Wired — the steering inbox records human approve/deny/tier decisions, each bound to the task envelope and written into the signed receipt. No decision has been recorded in this namespace yet.".to_string() + }, + }, + PipelineStage { + id: "telemetry", + name: "Live activity & tool-call stream", + description: "Watch tool calls, decisions, and token burn in flight.", + status: if counts.trace_records > 0 { + WiringStatus::Live + } else { + WiringStatus::Partial + }, + detail: format!( + "Per-mission token cost + a per-round/per-tool execution trace stream live over SSE (GET /tasks/{{name}}/stream) into the activity panel + active-agents view — {} mission(s) carry a trace. Each tool call shows its real params/result/duration; egress domains surface. Streams on a 2s tail; never fakes ticks.", + counts.trace_records + ), + }, + PipelineStage { + id: "receipt", + name: "Governance Receipt", + description: "Signed, independently verifiable record of a governed task.", + status: live_if(counts.receipts > 0), + detail: if counts.receipts > 0 { + format!( + "{} signed in-toto/DSSE receipt(s) issued; entered in a hash-chained inclusion log ({} entr{}){}{}. Verify independently with `kars receipt verify` — on a plain kars cluster, no Bridge required.", + counts.receipts, + counts.inclusion_log_size, + if counts.inclusion_log_size == 1 { "y" } else { "ies" }, + if counts.checkpoint_published { + " and committed to a published signed checkpoint (signed tree head)" + } else { + "" + }, + if counts.transparency_witnessed { + ", independently co-signed by a separate transparency witness key" + } else { + "" + } + ) + } else { + "Wired — a delivered task is signed as an in-toto/DSSE receipt and entered in a hash-chained inclusion log, independently verifiable with `kars receipt verify`. No receipt has been issued in this namespace yet.".to_string() + }, + }, + PipelineStage { + id: "artifacts", + name: "Artifact review (§16)", + description: "Review deliverables in place with provenance.", + status: if counts.deliverables > 0 { + WiringStatus::Live + } else { + WiringStatus::Partial + }, + detail: format!( + "Captured deliverables ({}) carry an in-place review loop (approve / request-changes re-drives on the delta), per-file sha256 content-addresses + a did:kars deliverable identity, and a standalone provenance overlay (envelope → predicate → claims → inclusion → signed checkpoint). Honestly empty — never a mockup — until a mission produces one.", + counts.deliverables + ), + }, + ] +} diff --git a/bridge/bff/src/routes/tasks.rs b/bridge/bff/src/routes/tasks.rs new file mode 100644 index 000000000..dc2837324 --- /dev/null +++ b/bridge/bff/src/routes/tasks.rs @@ -0,0 +1,5173 @@ +// kars Bridge BFF — task API DTOs + handlers. +// +// These endpoints are the browser's only way to touch KarsTask resources. +// They map the typed CRD (kars::task) to stable, browser-facing JSON shapes, +// so the UI never depends on raw Kubernetes object envelopes. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::{KarsTask, KarsTaskSpec, LocalObjectRef, TaskBudget, TaskEnvelope}; +use crate::routes::ownership::{ + require_owned_task, require_owned_task_or_output, task_is_owned_by, +}; +use crate::state::AppState; +use kube::ResourceExt; +use kube::api::{Api, ListParams, PostParams}; + +/// Map a `kube::Error` to the right client-facing error. An API rejection with +/// a 4xx status (admission/CEL/validation) is the user's invalid input — a 422 +/// carrying the API server's own message — not a gateway failure. +fn map_kube_err(e: kube::Error) -> AppError { + if let kube::Error::Api(resp) = &e + && (400..500).contains(&resp.code) + { + return AppError::Rejected(resp.message.clone()); + } + AppError::Upstream(e.to_string()) +} + +/// Browser-facing budget shape. +#[derive(Debug, Serialize, Deserialize)] +pub struct BudgetDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + pub tokens: Option, + pub usd_micros: Option, +} + +/// Browser-facing envelope shape. +#[derive(Debug, Serialize, Deserialize)] +pub struct EnvelopeDto { + pub tier: i32, + pub authority_ceiling: i32, + pub delegation_depth: i32, + pub budget: Option, + pub tool_policy: Option, + pub egress_allowlist: Option, +} + +/// Browser-facing blueprint shape. The request layer is **snake_case** (like +/// every other DTO here and the web's TS types); it maps to the camelCase CRD +/// `TaskBlueprint` on write. Keeping the wire contract consistent here is what +/// prevents silent field-drop on multi-word fields (`tool_policy`, +/// `mcp_servers`). +#[derive(Debug, Deserialize, Default)] +pub struct BlueprintDto { + #[serde(default)] + pub runtime: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub model_fallbacks: Vec, + #[serde(default)] + pub instructions: Option, + #[serde(default)] + pub tool_policy: Option, + #[serde(default)] + pub mcp_servers: Vec, + #[serde(default)] + pub egress: Vec, + #[serde(default)] + pub egress_mode: Option, + #[serde(default)] + pub isolation: Option, + #[serde(default)] + pub memory: Option, + #[serde(default)] + pub skills: Vec, + #[serde(default)] + pub execution_plan: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionPlanDto { + pub schema: String, + pub roles: Vec, + pub max_parallel: i32, + pub synthesis: ExecutionSynthesisDto, + #[serde(default)] + pub deliverables: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionRoleDto { + pub name: String, + pub objective: String, + #[serde(default)] + pub depends_on: Vec, + pub phases: Vec, + #[serde(default)] + pub budget_tokens: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionPhaseDto { + pub name: String, + pub objective: String, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub required_tool_calls: Vec, + #[serde(default)] + pub min_tool_calls: i32, + pub max_tool_calls: i32, + #[serde(default)] + pub fresh_context: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionRequiredToolCallDto { + pub name: String, + #[serde(default)] + pub arguments: std::collections::BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionSynthesisDto { + pub objective: String, + #[serde(default)] + pub capabilities: Vec, + pub max_tool_calls: i32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionDeliverableDto { + pub name: String, + #[serde(default)] + pub media_type: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModelDto { + pub provider: String, + pub deployment: String, +} + +#[derive(Debug, Deserialize)] +pub struct EgressDto { + pub host: String, + #[serde(default)] + pub port: Option, +} + +impl BlueprintDto { + fn into_crd(self) -> crate::kars::task::TaskBlueprint { + use crate::kars::task::{TaskBlueprint, TaskEgress, TaskModel}; + TaskBlueprint { + runtime: self.runtime, + model: self.model.map(|m| TaskModel { + provider: m.provider, + deployment: m.deployment, + }), + model_fallbacks: self + .model_fallbacks + .into_iter() + .map(|m| TaskModel { + provider: m.provider, + deployment: m.deployment, + }) + .collect(), + instructions: self.instructions, + tool_policy: self.tool_policy, + mcp_servers: self.mcp_servers, + egress: self + .egress + .into_iter() + .map(|e| TaskEgress { + host: e.host, + port: e.port, + }) + .collect(), + egress_mode: self.egress_mode, + isolation: self.isolation, + memory: self.memory, + skills: self.skills, + git_write: None, + credential_bindings: None, + github_binding: None, + execution_plan: self.execution_plan.map(ExecutionPlanDto::into_crd), + } + } +} + +impl ExecutionPlanDto { + pub(crate) fn from_crd(plan: &crate::kars::task::ExecutionPlan) -> Self { + Self { + schema: plan.schema.clone(), + roles: plan + .roles + .iter() + .map(|role| ExecutionRoleDto { + name: role.name.clone(), + objective: role.objective.clone(), + depends_on: role.depends_on.clone(), + phases: role + .phases + .iter() + .map(|phase| ExecutionPhaseDto { + name: phase.name.clone(), + objective: phase.objective.clone(), + capabilities: phase.capabilities.clone(), + required_tool_calls: phase + .required_tool_calls + .iter() + .map(|call| ExecutionRequiredToolCallDto { + name: call.name.clone(), + arguments: call.arguments.clone(), + }) + .collect(), + min_tool_calls: phase.min_tool_calls, + max_tool_calls: phase.max_tool_calls, + fresh_context: phase.fresh_context, + }) + .collect(), + budget_tokens: role.budget_tokens, + }) + .collect(), + max_parallel: plan.max_parallel, + synthesis: ExecutionSynthesisDto { + objective: plan.synthesis.objective.clone(), + capabilities: plan.synthesis.capabilities.clone(), + max_tool_calls: plan.synthesis.max_tool_calls, + }, + deliverables: plan + .deliverables + .iter() + .map(|deliverable| ExecutionDeliverableDto { + name: deliverable.name.clone(), + media_type: deliverable.media_type.clone(), + }) + .collect(), + } + } + + pub(crate) fn into_crd(self) -> crate::kars::task::ExecutionPlan { + use crate::kars::task::{ + ExecutionDeliverable, ExecutionPhase, ExecutionPlan, ExecutionRequiredToolCall, + ExecutionRole, ExecutionSynthesis, + }; + ExecutionPlan { + schema: self.schema, + roles: self + .roles + .into_iter() + .map(|role| ExecutionRole { + name: role.name, + objective: role.objective, + depends_on: role.depends_on, + phases: role + .phases + .into_iter() + .map(|phase| ExecutionPhase { + name: phase.name, + objective: phase.objective, + capabilities: phase.capabilities, + required_tool_calls: phase + .required_tool_calls + .into_iter() + .map(|call| ExecutionRequiredToolCall { + name: call.name, + arguments: call.arguments, + }) + .collect(), + min_tool_calls: phase.min_tool_calls, + max_tool_calls: phase.max_tool_calls, + fresh_context: phase.fresh_context, + }) + .collect(), + budget_tokens: role.budget_tokens, + }) + .collect(), + max_parallel: self.max_parallel, + synthesis: ExecutionSynthesis { + objective: self.synthesis.objective, + capabilities: self.synthesis.capabilities, + max_tool_calls: self.synthesis.max_tool_calls, + }, + deliverables: self + .deliverables + .into_iter() + .map(|deliverable| ExecutionDeliverable { + name: deliverable.name, + media_type: deliverable.media_type, + }) + .collect(), + } + } +} + +/// Browser-facing task summary (list view). +#[derive(Debug, Serialize)] +pub struct TaskSummaryDto { + pub name: String, + pub namespace: String, + pub objective: String, + pub display_name: Option, + pub created_at: Option, + pub tier: i32, + pub phase: String, + pub envelope_digest: Option, + /// The standing team that owns this task (from the kars.azure.com/team + /// label), when it is team machinery rather than a standalone mission. The + /// Missions surface hides team-owned tasks — they belong to the Team view. + pub team: Option, + /// Whether this mission has captured a delivered result (an `ok` run output + /// exists). The authoritative "done" signal — execution phase returns to + /// Idle after delivery, so phase alone cannot tell delivered from drafting. + pub delivered: bool, + /// Whether this mission's run captured an `error` output — a run that + /// completed but did NOT succeed. Lets the list badge read "Run failed" + /// instead of a misleading "Ready to launch" (audit f6). + pub failed: bool, + /// Whether the task has been launched (execution gate opened). Without this + /// the list cannot tell a launched-and-running mission from an un-launched + /// draft, so a live mission wrongly reads "Ready to launch". + pub launched: bool, + /// The controller's execution phase (Running/Idle/Degraded/…), so the list + /// badge agrees with the detail page — "Running" while the agent works, not + /// a stale "Ready to launch". + pub execution_phase: Option, +} + +/// Browser-facing task detail (single view). +#[derive(Debug, Serialize)] +pub struct TaskDetailDto { + pub name: String, + pub namespace: String, + pub objective: String, + pub display_name: Option, + pub created_at: Option, + pub envelope: EnvelopeDto, + pub phase: String, + pub envelope_digest: Option, + pub observed_generation: Option, + pub lineage: Vec, + /// Parent task name when this task is a delegated child. + pub parent: Option, + /// The standing team that owns this run. Team-owned runs stay inside the + /// team-native UX rather than leaking into the generic Missions surface. + pub team: Option, + /// The `Ready` condition message — surfaces *why* a task is Degraded + /// (e.g. an amplification rejection), so the UI can explain it. + pub status_message: Option, + /// Names of tasks that delegate from this one (its direct children). + pub children: Vec, + /// Whether the task is launched (execution gate). + pub launched: bool, + /// Execution phase: `Idle` | `Launching` | `Running` | `Degraded`. + pub execution_phase: Option, + /// Name of the materialized sandbox, when launched. + pub sandbox: Option, + /// The live egress enforcement mode the sandbox is running under, read from + /// the materialized `KarsSandbox`: `"Learn"` (observe + record every domain + /// the agent reaches, the default) or `"Strict"` (deny anything outside the + /// allowlist). `None` until a sandbox exists. This is the monitoring→enforced + /// surface: a customer watches in Learn, then promotes to Strict when + /// confident the agent's reach is what it should be. + pub egress_mode: Option, + /// Human-readable execution detail (e.g. the kind/Foundry caveat). + pub execution_detail: Option, + /// Authoritative durable root-assignment snapshot from Kars core. + pub assignment: Option, + /// Ordered durable root and child assignment transitions. + pub assignment_events: Vec, + /// Highest durable assignment event sequence observed by the controller. + pub assignment_sequence: Option, + /// The composed run — what model/harness/tools/services/egress/prompt this + /// mission actually runs with, projected from the blueprint. Lets a + /// task-giver review exactly what they launched. `None` when no blueprint + /// was set (the mission uses controller defaults). + pub composition: Option, + /// The agents the mission spawned at run time (the running agent/sub-agent + /// tree, distinct from the governed delegation roles in `children`). + pub sub_agents: Vec, + /// The mission's captured run result — a real deliverable produced by a + /// governed model run, with its real token cost. `None` until the mission + /// has been run. + pub result: Option, + /// The full set of artifact files the mission produced through the agent + /// loop over the mesh (research report, data files, decision matrix, …), + /// read from the persisted artifacts ConfigMap. Empty until a mesh run + /// produces files. + pub artifacts: Vec, + /// Bounded, server-parsed orchestration plan evidence. This remains complete + /// even when the source artifact preview is truncated or omitted. + pub role_plan: TeamRolePlanDto, + /// Bounded, server-parsed collaboration evidence. Parsing full artifact + /// contents in the BFF prevents preview limits from changing run truth. + pub collaboration_events: Vec, + /// Pull requests the mission opened, extracted from its output — surfaced as + /// first-class deliverables (a PR is a delivery type) on the mission's + /// Artifacts tab, not just buried in the prose. Empty when none were opened. + #[serde(default)] + pub pull_requests: Vec, + /// The mission's live execution activity — the real per-round and per-tool + /// trace the agent emitted (token usage, tool names, sanitized arg/result + /// previews, durations), read from the persisted trace ConfigMap. Empty + /// until a mesh run produces a trace. This is the source of the Activity + /// timeline and the clean per-tool audit path. + pub activity: Vec, + /// Run telemetry rollup (rounds, tool calls) parsed from the mission output. + /// Token totals live on `result`; this carries the loop-shape counts. + pub telemetry: Option, + /// Latest durable milestone checkpoint emitted by the running harness. + pub checkpoint: Option, + /// The running agent's real mesh identity (DID), discovered from the AGT + /// registry — proof the agent is a live, harness-neutral mesh participant. + /// `None` when not launched / not yet registered / registry unreachable. + pub agent_identity: Option, + /// A governed capability-routing decision recorded at creation: set when the + /// requested harness could not run this mission (a chat-gateway harness on a + /// one-shot mission) and was corrected. Surfaced so the swap is attested, not + /// silent. `None` when no correction was needed. + pub harness_corrected: Option, + /// A governed emergency-stop decision: set when an operator halted this + /// mission (agent torn down, record retained). Carries the operator/reason/at + /// string. `None` when the mission was never halted. + pub halted: Option, + /// Whether a run has EVER been requested for this mission (the + /// `kars.azure.com/run-requested` annotation is set). Used by the client + /// auto-kickoff to fire the first run exactly once — gating on this instead + /// of "no activity yet" avoids a race where the agent's startup telemetry + /// (MCP init / tool list) makes the mission look already-active and the + /// first run is never triggered, leaving it silently idle. + pub run_requested: bool, + /// The exact latest requested run nonce. This appears before assignment + /// acknowledgement and is the authoritative scope for run-bound approvals. + pub current_run_nonce: Option, +} + +#[derive(Debug, Serialize)] +pub struct TaskAssignmentStatusDto { + pub task_id: String, + pub state: String, + pub worker_did: Option, + pub stage: Option, + pub child_task_id: Option, + pub child_role: Option, + pub last_progress_at: Option, + pub completed_at: Option, + pub error: Option, +} + +impl From<&crate::kars::task::TaskAssignmentStatus> for TaskAssignmentStatusDto { + fn from(value: &crate::kars::task::TaskAssignmentStatus) -> Self { + Self { + task_id: value.task_id.clone(), + state: value.state.clone(), + worker_did: value.worker_did.clone(), + stage: value.stage.clone(), + child_task_id: value.child_task_id.clone(), + child_role: value.child_role.clone(), + last_progress_at: value.last_progress_at.clone(), + completed_at: value.completed_at.clone(), + error: value.error.clone(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct TaskAssignmentEventDto { + pub sequence: i64, + pub event_id: String, + pub task_id: String, + pub event_type: String, + pub state: String, + pub at: String, + pub worker_did: Option, + pub stage: Option, + pub child_task_id: Option, + pub child_role: Option, + pub outcome: Option, + pub message: Option, +} + +impl From<&crate::kars::task::TaskAssignmentEvent> for TaskAssignmentEventDto { + fn from(value: &crate::kars::task::TaskAssignmentEvent) -> Self { + Self { + sequence: value.sequence, + event_id: value.event_id.clone(), + task_id: value.task_id.clone(), + event_type: value.event_type.clone(), + state: value.state.clone(), + at: value.at.clone(), + worker_did: value.worker_did.clone(), + stage: value.stage.clone(), + child_task_id: value.child_task_id.clone(), + child_role: value.child_role.clone(), + outcome: value.outcome.clone(), + message: value.message.clone(), + } + } +} + +/// Loop-shape telemetry for a mission run (token totals are on the result DTO). +#[derive(Debug, Serialize)] +pub struct MissionTelemetryDto { + pub rounds: Option, + pub tool_calls: Option, +} + +/// A captured mission run result (read from the persisted output ConfigMap). +#[derive(Debug, Serialize)] +pub struct MissionResultDto { + pub output: String, + /// Run status the output reflects: `ok` (a real deliverable) or `error` + /// (e.g. a delivery timeout). The UI must not present an `error` output as + /// the mission's deliverable. + pub status: Option, + pub model: Option, + pub total_tokens: Option, + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub finished_at: Option, + /// Assignment nonce that produced this output. Used to hide stale results + /// while a newer run is materializing. + pub assignment_nonce: Option, + /// How the deliverable was produced: `"single_turn"` when the mesh agent + /// loop was unavailable and this is one model turn (no tools/sub-agents). + /// Absent (`None`) for a full agent-loop run — the normal case. + pub source: Option, + /// Set when the run's `ok` output is actually a capability/limit STOP rather + /// than a real deliverable — today the daily token budget (enforced by the + /// sandbox InferencePolicy / router). The UI renders this as an actionable + /// state ("raise the budget / narrow the objective"), never as the answer. + pub blocked: Option, + /// Whether every artifact declared by the agent was durably persisted. + /// Older runs may not carry this field. + pub artifact_persistence: Option, + pub artifact_count: Option, + pub declared_artifact_count: Option, +} + +/// A run that returned transport-`ok` but whose body is a capability/limit stop, +/// not a deliverable. Surfaced so the operator gets an honest, actionable state +/// instead of a non-answer dressed up as the mission's output. +#[derive(Debug, Serialize, Clone)] +pub struct RunBlockedDto { + /// Machine reason. Today: `"budget"`. + pub reason: String, + /// One-line, plain-language explanation. + pub detail: String, + /// Tokens spent / the enforced limit, parsed from the router's message when + /// present (the limit ideally originates from the sandbox InferencePolicy). + pub spent: Option, + pub limit: Option, +} + +/// Classify a transport-`ok` run whose body is really a STOP condition (not a +/// deliverable). Today this recognises the daily token-budget block the router +/// enforces from the sandbox InferencePolicy — its message reads +/// "Daily token budget exceeded (23131/20000 tokens)". Returns `None` for a +/// genuine deliverable (or an already-`error` run, handled separately). +pub(crate) fn classify_blocked(status: Option<&str>, output: &str) -> Option { + if status == Some("error") { + return None; + } + let low = output.to_ascii_lowercase(); + let budget_hit = low.contains("token budget") + && (low.contains("exceeded") || low.contains("429") || low.contains("budget at")); + if budget_hit { + let (spent, limit) = parse_budget_pair(output); + return Some(RunBlockedDto { + reason: "budget".into(), + detail: "The run reached its daily token budget and stopped before finishing.".into(), + spent, + limit, + }); + } + None +} + +/// Extract the `spent/limit` pair from a budget message like +/// "... (23131/20000 tokens)". Returns `(None, None)` when absent/unparseable. +fn parse_budget_pair(output: &str) -> (Option, Option) { + // Find a "/" run (optionally followed by " tokens"). + let bytes = output.as_bytes(); + for (i, _) in output.match_indices('/') { + // Walk left over digits. + let mut l = i; + while l > 0 && bytes[l - 1].is_ascii_digit() { + l -= 1; + } + // Walk right over digits. + let mut r = i + 1; + while r < bytes.len() && bytes[r].is_ascii_digit() { + r += 1; + } + if l < i && r > i + 1 { + let spent = output[l..i].parse::().ok(); + let limit = output[i + 1..r].parse::().ok(); + if spent.is_some() && limit.is_some() { + return (spent, limit); + } + } + } + (None, None) +} + +/// One artifact file in a mission's deliverable set. `content` is present for +/// text artifacts (markdown, json, csv, …) and `None` for binary ones, which +/// are still listed by name + size so the set is honestly complete. +#[derive(Serialize)] +pub struct MissionArtifactDto { + pub name: String, + pub size_bytes: Option, + pub content: Option, + pub content_bytes: Option, + pub content_truncated: bool, + pub source_agent: Option, + pub source_path: Option, + pub digest: Option, + #[serde(skip_serializing)] + full_content: Option, +} + +#[derive(Debug, Default, Serialize)] +pub struct TeamRolePlanDto { + pub selected_roles: Vec, + pub skipped_roles: Vec, +} + +#[derive(Debug, Serialize)] +pub struct TeamCollaborationEventDto { + pub at: Option, + pub event: String, + pub agent: Option, + pub member: Option, + pub outcome: Option, + pub message_id: Option, + pub reply_preview: Option, + pub content_preview: Option, +} + +impl std::fmt::Debug for MissionArtifactDto { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MissionArtifactDto") + .field("name", &self.name) + .field("size_bytes", &self.size_bytes) + .field("content_bytes", &self.content_bytes) + .field("content_truncated", &self.content_truncated) + .field("source_agent", &self.source_agent) + .field("source_path", &self.source_path) + .field("digest", &self.digest) + .finish_non_exhaustive() + } +} + +const ARTIFACT_PREVIEW_MAX_BYTES: usize = 8 * 1024; +const ARTIFACT_PREVIEW_TOTAL_BYTES: usize = 64 * 1024; + +fn artifact_preview( + content: Option, + remaining_budget: &mut usize, +) -> (Option, Option, bool, Option) { + let Some(full) = content else { + return (None, None, false, None); + }; + let content_bytes = full.len() as i64; + if full.is_empty() { + return (Some(String::new()), Some(0), false, Some(full)); + } + + let max_bytes = ARTIFACT_PREVIEW_MAX_BYTES + .min(*remaining_budget) + .min(full.len()); + if max_bytes == 0 { + return (None, Some(content_bytes), true, Some(full)); + } + let mut end = max_bytes; + while end > 0 && !full.is_char_boundary(end) { + end -= 1; + } + let preview = full[..end].to_string(); + *remaining_budget = remaining_budget.saturating_sub(preview.len()); + let truncated = end < full.len(); + (Some(preview), Some(content_bytes), truncated, Some(full)) +} + +fn string_field(value: &serde_json::Value, field: &str) -> Option { + value + .get(field) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +fn bounded_text(value: Option, max_bytes: usize) -> Option { + let value = value?; + if value.len() <= max_bytes { + return Some(value); + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + Some(value[..end].to_string()) +} + +fn bounded_string_field( + value: &serde_json::Value, + field: &str, + max_bytes: usize, +) -> Option { + bounded_text(string_field(value, field), max_bytes) +} + +fn collect_role_names( + value: Option<&serde_json::Value>, + target: &mut Vec, + seen: &mut std::collections::HashSet, +) { + const MAX_ROLE_NAMES: usize = 128; + const MAX_ROLE_NAME_BYTES: usize = 256; + if target.len() >= MAX_ROLE_NAMES { + return; + } + match value { + Some(serde_json::Value::Array(entries)) => { + for entry in entries { + let role = entry + .as_str() + .and_then(|role| bounded_text(Some(role.to_string()), MAX_ROLE_NAME_BYTES)) + .or_else(|| bounded_string_field(entry, "role", MAX_ROLE_NAME_BYTES)) + .or_else(|| bounded_string_field(entry, "name", MAX_ROLE_NAME_BYTES)); + if let Some(role) = role + && seen.insert(role.clone()) + { + target.push(role); + if target.len() >= MAX_ROLE_NAMES { + break; + } + } + } + } + Some(serde_json::Value::Object(entries)) => { + for role in entries.keys() { + let role = bounded_text(Some(role.clone()), MAX_ROLE_NAME_BYTES) + .expect("object keys are present"); + if seen.insert(role.clone()) { + target.push(role); + if target.len() >= MAX_ROLE_NAMES { + break; + } + } + } + } + _ => {} + } +} + +fn structured_team_evidence( + artifacts: &[MissionArtifactDto], +) -> (TeamRolePlanDto, Vec) { + const MAX_COLLABORATION_EVENTS: usize = 1_000; + const MAX_COLLABORATION_METADATA_BYTES: usize = 512; + const MAX_COLLABORATION_PREVIEW_BYTES: usize = 2 * 1024; + + let mut role_plan = TeamRolePlanDto::default(); + let mut selected_seen = std::collections::HashSet::new(); + let mut skipped_seen = std::collections::HashSet::new(); + for artifact in artifacts + .iter() + .filter(|artifact| artifact.name.ends_with(".json")) + { + let Some(content) = artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + else { + continue; + }; + let Ok(parsed) = serde_json::from_str::(content) else { + continue; + }; + collect_role_names( + parsed.get("selected_roles"), + &mut role_plan.selected_roles, + &mut selected_seen, + ); + collect_role_names( + parsed.get("skipped_roles"), + &mut role_plan.skipped_roles, + &mut skipped_seen, + ); + } + + let collaboration = artifacts + .iter() + .find(|artifact| { + artifact.name == "collaboration.jsonl" + || artifact + .source_path + .as_deref() + .is_some_and(|path| path.ends_with("/collaboration.jsonl")) + }) + .and_then(|artifact| { + artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + }) + .map(|content| { + content + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .take(MAX_COLLABORATION_EVENTS) + .map(|event| TeamCollaborationEventDto { + at: bounded_string_field(&event, "at", MAX_COLLABORATION_METADATA_BYTES), + event: bounded_string_field(&event, "event", MAX_COLLABORATION_METADATA_BYTES) + .unwrap_or_else(|| "event".to_string()), + agent: bounded_string_field(&event, "agent", MAX_COLLABORATION_METADATA_BYTES), + member: bounded_string_field( + &event, + "member", + MAX_COLLABORATION_METADATA_BYTES, + ) + .or_else(|| { + bounded_string_field(&event, "from_agent", MAX_COLLABORATION_METADATA_BYTES) + }) + .or_else(|| { + bounded_string_field(&event, "to_agent", MAX_COLLABORATION_METADATA_BYTES) + }), + outcome: bounded_string_field( + &event, + "outcome", + MAX_COLLABORATION_METADATA_BYTES, + ), + message_id: bounded_string_field( + &event, + "message_id", + MAX_COLLABORATION_METADATA_BYTES, + ), + reply_preview: bounded_text( + string_field(&event, "reply_preview"), + MAX_COLLABORATION_PREVIEW_BYTES, + ), + content_preview: bounded_text( + string_field(&event, "content_preview"), + MAX_COLLABORATION_PREVIEW_BYTES, + ), + }) + .collect() + }) + .unwrap_or_default(); + + (role_plan, collaboration) +} + +fn canonicalize_assignment_event_roles( + events: &mut [TaskAssignmentEventDto], + collaboration: &[TeamCollaborationEventDto], +) { + let roles_by_child_task = collaboration + .iter() + .filter_map(|event| { + Some(( + event.message_id.as_deref()?.to_string(), + event.member.as_deref()?.to_string(), + )) + }) + .collect::>(); + + for event in events { + let Some(child_task_id) = event.child_task_id.as_deref() else { + continue; + }; + if let Some(role) = roles_by_child_task.get(child_task_id) { + event.child_role = Some(role.clone()); + } + } +} + +fn select_task_checkpoint( + progress: Option, + artifacts: &[MissionArtifactDto], + successful_result: bool, +) -> Option { + let artifact_checkpoint = artifacts + .iter() + .find(|artifact| artifact.name.ends_with("task-checkpoint.json")) + .and_then(|artifact| { + artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + }) + .and_then(|content| serde_json::from_str(content).ok()) + .and_then(valid_task_checkpoint); + let checkpoint = artifact_checkpoint.or_else(|| progress.and_then(valid_task_checkpoint)); + + checkpoint.filter(|checkpoint| { + !successful_result + || !matches!( + checkpoint.get("status").and_then(serde_json::Value::as_str), + Some("pending" | "in_progress") + ) + }) +} + +fn merge_trace_total_tokens(result: &mut Option, trace_total_tokens: i64) { + if trace_total_tokens <= 0 { + return; + } + if let Some(result) = result { + result.total_tokens = Some( + result + .total_tokens + .unwrap_or_default() + .max(trace_total_tokens), + ); + } +} + +fn subagent_trace_from_artifacts(artifacts: &[MissionArtifactDto]) -> Vec { + let mut events = Vec::new(); + for artifact in artifacts { + if !artifact.name.ends_with("subagent-telemetry.jsonl") { + continue; + } + let Some(content) = artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + else { + continue; + }; + for line in content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let Ok(record) = serde_json::from_str::(line) else { + continue; + }; + if record.get("event").and_then(serde_json::Value::as_str) != Some("subagent_trace") { + continue; + } + let Some(mut trace) = record.get("trace").cloned() else { + continue; + }; + if let Some(object) = trace.as_object_mut() { + let member = record + .get("member") + .and_then(serde_json::Value::as_str) + .unwrap_or("subagent"); + object.insert("agent".into(), serde_json::json!(member)); + if let Some(mesh_name) = record.get("mesh_name").and_then(serde_json::Value::as_str) + { + object.insert("agentInstance".into(), serde_json::json!(mesh_name)); + } + object.insert("agentRole".into(), serde_json::json!("subagent")); + if object.get("ts").is_none() + && let Some(at) = record.get("at").cloned() + { + object.insert("ts".into(), at); + } + } + events.push(trace); + } + } + events +} + +fn valid_task_checkpoint(value: serde_json::Value) -> Option { + let schema = value.get("schema")?.as_str()?; + let milestone = value.get("milestone_id")?.as_str()?.trim(); + let status = value.get("status")?.as_str()?; + let summary = value.get("summary")?.as_str()?.trim(); + let string_array = |key: &str| { + value.get(key).is_none_or(|field| { + field + .as_array() + .is_some_and(|items| items.iter().all(serde_json::Value::is_string)) + }) + }; + (schema == "kars.checkpoint/v1" + && !milestone.is_empty() + && !summary.is_empty() + && matches!(status, "pending" | "in_progress" | "completed" | "blocked") + && string_array("acceptance_criteria") + && string_array("artifacts") + && string_array("next_steps")) + .then_some(value) +} + +/// The composed run, in plain terms, for the mission-review surface. +#[derive(Debug, Serialize)] +pub struct CompositionDto { + pub runtime: Option, + pub model: Option, + pub instructions: Option, + pub tool_policy: Option, + pub mcp_servers: Vec, + pub egress: Vec, + pub isolation: Option, + pub memory: Option, +} + +/// Create-task request body from the UI. +#[derive(Debug, Deserialize)] +pub struct CreateTaskRequest { + pub name: String, + pub objective: String, + pub display_name: Option, + pub envelope: EnvelopeDto, + /// Optional parent task name — when set, this creates a delegated child + /// whose envelope the controller verifies against the parent's. + #[serde(default)] + pub parent: Option, + /// The editable run blueprint composed on the launch package + /// (runtime/model/instructions/tools/MCP/egress/isolation/memory). + #[serde(default)] + pub blueprint: Option, + #[serde(default)] + pub delegation: Option, + /// When true, the task is created already launched — the controller + /// materializes the sandbox immediately. The package's "launch" action. + #[serde(default)] + pub launch: bool, + /// Repos selected from the authenticated principal's GitHub connection. The + /// server validates the full set and derives the typed connection reference. + #[serde(default)] + pub git_write_repos: Option>, + /// The identity creating this mission (the Bridge principal), stamped as + /// `kars.azure.com/created-by` for per-user budget attribution. The web sets + /// it from the current session; absent => "unattributed". + #[serde(default)] + pub created_by: Option, + /// Per-mission retention override, in seconds — auto-delete this mission's + /// record this long after its deliverable lands (mirrors Kubernetes' + /// `Job.ttlSecondsAfterFinished`). `0` disables retention for this mission + /// specifically even if a cluster-wide default is set. Absent inherits the + /// cluster-wide default (which itself defaults to "never"). + #[serde(default)] + pub retention_ttl_seconds: Option, +} + +fn phase_of(task: &KarsTask) -> String { + task.status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_else(|| "Pending".to_string()) +} + +fn to_summary(task: &KarsTask) -> TaskSummaryDto { + TaskSummaryDto { + name: task.name_any(), + namespace: task.namespace().unwrap_or_default(), + objective: clean_objective(&task.spec.objective), + display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), + created_at: task + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + tier: task.spec.envelope.tier, + phase: phase_of(task), + envelope_digest: task.status.as_ref().and_then(|s| s.envelope_digest.clone()), + team: task + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/team").cloned()), + delivered: false, + failed: false, + launched: task + .spec + .execution + .as_ref() + .map(|e| e.launch) + .unwrap_or(false), + execution_phase: task.status.as_ref().and_then(|s| s.execution_phase.clone()), + } +} + +fn is_task_owner(task: &KarsTask, principal: &Principal) -> bool { + task_is_owned_by(task, principal) +} + +/// Extract the human deliverable from the agent's run output. The native +/// OpenClaw agent returns a structured `--json` envelope +/// (`{ runId, status, summary, result: { payloads: [ { text } ] } }`); showing +/// that raw — escaped quotes, literal `\n`, JSON braces — is the single most +/// embarrassing thing in the UI. Pull out the actual prose (joining payload +/// texts), tolerating a few shapes; pass plain-text output through unchanged. +fn repair_replacement_question_marks(text: &str) -> String { + let characters = text.chars().collect::>(); + let mut repaired = String::with_capacity(text.len()); + for (index, character) in characters.iter().copied().enumerate() { + if character != '?' { + repaired.push(character); + continue; + } + let previous = index + .checked_sub(1) + .and_then(|at| characters.get(at)) + .copied(); + let next = characters.get(index + 1).copied(); + if previous.is_some_and(char::is_alphanumeric) && next.is_some_and(char::is_alphanumeric) { + repaired.push('-'); + } else if previous.is_some_and(|value| value.is_ascii_digit()) + && next.is_some_and(char::is_whitespace) + { + repaired.push('.'); + } else { + repaired.push('?'); + } + } + repaired +} + +fn strip_sandbox_banner(text: &str) -> String { + let lines = text.lines().collect::>(); + let first_content = lines.iter().position(|line| !line.trim().is_empty()); + let Some(start) = first_content else { + return String::new(); + }; + let prefix_end = (start + 16).min(lines.len()); + let prefix = &lines[start..prefix_end]; + let lower_prefix = prefix.join("\n").to_ascii_lowercase(); + if !lower_prefix.contains("kars sandbox") + || !lower_prefix.contains("sandbox id:") + || !lower_prefix.contains("security:") + || !lower_prefix.contains("capabilities:") + { + return repair_replacement_question_marks(text.trim()); + } + let Some(capabilities_offset) = prefix + .iter() + .position(|line| line.to_ascii_lowercase().contains("capabilities:")) + else { + return repair_replacement_question_marks(text.trim()); + }; + repair_replacement_question_marks(lines[start + capabilities_offset + 1..].join("\n").trim()) +} + +pub(crate) fn deliverable_text(raw: &str) -> String { + let trimmed = raw.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + // Native agent envelope: result.payloads[].text + if let Some(payloads) = v + .get("result") + .and_then(|r| r.get("payloads")) + .and_then(|p| p.as_array()) + { + let joined = payloads + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n\n"); + if !joined.trim().is_empty() { + return strip_sandbox_banner(&joined); + } + } + // Other harness shapes. + for path in [["reply", "text"], ["result", "text"]] { + if let Some(t) = v + .get(path[0]) + .and_then(|x| x.get(path[1])) + .and_then(|t| t.as_str()) + && !t.trim().is_empty() + { + return strip_sandbox_banner(t); + } + } + for key in ["text", "output", "summary"] { + if let Some(t) = v.get(key).and_then(|t| t.as_str()) + && !t.trim().is_empty() + { + return strip_sandbox_banner(t); + } + } + } + // Tolerant fallback: a *truncated* native envelope (the commons caps stored + // content, which can cut the JSON mid-string so `serde` can't parse it) still + // begins like `{ "runId": ..., "result": { "payloads": [ { "text": "…` — pull + // the first `"text"` string value out by hand and JSON-unescape it so old, + // truncated entries render as prose instead of raw JSON. + if trimmed.starts_with('{') + && trimmed.contains("\"text\"") + && let Some(extracted) = extract_first_json_string(trimmed, "text") + && !extracted.trim().is_empty() + { + return strip_sandbox_banner(&extracted); + } + strip_sandbox_banner(raw) +} + +/// The sentinel a standing-team run emits when nothing changed since last time. +/// A deliverable that is ONLY this is a no-op, not a real deliverable. +pub(crate) const NO_CHANGE_SENTINEL: &str = "[[NO_MATERIAL_CHANGE]]"; + +/// A pull request the mission opened — a first-class deliverable type. +#[derive(Debug, Clone, serde::Serialize, PartialEq)] +pub struct PullRequestRef { + /// `owner/repo`. + pub repo: String, + pub number: i64, + /// The canonical GitHub URL. + pub url: String, +} + +/// Extract the pull requests a mission opened from its deliverable text. The +/// router authors PRs via the keyless git proxy and the agent reports the URL; +/// we surface each as a tracked deliverable. Deduplicated, in first-seen order. +pub(crate) fn extract_pull_requests(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + // Scan for `github.com///pull/` occurrences without a + // regex dep: split on the marker and parse each following segment. + for seg in text.split("github.com/").skip(1) { + // owner/repo/pull/NUMBER + let mut it = seg.splitn(4, '/'); + let (Some(owner), Some(repo), Some(kind)) = (it.next(), it.next(), it.next()) else { + continue; + }; + if kind != "pull" && kind != "pulls" { + continue; + } + let Some(rest) = it.next() else { continue }; + let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if owner.is_empty() || repo.is_empty() || num.is_empty() { + continue; + } + let Ok(number) = num.parse::() else { + continue; + }; + let repo_full = format!("{owner}/{repo}"); + let url = format!("https://github.com/{repo_full}/pull/{number}"); + let pr = PullRequestRef { + repo: repo_full, + number, + url, + }; + if !out.contains(&pr) { + out.push(pr); + } + } + out +} + +fn deliverable_pull_requests( + data: &std::collections::BTreeMap, +) -> Vec { + let status = data.get("status").map(String::as_str); + let output = data.get("output").map(String::as_str).unwrap_or(""); + if !is_real_deliverable(status, output) { + return Vec::new(); + } + extract_pull_requests(&deliverable_text(output)) +} + +pub(crate) fn is_failure_shaped_output(output: &str) -> bool { + let text = deliverable_text(output); + let lower = text + .trim_start_matches(|character: char| { + character.is_whitespace() + || matches!(character, '*' | '_' | '#' | '>' | '`' | '-' | '?' | '🔒') + }) + .to_ascii_lowercase(); + let head: String = lower.chars().take(800).collect(); + head.starts_with("unexpected tokens remaining in message header") + || head.starts_with("assignment progress lease expired") + || head.starts_with("native agent failed") + || head.starts_with("error processing task") + || (head.starts_with("kars sandbox - secure ai runtime") && head.contains("how can i help")) + || head.starts_with("now await pr-watcher") + || head.starts_with("awaiting handback from") +} + +pub(crate) fn is_no_change_output(output: &str) -> bool { + let text = deliverable_text(output); + let head = text.trim_start(); + if head.starts_with(NO_CHANGE_SENTINEL) { + return true; + } + let Some(sentinel_at) = head.find(NO_CHANGE_SENTINEL) else { + return false; + }; + let prefix = &head[..sentinel_at]; + sentinel_at <= 1_200 + && prefix.to_ascii_lowercase().contains("kars sandbox") + && prefix.contains("Sandbox ID:") + && prefix.contains("Security:") + && prefix.contains("Capabilities:") +} + +/// True when a run output is NOT a real, showable deliverable — either the run +/// errored, produced nothing, or reported "no material change". Used to keep +/// hung / zero-output / no-op runs out of the deliverable index and the "latest +/// deliverable" hero (audit f9/f13: a receipt/deliverable requires real work). +pub(crate) fn is_real_deliverable(status: Option<&str>, deliverable: &str) -> bool { + if status == Some("error") { + return false; + } + let t = deliverable.trim(); + if t.is_empty() { + return false; + } + if is_no_change_output(deliverable) { + return false; + } + if is_failure_shaped_output(deliverable) { + return false; + } + // A capability/limit STOP (e.g. the daily token budget) came back transport-ok + // but is not the mission's answer — never treat it as a deliverable. + if classify_blocked(status, deliverable).is_some() { + return false; + } + true +} + +/// A clean 2–3 line preview of a deliverable for cards and list rows — never the +/// raw transcript. Strips the no-change sentinel, markdown table/heading noise, +/// and collapses whitespace, then caps the length (audit f3). +pub(crate) fn deliverable_excerpt(raw: &str) -> String { + let text = deliverable_text(raw); + let mut out: Vec = Vec::new(); + for line in text.lines() { + let l = line.trim(); + if l.is_empty() { + continue; + } + // Strip leading markdown wrapping (emphasis / heading / block-quote / + // inline-code / bullet markers) FIRST, so a wrapped control sentinel + // like `**[[NO_MATERIAL_CHANGE]]**` is unwrapped before we test for it. + // Previously the sentinel check ran on the raw line and a bold-wrapped + // sentinel slipped through into the excerpt. + let cleaned = l + .trim_start_matches(['*', '_', '#', '>', '`', '-', ' ']) + .trim(); + if cleaned.is_empty() { + continue; + } + // Drop the no-change sentinel (now unwrapped) and markdown table + // rows/rules. + let cleaned = if let Some(reason) = cleaned.strip_prefix(NO_CHANGE_SENTINEL) { + let reason = reason + .trim_start_matches(|character: char| { + character.is_whitespace() || matches!(character, ':' | '-' | '—') + }) + .trim(); + if reason.is_empty() { + continue; + } + reason + } else { + cleaned + }; + if cleaned.starts_with('|') { + continue; + } + if cleaned.starts_with("===") { + continue; + } + let lower = cleaned.to_ascii_lowercase(); + if [ + "kars sandbox - secure ai runtime", + "foundry project:", + "model:", + "sandbox id:", + "security summary", + "security:", + "capabilities:", + "role plan", + "role roster", + "roles spawned:", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + continue; + } + out.push(cleaned.to_string()); + if out.len() >= 3 { + break; + } + } + let joined = out.join(" "); + let joined = joined.split_whitespace().collect::>().join(" "); + if joined.chars().count() > 240 { + let mut s: String = joined.chars().take(240).collect(); + s.push('…'); + s + } else { + joined + } +} + +/// end of input. Returns `None` if the key/opening quote isn't present. +fn extract_first_json_string(s: &str, key: &str) -> Option { + let needle = format!("\"{key}\""); + let after_key = &s[s.find(&needle)? + needle.len()..]; + let colon = after_key.find(':')?; + let rest = &after_key[colon + 1..]; + let open = rest.find('"')?; + let body = &rest[open + 1..]; + let mut out = String::with_capacity(body.len()); + let mut chars = body.chars(); + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('/') => out.push('/'), + Some('u') => { + let hex: String = chars.by_ref().take(4).collect(); + if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + out.push(ch); + } + } + Some(other) => out.push(other), + None => break, + }, + _ => out.push(c), + } + } + Some(out) +} + +/// Human-readable objective for display. A standing-run objective is wrapped +/// with internal scaffolding — `Standing-operation run for team 'X'. Charter: +/// . Your capabilities: … Operating contract: … --- BEGIN UNTRUSTED +/// REFERENCE DATA …` — none of which a person should see. Extract the charter / +/// intent and drop the capability manifest + injected prior-knowledge preamble. +/// Ordinary mission objectives (no wrapper) pass through unchanged. +pub(crate) fn clean_objective(raw: &str) -> String { + // Everything from the first scaffolding marker onward is internal. + const MARKERS: [&str; 5] = [ + "Your capabilities:", + "Operating contract:", + "--- BEGIN UNTRUSTED REFERENCE DATA", + "\n\nMode note", + "BEGIN UNTRUSTED REFERENCE DATA", + ]; + let mut end = raw.len(); + for m in MARKERS { + if let Some(i) = raw.find(m) { + end = end.min(i); + } + } + let head = raw[..end].trim(); + // Unwrap the standing-run charter prefix when present. + if let Some(i) = head.find("Charter:") { + let charter = head[i + "Charter:".len()..].trim(); + let charter = charter.trim_end_matches('.').trim(); + if !charter.is_empty() { + return charter.to_string(); + } + } + // Defense in depth: strip any leaked 2026 loop scaffold so LOOP:/GOAL:/ + // CYCLE/[[…]] control-blobs never reach a title, card, or displayed + // objective. A scaffold's GOAL line IS the human intent — extract it. + strip_loop_scaffold(head) +} + +/// Extracts the human intent from a leaked loop scaffold. Loop scaffolds are +/// shaped as `LOOP: \nGOAL: \nCYCLE: …\nSUCCESS: …\nSTOP: …\n +/// SUB-AGENT INHERITANCE: …`. If a `GOAL:` line is present we return it (the +/// real intent); otherwise we drop the scaffold control lines and return what +/// remains. Plain objectives (no scaffold) pass through unchanged. +/// True when a string carries a leaked 2026 loop scaffold — used to keep the +/// control-blob out of titles, cards, URLs, and displayed objectives. +pub(crate) fn looks_scaffolded(text: &str) -> bool { + text.starts_with("LOOP:") + || text.contains("\nGOAL:") + || text.starts_with("GOAL:") + || text.contains("SUB-AGENT INHERITANCE") + || text.contains("[[") +} + +/// Conversational lead-ins that mark a string as a prompt rather than a title +/// ("Can you please …", "I need you to …"). Stripped when deriving a title. +const TITLE_LEAD_INS: [&str; 16] = [ + "can you please ", + "could you please ", + "would you please ", + "can you ", + "could you ", + "would you ", + "please ", + "i need you to ", + "i want you to ", + "i'd like you to ", + "i would like you to ", + "i need ", + "i want ", + "help me ", + "let's ", + "lets ", +]; + +/// Strip any leading conversational lead-in(s), case-insensitively. +fn strip_title_lead_in(s: &str) -> &str { + let mut cur = s.trim_start(); + loop { + let lower = cur.to_ascii_lowercase(); + let mut matched = false; + for lead in TITLE_LEAD_INS { + if lower.starts_with(lead) { + cur = cur[lead.len()..].trim_start(); + matched = true; + break; + } + } + if !matched { + return cur; + } + } +} + +/// Shorten a bare URL token to a compact, human label — a GitHub-style +/// `owner/repo`, else the last path segment, else the host — so a title reads +/// "analyse Azure/kars dependabot PRs", not a 60-char URL. +fn shorten_url_token(tok: &str) -> String { + let lower = tok.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) { + return tok.to_string(); + } + let rest = tok + .trim_end_matches(['.', ',', ')', ']', '?', '!']) + .split_once("://") + .map(|x| x.1) + .unwrap_or(tok); + let mut parts = rest.split('/'); + let host = parts.next().unwrap_or(""); + let segs: Vec<&str> = parts.filter(|s| !s.is_empty()).collect(); + if host.contains("github.") && segs.len() >= 2 { + format!("{}/{}", segs[0], segs[1]) + } else if let Some(last) = segs.last() { + (*last).to_string() + } else { + host.to_string() + } +} + +/// True when `display` is a genuine human title, not a truncated prompt: it has +/// no conversational lead-in, carries no URL, isn't just a prefix of the +/// objective, and isn't paragraph-length. +fn is_genuine_title(display: &str, clean_objective: &str) -> bool { + let lower = display.to_ascii_lowercase(); + if TITLE_LEAD_INS.iter().any(|l| lower.starts_with(l)) { + return false; + } + if lower.contains("http://") || lower.contains("https://") { + return false; + } + let d_trim = lower.trim_end_matches('…').trim(); + let obj_lower = clean_objective.to_ascii_lowercase(); + if d_trim.len() >= 24 && obj_lower.starts_with(d_trim) { + return false; + } + display.chars().count() <= 72 +} + +/// Derive a compact, title-like phrase from a verbose objective: strip the +/// conversational lead-in, shorten URLs, take the first sentence/clause, drop a +/// trailing " - …" condition tail, cap at a word boundary, and capitalize. +fn concise_title(text: &str) -> String { + let no_lead = strip_title_lead_in(text.trim()); + let shortened: String = no_lead + .split_whitespace() + .map(shorten_url_token) + .collect::>() + .join(" "); + let first = shortened + .split(['.', '\n', '?', '!']) + .find(|s| !s.trim().is_empty()) + .unwrap_or(&shortened) + .trim(); + // Prompts often append conditions after a dash ("… PRs - categorize the …"). + let first = first.split(" - ").next().unwrap_or(first).trim(); + let capped = if first.chars().count() > 56 { + // Cut at the last word boundary within the cap. + let head: String = first.chars().take(56).collect(); + let cut = head.rfind(' ').unwrap_or(head.len()); + format!("{}…", head[..cut].trim_end()) + } else { + first.to_string() + }; + let mut chars = capped.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// A clean, human display title for a task. Uses an explicit display name only +/// when it is a GENUINE title (not a conversational prompt truncated into the +/// display slot); otherwise derives a concise title from the cleaned objective. +/// Guarantees LOOP:/GOAL:/[[…]] and raw pasted prompts never reach a card, list +/// row, breadcrumb, or tab — it runs at the read/DTO boundary for every task. +pub(crate) fn clean_display_name(display: &Option, objective: &str) -> Option { + let clean_obj = clean_objective(objective); + if let Some(d) = display.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) + && !looks_scaffolded(d) + && is_genuine_title(d, &clean_obj) + { + return Some(d.to_string()); + } + // No genuine title — derive a concise one from the objective (or, when the + // objective is empty, from the de-scaffolded display string). + let source = if clean_obj.is_empty() { + strip_loop_scaffold(display.as_deref().unwrap_or("")) + } else { + clean_obj.clone() + }; + let title = concise_title(&source); + if title.is_empty() { None } else { Some(title) } +} + +fn strip_loop_scaffold(text: &str) -> String { + if !looks_scaffolded(text) { + return text.to_string(); + } + // Prefer the GOAL line — that is the human's restated intent. + for line in text.lines() { + let l = line.trim(); + if let Some(rest) = l.strip_prefix("GOAL:") { + let goal = rest + .trim() + .trim_start_matches("[[") + .trim_end_matches("]]") + .trim(); + if !goal.is_empty() { + return goal.to_string(); + } + } + } + // No GOAL line — drop the scaffold control lines and return the remainder. + const CONTROL_PREFIXES: [&str; 6] = [ + "LOOP:", + "CYCLE:", + "SUCCESS:", + "STOP:", + "SUB-AGENT INHERITANCE", + "[", + ]; + let kept: Vec<&str> = text + .lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() && !CONTROL_PREFIXES.iter().any(|p| t.starts_with(p)) + }) + .collect(); + kept.join(" ").trim().to_string() +} + +fn ready_message(task: &KarsTask) -> Option { + task.status + .as_ref()? + .conditions + .iter() + .find(|c| c.type_ == "Ready") + .and_then(|c| c.message.clone()) +} + +#[allow(clippy::too_many_arguments)] +fn to_detail( + task: &KarsTask, + children: Vec, + sub_agents: Vec, + effective: Option, + result: Option, + artifacts: Vec, + pull_requests: Vec, + activity: Vec, + telemetry: Option, + checkpoint: Option, + agent_identity: Option, + egress_mode: Option, +) -> TaskDetailDto { + let e = &task.spec.envelope; + let (role_plan, collaboration_events) = structured_team_evidence(&artifacts); + let mut assignment_events = task + .status + .as_ref() + .map(|s| { + s.assignment_events + .iter() + .map(TaskAssignmentEventDto::from) + .collect::>() + }) + .unwrap_or_default(); + canonicalize_assignment_event_roles(&mut assignment_events, &collaboration_events); + TaskDetailDto { + name: task.name_any(), + namespace: task.namespace().unwrap_or_default(), + objective: clean_objective(&task.spec.objective), + display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), + created_at: task + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + envelope: EnvelopeDto { + tier: e.tier, + authority_ceiling: e.authority_ceiling, + delegation_depth: e.delegation_depth, + budget: e.budget.as_ref().map(|b| BudgetDto { + scope: b.scope, + tokens: b.tokens, + usd_micros: b.usd_micros, + }), + tool_policy: e.tool_policy_ref.as_ref().map(|r| r.name.clone()), + egress_allowlist: e.egress_allowlist_ref.as_ref().map(|r| r.name.clone()), + }, + phase: phase_of(task), + envelope_digest: task.status.as_ref().and_then(|s| s.envelope_digest.clone()), + observed_generation: task.status.as_ref().and_then(|s| s.observed_generation), + lineage: task + .status + .as_ref() + .map(|s| s.lineage.clone()) + .unwrap_or_default(), + parent: task.spec.parent_ref.as_ref().map(|r| r.name.clone()), + team: task + .labels() + .get("kars.azure.com/team") + .cloned() + .or_else(|| task.annotations().get("kars.azure.com/team").cloned()), + status_message: ready_message(task), + children, + launched: task + .spec + .execution + .as_ref() + .map(|e| e.launch) + .unwrap_or(false), + execution_phase: task.status.as_ref().and_then(|s| s.execution_phase.clone()), + sandbox: task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()), + execution_detail: task + .status + .as_ref() + .and_then(|s| s.execution_detail.clone()), + assignment: task + .status + .as_ref() + .and_then(|s| s.assignment.as_ref()) + .map(TaskAssignmentStatusDto::from), + assignment_events, + assignment_sequence: task.status.as_ref().and_then(|s| s.assignment_sequence), + egress_mode, + composition: effective.or_else(|| { + task.spec.blueprint.as_ref().map(|b| CompositionDto { + runtime: b.runtime.clone(), + model: b.model.as_ref().map(|m| m.deployment.clone()), + instructions: b.instructions.clone(), + tool_policy: b.tool_policy.clone(), + mcp_servers: b.mcp_servers.clone(), + egress: b + .egress + .iter() + .map(|e| match e.port { + Some(p) => format!("{}:{}", e.host, p), + None => e.host.clone(), + }) + .collect(), + isolation: b.isolation.clone(), + memory: b.memory.clone(), + }) + }), + sub_agents, + result, + artifacts, + role_plan, + collaboration_events, + pull_requests, + activity, + telemetry, + checkpoint, + agent_identity, + harness_corrected: task + .annotations() + .get("kars.azure.com/harness-corrected") + .cloned(), + halted: task.annotations().get("kars.azure.com/halted").cloned(), + run_requested: task + .annotations() + .get("kars.azure.com/run-requested") + .is_some_and(|v| !v.trim().is_empty()), + current_run_nonce: task + .annotations() + .get("kars.azure.com/run-requested") + .filter(|value| !value.trim().is_empty()) + .cloned(), + } +} + +/// Resolve the cluster handle or surface a clear "cluster not wired" error. +pub(crate) fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +/// Sanitize a filename to the ConfigMap key form the controller uses (alnum, +/// '-', '_', '.') so the manifest name can look up its stored content. +fn artifact_key(name: &str) -> String { + let k: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + if k.is_empty() { "artifact".into() } else { k } +} + +/// Best-effort content type from a filename extension, so a downloaded artifact +/// opens sensibly in the browser instead of forcing a save dialog for text. +fn artifact_content_type(name: &str) -> &'static str { + match name + .rsplit('.') + .next() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("md" | "markdown" | "txt" | "log") => "text/markdown; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("csv") => "text/csv; charset=utf-8", + Some("html" | "htm") => "text/html; charset=utf-8", + Some("yaml" | "yml") => "application/yaml; charset=utf-8", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("svg") => "image/svg+xml", + Some("pdf") => "application/pdf", + _ => "application/octet-stream", + } +} + +/// `GET /api/tasks/:ns/:name/artifact/:file` — Bridge-native artifact fetch. +/// Streams one artifact file's bytes (text from `data`, binary from +/// `binaryData`) so operators download deliverables in-product, never via +/// `kubectl`. Inline for previewable types; attachment otherwise. +pub async fn download_artifact( + State(state): State, + Extension(principal): Extension, + Path((ns, name, file)): Path<(String, String, String)>, +) -> AppResult { + use axum::http::header; + let cluster = require_cluster(&state)?; + require_owned_task_or_output(cluster, &ns, &name, &principal).await?; + let key = artifact_key(&file); + let (bytes, is_binary) = cluster + .read_mission_artifact_bytes(&name, &key) + .await + .ok_or(AppError::NotFound)?; + let ctype = artifact_content_type(&file); + // Inline-render text/known media; force a download for opaque binaries. + let disposition = if is_binary && ctype == "application/octet-stream" { + format!("attachment; filename=\"{key}\"") + } else { + format!("inline; filename=\"{key}\"") + }; + axum::response::Response::builder() + .header(header::CONTENT_TYPE, ctype) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CACHE_CONTROL, "private, max-age=60") + .body(axum::body::Body::from(bytes)) + .map_err(|e| AppError::Upstream(e.to_string())) +} + +/// Merge a mission's artifact manifest (names + sizes, from the output +/// ConfigMap) with the text contents stored in the companion artifacts +/// ConfigMap. Binary artifacts appear in the manifest but carry `content: +/// None`. Returns an empty set honestly when the mission produced no artifacts. +async fn build_artifact_set( + cluster: &crate::kars::cluster::Cluster, + name: &str, + output_data: Option<&std::collections::BTreeMap>, +) -> Vec { + let manifest_json = output_data.and_then(|d| d.get("artifacts").cloned()); + let contents = cluster + .read_mission_artifacts(name) + .await + .unwrap_or_default(); + + // Prefer the manifest (authoritative order + sizes + binary entries); fall + // back to whatever text artifacts are stored if no manifest is present. + if let Some(mj) = manifest_json + && let Ok(entries) = serde_json::from_str::>(&mj) + { + let mut seen = std::collections::HashSet::new(); + let mut preview_budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + return entries + .into_iter() + .filter_map(|e| { + let fname = e.get("name")?.as_str()?.to_string(); + // The manifest can list the same file twice (e.g. an artifact + // recorded by both the run harness and the harvest step). Keep + // the first — duplicates crash the UI's name-keyed lists. + if !seen.insert(fname.clone()) { + return None; + } + let size_bytes = e.get("size_bytes").and_then(|v| v.as_i64()); + let (content, content_bytes, content_truncated, full_content) = artifact_preview( + contents.get(&artifact_key(&fname)).cloned(), + &mut preview_budget, + ); + Some(MissionArtifactDto { + name: fname, + size_bytes, + content, + content_bytes, + content_truncated, + source_agent: e + .get("source_agent") + .and_then(|v| v.as_str()) + .map(str::to_string), + source_path: e + .get("source_path") + .and_then(|v| v.as_str()) + .map(str::to_string), + digest: e.get("digest").and_then(|v| v.as_str()).map(str::to_string), + full_content, + }) + }) + .collect(); + } + + let mut preview_budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + contents + .into_iter() + .map(|(k, v)| { + let size_bytes = v.len() as i64; + let (content, content_bytes, content_truncated, full_content) = + artifact_preview(Some(v), &mut preview_budget); + MissionArtifactDto { + size_bytes: Some(size_bytes), + name: k, + content, + content_bytes, + content_truncated, + source_agent: None, + source_path: None, + digest: None, + full_content, + } + }) + .collect() +} + +/// `GET /api/namespaces/:ns/tasks` — list tasks in a namespace. +pub async fn list_tasks( + State(state): State, + principal: Option>, + Path(ns): Path, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let principal = principal + .map(|Extension(principal)| principal) + .ok_or_else(|| AppError::Forbidden("signed-in principal required".into()))?; + let api: Api = cluster.tasks(&ns); + let list = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + // Cross-reference delivered + failed missions in ONE pass over the persisted + // outputs, so the list can show "Delivered" / "Run failed" instead of + // misreading an idle delivered run — or a hung errored run — as "drafting". + let outputs = cluster.list_mission_outputs().await; + let mut terminal = std::collections::HashMap::::new(); + for record in &outputs { + match record.data.get("status").map(String::as_str) { + Some("ok") + if record + .data + .get("output") + .is_some_and(|output| !output.trim().is_empty()) => + { + terminal + .entry(record.task_name.clone()) + .or_insert("delivered"); + } + Some("error") => { + terminal.entry(record.task_name.clone()).or_insert("failed"); + } + _ => {} + } + } + let delivered: std::collections::HashSet = terminal + .iter() + .filter(|(_, status)| **status == "delivered") + .map(|(task, _)| task.clone()) + .collect(); + let failed: std::collections::HashSet = terminal + .iter() + .filter(|(_, status)| **status == "failed") + .map(|(task, _)| task.clone()) + .collect(); + let mut summaries: Vec = list + .items + .iter() + .filter(|task| is_task_owner(task, &principal)) + .map(|t| { + let mut s = to_summary(t); + s.delivered = delivered.contains(&s.name); + s.failed = failed.contains(&s.name); + s + }) + .collect(); + + // Persist history: a mission whose KarsTask CR has been garbage-collected + // (retired-run GC) still has its delivered/errored output ConfigMap. Without + // this, completed missions silently vanish from the list mid-session and + // their direct URLs 404 ("data loss", audit BUG-8). Re-add any output-only + // mission that isn't already represented by a live CR. Team-run machinery + // (`-run-`) is excluded — those belong to the Team view, which + // is exactly what the live-CR path already hides. + let live_names: std::collections::HashSet = + summaries.iter().map(|s| s.name.clone()).collect(); + for record in &outputs { + let task = &record.task_name; + let d = &record.data; + if d.get("ownerSub").map(String::as_str) != Some(principal.sub.as_str()) { + continue; + } + if live_names.contains(task) || regex_lite_is_team_run(task) { + continue; + } + let is_ok = delivered.contains(task); + let is_err = failed.contains(task); + // Only surface a genuinely terminal output (delivered or errored); skip + // stray/empty outputs so we don't invent phantom missions. + if !is_ok && !is_err { + continue; + } + summaries.push(TaskSummaryDto { + name: task.clone(), + namespace: ns.clone(), + objective: d.get("objective").cloned().unwrap_or_default(), + display_name: d + .get("displayName") + .cloned() + .filter(|s| !s.trim().is_empty()), + created_at: d.get("startedAt").cloned(), + tier: d.get("tier").and_then(|v| v.parse().ok()).unwrap_or(0), + phase: if is_err { + "Failed".into() + } else { + "Delivered".into() + }, + envelope_digest: None, + team: d.get("team").cloned(), + delivered: is_ok, + failed: is_err, + launched: true, + execution_phase: Some("Idle".into()), + }); + } + Ok(Json(summaries)) +} + +/// True when `name` looks like a standing-team run task (`-run-`), +/// which the Missions surface intentionally hides (they belong to the Team +/// view). A tiny hand-rolled check to avoid a regex dependency. +fn regex_lite_is_team_run(name: &str) -> bool { + if let Some(idx) = name.rfind("-run-") { + let suffix = &name[idx + "-run-".len()..]; + return !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()); + } + false +} + +/// `GET /api/namespaces/:ns/tasks/:name` — fetch one task, with its delegated +/// children resolved (tasks whose `parentRef` points at this task). +pub async fn get_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let api: Api = cluster.tasks(&ns); + let task = match api.get_opt(&name).await.map_err(map_kube_err)? { + Some(t) => t, + // The KarsTask CR was garbage-collected (retired-run GC) but the + // mission's terminal output persists. Synthesize a read-only detail from + // it so a delivered/failed mission's page — and the list link that now + // shows it — doesn't 404 mid-session. Genuine unknowns still 404. + None => return synth_detail_from_output(cluster, &ns, &name, &principal).await, + }; + if !is_task_owner(&task, &principal) { + return Err(AppError::NotFound); + } + // Resolve direct children by scanning the namespace for parentRef == name. + // Exclude RUN INSTANCES (cadence/taskforce runs named `*-run-` or + // annotated team-role=taskforce): those are run history, not org-chart roles. + // Without this the org chart floods with every historical run of a standing + // team as a duplicate node. Same predicate list_agents uses to identify runs. + let all = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + let children: Vec = all + .items + .iter() + .filter(|t| t.spec.parent_ref.as_ref().is_some_and(|r| r.name == name)) + .filter(|t| { + let is_run = t.name_any().contains("-run-") + || t.annotations() + .get("kars.azure.com/team-role") + .map(String::as_str) + == Some("taskforce"); + !is_run + }) + .map(to_summary) + .collect(); + + // Runtime agents: the sub-agents this mission's agent spawned at run time. + // The inference router labels each spawned KarsSandbox + // `kars.azure.com/parent=`; surface them so the org chart reflects + // the *running* agent/sub-agent tree, not only the governed role tree. + let sandbox_name = task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()); + let sub_agents = match &sandbox_name { + Some(sb) => cluster + .sub_agent_sandboxes(&ns, sb) + .await + .iter() + .map(to_sub_agent) + .collect(), + None => Vec::new(), + }; + + // Effective composition: once launched, show what the sandbox is ACTUALLY + // running (read from the materialized InferencePolicy + KarsSandbox, + // including any controller-defaulted model), not just the submitted + // blueprint. Pre-launch, fall back to the blueprint (the planned config). + let effective = match &sandbox_name { + Some(sb) => { + let ip = cluster + .get_kind(&ns, "InferencePolicy", &format!("{name}-inference")) + .await + .ok() + .flatten(); + let sandbox = cluster + .get_kind(&ns, "KarsSandbox", sb) + .await + .ok() + .flatten(); + composition_from_materialized(ip.as_ref(), sandbox.as_ref()) + } + None => None, + }; + + // Live egress enforcement mode (Learn/Strict) read from the materialized + // KarsSandbox — the real monitoring→enforced surface. + let egress_mode = match &sandbox_name { + Some(sb) => cluster.sandbox_egress_mode(sb).await, + None => None, + }; + + // The mission's captured run result (persisted deliverable + real tokens). + let output_data = cluster.read_mission_output(&name).await; + let mut result = output_data.as_ref().and_then(|d| { + let output = deliverable_text(d.get("output")?); + let blocked = classify_blocked(d.get("status").map(String::as_str), &output); + Some(MissionResultDto { + output, + status: d.get("status").cloned(), + model: d.get("model").cloned(), + total_tokens: d.get("totalTokens").and_then(|v| v.parse().ok()), + prompt_tokens: d.get("promptTokens").and_then(|v| v.parse().ok()), + completion_tokens: d.get("completionTokens").and_then(|v| v.parse().ok()), + finished_at: d.get("finishedAt").cloned(), + assignment_nonce: d.get("assignmentNonce").cloned(), + source: d.get("source").cloned(), + blocked, + artifact_persistence: d.get("artifactPersistence").cloned(), + artifact_count: d.get("artifactCount").and_then(|v| v.parse().ok()), + declared_artifact_count: d.get("declaredArtifactCount").and_then(|v| v.parse().ok()), + }) + }); + + // The mission's full artifact set: the manifest (name + size, incl. binary) + // comes from the output ConfigMap; text contents come from the companion + // artifacts ConfigMap. Merge them so the set is complete and honest. + let artifacts = build_artifact_set(cluster, &name, output_data.as_ref()).await; + let successful_result = result.as_ref().is_some_and(|result| { + result.status.as_deref() != Some("error") && result.blocked.is_none() + }); + let checkpoint = select_task_checkpoint( + cluster.read_mission_progress(&name).await, + &artifacts, + successful_result, + ); + + // The mission's live execution activity — the real per-round + per-tool + // trace the agent emitted, persisted by the controller as the clean audit + // record. Parsed from the trace ConfigMap; empty when no trace exists. + let mut activity: Vec = cluster + .read_mission_trace(&name) + .await + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default(); + activity.extend(subagent_trace_from_artifacts(&artifacts)); + activity.sort_by(|left, right| { + left.get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .cmp( + right + .get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + ) + }); + + // LIVE fallback. The persisted trace ConfigMap is written only once, at + // delivery — so a still-running mission would otherwise show an EMPTY + // activity trace (blank deploy timeline, agent graph, and map, and a + // "Waiting for the first model round" that lies while the agent is already + // on round 3). When no persisted trace exists yet and the mission is + // launched, pull the SAME live router telemetry the Activity SSE streams — + // the principal sandbox plus every sub-agent it spawned — so the WHOLE + // detail page is genuinely live on each poll, not just the SSE tab. + if activity.is_empty() + && let Some(principal) = &sandbox_name + { + let mut live: Vec = Vec::new(); + for mut ev in cluster.sandbox_live_trace(principal).await { + if let Some(obj) = ev.as_object_mut() { + obj.insert("agent".into(), serde_json::json!(name)); + obj.insert("agentInstance".into(), serde_json::json!(principal)); + obj.insert("agentRole".into(), serde_json::json!("principal")); + } + live.push(ev); + } + let mut descendants = cluster + .sub_agent_sandbox_names(&ns, principal) + .await + .into_iter(); + loop { + let sub_batch = descendants.by_ref().take(8).collect::>(); + if sub_batch.is_empty() { + break; + } + let mut polling = tokio::task::JoinSet::new(); + for sub in sub_batch { + let cluster = cluster.clone(); + polling.spawn(async move { + let events = cluster.sandbox_live_trace(&sub).await; + (sub, events) + }); + } + while let Some(result) = polling.join_next().await { + let Ok((sub, events)) = result else { + continue; + }; + for mut ev in events { + if let Some(obj) = ev.as_object_mut() { + obj.insert("agent".into(), serde_json::json!(sub.clone())); + obj.insert("agentInstance".into(), serde_json::json!(sub.clone())); + obj.insert("agentRole".into(), serde_json::json!("subagent")); + } + live.push(ev); + } + } + } + activity = live; + } + + // Loop-shape telemetry (rounds, tool calls). Token totals live on `result`. + // Derive rollups from the persisted per-round/per-tool trace when the run's + // output ConfigMap didn't include them — some harnesses persist the trace + // but not the totals, which left a DELIVERED mission's map reading + // "Not run yet" / "No activity". The trace is the honest source either way. + let trace_round_events = activity + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .count() as i64; + let trace_tool_events = activity + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("tool")) + .count() as i64; + let trace_total_tokens: i64 = activity + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .filter_map(|e| e.get("total_tokens").and_then(serde_json::Value::as_i64)) + .sum(); + + // Backfill the token total on the result from the trace when the output CM + // didn't carry it (so token burn shows on a delivered run with a trace). + merge_trace_total_tokens(&mut result, trace_total_tokens); + + let telemetry = { + let mut rounds = output_data + .as_ref() + .and_then(|d| d.get("rounds").and_then(|v| v.parse::().ok())); + let mut tool_calls = output_data + .as_ref() + .and_then(|d| d.get("toolCalls").and_then(|v| v.parse::().ok())); + if trace_round_events > 0 { + rounds = Some(rounds.unwrap_or_default().max(trace_round_events)); + } + if trace_tool_events > 0 { + tool_calls = Some(tool_calls.unwrap_or_default().max(trace_tool_events)); + } + if rounds.is_some() || tool_calls.is_some() { + Some(MissionTelemetryDto { rounds, tool_calls }) + } else { + None + } + }; + + // The running agent's real mesh identity, discovered from the AGT registry + // (harness-neutral). Only meaningful once a sandbox is running. + let agent_identity = match &sandbox_name { + Some(sb) => cluster.discover_agent_identity(sb).await, + None => None, + }; + + // Pull requests the mission opened, extracted from its raw output — a PR is a + // first-class delivery type, surfaced on the Artifacts tab (not just prose). + let pull_requests = output_data + .as_ref() + .map(deliverable_pull_requests) + .unwrap_or_default(); + + Ok(Json(to_detail( + &task, + children, + sub_agents, + effective, + result, + artifacts, + pull_requests, + activity, + telemetry, + checkpoint, + agent_identity, + egress_mode, + ))) +} + +/// `DELETE /api/namespaces/:ns/tasks/:name` — delete a mission and sweep its +/// persisted artifacts (deliverable, files, trace, review), so a deleted mission +/// leaves no orphaned ConfigMaps behind on the Artifacts page or as output-only +/// history. Mirrors the team-delete sweep. Idempotent-ish: 404 for unknowns. +pub async fn delete_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let has_cr = require_owned_task_or_output(cluster, &ns, &name, &principal) + .await? + .is_some(); + if has_cr { + cluster + .delete_task(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } else { + // CR already gone — just sweep the leftover ConfigMaps. + cluster.sweep_mission_artifacts(&name).await; + } + Ok(Json(serde_json::json!({ + "deleted": true, + "note": "Mission deleted. Its sandbox, deliverable, files, trace, and review record were removed." + }))) +} + +/// Build a read-only mission detail purely from persisted ConfigMaps when the +/// KarsTask CR is gone (retired-run GC). Returns `NotFound` only when there is +/// genuinely no persisted output for the name. The envelope/composition are +/// left empty (the CR that carried them is gone) but the deliverable, artifacts, +/// activity trace, and telemetry — the parts a reviewer actually needs after the +/// fact — are surfaced, along with a terminal phase. +async fn synth_detail_from_output( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult> { + let output_data = match cluster.read_mission_output(name).await { + Some(d) => d, + None => return Err(AppError::NotFound), + }; + if output_data.get("ownerSub").map(String::as_str) != Some(principal.sub.as_str()) { + return Err(AppError::NotFound); + } + let assignment_nonce = output_data.get("assignmentNonce").cloned(); + let historical_task = match output_data.get("taskName") { + Some(task_name) => cluster + .tasks(ns) + .get_opt(task_name) + .await + .map_err(map_kube_err)? + .filter(|task| is_task_owner(task, principal)), + None => None, + }; + let mut assignment_events = historical_task + .as_ref() + .and_then(|task| task.status.as_ref()) + .map(|status| { + status + .assignment_events + .iter() + .filter(|event| { + assignment_nonce + .as_deref() + .is_none_or(|nonce| event.task_id == nonce) + }) + .map(TaskAssignmentEventDto::from) + .collect::>() + }) + .unwrap_or_default(); + + let mut activity: Vec = cluster + .read_mission_trace(name) + .await + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default(); + let status = output_data.get("status").map(String::as_str); + let mut result = { + let output = deliverable_text(output_data.get("output").map(String::as_str).unwrap_or("")); + let blocked = classify_blocked(output_data.get("status").map(String::as_str), &output); + Some(MissionResultDto { + output, + status: output_data.get("status").cloned(), + model: output_data.get("model").cloned(), + total_tokens: output_data.get("totalTokens").and_then(|v| v.parse().ok()), + prompt_tokens: output_data.get("promptTokens").and_then(|v| v.parse().ok()), + completion_tokens: output_data + .get("completionTokens") + .and_then(|v| v.parse().ok()), + finished_at: output_data.get("finishedAt").cloned(), + assignment_nonce: output_data.get("assignmentNonce").cloned(), + source: output_data.get("source").cloned(), + blocked, + artifact_persistence: output_data.get("artifactPersistence").cloned(), + artifact_count: output_data + .get("artifactCount") + .and_then(|v| v.parse().ok()), + declared_artifact_count: output_data + .get("declaredArtifactCount") + .and_then(|v| v.parse().ok()), + }) + }; + let artifacts = build_artifact_set(cluster, name, Some(&output_data)).await; + let successful_result = result.as_ref().is_some_and(|result| { + result.status.as_deref() != Some("error") && result.blocked.is_none() + }); + let checkpoint = select_task_checkpoint(None, &artifacts, successful_result); + activity.extend(subagent_trace_from_artifacts(&artifacts)); + activity.sort_by(|left, right| { + left.get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .cmp( + right + .get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + ) + }); + let trace_round_events = activity + .iter() + .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("round")) + .count() as i64; + let trace_tool_events = activity + .iter() + .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("tool")) + .count() as i64; + let trace_total_tokens: i64 = activity + .iter() + .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("round")) + .filter_map(|event| { + event + .get("total_tokens") + .and_then(serde_json::Value::as_i64) + }) + .sum(); + merge_trace_total_tokens(&mut result, trace_total_tokens); + + let telemetry = { + let mut rounds = output_data + .get("rounds") + .and_then(|v| v.parse::().ok()); + let mut tool_calls = output_data + .get("toolCalls") + .and_then(|v| v.parse::().ok()); + if trace_round_events > 0 { + rounds = Some(rounds.unwrap_or_default().max(trace_round_events)); + } + if trace_tool_events > 0 { + tool_calls = Some(tool_calls.unwrap_or_default().max(trace_tool_events)); + } + if rounds.is_some() || tool_calls.is_some() { + Some(MissionTelemetryDto { rounds, tool_calls }) + } else { + None + } + }; + + let phase = if status == Some("error") { + "Failed" + } else { + "Delivered" + }; + let (role_plan, collaboration_events) = structured_team_evidence(&artifacts); + canonicalize_assignment_event_roles(&mut assignment_events, &collaboration_events); + + Ok(Json(TaskDetailDto { + name: name.to_string(), + namespace: ns.to_string(), + objective: output_data.get("objective").cloned().unwrap_or_default(), + display_name: output_data + .get("displayName") + .cloned() + .filter(|s| !s.trim().is_empty()), + created_at: output_data.get("startedAt").cloned(), + envelope: EnvelopeDto { + tier: output_data.get("tier").and_then(|v| v.parse().ok()).unwrap_or(0), + authority_ceiling: 0, + delegation_depth: 0, + budget: None, + tool_policy: None, + egress_allowlist: None, + }, + phase: phase.to_string(), + envelope_digest: None, + observed_generation: None, + lineage: Vec::new(), + parent: None, + team: output_data.get("team").cloned(), + status_message: Some( + "This run's governance record was retired (garbage-collected); the deliverable and audit trail below are read from the persisted mission output.".to_string(), + ), + children: Vec::new(), + launched: true, + execution_phase: Some("Idle".to_string()), + sandbox: None, + egress_mode: None, + execution_detail: None, + assignment: None, + assignment_events, + assignment_sequence: None, + composition: None, + sub_agents: Vec::new(), + result, + artifacts, + role_plan, + collaboration_events, + pull_requests: deliverable_pull_requests(&output_data), + activity, + telemetry, + checkpoint, + agent_identity: None, + harness_corrected: None, + halted: None, + // This view is reconstructed from a delivered/terminal output, so a run + // was necessarily requested — never auto-kickoff it again. + run_requested: true, + current_run_nonce: assignment_nonce, + })) +} + +#[derive(serde::Serialize)] +pub struct TroubleshootDto { + /// Whether a sandbox pod was found for this run at all. + pub pod_found: bool, + /// Ready containers vs total (e.g. "2/2") when a pod exists. + pub pod_summary: Option, + /// Per-container state (name, ready, restarts, running/waiting/terminated). + pub containers: Vec, + /// The tail of the agent container's REAL logs — the ground-truth evidence. + pub agent_log_tail: Vec, + /// The specific log/status lines that matched a known failure signature — + /// the smoking gun, highlighted for the reader. + pub evidence: Vec, + /// Plain-language cause + remedy, derived from the REAL evidence above. + pub cause: String, + pub remedy: String, + /// True when the harness itself is the problem (a chat-gateway on a one-shot + /// mission) — the UI steers the re-compose to OpenClaw. + pub harness_issue: bool, + /// The recorded run status/reason, for cross-reference. + pub result_status: Option, + pub result_reason: Option, +} + +/// `GET /api/namespaces/:ns/tasks/:name/troubleshoot` — actually troubleshoot a +/// run by reading the sandbox pod's real container states + agent logs and +/// diagnosing from that ground truth (not by pattern-matching a status string). +pub async fn troubleshoot_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let task = require_owned_task_or_output(cluster, &ns, &name, &principal).await?; + // Resolve the sandbox: for a mission the sandbox is named after the task. + // Fall back to the task's recorded sandbox reference when present. + let sandbox = task + .and_then(|t| t.status.and_then(|s| s.sandbox_ref).map(|r| r.name)) + .unwrap_or_else(|| name.clone()); + + let logs = cluster + .read_sandbox_logs(&sandbox, "agent", 120) + .await + .unwrap_or_default(); + let containers = cluster.sandbox_container_states(&sandbox).await; + let health = cluster.sandbox_pod_health(&sandbox).await; + let output = cluster.read_mission_output(&name).await; + let result_status = output.as_ref().and_then(|d| d.get("status").cloned()); + let result_reason = output.as_ref().and_then(|d| d.get("output").cloned()); + + let log_lines: Vec = logs.lines().map(|s| s.to_string()).collect(); + let (cause, remedy, harness_issue, evidence) = + diagnose_run_failure(&log_lines, &containers, result_reason.as_deref()); + + // Keep the last ~30 log lines for the "raw evidence" view. + let agent_log_tail: Vec = log_lines.iter().rev().take(30).rev().cloned().collect(); + + Ok(Json(TroubleshootDto { + pod_found: !containers.is_empty() || health.is_some(), + pod_summary: health + .as_ref() + .map(|h| format!("{}/{}", h.ready_containers, h.total_containers)), + containers, + agent_log_tail, + evidence, + cause, + remedy, + harness_issue, + result_status, + result_reason, + })) +} + +/// Diagnose a run failure from REAL evidence: the agent log lines, the container +/// states, and the recorded reason. Returns (cause, remedy, harness_issue, +/// evidence-lines). Signatures are ordered most-specific first. +fn diagnose_run_failure( + log_lines: &[String], + containers: &[crate::kars::cluster::ContainerState], + reason: Option<&str>, +) -> (String, String, bool, Vec) { + let find = |needles: &[&str]| -> Vec { + log_lines + .iter() + .filter(|l| { + let low = l.to_lowercase(); + needles.iter().any(|n| low.contains(&n.to_lowercase())) + }) + .cloned() + .collect::>() + }; + + // 1. Container-level infrastructure failures (authoritative). + for c in containers { + if let Some(r) = c.reason.as_deref() { + let rl = r.to_lowercase(); + if rl.contains("imagepull") || rl.contains("errimage") { + return ( + format!("The “{}” container can't pull its image ({r}).", c.name), + "This is an infrastructure issue — the image tag is missing or the registry is unreachable. An operator should check the image reference and ACR/registry access.".into(), + false, + vec![format!("container {} is {} ({r})", c.name, c.state)], + ); + } + if rl.contains("crashloop") { + return ( + format!("The “{}” container is crash-looping (restarted {} times).", c.name, c.restarts), + "The container starts and immediately exits. Check the agent logs below for the panic/exit reason; often a bad config, missing secret, or an incompatible image.".into(), + false, + find(&["error", "panic", "fatal", "exited", "traceback"]), + ); + } + if rl.contains("oomkill") { + return ( + format!("The “{}” container was OOM-killed (out of memory).", c.name), + "The run exceeded the sandbox memory limit. Reduce the working set or raise the sandbox resources.".into(), + false, + vec![format!("container {} terminated: OOMKilled", c.name)], + ); + } + } + } + + // 2. Hermes chat-gateway idle — the exact evidence from the entrypoint. + let hermes = find(&[ + "no channels", + "idle daemon mode", + "no messaging platforms enabled", + "gateway in idle", + ]); + if !hermes.is_empty() { + return ( + "The agent is running on the Hermes chat-gateway harness, which started in IDLE DAEMON MODE because no messaging channels are configured. It is waiting for inbound messages (Telegram/Slack/…) and never executes a one-shot autonomous mission — so the run produced nothing and timed out.".into(), + "Re-compose this mission on the OpenClaw harness (built for autonomous missions). Hermes only fits work that is DRIVEN by a chat channel.".into(), + true, + hermes, + ); + } + + // 3. Content safety / auth / rate limit from logs. + let safety = find(&[ + "content safety", + "jailbreak", + "blocked by policy", + "content_filter", + ]); + if !safety.is_empty() { + return ( + "A content-safety policy blocked the run.".into(), + "Adjust the objective to avoid the flagged content, or ask an operator about the content-safety floor.".into(), + false, + safety, + ); + } + let auth = find(&[ + "401 unauthorized", + "403 forbidden", + "authentication failed", + "invalid api key", + ]); + if !auth.is_empty() { + return ( + "The agent's model calls were rejected by the provider (authentication/authorization).".into(), + "An operator should check the router's provider credentials / workload-identity role for this model.".into(), + false, + auth, + ); + } + let rate = find(&["429", "rate limit", "too many requests", "quota"]); + if !rate.is_empty() { + return ( + "The model provider rate-limited or quota-limited the run.".into(), + "Re-run after a short wait, or an operator can raise the model deployment's quota." + .into(), + false, + rate, + ); + } + let schema = find(&[ + "stream_options.include_usage", + "unknown parameter: 'stream_options", + "stream_options: extra inputs", + ]); + if !schema.is_empty() { + return ( + "The selected model rejected the translated inference request before it could reason or call tools.".into(), + "This is a model/router compatibility issue, not an egress or prompt problem. Deploy the corrected inference router, then re-run the same mission; selecting another catalogue model is only a temporary workaround.".into(), + false, + schema, + ); + } + + // 4. Fall back to the recorded reason. + let rl = reason.unwrap_or("").to_lowercase(); + if rl.contains("did not come online") + || rl.contains("not yet discoverable") + || rl.contains("mesh registry") + { + return ( + "The agent never registered on the encrypted mesh within the startup window, so the controller timed the run out.".into(), + "Re-run it — a fresh sandbox often comes up cleanly. If it repeats, check the agent logs below and the sandbox events.".into(), + false, + find(&["mesh", "relay", "register", "keepalive"]), + ); + } + if rl.contains("no progress heartbeat") || rl.contains("timed out") || rl.contains("timeout") { + return ( + "The agent started but stopped making progress, so the controller timed the run out." + .into(), + "Re-run it; if it stalls again, narrow the objective or raise the token/time budget." + .into(), + false, + find(&["error", "timeout", "stalled"]), + ); + } + + ( + "The run ended without producing a deliverable. See the agent's own logs below for the specifics.".into(), + "Re-run it, or re-compose with a different harness/model. If the logs show a repeating error, address that first.".into(), + false, + find(&["error", "panic", "fatal", "exception"]), + ) +} + +/// Build the effective composition from the materialized InferencePolicy + +/// KarsSandbox — the real running config, including controller-defaulted fields. +fn composition_from_materialized( + ip: Option<&kube::core::DynamicObject>, + sandbox: Option<&kube::core::DynamicObject>, +) -> Option { + let sb = sandbox?; + let spec = sb.data.get("spec")?; + let model = ip.and_then(|p| { + let prim = p.data.get("spec")?.get("modelPreference")?.get("primary")?; + let dep = prim.get("deployment")?.as_str()?; + // The deployment string identifies the model; the inference provider is + // a single cluster-level fact (see Options.provider), not a per-model + // tag — so we do NOT append a guessed provider here. + Some(dep.to_string()) + }); + let runtime = spec + .get("runtime") + .and_then(|r| r.get("kind")) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()); + let isolation = spec + .get("sandbox") + .and_then(|s| s.get("isolation")) + .and_then(|i| i.as_str()) + .map(|s| s.to_string()); + let instructions = spec + .get("agent") + .and_then(|a| a.get("instructions")) + .and_then(|i| i.as_str()) + .map(|s| s.to_string()); + let gov = spec.get("governance"); + let tool_policy = gov + .and_then(|g| g.get("toolPolicyRef")) + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + let mcp_servers = gov + .and_then(|g| g.get("mcpServerRefs")) + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| { + x.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + let egress = spec + .get("networkPolicy") + .and_then(|n| n.get("allowedEndpoints")) + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host")?.as_str()?; + Some(match e.get("port").and_then(|p| p.as_i64()) { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + let memory = spec + .get("memoryRef") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + Some(CompositionDto { + runtime, + model, + instructions, + tool_policy, + mcp_servers, + egress, + isolation, + memory, + }) +} + +/// A sub-agent the mission's agent spawned at run time (a labelled KarsSandbox). +#[derive(Debug, Serialize)] +pub struct SubAgentDto { + pub name: String, + pub namespace: String, + pub phase: Option, + pub runtime: Option, + pub role: Option, + pub parent: Option, + pub logical_agent_id: Option, + pub model: Option, +} + +fn to_sub_agent(o: &kube::core::DynamicObject) -> SubAgentDto { + let spec = o.data.get("spec"); + let status = o.data.get("status"); + SubAgentDto { + name: o.metadata.name.clone().unwrap_or_default(), + namespace: o.metadata.namespace.clone().unwrap_or_default(), + phase: status + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()), + runtime: spec + .and_then(|s| s.get("runtime")) + .and_then(|r| r.get("kind").or(Some(r))) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()), + role: o.labels().get("kars.azure.com/role").cloned(), + parent: o.labels().get("kars.azure.com/parent").cloned(), + logical_agent_id: o + .annotations() + .get("kars.azure.com/logical-agent-id") + .cloned(), + model: o.annotations().get("kars.azure.com/model").cloned(), + } +} + +fn validate_mission_fallback_route( + options: &crate::routes::options::Options, + blueprint: &BlueprintDto, + runtime: &str, + model: &ModelDto, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, + total_tokens: Option, +) -> AppResult<()> { + if !options + .models + .iter() + .any(|option| option.provider == model.provider && option.deployment == model.deployment) + { + return Err(AppError::BadRequest(format!( + "fallback model route `{}::{}` is not present in the live model catalogue", + model.provider, model.deployment + ))); + } + match crate::routes::options::route_qualification( + runtime, + &model.provider, + &model.deployment, + required_capabilities, + max_parallel, + total_tokens, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "fallback route `{runtime} · {}::{}` lacks atomic qualification for capabilities: {}", + model.provider, + model.deployment, + required_capabilities + .iter() + .cloned() + .collect::>() + .join(", ") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "route qualification configuration error: {error}" + ))); + } + } + let route = crate::routes::options::route_label(runtime, &model.provider, &model.deployment); + for server in &blueprint.mcp_servers { + let option = options + .mcp_servers + .iter() + .find(|option| option.name == *server) + .ok_or_else(|| { + AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + )) + })?; + if !crate::routes::options::mcp_server_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map_err(|error| { + AppError::Upstream(format!( + "resource qualification configuration error: {error}" + )) + })? { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` lacks current resource qualification for fallback {route}" + ))); + } + } + if let Some(memory) = blueprint + .memory + .as_deref() + .filter(|memory| !memory.is_empty()) + { + let option = options + .memories + .iter() + .find(|option| option.name == memory) + .ok_or_else(|| { + AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + )) + })?; + if !crate::routes::options::memory_binding_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map_err(|error| { + AppError::Upstream(format!( + "resource qualification configuration error: {error}" + )) + })? { + return Err(AppError::BadRequest(format!( + "memory `{memory}` lacks current resource qualification for fallback {route}" + ))); + } + } + for skill in &blueprint.skills { + let option = options + .skills + .iter() + .find(|option| option.name == *skill) + .ok_or_else(|| { + AppError::BadRequest(format!( + "skill `{skill}` is not present in the approved live catalogue" + )) + })?; + if !crate::routes::options::skill_version_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map_err(|error| { + AppError::Upstream(format!( + "resource qualification configuration error: {error}" + )) + })? { + return Err(AppError::BadRequest(format!( + "skill `{skill}` lacks current version qualification for fallback {route}" + ))); + } + } + Ok(()) +} + +/// `POST /api/namespaces/:ns/tasks` — create a task. +/// +/// The BFF never sets status — it submits the spec and lets the controller +/// validate the envelope and stamp the digest. Admission (CEL) rejects an +/// amplifying envelope here, which we surface as a 422-style upstream error. +pub async fn create_task( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, + Json(mut req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + cluster.credential_grant(&ns).await.map_err(map_kube_err)?; + let api: Api = cluster.tasks(&ns); + // The caller cannot choose attribution; it is derived from the verified + // Bridge session inserted by auth middleware. + req.created_by = Some(principal.name.clone()); + let created_by = principal.name.clone(); + if let Some(plan) = req + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + { + crate::routes::compose::validate_execution_plan(plan).map_err(AppError::BadRequest)?; + req.envelope.delegation_depth = 1; + } else if let Some(delegation) = req.delegation.as_ref() { + crate::routes::compose::validate_delegation(delegation).map_err(AppError::BadRequest)?; + req.envelope.delegation_depth = i32::from(delegation.mode == "principal-specialists"); + } + let mut harness_correction: Option = None; + if let Some(blueprint) = req.blueprint.as_mut() + && let Some(runtime) = blueprint.runtime.as_deref() + && crate::routes::compose::is_non_autonomous_harness(runtime) + { + harness_correction = Some(format!( + "harness {runtime} is a bootstrap-only adapter (no autonomous task loop) and cannot run a one-shot mission; corrected to OpenClaw" + )); + blueprint.runtime = Some("OpenClaw".to_string()); + } + let git_write = crate::routes::github::authorize_git_write( + cluster, + &ns, + &principal, + req.git_write_repos.as_deref(), + ) + .await?; + if req.blueprint.as_ref().is_some_and(|blueprint| { + blueprint.runtime.as_deref().unwrap_or("OpenClaw") != "OpenClaw" + && !blueprint.skills.is_empty() + }) { + return Err(AppError::BadRequest( + "controller-mounted file skills are currently supported only by OpenClaw".into(), + )); + } + if req + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.tool_policy.as_deref()) + == Some("kars-team-member") + { + return Err(AppError::BadRequest( + "kars-team-member is reserved for declared standing-team specialists".into(), + )); + } + if let Some(blueprint) = req.blueprint.as_mut() { + if blueprint.model_fallbacks.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 routes".into(), + )); + } + if blueprint.model.is_none() { + let options = crate::routes::options::build_options(cluster).await?; + blueprint.model = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }); + } + } + if let Some(blueprint) = req.blueprint.as_ref() + && let Some(model) = blueprint.model.as_ref() + { + let options = crate::routes::options::build_options(cluster).await?; + let served = options.models.iter().any(|option| { + option.provider == model.provider && option.deployment == model.deployment + }); + if !served { + return Err(AppError::BadRequest(format!( + "model route `{}::{}` is not present in the live model catalogue", + model.provider, model.deployment + ))); + } + let runtime = blueprint.runtime.as_deref().unwrap_or("OpenClaw"); + if !cluster.runnable_runtimes().await.contains(runtime) { + return Err(AppError::BadRequest(format!( + "runtime `{runtime}` cannot start on this cluster" + ))); + } + let (required_capabilities, max_parallel) = + crate::routes::validate::qualification_requirements(blueprint, None); + let total_tokens = req + .envelope + .budget + .as_ref() + .and_then(|budget| budget.tokens); + match crate::routes::options::route_qualification( + runtime, + &model.provider, + &model.deployment, + &required_capabilities, + max_parallel, + total_tokens, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "runtime/model route `{runtime} · {}::{}` lacks qualification evidence for capabilities: {}", + model.provider, + model.deployment, + required_capabilities + .iter() + .cloned() + .collect::>() + .join(", ") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "route qualification configuration error: {error}" + ))); + } + } + fn find_resource<'a>( + items: &'a [crate::routes::options::RefOption], + name: &str, + ) -> Option<&'a crate::routes::options::RefOption> { + items.iter().find(|option| option.name == name) + } + for server in &blueprint.mcp_servers { + let Some(option) = find_resource(&options.mcp_servers, server) else { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + ))); + }; + match crate::routes::options::mcp_server_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` lacks retained resource qualification for {} at current schema {}", + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ), + option.tool_schema_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + if let Some(memory) = blueprint + .memory + .as_deref() + .filter(|memory| !memory.is_empty()) + { + let Some(option) = find_resource(&options.memories, memory) else { + return Err(AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + ))); + }; + match crate::routes::options::memory_binding_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "memory `{memory}` lacks retained resource qualification for {} at backend {} / compiled digest {}", + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ), + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + for skill in &blueprint.skills { + let Some(option) = find_resource(&options.skills, skill) else { + return Err(AppError::BadRequest(format!( + "skill `{skill}` is not present in the approved live catalogue" + ))); + }; + match crate::routes::options::skill_version_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "skill `{skill}` lacks retained resource qualification for {} at version digest {}", + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ), + option.version_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + let mut seen = std::collections::BTreeSet::new(); + for fallback in &blueprint.model_fallbacks { + let key = format!("{}::{}", fallback.provider, fallback.deployment); + if key == format!("{}::{}", model.provider, model.deployment) || !seen.insert(key) { + continue; + } + validate_mission_fallback_route( + &options, + blueprint, + runtime, + fallback, + &required_capabilities, + max_parallel, + total_tokens, + )?; + } + } + + // Aggregate inference-budget gate (cluster + workspace + user). A launched + // mission consumes inference tokens, so a strict/over-buffer budget at any + // tier blocks starting new work. Draft (unlaunched) missions don't run yet, + // so they pass — the gate re-applies when they run. + if req.launch { + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; + } + + // Default the tool policy to `kars-default` when neither the request envelope + // nor the blueprint pins one. This is not cosmetic: the AGT mesh transport the + // run's delivery rides on requires a mounted ToolPolicy. With governance OFF + // the sandbox mounts no policy, the AGT engine fails closed, and the agent can + // never send its `task_response` back to the controller — the run streams live + // but NEVER delivers (no output ConfigMap, endless re-dispatch). Every bridge + // mission must be governed; `kars-default` is the cluster's baseline policy. + // An explicit blueprint tool policy still wins (governance_spec prefers it), so + // we only inject the default when the blueprint carries none. + let blueprint_has_tool_policy = req + .blueprint + .as_ref() + .and_then(|b| b.tool_policy.as_ref()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let tool_policy_ref = req + .envelope + .tool_policy + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| (!blueprint_has_tool_policy).then(|| "kars-default".to_string())) + .map(|name| LocalObjectRef { name }); + + // ── Hard capability match, defense-in-depth ───────────────────────────── + // A direct mission is one-shot autonomous; a bootstrap-only adapter has no + // task-execution loop and delivers nothing. The compose flow already + // corrects this, but a manually-edited package could still name one — so + // enforce it again at creation: rewrite the harness to OpenClaw and record + // the correction as a governance annotation on the task so it survives into + // the run and the receipt/decision view. (Hermes/BYO are autonomous — kept.) + let mut blueprint = req.blueprint.map(BlueprintDto::into_crd); + if let Some((git_write, binding)) = git_write { + let blueprint = blueprint.get_or_insert_with(Default::default); + blueprint.git_write = Some(git_write); + blueprint.github_binding = Some(binding); + } + let spec = KarsTaskSpec { + objective: req.objective, + display_name: req.display_name, + execution: req.launch.then_some(crate::kars::task::TaskExecution { + launch: true, + runtime: None, + }), + blueprint, + parent_ref: req + .parent + .filter(|s| !s.is_empty()) + .map(|name| LocalObjectRef { name }), + envelope: TaskEnvelope { + tier: req.envelope.tier, + authority_ceiling: req.envelope.authority_ceiling, + delegation_depth: req.envelope.delegation_depth, + budget: req.envelope.budget.map(|b| TaskBudget { + scope: b.scope, + tokens: b.tokens, + usd_micros: b.usd_micros, + }), + tool_policy_ref, + egress_allowlist_ref: req + .envelope + .egress_allowlist + .filter(|s| !s.is_empty()) + .map(|name| LocalObjectRef { name }), + }, + retention_ttl_seconds: req.retention_ttl_seconds, + }; + let mut task = KarsTask::new(&req.name, spec); + if let Some(plan) = task + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + { + let total_tokens = task + .spec + .envelope + .budget + .as_ref() + .and_then(|budget| budget.tokens) + .ok_or_else(|| { + AppError::BadRequest( + "execution-plan missions require an explicit total token budget".into(), + ) + })?; + let (principal_tokens, child_tokens) = + crate::routes::compose::delegation_budget_allocation(total_tokens, plan.roles.len()) + .map_err(AppError::BadRequest)?; + let annotations = task + .metadata + .annotations + .get_or_insert_with(Default::default); + annotations.insert( + "kars.azure.com/mission-budget-total".into(), + total_tokens.to_string(), + ); + annotations.insert( + "kars.azure.com/mission-principal-budget".into(), + principal_tokens.to_string(), + ); + annotations.insert( + "kars.azure.com/mission-child-budget".into(), + child_tokens.to_string(), + ); + annotations.insert( + "kars.azure.com/mission-specialist-count".into(), + plan.roles.len().to_string(), + ); + annotations.insert( + "kars.azure.com/mission-decomposition".into(), + "execution-plan/v1".into(), + ); + } + // Record the capability correction on the task so it's durable and surfaces in + // the governed record (the run reads task annotations; the receipt/decision + // view can attest the harness was corrected rather than silently swapped). + if let Some(reason) = &harness_correction { + task.metadata + .annotations + .get_or_insert_with(Default::default) + .insert( + "kars.azure.com/harness-corrected".to_string(), + reason.clone(), + ); + } + // Stamp the creator for per-user budget attribution. + task.metadata + .annotations + .get_or_insert_with(Default::default) + .insert("kars.azure.com/created-by".to_string(), created_by.clone()); + let annotations = task + .metadata + .annotations + .get_or_insert_with(Default::default); + annotations.insert( + "kars.azure.com/owner-sub".to_string(), + principal.sub.clone(), + ); + annotations.insert( + "kars.azure.com/owner-name".to_string(), + principal.name.clone(), + ); + let launch = task + .spec + .execution + .as_ref() + .is_some_and(|execution| execution.launch); + if let Some(execution) = task.spec.execution.as_mut() { + execution.launch = false; + } + let created = api + .create(&PostParams::default(), &task) + .await + .map_err(map_kube_err)?; + cluster + .finish_created_credentials( + &crate::kars::credentials::Target { + kind: "KarsTask".into(), + namespace: ns.clone(), + name: created.name_any(), + uid: created + .uid() + .ok_or_else(|| AppError::Upstream("Task CREATE omitted UID".into()))?, + }, + launch, + ) + .await + .map_err(map_kube_err)?; + let created = api.get(&created.name_any()).await.map_err(map_kube_err)?; + Ok(Json(to_detail( + &created, + Vec::new(), + Vec::new(), + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + None, + None, + ))) +} + +/// Per-mission promote request body. +#[derive(Debug, Deserialize)] +pub struct PromoteMissionRequest { + pub tier: i32, +} + +/// `POST /api/namespaces/:ns/tasks/:name/promote` — request a per-mission tier +/// promotion (§12). Patches `spec.requestedTier`; the controller opens a human +/// `KarsApproval` and widens the envelope only once approved. The BFF never +/// widens an envelope directly. +pub async fn promote_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_task(cluster, &ns, &name, &principal).await?; + if !(1..=5).contains(&body.tier) { + return Err(AppError::BadRequest("tier must be in 1..5".into())); + } + let api: Api = cluster.tasks(&ns); + let patch = serde_json::json!({ "spec": { "requestedTier": body.tier } }); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "requested": true, + "tier": body.tier, + "note": "A human approval has been opened. This mission is promoted only once it is approved." + }))) +} + +/// Governed emergency-stop request. +#[derive(Debug, Deserialize)] +pub struct HaltRequest { + /// Why the operator is halting — recorded on the governed decision so the + /// stop is attestable ("who halted this, when, and why"), not anonymous. + pub reason: Option, +} + +/// `POST /api/namespaces/:ns/tasks/:name/halt` — governed emergency-stop. +/// +/// A one-click halt that STOPS a running mission/agent without destroying its +/// record: it flips `spec.execution.launch` to false (the controller's teardown +/// reconcile then deletes the sandbox + InferencePolicy, so the agent is removed +/// from the mesh and can no longer receive or answer delegated work) and stamps +/// a governed decision annotation (`kars.azure.com/halted` = operator/reason/at) +/// so the halt itself is a durable, attestable record. The deliverable, trace, +/// and receipt remain — unlike DELETE, which removes everything. No major agent +/// platform ships a governed kill; kars can, because it owns the K8s control +/// plane (to stop) and the governance record (to attest). +pub async fn halt_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let api: Api = cluster.tasks(&ns); + require_owned_task(cluster, &ns, &name, &principal).await?; + let reason = body + .reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("operator emergency-stop"); + let at = chrono::Utc::now().to_rfc3339(); + let decision = format!("halted by operator at {at}: {reason}"); + // Un-launch (controller tears down the running sandbox) AND record the + // governed decision atomically in one merge patch. + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/halted": decision } }, + "spec": { "execution": { "launch": false } }, + }); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "halted": true, + "at": at, + "reason": reason, + "note": "The agent's sandbox is being torn down; the mission record, deliverable, and audit trail are retained. The halt is recorded as a governed decision.", + }))) +} + +/// Replicate request — how many identical runs to launch for reliability (pass^k). +#[derive(Debug, Deserialize)] +pub struct ReplicateRequest { + /// Number of additional identical runs to create (2–5). Each becomes a + /// distinct KarsTask sharing this task's exact objective + envelope, so the + /// efficiency frontier can compute pass^k reliability across them. + pub count: u32, + /// When true, each clone is launched immediately; when false, they are + /// created as ready-to-run packages the caller launches. Default true. + #[serde(default = "default_true")] + pub launch: bool, +} + +fn default_true() -> bool { + true +} + +/// `POST /api/namespaces/:ns/tasks/:name/replicate` — the pass^k runner. +/// +/// Clones a mission's EXACT package (objective + envelope + blueprint) into +/// `count` distinct sibling tasks so they run independently and the efficiency +/// engine can measure pass^k reliability (fraction of the repeated package +/// accepted on EVERY attempt). Honest: this creates real, governed runs — the +/// same package, nothing weakened — not a simulated repeat. +pub async fn replicate_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let count = req.count.clamp(1, 5); + let api: Api = cluster.tasks(&ns); + let source = require_owned_task(cluster, &ns, &name, &principal).await?; + if source + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.credential_bindings.as_ref()) + .is_some_and(|bindings| { + bindings + .sources + .iter() + .any(|source| source.scope != "workspace") + }) + { + return Err(AppError::BadRequest("Independent replicas cannot inherit another target's credential UID; use an approved workspace source or stage per-replica credentials".into())); + } + + // A short suffix keyed off the current time keeps clone names unique across + // repeated replicate calls (so a second batch doesn't collide with a first). + let batch = chrono::Utc::now().timestamp() % 100000; + let mut created: Vec = Vec::new(); + for i in 1..=count { + let clone_name = format!("{name}-rep-{batch}-{i}"); + let mut spec = source.spec.clone(); + // Force the execution gate to the requested launch state; strip parent + // linkage so each clone is an independent, top-level run. + spec.execution = Some(crate::kars::task::TaskExecution { + launch: false, + runtime: None, + }); + spec.parent_ref = None; + let mut task = KarsTask::new(&clone_name, spec); + // Label the batch so the UI can group a reliability cohort together. + task.metadata + .labels + .get_or_insert_with(Default::default) + .insert("kars.azure.com/reliability-of".into(), name.clone()); + let annotations = task + .metadata + .annotations + .get_or_insert_with(Default::default); + annotations.insert("kars.azure.com/owner-sub".into(), principal.sub.clone()); + annotations.insert("kars.azure.com/owner-name".into(), principal.name.clone()); + let captured = api + .create(&PostParams::default(), &task) + .await + .map_err(map_kube_err)?; + cluster + .finish_created_credentials( + &crate::kars::credentials::Target { + kind: "KarsTask".into(), + namespace: ns.clone(), + name: captured.name_any(), + uid: captured + .uid() + .ok_or_else(|| AppError::Upstream("Replica CREATE omitted UID".into()))?, + }, + req.launch, + ) + .await + .map_err(map_kube_err)?; + created.push(clone_name); + } + + Ok(Json(serde_json::json!({ + "replicated": name, + "count": created.len(), + "runs": created, + "note": format!("{} identical runs created — pass^{} reliability will appear on the efficiency frontier once they complete and are reviewed.", created.len(), created.len() + 1), + }))) +} + +/// Launch/un-launch request body. +#[derive(Debug, Deserialize)] +pub struct LaunchRequest { + pub launch: bool, +} + +/// `POST /api/namespaces/:ns/tasks/:name/launch` — flip the execution gate. +/// +/// The §20 launch action: setting `launch: true` asks the controller to +/// materialize a governed sandbox; `false` tears it down. The BFF only patches +/// the spec — the controller does the materialization and reports status. +pub async fn launch_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_task(cluster, &ns, &name, &principal).await?; + let api: Api = cluster.tasks(&ns); + let patch = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "spec": { "execution": { "launch": req.launch } }, + }); + let patched = api + .patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(to_detail( + &patched, + Vec::new(), + Vec::new(), + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + None, + None, + ))) +} + +#[derive(Debug, Deserialize)] +pub struct IncreaseTaskBudgetRequest { + pub daily_tokens: i64, +} + +/// Request an owned Mission's token-budget increase. Bridge never widens the +/// trust envelope directly; the controller opens a typed human approval and is +/// the sole writer of the new ceiling after approval. +pub async fn increase_task_budget( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let current = task + .spec + .envelope + .budget + .as_ref() + .and_then(|budget| budget.tokens) + .unwrap_or(0); + if req.daily_tokens <= current { + return Err(AppError::BadRequest(format!( + "new daily token budget must be greater than the current {current}" + ))); + } + + let tasks: Api = cluster.tasks(&ns); + let request_id = chrono::Utc::now().timestamp_micros().to_string(); + let patched = tasks + .patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/requested-by": principal.name, + "kars.azure.com/requested-by-sub": principal.sub, + "kars.azure.com/budget-request-id": request_id + } + }, + "spec": { + "requestedBudgetTokens": req.daily_tokens + } + })), + ) + .await + .map_err(map_kube_err)?; + + Ok(Json(serde_json::json!({ + "requested": true, + "name": name, + "budget_tokens": req.daily_tokens, + "resource_version": patched.metadata.resource_version, + "note": "A typed human approval is being opened. The controller widens the budget only after approval." + }))) +} + +/// Body for a temporary egress request from a mission: the agent (or operator +/// on its behalf) asks to reach an extra website. Materialized as an +/// `EgressApproval` the controller reconciles through human approval — the BFF +/// never widens the sandbox's allowlist directly. +#[derive(Debug, Deserialize)] +pub struct EgressRequest { + pub host: String, + pub port: Option, + pub reason: String, + /// Time-to-live, e.g. "2h". Bounded by the cluster ceiling. Default "2h". + pub ttl: Option, +} + +/// Normalize a human-friendly TTL (`"2h"`, `"30m"`, `"24h"`, `"1d"`, `"90s"`) to +/// the ISO-8601 duration the controller's `EgressApproval` reconciler requires +/// (`"PT2H"`, `"PT30M"`, `"P1D"`, `"PT90S"`). An already-ISO value (starts with +/// `P`) passes through uppercased. Unrecognized input falls back to `"PT2H"` +/// rather than emitting an invalid TTL that leaves the grant Pending forever. +fn normalize_ttl(raw: &str) -> String { + let t = raw.trim(); + if t.is_empty() { + return "PT2H".into(); + } + if t.starts_with('P') || t.starts_with('p') { + return t.to_ascii_uppercase(); + } + let split = t.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(t.len()); + let (num, unit) = t.split_at(split); + let n: u64 = num.trim().parse().unwrap_or(0); + if n == 0 { + return "PT2H".into(); + } + match unit.trim().to_ascii_lowercase().as_str() { + "s" | "sec" | "secs" => format!("PT{n}S"), + "m" | "min" | "mins" => format!("PT{n}M"), + "h" | "hr" | "hrs" | "hour" | "hours" => format!("PT{n}H"), + "d" | "day" | "days" => format!("P{n}D"), + _ => "PT2H".into(), + } +} + +/// `POST /api/namespaces/:ns/tasks/:name/egress` — file a temporary egress +/// grant request for this mission's sandbox. Returns the created EgressApproval +/// name; it widens nothing until a human approves it. +pub async fn request_egress( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let host = req.host.trim().to_string(); + if host.is_empty() { + return Err(AppError::BadRequest("host is required".into())); + } + if req.reason.trim().len() < 3 { + return Err(AppError::BadRequest("reason is required".into())); + } + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + task.status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .ok_or_else(|| AppError::BadRequest("mission has no running sandbox to widen".into()))?; + let port = req.port.unwrap_or(443); + let ttl = normalize_ttl(req.ttl.as_deref().unwrap_or("2h")); + use sha2::{Digest, Sha256}; + let suffix = hex::encode(Sha256::digest(format!("{host}:{port}").as_bytes())); + let approval_name = format!("{name}-eg-{}", &suffix[..12]); + let task_uid = task + .metadata + .uid + .clone() + .ok_or_else(|| AppError::Upstream("task has no Kubernetes UID".into()))?; + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "metadata": { + "name": approval_name, + "namespace": ns, + "ownerReferences": [{ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "name": name, + "uid": task_uid, + "controller": true, + "blockOwnerDeletion": true + }], + "labels": { + "kars.azure.com/req-task": name, + "kars.azure.com/req-kind": "egress" + }, + "annotations": { + "kars.azure.com/req-kind": "egress", + "kars.azure.com/req-target": host, + "kars.azure.com/req-port": port.to_string(), + "kars.azure.com/req-ttl": ttl, + "kars.azure.com/requested-by": principal.name, + "kars.azure.com/requested-by-sub": principal.sub, + "kars.azure.com/owner-sub": principal.sub, + "kars.azure.com/owner-name": principal.name + } + }, + "spec": { + "taskRef": {"name": name}, + "requestedBy": { + "subject": principal.sub, + "name": principal.name + }, + "action": { + "kind": "egress", + "summary": format!("Allow the mission to reach {host}:{port}"), + "detail": format!( + "{} Approving creates an exact, time-boxed {host}:{port} grant.", + req.reason.trim() + ) + }, + "ttl": "PT24H" + }, + }); + let created = cluster + .apply_kind(&ns, "KarsApproval", body, false) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "requested": true, + "name": created.metadata.name, + "note": "Pending human approval. No egress is granted until a distinct operator approves it in the Bridge inbox." + }))) +} + +/// `GET /api/namespaces/:ns/tasks/:name/egress/learned` — the domains the agent +/// has actually reached, observed by the router in Learn mode. This is the +/// evidence a customer reviews before promoting the mission to enforced. +pub async fn get_learned_egress( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let sandbox = task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()); + let Some(sandbox) = sandbox else { + return Ok(Json( + serde_json::json!({ "available": false, "reason": "no running sandbox yet", "domains": [] }), + )); + }; + let mode = cluster + .sandbox_egress_mode(&sandbox) + .await + .unwrap_or_else(|| "Learn".into()); + let enforced = cluster.sandbox_allowlist(&sandbox).await; + match cluster.sandbox_learned_domains(&sandbox).await { + Ok(domains) => Ok(Json( + serde_json::json!({ "available": true, "mode": mode, "domains": domains, "enforced": enforced }), + )), + Err(e) => Ok(Json( + serde_json::json!({ "available": false, "mode": mode, "reason": e.to_string(), "domains": [], "enforced": enforced }), + )), + } +} + +/// Body for flipping a mission's egress enforcement mode. +#[derive(Debug, Deserialize)] +pub struct EgressModeRequest { + /// `"learning"` (clear the allowlist → controller runs Learn) or + /// `"enforced"` (pin the allowlist → controller runs Strict). + pub mode: String, + /// The hosts to enforce when `mode == "enforced"`. Typically the reviewed + /// subset of the learned domains. + #[serde(default)] + pub allow: Vec, + /// When true, UNION `allow` with the mission's current enforced allowlist + /// instead of replacing it — so granting one host (e.g. from a blocker) can + /// never silently wipe previously-approved hosts. The NetworkMode panel, + /// which sets the full list deliberately, leaves this false (replace). + #[serde(default)] + pub merge: bool, +} + +/// `POST /api/namespaces/:ns/tasks/:name/egress-mode` — promote a mission from +/// learning (monitoring) to enforced, or back. This drives the REAL lever: the +/// controller derives `egressMode: Strict` + an allowlist when the blueprint +/// names egress hosts, and `Learn` when it is empty. Operator-gated; the +/// controller re-reconciles the sandbox, so this is durable, not a UI toggle. +pub async fn set_egress_mode( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let enforced = match req.mode.as_str() { + "enforced" | "strict" => true, + "learning" | "learn" => false, + _ => { + return Err(AppError::BadRequest( + "mode must be 'enforced' or 'learning'".into(), + )); + } + }; + // Parse "host" or "host:port" into the blueprint egress shape. + let egress: Vec = if enforced { + let mut parsed: Vec = req + .allow + .iter() + .filter_map(|h| { + let h = h.trim(); + if h.is_empty() { + return None; + } + match h + .rsplit_once(':') + .and_then(|(host, p)| p.parse::().ok().map(|p| (host, p))) + { + Some((host, port)) => Some(serde_json::json!({ "host": host, "port": port })), + None => Some(serde_json::json!({ "host": h })), + } + }) + .collect(); + // Additive grant: union with the mission's CURRENT enforced allowlist so + // approving one host never clobbers the others (a k8s merge-patch of an + // array replaces it wholesale, so we must merge here, before patching). + if req.merge { + let existing: Vec = task + .spec + .blueprint + .map(|b| b.egress) + .unwrap_or_default() + .into_iter() + .map(|e| match e.port { + Some(p) => serde_json::json!({ "host": e.host, "port": p }), + None => serde_json::json!({ "host": e.host }), + }) + .collect(); + let key = |v: &serde_json::Value| { + format!( + "{}:{}", + v.get("host").and_then(|h| h.as_str()).unwrap_or(""), + v.get("port").and_then(|p| p.as_u64()).unwrap_or(0) + ) + }; + let mut seen: std::collections::HashSet = parsed.iter().map(key).collect(); + for e in existing { + if seen.insert(key(&e)) { + parsed.push(e); + } + } + } + if parsed.is_empty() { + return Err(AppError::BadRequest( + "enforcing requires at least one allowed host — review the learned domains first" + .into(), + )); + } + parsed + } else { + Vec::new() + }; + // Patch the mission's blueprint egress; the controller compiles it into the + // sandbox's networkPolicy (Strict + allowlist, or Learn when empty). + let patch = serde_json::json!({ "spec": { "blueprint": { "egress": egress } } }); + cluster + .tasks(&ns) + .patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "updated": true, + "mode": if enforced { "enforced" } else { "learning" }, + "note": if enforced { + "Promoted to enforced — the sandbox will deny anything outside the approved allowlist on its next reconcile." + } else { + "Back to learning — the sandbox observes and records every domain it reaches without denying." + } + }))) +} + +/// One running agent + what it is doing now, for the lifecycle view. +#[derive(Debug, Serialize)] +pub struct AgentLifecycleDto { + pub sandbox: String, + pub namespace: String, + pub phase: Option, + pub parent: Option, + /// The task this agent is executing (label-derived), if any. + pub task: Option, + pub objective: Option, + pub tier: Option, + /// Live activity counts from the persisted trace (rounds + tool calls). + pub rounds: usize, + pub tool_calls: usize, + pub last_action: Option, + /// True when the agent's sandbox pod is still running (working now) vs a + /// recently-completed run (its ephemeral sandbox already torn down). + pub live: bool, + /// Real token cost of the run (from the mission output), when known. + pub tokens: Option, + /// The run's token budget ceiling (from the envelope), when set — so the UI + /// can render spend against limit ("spent / budget") rather than a bare + /// number. `None` for an uncapped run. + pub budget_tokens: Option, + /// Run outcome: `ok` | `error` (from the mission output), when finished. + pub status: Option, + /// When the run delivered (from the mission output). + pub finished_at: Option, + /// Owning standing team, if this is a team run. + pub team: Option, + /// Human label for the run. + pub display_name: Option, + /// Live pod health (readiness, restarts, uptime, node) — only for live + /// agents; `None` for finished runs whose sandbox was torn down. + pub health: Option, +} + +/// Whether a `KarsTask` should surface on the "Active agents" fleet views. A +/// surfaceable run is either a team taskforce run, a `*-run-` scheduled run, +/// OR a launched direct mission (a one-off the user kicked off from `/new`). +/// Un-launched drafts and team structural tasks (a non-taskforce `team-role`) +/// are NOT agents yet, so they stay out. Without the direct-mission arm the +/// flagship "Active agents" page was empty for the single most common action — +/// launch a mission and watch it — because a direct mission carries neither the +/// taskforce role nor a `-run-` suffix. +fn is_surfaceable_run(t: &KarsTask) -> bool { + let name = t.name_any(); + let role = t + .annotations() + .get("kars.azure.com/team-role") + .map(String::as_str); + if role == Some("taskforce") || name.contains("-run-") { + return true; + } + // A launched direct mission: no team structural role, and it was launched. + let launched = t.spec.execution.as_ref().map(|e| e.launch).unwrap_or(false); + role.is_none() && launched +} + +/// `GET /api/agents` — recent and live agent runs with their real work. Sources +/// from run KarsTasks + their persisted mission telemetry (not idle pods), so +/// the page answers "what have my agents been doing, and what's working now" — +/// live runs first, then recently completed. Ephemeral run sandboxes tear down +/// after delivering, so their work would otherwise vanish; here it persists. +pub async fn list_agents( + State(state): State, + Extension(principal): Extension, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let tasks_api = cluster.tasks("kars-system"); + let all = tasks_api + .list(&kube::api::ListParams::default()) + .await + .map_err(map_kube_err)?; + + // Runs only: a team-owned run, a `*-run-` task, or a launched direct + // mission. Members/principals (structural team roles) are standing authority, + // not work to surface here. + let mut runs: Vec<&KarsTask> = all + .items + .iter() + .filter(|task| is_surfaceable_run(task) && is_task_owner(task, &principal)) + .collect(); + // Freshest first. + runs.sort_by(|a, b| { + let ta = a.metadata.creation_timestamp.as_ref().map(|t| t.0); + let tb = b.metadata.creation_timestamp.as_ref().map(|t| t.0); + tb.cmp(&ta) + }); + + let mut out = Vec::new(); + for task in runs.into_iter().take(24) { + let name = task.name_any(); + let team = task + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/team").cloned()); + + // Mission output: tokens, status, finished, round/tool rollup. + let output = cluster.read_mission_output(&name).await; + let (tokens, status, finished_at, mut rounds, mut tool_calls) = match &output { + Some(d) => ( + d.get("totalTokens").and_then(|v| v.parse::().ok()), + d.get("status").cloned(), + d.get("finishedAt").cloned(), + d.get("rounds") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0), + d.get("toolCalls") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0), + ), + None => (None, None, None, 0, 0), + }; + + // Live iff the run's sandbox pod is running AND it hasn't delivered a + // terminal result yet. A DIRECT mission's sandbox lingers (Running) after + // it delivers, so "Running pod" alone would mislabel a finished, idle + // mission as live and inflate the fleet's "working now" count. A delivered + // (or errored) run is Recent, not Live — its outcome and telemetry show in + // the recent list. (Team run sandboxes tear down on delivery, so this is a + // no-op for them.) + let delivered = status.is_some(); + let live = !delivered && cluster.running_pod_for_sandbox(&name).await.is_some(); + // Honest pod health for a live agent (readiness/restarts/uptime/node). + let health = if live { + cluster.sandbox_pod_health(&name).await + } else { + None + }; + + // For a live run, the trace's last tool tells "what it's doing now"; + // also a more current round/tool count than the (post-hoc) output. + let mut last_action = None; + if live { + // A live run has no persisted trace CM yet (it's written at delivery), + // so fall back to the router's live trace — otherwise a working agent + // reports 0 rounds / 0 tool calls / no current action. + let trace: Vec = match cluster + .read_mission_trace(&name) + .await + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + { + Some(t) if !t.is_empty() => t, + _ => cluster.sandbox_live_trace(&name).await, + }; + if !trace.is_empty() { + let r = trace + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .count(); + let tc = trace + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("tool")) + .count(); + if r > 0 { + rounds = r; + } + if tc > 0 { + tool_calls = tc; + } + last_action = trace + .last() + .and_then(|e| e.get("name").and_then(|n| n.as_str()).map(String::from)); + } + } + + let phase = if live { + Some("Running".into()) + } else if status.as_deref() == Some("ok") { + Some("Delivered".into()) + } else if status.is_some() { + Some("Errored".into()) + } else { + Some("Idle".into()) + }; + + out.push(AgentLifecycleDto { + sandbox: name.clone(), + namespace: task.namespace().unwrap_or_default(), + phase, + parent: task.spec.parent_ref.as_ref().map(|p| p.name.clone()), + task: Some(name.clone()), + objective: Some(clean_objective(&task.spec.objective)), + tier: Some(task.spec.envelope.tier), + rounds, + tool_calls, + last_action, + live, + tokens, + budget_tokens: task.spec.envelope.budget.as_ref().and_then(|b| b.tokens), + status, + finished_at, + team, + display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), + health, + }); + } + + // Live runs first, then most-recent finished. + out.sort_by(|a, b| b.live.cmp(&a.live).then(b.finished_at.cmp(&a.finished_at))); + Ok(Json(out)) +} + +// ─── Fleet live telemetry (at-scale "what's happening now") ────────────────── + +#[derive(serde::Serialize)] +pub struct FleetActivityItem { + /// The run/agent this event came from. + pub agent: String, + pub display_name: Option, + pub team: Option, + /// "tool" | "round". + pub kind: String, + /// For a tool event, the tool name; for a round, the finish reason. + pub label: String, + /// Optional short argument/host preview for a tool event. + pub detail: Option, + /// Whether a tool event failed (ok=false) — surfaced in red. + pub failed: bool, + /// Round index the event belongs to. + pub round: i64, + /// Monotonic sequence within the run's trace (for stable ordering). + pub seq: i64, + /// Milliseconds the step took, when known. + pub ms: Option, +} + +#[derive(serde::Serialize)] +pub struct FleetTelemetryDto { + /// Agents whose sandbox pod is running right now. + pub working: usize, + /// Distinct standing teams with a live run. + pub teams_active: usize, + /// Sub-agents (runs with a parent) currently live. + pub sub_agents: usize, + /// Live token burn summed across working agents (from their in-flight trace). + pub tokens_in_flight: i64, + /// Tool calls summed across working agents this run. + pub tool_calls: i64, + /// Model rounds summed across working agents this run. + pub rounds: i64, + /// The most recent activity across ALL live agents, newest first — a single + /// chronological fleet feed of what every working agent is doing right now. + pub feed: Vec, +} + +/// `GET /api/agents/fleet` — aggregate LIVE telemetry across every working +/// agent, plus a single merged activity feed of what they're all doing right +/// now. This is the "at scale" view: instead of drilling into one mission, see +/// the whole fleet's live tool-by-tool work in one stream. Sourced from each +/// live run's real execution trace — never fabricated; an idle fleet returns +/// zeros and an empty feed. +pub async fn fleet_telemetry( + State(state): State, + Extension(principal): Extension, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let tasks_api = cluster.tasks("kars-system"); + let all = tasks_api + .list(&kube::api::ListParams::default()) + .await + .map_err(map_kube_err)?; + + let runs: Vec<&KarsTask> = all + .items + .iter() + .filter(|task| is_surfaceable_run(task) && is_task_owner(task, &principal)) + .collect(); + + let mut working = 0usize; + let mut teams: std::collections::BTreeSet = Default::default(); + let mut sub_agents = 0usize; + let mut tokens_in_flight = 0i64; + let mut tool_calls = 0i64; + let mut rounds = 0i64; + let mut feed: Vec = Vec::new(); + + for task in runs { + let name = task.name_any(); + // Only agents that are actually running right now contribute trace. + if cluster.running_pod_for_sandbox(&name).await.is_none() { + continue; + } + // A delivered direct mission keeps a lingering Running pod but is idle — + // its historical tokens are NOT "in flight". Skip it here so the live + // counters reflect only work happening now (it still shows, with its + // outcome, in the Recent runs list from /api/agents). + if cluster + .read_mission_output(&name) + .await + .and_then(|d| d.get("status").cloned()) + .is_some() + { + continue; + } + working += 1; + for sub in cluster.sub_agent_sandbox_names("kars-system", &name).await { + if cluster.running_pod_for_sandbox(&sub).await.is_some() { + working += 1; + sub_agents += 1; + } + } + let team = task + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/team").cloned()); + if let Some(t) = &team { + teams.insert(t.clone()); + } + let display_name = clean_display_name(&task.spec.display_name, &task.spec.objective); + + // Prefer the persisted trace (delivered runs); for a LIVE run the trace + // CM doesn't exist yet, so fall back to the router's live trace — else + // every actively-working agent shows zero rounds/tokens/tools (the exact + // opposite of "what's happening now"). Mirrors get_task's live fallback. + let trace: Vec = match cluster + .read_mission_trace(&name) + .await + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + { + Some(t) if !t.is_empty() => t, + _ => cluster.sandbox_live_trace(&name).await, + }; + if trace.is_empty() { + continue; + } + + for e in &trace { + let kind = e.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + let round = e.get("round").and_then(|v| v.as_i64()).unwrap_or(0); + let seq = e.get("seq").and_then(|v| v.as_i64()).unwrap_or(0); + let ms = e.get("ms").and_then(|v| v.as_i64()); + match kind { + "round" => { + rounds += 1; + tokens_in_flight += e.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + feed.push(FleetActivityItem { + agent: name.clone(), + display_name: display_name.clone(), + team: team.clone(), + kind: "round".into(), + label: e + .get("finish_reason") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("model round") + .to_string(), + detail: None, + failed: false, + round, + seq, + ms, + }); + } + "tool" => { + tool_calls += 1; + let failed = e.get("ok").and_then(|v| v.as_bool()) == Some(false); + feed.push(FleetActivityItem { + agent: name.clone(), + display_name: display_name.clone(), + team: team.clone(), + kind: "tool".into(), + label: e + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("tool") + .to_string(), + detail: e + .get("args_preview") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.chars().take(80).collect()), + failed, + round, + seq, + ms, + }); + } + _ => {} + } + } + } + + // Newest activity first, capped so a busy fleet stays responsive. + feed.sort_by(|a, b| b.round.cmp(&a.round).then(b.seq.cmp(&a.seq))); + feed.truncate(40); + + Ok(Json(FleetTelemetryDto { + working, + teams_active: teams.len(), + sub_agents, + tokens_in_flight, + tool_calls, + rounds, + feed, + })) +} + +#[cfg(test)] +mod tests { + use super::BlueprintDto; + use super::ExecutionPhaseDto; + use super::ExecutionPlanDto; + use super::ExecutionRoleDto; + use super::ExecutionSynthesisDto; + use super::MissionResultDto; + use super::ModelDto; + use super::TaskAssignmentEventDto; + use super::TeamCollaborationEventDto; + use super::canonicalize_assignment_event_roles; + use super::clean_objective; + use super::deliverable_pull_requests; + use super::diagnose_run_failure; + use super::merge_trace_total_tokens; + use super::normalize_ttl; + use super::select_task_checkpoint; + use super::to_sub_agent; + use super::{ + ARTIFACT_PREVIEW_MAX_BYTES, ARTIFACT_PREVIEW_TOTAL_BYTES, MissionArtifactDto, + artifact_preview, structured_team_evidence, subagent_trace_from_artifacts, + valid_task_checkpoint, + }; + + #[test] + fn execution_plan_dto_into_crd_preserves_web_search_capability() { + let blueprint = BlueprintDto { + execution_plan: Some(ExecutionPlanDto { + schema: "kars.execution-plan/v1".into(), + roles: vec![ExecutionRoleDto { + name: "source-scout".into(), + objective: "Discover exact URLs and fetch the evidence.".into(), + depends_on: Vec::new(), + phases: vec![ExecutionPhaseDto { + name: "discover".into(), + objective: "Search and fetch the exact URLs.".into(), + capabilities: vec!["web-search".into(), "network".into()], + required_tool_calls: Vec::new(), + min_tool_calls: 1, + max_tool_calls: 4, + fresh_context: true, + }], + budget_tokens: None, + }], + max_parallel: 1, + synthesis: ExecutionSynthesisDto { + objective: "Return the verified answer.".into(), + capabilities: Vec::new(), + max_tool_calls: 0, + }, + deliverables: Vec::new(), + }), + ..Default::default() + }; + + let crd = blueprint.into_crd(); + assert_eq!( + crd.execution_plan.expect("execution plan").roles[0].phases[0].capabilities, + vec!["web-search".to_string(), "network".to_string()] + ); + } + + #[test] + fn blueprint_dto_preserves_ordered_model_fallbacks() { + let blueprint = BlueprintDto { + model: Some(ModelDto { + provider: "local-inference".into(), + deployment: "gpt-oss-120b".into(), + }), + model_fallbacks: vec![ + ModelDto { + provider: "github-copilot".into(), + deployment: "gpt-5.6-sol".into(), + }, + ModelDto { + provider: "foundry".into(), + deployment: "gpt-5.4-pro".into(), + }, + ], + ..Default::default() + }; + + let crd = blueprint.into_crd(); + assert_eq!(crd.model_fallbacks.len(), 2); + assert_eq!(crd.model_fallbacks[0].provider, "github-copilot"); + assert_eq!(crd.model_fallbacks[1].deployment, "gpt-5.4-pro"); + } + + #[test] + fn schema_rejection_is_diagnosed_as_router_model_compatibility() { + let logs = vec![ + "rawError=400 Unknown parameter: 'stream_options.include_usage'".to_string(), + "LLM request failed: provider rejected the request schema or tool payload.".to_string(), + ]; + let (cause, remedy, harness_issue, evidence) = + diagnose_run_failure(&logs, &[], Some("provider rejected the request schema")); + assert!(cause.contains("translated inference request")); + assert!(remedy.contains("corrected inference router")); + assert!(!harness_issue); + assert_eq!(evidence.len(), 1); + } + + #[test] + fn completed_subagent_trace_is_rehydrated_from_artifact() { + let artifacts = vec![MissionArtifactDto { + name: "artifacts/.run-x/subagent-telemetry.jsonl".into(), + size_bytes: None, + content: Some( + r#"{"at":"2026-07-23T12:00:00Z","event":"subagent_trace","member":"ci-verifier","trace":{"kind":"tool","name":"github_checks","ok":true}}"# + .into(), + ), + content_bytes: None, + content_truncated: false, + source_agent: None, + source_path: None, + digest: None, + full_content: None, + }]; + + let events = subagent_trace_from_artifacts(&artifacts); + + assert_eq!(events.len(), 1); + assert_eq!(events[0]["agent"], "ci-verifier"); + assert_eq!(events[0]["agentRole"], "subagent"); + assert_eq!(events[0]["ts"], "2026-07-23T12:00:00Z"); + assert_eq!(events[0]["name"], "github_checks"); + } + + #[test] + fn artifact_preview_is_bounded_but_full_content_remains_internal() { + let mut budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + let full = "x".repeat(ARTIFACT_PREVIEW_MAX_BYTES + 100); + let (preview, bytes, truncated, internal) = + artifact_preview(Some(full.clone()), &mut budget); + + assert_eq!( + preview.as_ref().map(String::len), + Some(ARTIFACT_PREVIEW_MAX_BYTES) + ); + assert_eq!(bytes, Some(full.len() as i64)); + assert!(truncated); + assert_eq!(internal.as_deref(), Some(full.as_str())); + } + + #[test] + fn artifact_previews_share_a_bounded_response_budget() { + let mut budget = ARTIFACT_PREVIEW_MAX_BYTES + 100; + let first = "a".repeat(ARTIFACT_PREVIEW_MAX_BYTES + 1); + let second = "b".repeat(ARTIFACT_PREVIEW_MAX_BYTES); + + let (first_preview, _, first_truncated, _) = artifact_preview(Some(first), &mut budget); + let (second_preview, _, second_truncated, _) = artifact_preview(Some(second), &mut budget); + + assert_eq!( + first_preview.as_ref().map(String::len), + Some(ARTIFACT_PREVIEW_MAX_BYTES) + ); + assert_eq!(second_preview.as_ref().map(String::len), Some(100)); + assert!(first_truncated); + assert!(second_truncated); + assert_eq!(budget, 0); + } + + #[test] + fn empty_artifact_is_not_reported_as_truncated() { + let mut budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + let (preview, bytes, truncated, internal) = + artifact_preview(Some(String::new()), &mut budget); + + assert_eq!(preview.as_deref(), Some("")); + assert_eq!(bytes, Some(0)); + assert!(!truncated); + assert_eq!(internal.as_deref(), Some("")); + } + + #[test] + fn artifact_preview_respects_utf8_boundaries_and_exhausted_budget() { + let mut budget = 5; + let (preview, bytes, truncated, _) = + artifact_preview(Some("abcd\u{1f642}".to_string()), &mut budget); + + assert_eq!(preview.as_deref(), Some("abcd")); + assert_eq!(bytes, Some(8)); + assert!(truncated); + assert_eq!(budget, 1); + + budget = 0; + let (preview, bytes, truncated, _) = + artifact_preview(Some("still here".to_string()), &mut budget); + assert!(preview.is_none()); + assert_eq!(bytes, Some(10)); + assert!(truncated); + } + + #[test] + fn full_artifact_content_is_private_but_available_for_trace_recovery() { + let telemetry = r#"{"at":"2026-07-23T12:00:00Z","event":"subagent_trace","member":"ci-verifier","trace":{"kind":"tool","name":"github_checks","ok":true}}"#; + let artifact = MissionArtifactDto { + name: "artifacts/.run-x/subagent-telemetry.jsonl".into(), + size_bytes: Some(telemetry.len() as i64), + content: Some("{\"at\":\"2026".into()), + content_bytes: Some(telemetry.len() as i64), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some(telemetry.into()), + }; + + let serialized = serde_json::to_value(&artifact).expect("serialize artifact preview"); + assert_eq!(serialized["content"], "{\"at\":\"2026"); + assert!(serialized.get("full_content").is_none()); + + let events = subagent_trace_from_artifacts(&[artifact]); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["name"], "github_checks"); + } + + #[test] + fn structured_team_evidence_uses_full_content_not_preview_order() { + let role_plan = MissionArtifactDto { + name: "role-plan.json".into(), + size_bytes: None, + content: None, + content_bytes: Some(91), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some( + r#"{"selected_roles":[{"role":"builder"}],"skipped_roles":["observer"]}"#.into(), + ), + }; + let collaboration = MissionArtifactDto { + name: "collaboration.jsonl".into(), + size_bytes: None, + content: Some("{\"at\":\"truncated".into()), + content_bytes: Some(200), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some( + r#"{"at":"2026-07-23T12:00:00Z","event":"child_handback","from_agent":"builder","outcome":"success","reply_preview":"done"}"# + .into(), + ), + }; + + let (plan, events) = structured_team_evidence(&[role_plan, collaboration]); + + assert_eq!(plan.selected_roles, ["builder"]); + assert_eq!(plan.skipped_roles, ["observer"]); + assert_eq!(events.len(), 1); + assert_eq!(events[0].member.as_deref(), Some("builder")); + assert_eq!(events[0].event, "child_handback"); + } + + #[test] + fn assignment_ledger_uses_canonical_role_from_assignment_message() { + let mut events = vec![TaskAssignmentEventDto { + sequence: 1, + event_id: "event-1".into(), + task_id: "run-1".into(), + event_type: "child_progress".into(), + state: "Completed".into(), + at: "2026-08-04T21:36:50Z".into(), + worker_did: None, + stage: Some("child_handback".into()), + child_task_id: Some("message-1".into()), + child_role: Some("principal-remediatio-b9220dcf".into()), + outcome: Some("success".into()), + message: None, + }]; + let collaboration = vec![TeamCollaborationEventDto { + at: Some("2026-08-04T21:35:32Z".into()), + event: "assignment_sent".into(), + agent: Some("principal".into()), + member: Some("remediation-engineer".into()), + outcome: None, + message_id: Some("message-1".into()), + reply_preview: None, + content_preview: None, + }]; + + canonicalize_assignment_event_roles(&mut events, &collaboration); + + assert_eq!( + events[0].child_role.as_deref(), + Some("remediation-engineer") + ); + } + + #[test] + fn successful_result_hides_stale_bootstrap_checkpoint() { + let progress = serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "dependency-pr", + "status": "in_progress", + "summary": "Controller initialized the durable milestone checkpoint." + }); + + assert!(select_task_checkpoint(Some(progress.clone()), &[], true).is_none()); + assert!(select_task_checkpoint(Some(progress), &[], false).is_some()); + } + + #[test] + fn completed_artifact_checkpoint_wins_over_bootstrap_progress() { + let completed = r#"{ + "schema": "kars.checkpoint/v1", + "milestone_id": "dependency-pr", + "status": "completed", + "summary": "All required handbacks were retained." + }"#; + let artifact = MissionArtifactDto { + name: "task-checkpoint.json".into(), + size_bytes: Some(completed.len() as i64), + content: None, + content_bytes: Some(completed.len() as i64), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some(completed.into()), + }; + let progress = serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "dependency-pr", + "status": "in_progress", + "summary": "Controller initialized the durable milestone checkpoint." + }); + + let checkpoint = + select_task_checkpoint(Some(progress), &[artifact], true).expect("checkpoint"); + + assert_eq!(checkpoint["status"], "completed"); + } + + #[test] + fn aggregate_trace_tokens_replace_principal_only_total() { + let mut result = Some(MissionResultDto { + output: "done".into(), + status: Some("ok".into()), + model: None, + total_tokens: Some(8_505), + prompt_tokens: None, + completion_tokens: None, + finished_at: None, + assignment_nonce: None, + source: None, + blocked: None, + artifact_persistence: None, + artifact_count: None, + declared_artifact_count: None, + }); + + merge_trace_total_tokens(&mut result, 22_016); + + assert_eq!(result.and_then(|value| value.total_tokens), Some(22_016)); + } + + #[test] + fn subagent_projects_human_identity_and_runtime_metadata() { + let object: kube::core::DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { + "name": "researcher-run-7f4c", + "namespace": "kars-team", + "labels": { + "kars.azure.com/role": "Research specialist", + "kars.azure.com/parent": "principal-run" + }, + "annotations": { + "kars.azure.com/logical-agent-id": "researcher", + "kars.azure.com/model": "gpt-5.4" + } + }, + "spec": { + "runtime": { + "kind": "openclaw" + } + }, + "status": { + "phase": "Running" + } + })) + .expect("deserialize KarsSandbox"); + + let dto = to_sub_agent(&object); + + assert_eq!(dto.name, "researcher-run-7f4c"); + assert_eq!(dto.namespace, "kars-team"); + assert_eq!(dto.phase.as_deref(), Some("Running")); + assert_eq!(dto.runtime.as_deref(), Some("openclaw")); + assert_eq!(dto.role.as_deref(), Some("Research specialist")); + assert_eq!(dto.parent.as_deref(), Some("principal-run")); + assert_eq!(dto.logical_agent_id.as_deref(), Some("researcher")); + assert_eq!(dto.model.as_deref(), Some("gpt-5.4")); + } + + #[test] + fn malformed_checkpoint_is_not_exposed_to_the_ui() { + assert!( + valid_task_checkpoint(serde_json::json!({ + "schema": "kars.checkpoint/v1", + "status": "completed" + })) + .is_none() + ); + assert!( + valid_task_checkpoint(serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "build", + "status": "completed", + "summary": "Artifact produced", + "artifacts": "not-an-array" + })) + .is_none() + ); + assert!( + valid_task_checkpoint(serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "build", + "status": "completed", + "summary": "Artifact produced" + })) + .is_some() + ); + } + + #[test] + fn clean_objective_strips_loop_scaffold() { + // A leaked loop scaffold must never reach a title — extract the GOAL. + let scaffolded = "LOOP: ReAct — Reason + Act\nGOAL: find the Azure/kars star count and write a paragraph\nCYCLE: reason, act, observe\nSUCCESS: a paragraph with the count\nSTOP: when delivered\nSUB-AGENT INHERITANCE: give each sub-agent the same loop"; + assert_eq!( + clean_objective(scaffolded), + "find the Azure/kars star count and write a paragraph" + ); + } + + #[test] + fn clean_objective_passes_plain_through() { + assert_eq!( + clean_objective("Summarize the Q3 report"), + "Summarize the Q3 report" + ); + } + + #[test] + fn clean_objective_strips_bracket_goal() { + let s = "LOOP: eval-iterate\nGOAL: [[raise CLI test coverage]]\nSTOP: green"; + assert_eq!(clean_objective(s), "raise CLI test coverage"); + } + + #[test] + fn deliverable_text_strips_fixed_sandbox_banner() { + let raw = "# ? kars Sandbox - Secure AI Runtime on Azure\n\ + - **Foundry Project:** project\n\ + - **Model:** gpt\n\ + - **Sandbox ID:** run-1\n\ + - **Security:** isolated\n\ + - **Capabilities:** tools and reasoning\n\n\ + [[NO_MATERIAL_CHANGE]] nothing changed."; + assert_eq!( + super::deliverable_text(raw), + "[[NO_MATERIAL_CHANGE]] nothing changed." + ); + } + + #[test] + fn deliverable_text_repairs_legacy_question_mark_replacements() { + assert_eq!( + super::deliverable_text("1? Role?plan: non?root; GHSA?w8wr?v893?vjvp"), + "1. Role-plan: non-root; GHSA-w8wr-v893-vjvp" + ); + } + + #[test] + fn real_deliverable_gates_error_and_no_change() { + use super::is_real_deliverable; + assert!(!is_real_deliverable(Some("error"), "anything")); + assert!(!is_real_deliverable(Some("ok"), " ")); + assert!(!is_real_deliverable( + Some("ok"), + "[[NO_MATERIAL_CHANGE]] nothing changed" + )); + assert!(!is_real_deliverable( + Some("ok"), + "kars Sandbox - Secure AI Runtime on Azure\nSandbox ID: run-1\nSecurity: isolated\nCapabilities: tools\n[[NO_MATERIAL_CHANGE]] nothing changed" + )); + assert!(is_real_deliverable(Some("ok"), "Here is the report.")); + assert!(is_real_deliverable(None, "Some output")); + // A budget-blocked ok-run is NOT a deliverable. + assert!(!is_real_deliverable( + Some("ok"), + "API call failed after 3 retries: HTTP 429: Daily token budget exceeded (23131/20000 tokens)." + )); + assert!(!is_real_deliverable( + Some("ok"), + "unexpected tokens remaining in message header: Some(...)" + )); + assert!(!is_real_deliverable( + Some("ok"), + "assignment progress lease expired after 90s without renewal" + )); + assert!(is_real_deliverable( + Some("ok"), + "Completed remediation successfully. A prior child reported assignment progress lease expired, but its replacement delivered." + )); + } + + #[test] + fn failed_output_cannot_create_pull_request_deliverables() { + let data = std::collections::BTreeMap::from([ + ("status".to_string(), "error".to_string()), + ( + "output".to_string(), + "Claimed https://github.com/example/repo/pull/134".to_string(), + ), + ]); + assert!(deliverable_pull_requests(&data).is_empty()); + } + + #[test] + fn classify_blocked_detects_budget_and_parses_pair() { + use super::classify_blocked; + let b = classify_blocked( + Some("ok"), + "API call failed after 3 retries: HTTP 429: Daily token budget exceeded (23131/20000 tokens).", + ) + .expect("budget block detected"); + assert_eq!(b.reason, "budget"); + assert_eq!(b.spent, Some(23131)); + assert_eq!(b.limit, Some(20000)); + // A real deliverable is not blocked. + assert!(classify_blocked(Some("ok"), "Here is the finished report.").is_none()); + // An error run is handled elsewhere, not as blocked. + assert!(classify_blocked(Some("error"), "Daily token budget exceeded").is_none()); + } + + #[test] + fn assignment_dto_uses_the_web_snake_case_contract() { + let event = TaskAssignmentEventDto { + sequence: 3, + event_id: "root:3".into(), + task_id: "root".into(), + event_type: "child_progress".into(), + state: "Completed".into(), + at: "2026-07-20T12:00:00Z".into(), + worker_did: Some("did:agt:worker".into()), + stage: Some("child_handback".into()), + child_task_id: Some("child-1".into()), + child_role: Some("reviewer".into()), + outcome: Some("success".into()), + message: None, + }; + let value = serde_json::to_value(event).expect("serialize assignment event"); + assert_eq!(value["child_task_id"], "child-1"); + assert_eq!(value["child_role"], "reviewer"); + assert_eq!(value["event_type"], "child_progress"); + assert!(value.get("childTaskId").is_none()); + } + + #[test] + fn deliverable_excerpt_strips_noise() { + use super::deliverable_excerpt; + let raw = + "[[NO_MATERIAL_CHANGE]]\n# Heading\n| a | b |\n---\nThe repo star count is 1,234."; + let ex = deliverable_excerpt(raw); + assert!(ex.contains("star count")); + assert!(!ex.contains("NO_MATERIAL_CHANGE")); + assert!(!ex.contains('|')); + } + + #[test] + fn team_run_names_are_detected() { + use super::regex_lite_is_team_run; + assert!(regex_lite_is_team_run("kars-repo-health-run-1783099875")); + assert!(regex_lite_is_team_run("ci-monitor-team-run-42")); + // Standalone missions and non-numeric suffixes are NOT team runs. + assert!(!regex_lite_is_team_run("audit-the-readme")); + assert!(!regex_lite_is_team_run("some-run-abc")); + assert!(!regex_lite_is_team_run("foo-run-")); + assert!(!regex_lite_is_team_run("plainname")); + } + + #[test] + fn deliverable_excerpt_drops_markdown_wrapped_sentinel() { + use super::deliverable_excerpt; + // Bold-/emphasis-wrapped sentinel must still be recognized and dropped + // (regression: it used to leak into the excerpt because the sentinel + // check ran before markdown-wrapping was stripped). + let raw = "**[[NO_MATERIAL_CHANGE]]** +3 stars\nThe repo now has 1,234 stars."; + let ex = deliverable_excerpt(raw); + assert!( + !ex.contains("NO_MATERIAL_CHANGE"), + "excerpt leaked sentinel: {ex}" + ); + assert!(ex.contains("1,234 stars")); + } + + #[test] + fn clean_display_name_prefers_intent_over_scaffold() { + use super::clean_display_name; + // Scaffold display name -> derive (capitalized) from objective. + assert_eq!( + clean_display_name( + &Some("LOOP: ReAct".to_string()), + "GOAL: count the stars\nSTOP: done" + ), + Some("Count the stars".to_string()) + ); + // Real display name -> kept. + assert_eq!( + clean_display_name(&Some("Weekly repo digest".to_string()), "whatever"), + Some("Weekly repo digest".to_string()) + ); + // A conversational prompt pasted into the display slot is NOT a title — + // derive a concise one: strip the lead-in, shorten the URL, drop the + // trailing "- …" condition tail, capitalize. + assert_eq!( + clean_display_name( + &Some( + "Can you please check https://github.com/Azure/kars and analyse all dependabot PR" + .to_string() + ), + "Can you please check https://github.com/Azure/kars and analyse all dependabot PRs - categorize the ones which are safe to merge", + ), + Some("Check Azure/kars and analyse all dependabot PRs".to_string()) + ); + } + + #[test] + fn concise_title_strips_lead_in_and_shortens_url() { + use super::concise_title; + assert_eq!( + concise_title("I need you to summarise https://example.com/reports/q3 today"), + "Summarise q3 today".to_string() + ); + // Long objective is capped at a word boundary with an ellipsis. + let long = "review every open pull request across the entire organisation and produce a ranked risk report"; + let t = concise_title(long); + assert!(t.chars().count() <= 57, "title too long: {t}"); + assert!(t.ends_with('…'), "expected ellipsis: {t}"); + assert!(t.starts_with("Review "), "expected capitalized start: {t}"); + } + + #[test] + fn ttl_human_to_iso8601() { + assert_eq!(normalize_ttl("2h"), "PT2H"); + assert_eq!(normalize_ttl("30m"), "PT30M"); + assert_eq!(normalize_ttl("24h"), "PT24H"); + assert_eq!(normalize_ttl("1d"), "P1D"); + assert_eq!(normalize_ttl("90s"), "PT90S"); + assert_eq!(normalize_ttl(" 8 h "), "PT8H"); + } + + #[test] + fn ttl_passthrough_and_fallback() { + assert_eq!(normalize_ttl("PT2H"), "PT2H"); // already ISO + assert_eq!(normalize_ttl("pt45m"), "PT45M"); // uppercased + assert_eq!(normalize_ttl(""), "PT2H"); // empty → default + assert_eq!(normalize_ttl("garbage"), "PT2H"); // unrecognized → default + assert_eq!(normalize_ttl("0h"), "PT2H"); // zero → default + } +} diff --git a/bridge/bff/src/routes/teams.rs b/bridge/bff/src/routes/teams.rs new file mode 100644 index 000000000..5f42dd95a --- /dev/null +++ b/bridge/bff/src/routes/teams.rs @@ -0,0 +1,3548 @@ +// kars Bridge BFF — Teams API DTOs + handlers. +// +// A Team (KarsTeam) is a standing org with a charter and a cadence loop that +// mints task-force KarsTasks autonomously (design note §11). These endpoints +// project the typed CRD into stable, browser-facing JSON so the Teams surface +// never depends on raw Kubernetes envelopes. Read-only: the controller is the +// sole writer of team membership + generated tasks. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::team::KarsTeam; +use crate::routes::options::{ModelOption, Options, RefOption, build_options}; +use crate::routes::tasks::{ + clean_display_name, clean_objective, deliverable_excerpt, deliverable_text, + extract_pull_requests, is_failure_shaped_output, is_no_change_output, require_cluster, +}; +use kube::ResourceExt; +use kube::api::{Api, ListParams, Patch, PatchParams}; + +/// The on-disk commons index entry (mirrors the controller's `CommonsEntry`). +#[derive(Debug, Deserialize)] +pub struct CommonsIndexEntry { + pub id: String, + pub title: String, + pub author: String, + pub source_task: String, + pub created_at: String, + pub digest: String, + pub size_bytes: i64, +} + +/// Browser-facing commons entry — the index record plus resolved content. +#[derive(Debug, Serialize)] +pub struct CommonsEntryDto { + pub id: String, + pub title: String, + pub author: String, + pub source_task: String, + pub created_at: String, + pub digest: String, + pub size_bytes: i64, + pub content: Option, +} + +/// Browser-facing commons response. +#[derive(Debug, Serialize)] +pub struct CommonsResponse { + pub commons: String, + pub count: i64, + pub entries: Vec, +} + +fn commons_entry_key(id: &str) -> String { + format!( + "entry-{}", + id.chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '_' + } + },) + .collect::() + ) +} + +/// One seat in the team roster (the org chart). +#[derive(Debug, Serialize)] +pub struct TeamRoleDto { + pub name: String, + pub system_prompt: Option, + pub tier: Option, + /// The materialized member task name, when reconciled. + pub member_task: Option, + /// Skills (KarsSkill names) this role acquires (§13). + pub skills: Vec, + /// Per-member harness (runtime kind) — the differentiator: members can each + /// run a different harness. `None` means inherit the team default. + pub runtime: Option, + /// Per-member model deployment — `None` means inherit the team default. + pub model: Option, +} + +/// Browser-facing team summary for the Teams index. +#[derive(Debug, Serialize)] +pub struct TeamSummaryDto { + pub name: String, + pub display_name: Option, + pub charter: String, + pub phase: String, + pub reporting_to: Option, + pub tier: i32, + pub member_count: i64, + pub generated_task_count: i64, + pub every_minutes: Option, + pub lifecycle_mode: String, + pub warm_idle_seconds: Option, + pub runtime_state: Option, + pub current_assignment_task: Option, + pub idle_deadline_at: Option, + pub paused: bool, + pub created_at: Option, + pub last_run_at: Option, + pub last_success_at: Option, + pub last_activity_at: Option, + pub next_run_at: Option, + pub health: Option, + pub detail: Option, + /// Delivered standing-run count — shown alongside generated so a card + /// surfaces actual yield, not just scheduling activity. + pub runs_succeeded: i64, + pub retained_delivered: i64, + pub retained_no_action: i64, + pub retained_failed: i64, +} + +/// Browser-facing team detail (charter, org chart, watching status, history). +#[derive(Debug, Serialize)] +pub struct TeamDetailDto { + pub name: String, + pub display_name: Option, + pub charter: String, + pub phase: String, + pub reporting_to: Option, + pub knowledge_commons: Option, + pub tier: i32, + pub authority_ceiling: i32, + pub delegation_depth: i32, + pub paused: bool, + pub every_minutes: Option, + pub lifecycle_mode: String, + pub warm_idle_seconds: Option, + pub runtime_state: Option, + pub current_assignment_nonce: Option, + pub current_assignment_task: Option, + pub idle_deadline_at: Option, + pub envelope_digest: Option, + pub principal_task: Option, + pub roster: Vec, + pub member_count: i64, + pub generated_task_count: i64, + pub last_generated_task: Option, + pub last_run_at: Option, + pub next_run_at: Option, + pub detail: Option, + pub health: Option, + pub runs_succeeded: i64, + pub tokens_spent_total: i64, + pub commons_entry_count: i64, + pub last_success_at: Option, + pub created_at: Option, + pub last_activity_at: Option, + /// The task-force tasks the charter loop has minted, newest first. + pub generated_tasks: Vec, + /// Customer-facing outcomes for retained runs, newest first. A cadence tick + /// that correctly finds no work is a resolved outcome, not a failed delivery. + pub recent_outcomes: Vec, + pub recent_outcome_summary: TeamOutcomeSummaryDto, + /// What the team can reach/use — surfaced for management at a glance. + /// `*_default` flags mark values inherited from the cluster (no explicit + /// blueprint override) so the UI can label them honestly rather than + /// implying the operator chose them. + pub tool_policy: Option, + pub tool_policy_default: bool, + pub mcp_servers: Vec, + pub git_write_repos: Vec, + pub egress: Vec, + pub egress_mode: Option, + /// Domains the team's agents have ACTUALLY reached, aggregated live from the + /// learn-mode observation buffer of its currently-running run sandboxes. This + /// is the concrete "what has it touched so far" — distinct from the declared + /// `egress` allowlist. Empty when no run is active (the buffer is per-run). + pub learned_egress: Vec, + /// Network reachability posture when no explicit egress is declared — the + /// kernel-level default-deny stance every sandbox runs under. + pub network_posture: String, + pub model: Option, + pub model_fallbacks: Vec, + pub model_default: bool, + pub memory: Option, + /// The harness every run this team mints executes on (blueprint override, + /// else the sandbox default OpenClaw). `runtime_default` marks the inherited + /// case so the UI can badge it honestly. + pub runtime: Option, + pub runtime_default: bool, + pub isolation: Option, + pub execution_plan: Option, + /// The team's assigned task backlog (pending/active/done), newest last. + pub tasks: Vec, + /// Communication channels enabled on this team's envelope (telegram/slack/…). + pub channels: Vec, +} + +#[derive(Debug, Serialize)] +pub struct TeamOutcomeDto { + pub run: String, + pub disposition: String, + pub headline: String, + pub detail: String, + pub objective: String, + pub finished_at: Option, + pub duration_seconds: Option, + pub tokens: Option, + pub model: Option, + pub pull_requests: Vec, + pub artifact_count: i64, +} + +#[derive(Debug, Default, Serialize)] +pub struct TeamOutcomeSummaryDto { + pub change_proposed: i64, + pub no_action_needed: i64, + pub completed: i64, + pub failed: i64, +} + +fn run_started_at(run: &str) -> Option> { + let epoch = run.rsplit("-run-").next()?.get(..10)?.parse::().ok()?; + chrono::DateTime::from_timestamp(epoch, 0) +} + +fn is_internal_artifact_name(name: &str) -> bool { + let normalized = name.to_ascii_lowercase(); + normalized.contains("collaboration.jsonl") + || normalized.ends_with("role-plan.json") + || normalized.ends_with("research-evidence.jsonl") + || normalized.ends_with("activity.jsonl") + || normalized.ends_with("subagent-telemetry.jsonl") + || normalized.ends_with("execution-contract.json") + || normalized.ends_with("task-checkpoint.json") +} + +fn outcome_work_item(raw: &str) -> String { + let objective = clean_objective(raw); + if let Some(task) = objective + .split_once("TASK:") + .map(|(_, rest)| rest) + .map(|rest| rest.split("DETAILS:").next().unwrap_or(rest)) + .and_then(|rest| rest.lines().next()) + .map(str::trim) + .filter(|task| !task.is_empty()) + { + return task.chars().take(180).collect(); + } + clean_display_name(&None, &objective).unwrap_or(objective) +} + +fn outcome_from_output( + run: String, + data: &std::collections::BTreeMap, +) -> TeamOutcomeDto { + let status = data.get("status").map(String::as_str); + let raw_output = data.get("output").map(String::as_str).unwrap_or(""); + let output = deliverable_text(raw_output); + let objective = outcome_work_item(data.get("objective").map(String::as_str).unwrap_or("")); + let pull_requests = extract_pull_requests(&output); + let no_action = is_no_change_output(&output); + let disposition = if status == Some("error") || is_failure_shaped_output(&output) { + "failed" + } else if !pull_requests.is_empty() { + "change_proposed" + } else if no_action { + "no_action_needed" + } else { + "completed" + }; + let detail = deliverable_excerpt(&output); + let headline = if let Some(pr) = pull_requests.first() { + format!("Change proposed in {} PR #{}", pr.repo, pr.number) + } else if disposition == "no_action_needed" { + if detail.is_empty() { + "No action needed".to_string() + } else { + detail.clone() + } + } else if disposition == "failed" { + let lower = output.to_ascii_lowercase(); + if lower.contains("unexpected tokens remaining in message header") { + "Agent response parser failed".to_string() + } else if lower.contains("kars sandbox - secure ai runtime") + && lower.contains("how can i help") + { + "Agent returned its runtime banner instead of work".to_string() + } else if lower.contains("llm request failed") + || lower.contains("network connection") + || lower.contains("connection refused") + { + "Model or network request failed".to_string() + } else if lower.contains("now await") + || lower.contains("awaiting handback") + || lower.contains("waiting for") && lower.contains("handback") + { + "Team run ended before all selected roles returned".to_string() + } else if detail.is_empty() { + "Run failed before producing an outcome".to_string() + } else { + detail.clone() + } + } else { + clean_display_name(&None, &detail) + .or_else(|| clean_display_name(&None, &objective)) + .unwrap_or_else(|| "Completed work".to_string()) + }; + let finished_at = data.get("finishedAt").cloned(); + let duration_seconds = finished_at + .as_deref() + .and_then(|finished| chrono::DateTime::parse_from_rfc3339(finished).ok()) + .and_then(|finished| { + run_started_at(&run).map(|started| { + finished + .with_timezone(&chrono::Utc) + .signed_duration_since(started) + .num_seconds() + .max(0) + }) + }); + let artifact_count = data + .get("artifacts") + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .map(|artifacts| { + artifacts + .iter() + .filter(|artifact| { + artifact + .get("name") + .and_then(serde_json::Value::as_str) + .is_none_or(|name| !is_internal_artifact_name(name)) + }) + .count() as i64 + }) + .unwrap_or(0); + TeamOutcomeDto { + run, + disposition: disposition.to_string(), + headline, + detail, + objective, + finished_at, + duration_seconds, + tokens: data.get("totalTokens").and_then(|value| value.parse().ok()), + model: data.get("model").cloned(), + pull_requests, + artifact_count, + } +} + +fn phase_of(team: &KarsTeam) -> String { + team.status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_else(|| "Forming".to_string()) +} + +fn is_team_owner(team: &KarsTeam, principal: &Principal) -> bool { + team.annotations() + .get("kars.azure.com/owner-sub") + .is_some_and(|subject| subject == &principal.sub) +} + +pub(crate) async fn require_owned_team( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult { + let team = cluster + .teams(ns) + .get_opt(name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or(AppError::NotFound)?; + if !is_team_owner(&team, principal) { + return Err(AppError::NotFound); + } + Ok(team) +} + +fn to_summary(team: &KarsTeam) -> TeamSummaryDto { + let st = team.status.as_ref(); + let created_at = team + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()); + let last_run_at = st.and_then(|status| status.last_run_at.clone()); + let last_success_at = st.and_then(|status| status.last_success_at.clone()); + let last_activity_at = [ + st.and_then(|status| status.last_activity_at.clone()), + last_run_at.clone(), + last_success_at.clone(), + created_at.clone(), + ] + .into_iter() + .flatten() + .max(); + TeamSummaryDto { + name: team.name_any(), + display_name: team.spec.display_name.clone(), + charter: team.spec.charter.clone(), + phase: phase_of(team), + reporting_to: team.spec.reporting_to.clone(), + tier: team.spec.envelope.tier, + member_count: st.and_then(|s| s.member_count).unwrap_or(0), + generated_task_count: st.and_then(|s| s.generated_task_count).unwrap_or(0), + every_minutes: team.spec.cadence.as_ref().and_then(|c| c.every_minutes), + lifecycle_mode: effective_lifecycle_mode(team), + warm_idle_seconds: team.spec.warm_idle_seconds, + runtime_state: runtime_state(team), + current_assignment_task: st.and_then(|s| s.current_assignment_task.clone()), + idle_deadline_at: st.and_then(|s| s.idle_deadline_at.clone()), + paused: team.spec.paused, + created_at, + last_run_at, + last_success_at, + last_activity_at, + next_run_at: st.and_then(|s| s.next_run_at.clone()), + health: st.and_then(|s| s.health.clone()), + detail: st.and_then(|s| s.detail.clone()), + runs_succeeded: st.and_then(|s| s.runs_succeeded).unwrap_or(0), + retained_delivered: 0, + retained_no_action: 0, + retained_failed: 0, + } +} + +fn effective_lifecycle_mode(team: &KarsTeam) -> String { + team.status + .as_ref() + .and_then(|status| status.lifecycle_mode.clone()) + .or_else(|| team.spec.lifecycle_mode.clone()) + .unwrap_or_else(|| "ephemeral".into()) +} + +fn runtime_state(team: &KarsTeam) -> Option { + team.status + .as_ref() + .and_then(|status| status.runtime_state.clone()) +} + +/// `GET /api/namespaces/:ns/teams` — list standing teams in a namespace. +pub async fn list_teams( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + let api: Api = cluster.teams(&ns); + let list = api + .list(&ListParams::default()) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let task_list = cluster + .tasks(&ns) + .list(&ListParams::default()) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let mut retained_runs: std::collections::HashMap = + std::collections::HashMap::new(); + let visible_team_names = list + .items + .iter() + .filter(|team| is_team_owner(team, &principal)) + .map(ResourceExt::name_any) + .collect::>(); + let mut retained_outcomes: std::collections::HashMap = + std::collections::HashMap::new(); + for record in cluster.list_mission_output_evidence().await { + let data = record.data; + let run = data + .get("assignmentNonce") + .cloned() + .unwrap_or(record.evidence_key); + let team_name = data + .get("team") + .filter(|team| visible_team_names.contains(team)) + .or_else(|| { + visible_team_names + .iter() + .find(|team_name| run.starts_with(&format!("{team_name}-run-"))) + }); + let Some(team_name) = team_name else { + continue; + }; + let counts = retained_outcomes.entry(team_name.clone()).or_default(); + let status = data.get("status").map(String::as_str); + let output = data.get("output").map(String::as_str).unwrap_or(""); + if status == Some("error") || is_failure_shaped_output(output) { + counts.2 += 1; + } else if is_no_change_output(output) { + counts.1 += 1; + } else if crate::routes::tasks::is_real_deliverable(status, output) { + counts.0 += 1; + } + } + for task in task_list.items { + let is_run = task + .annotations() + .get("kars.azure.com/team-role") + .is_some_and(|role| role == "taskforce"); + if !is_run { + continue; + } + if let Some(team_name) = task.labels().get("kars.azure.com/team") { + *retained_runs.entry(team_name.clone()).or_default() += 1; + } + } + let mut summaries = list + .items + .iter() + .filter(|team| is_team_owner(team, &principal)) + .map(|team| { + let mut summary = to_summary(team); + summary.generated_task_count = summary + .generated_task_count + .max(*retained_runs.get(&summary.name).unwrap_or(&0)); + if let Some((delivered, no_action, failed)) = retained_outcomes.get(&summary.name) { + summary.retained_delivered = *delivered; + summary.retained_no_action = *no_action; + summary.retained_failed = *failed; + } + summary + }) + .collect::>(); + summaries.sort_by(|left, right| right.last_activity_at.cmp(&left.last_activity_at)); + Ok(Json(summaries)) +} + +/// Derive a meaningful, distinct title for a commons entry. The controller +/// historically titled every entry by the team charter's first line, so the +/// Knowledge tab showed 50+ identical rows. We recover a real headline from the +/// (already envelope-unwrapped) content: the first markdown heading, else the +/// first substantive line, capped. Falls back to the stored title only when the +/// content yields nothing usable. `charter_line` is passed so we can recognize +/// (and replace) the legacy charter-as-title rows. +fn commons_title(stored: &str, content: &str, charter_line: &str) -> String { + let derive = || -> Option { + let lines: Vec<&str> = content.lines().collect(); + let clean = |line: &str| -> Option { + let heading = line + .trim() + .trim_start_matches('#') + .trim() + .trim_start_matches("**") + .trim_end_matches("**") + .trim(); + // Drop leading noise — stray "?" placeholders (where an emoji was + // stripped upstream), bullets, dashes — so the title starts on a word. + let heading = heading + .trim_start_matches(|c: char| !c.is_alphanumeric()) + .trim(); + if !heading.chars().any(char::is_alphanumeric) { + return None; + } + let lower = heading.to_ascii_lowercase(); + if [ + "kars sandbox - secure ai runtime", + "foundry project", + "model:", + "sandbox id", + "security summary", + "capabilities", + "role plan", + "role roster", + "roles spawned", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return None; + } + + let title: String = heading.chars().take(90).collect(); + Some(if heading.chars().count() > 90 { + format!("{}…", title.trim_end()) + } else { + title + }) + }; + // Prefer the first real markdown heading near the top — briefings lead + // with a status sentence then a "## …" headline, which reads far better + // as a title than the preamble line. + for line in lines.iter().take(14) { + if line.trim_start().starts_with('#') + && let Some(t) = clean(line) + { + return Some(t); + } + } + // Otherwise the first substantive line. + lines.iter().find_map(|l| clean(l)) + }; + // Replace the legacy "title == charter" rows and any empty title. The + // controller stored the title as the charter's first line *truncated to 160 + // chars*, so we match by prefix rather than equality. + let stored_t = stored.trim(); + let stored_lower = stored_t.to_ascii_lowercase(); + let cl = charter_line.trim(); + let legacy = stored_t.is_empty() + || stored_t == cl + || (stored_t.len() >= 24 && cl.starts_with(stored_t)) + || (cl.len() >= 24 && stored_t.starts_with(cl)) + || ["kars sandbox", "role plan", "role roster", "current state"] + .iter() + .any(|prefix| stored_lower.starts_with(prefix)); + if legacy { + derive().unwrap_or_else(|| stored_t.to_string()) + } else { + stored_t.to_string() + } +} + +/// `GET /api/namespaces/:ns/teams/:name/commons` — the team's shared, +/// provenance-tracked knowledge commons (design note §14). Each entry records +/// which run authored it, when, and a content digest. +pub async fn get_team_commons( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + // Commons name defaults to the team name when unset. + let commons = team + .spec + .knowledge_commons + .clone() + .unwrap_or_else(|| name.clone()); + + let data = cluster.read_commons(&commons).await.unwrap_or_default(); + let index: Vec = data + .get("index.json") + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + // The charter's first line is what the controller historically used as every + // entry's title; we use it to recognize and replace those duplicate rows. + let charter_line = team + .spec + .charter + .lines() + .next() + .unwrap_or(&team.spec.charter) + .to_string(); + + // Newest first, with content resolved from the companion keys. We *heal* two + // legacy defects here so the Knowledge tab is readable for entries written + // before the source-side fixes: (1) content stored as the raw agent JSON + // envelope is unwrapped to its prose deliverable; (2) the duplicate + // charter-as-title is replaced with a real headline derived from that prose. + let mut entries: Vec = index + .into_iter() + .rev() + .map(|e| { + let key = format!( + "entry-{}", + e.id.chars() + .map( + |c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + ) + .collect::() + ); + let content = data.get(&key).map(|c| deliverable_text(c)); + let title = match content.as_deref() { + Some(c) => commons_title(&e.title, c, &charter_line), + None => e.title.clone(), + }; + CommonsEntryDto { + id: e.id, + title, + author: e.author, + source_task: e.source_task, + created_at: e.created_at, + digest: e.digest, + size_bytes: e.size_bytes, + content, + } + }) + .collect(); + let total_entries = entries.len() as i64; + entries.truncate(50); + + Ok(Json(CommonsResponse { + commons, + count: total_entries, + entries, + })) +} + +/// `GET /api/namespaces/:ns/teams/:name/runs/:run/archive` — retrieve one +/// durable archived run directly from the full commons index. +pub async fn get_archived_run( + State(state): State, + Extension(principal): Extension, + Path((ns, name, run)): Path<(String, String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let taskforce = run.starts_with(&format!("{name}-run-")); + let persistent = run.starts_with(&format!("{name}-principal-assign-")); + if !taskforce && !persistent { + return Err(AppError::NotFound); + } + let commons_name = team + .spec + .knowledge_commons + .as_deref() + .filter(|commons| !commons.trim().is_empty()) + .unwrap_or(&name); + let data = cluster + .read_commons(commons_name) + .await + .ok_or(AppError::NotFound)?; + let entry = data + .get("index.json") + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .and_then(|entries| { + entries + .into_iter() + .find(|entry| entry.id == run || entry.source_task == run) + }) + .ok_or(AppError::NotFound)?; + let content = data + .get(&commons_entry_key(&entry.id)) + .map(|content| deliverable_text(content)); + let charter_line = team + .spec + .charter + .lines() + .next() + .unwrap_or(&team.spec.charter); + let title = content + .as_deref() + .map(|content| commons_title(&entry.title, content, charter_line)) + .unwrap_or(entry.title); + Ok(Json(CommonsEntryDto { + id: entry.id, + title, + author: entry.author, + source_task: entry.source_task, + created_at: entry.created_at, + digest: entry.digest, + size_bytes: entry.size_bytes, + content, + })) +} + +#[derive(Debug, serde::Deserialize)] +pub struct PromoteRequest { + pub tier: i32, +} + +/// `POST /api/namespaces/:ns/teams/:name/promote` — request a governed +/// promotion to a higher autonomy tier (§12). Sets `spec.requestedTier`; the +/// controller opens a human approval and only widens the envelope on approval. +/// The BFF never raises the envelope directly (the envelope-write VAP forbids +/// it) — it only records the request. +pub async fn promote_team( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + if !(1..=5).contains(&body.tier) { + return Err(AppError::BadRequest("tier must be in 1..5".into())); + } + let api: Api = cluster.teams(&ns); + let patch = serde_json::json!({ "spec": { "requestedTier": body.tier } }); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(serde_json::json!({ + "requested": true, + "tier": body.tier, + "note": "A human approval has been opened. The team is promoted only once it is approved." + }))) +} + +/// `POST /api/namespaces/:ns/teams/:name/run` — trigger an immediate run +/// ("Run now"). Sets the `kars.azure.com/run-now` annotation; the controller +/// mints one taskforce run under the normal readiness gates and clears the +/// annotation. This is the only way to make a cadence-less ("on demand") team +/// act, and a manual kick for cadenced teams. +pub async fn run_team( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + Ok(Json( + request_team_run(cluster, &ns, &name, &principal).await?, + )) +} + +pub(crate) async fn request_team_run( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult { + let api: Api = cluster.teams(ns); + let team = require_owned_team(cluster, ns, name, principal).await?; + if team.spec.paused { + return Err(AppError::BadRequest( + "team is paused — resume it before running".into(), + )); + } + if team + .annotations() + .get("kars.azure.com/run-now") + .is_some_and(|value| !value.trim().is_empty()) + { + return Err(AppError::BadRequest( + "a run request is already pending for this team".into(), + )); + } + let active_run = cluster + .tasks(ns) + .list(&ListParams::default().labels(&format!("kars.azure.com/team={name}"))) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .items + .into_iter() + .any(|task| { + task.annotations() + .get("kars.azure.com/team-role") + .is_some_and(|role| role == "taskforce") + && task + .spec + .execution + .as_ref() + .is_some_and(|execution| execution.launch) + }); + if active_run { + return Err(AppError::BadRequest( + "this team already has a run in progress".into(), + )); + } + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/run-now": chrono::Utc::now().to_rfc3339() } } + }); + api.patch( + name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(serde_json::json!({ + "triggered": true, + "note": "A run has been requested. It appears under the team's runs once the principal launches." + })) +} + +#[derive(Debug, Deserialize)] +pub struct HaltTeamRunRequest { + pub reason: Option, +} + +/// Governed emergency stop for a standing-team run. The team is paused first +/// so cadence/intake cannot immediately mint replacement work, then the active +/// task is un-launched while its trace, output, and halt decision remain. +pub async fn halt_team_run( + State(state): State, + Extension(principal): Extension, + Path((ns, name, run)): Path<(String, String, String)>, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + Ok(Json( + request_team_run_halt( + cluster, + &ns, + &name, + &run, + body.reason.as_deref(), + &principal, + ) + .await?, + )) +} + +pub(crate) async fn request_team_run_halt( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + run: &str, + reason: Option<&str>, + principal: &Principal, +) -> AppResult { + require_owned_team(cluster, ns, name, principal).await?; + let tasks = cluster.tasks(ns); + let task = tasks + .get(run) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + if task + .labels() + .get("kars.azure.com/team") + .is_none_or(|team| team != name) + || task + .annotations() + .get("kars.azure.com/team-role") + .is_none_or(|role| role != "taskforce") + { + return Err(AppError::BadRequest( + "the requested task is not a taskforce run owned by this team".into(), + )); + } + if !task + .spec + .execution + .as_ref() + .is_some_and(|execution| execution.launch) + { + return Err(AppError::Conflict( + "the requested team run is not active".into(), + )); + } + + let reason = reason + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .unwrap_or("operator emergency-stop"); + let at = chrono::Utc::now().to_rfc3339(); + cluster + .teams(ns) + .patch( + name, + &PatchParams::default(), + &Patch::Merge(serde_json::json!({"spec": {"paused": true}})), + ) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + tasks + .patch( + run, + &PatchParams::default(), + &Patch::Merge(serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/halted": format!( + "halted by operator at {at}: {reason}" + ) + } + }, + "spec": {"execution": {"launch": false}} + })), + ) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + + Ok(serde_json::json!({ + "halted": true, + "team_paused": true, + "run": run, + "at": at, + "reason": reason, + "note": "The run sandbox is being torn down and the standing team is paused. Retained evidence remains available." + })) +} + +/// `DELETE /api/namespaces/:ns/teams/:name` — permanently delete a standing +/// team. Deleting the `KarsTeam` cascade-removes its runs + member sandboxes; +/// the BFF then sweeps the team's shared-memory commons, task backlog, and +/// channel secret so nothing is orphaned. Idempotent-ish: a not-found team is a +/// 404, but missing aux objects are ignored. +pub async fn delete_team( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + cluster + .delete_team( + &ns, + &name, + team.metadata + .uid + .as_deref() + .ok_or_else(|| AppError::Conflict("Team UID missing".into()))?, + team.metadata + .resource_version + .as_deref() + .ok_or_else(|| AppError::Conflict("Team resourceVersion missing".into()))?, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(serde_json::json!({ + "deleted": true, + "note": "Team deletion requested. Core garbage-collects sources bound to this exact Team UID; legacy credential stores are retained for operator review." + }))) +} + +/// One backlog task (mirrors the controller's `team_tasks::TeamTask`). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TeamTaskDto { + pub id: String, + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub acceptance_criteria: Vec, + #[serde(default)] + pub review_required: bool, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub done_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stuck_since: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment_nonce: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AddTaskRequest { + #[serde(default)] + pub id: Option, + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub depends_on: Vec, + #[serde(default)] + pub acceptance_criteria: Vec, + #[serde(default)] + pub review_required: bool, +} + +pub(crate) fn read_task_list(raw: &str) -> Vec { + serde_json::from_str::>(raw).unwrap_or_default() +} + +/// `GET /api/namespaces/:ns/teams/:name/tasks` — the team's task backlog. +pub async fn list_team_tasks( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + Ok(Json(read_task_list(&cluster.read_team_tasks(&name).await))) +} + +/// `POST /api/namespaces/:ns/teams/:name/tasks` — append a task to the backlog. +/// The controller picks up the oldest `pending` task on its next run. +pub async fn add_team_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(b): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + if b.title.trim().is_empty() { + return Err(AppError::BadRequest("task title is required".into())); + } + let existing_tasks = read_task_list(&cluster.read_team_tasks(&name).await); + if let Some(missing) = b.depends_on.iter().find(|dependency| { + !existing_tasks + .iter() + .any(|task| task.id.as_str() == dependency.as_str()) + }) { + return Err(AppError::BadRequest(format!( + "task dependency '{missing}' does not exist" + ))); + } + let requested_id = + b.id.as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|id| { + id.to_ascii_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' { + character + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .chars() + .take(63) + .collect::() + }) + .filter(|id| !id.is_empty()); + let task_id = + requested_id.unwrap_or_else(|| format!("t-{}", chrono::Utc::now().timestamp_micros())); + if existing_tasks.iter().any(|task| task.id == task_id) { + return Err(AppError::BadRequest(format!( + "task id '{task_id}' already exists" + ))); + } + let task = TeamTaskDto { + id: task_id, + title: b.title.trim().to_string(), + description: b.description.trim().to_string(), + depends_on: b.depends_on, + acceptance_criteria: b + .acceptance_criteria + .into_iter() + .map(|criterion| criterion.trim().to_string()) + .filter(|criterion| !criterion.is_empty()) + .take(20) + .collect(), + review_required: b.review_required, + status: "pending".into(), + run: None, + created_at: Some(chrono::Utc::now().to_rfc3339()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }; + let task_for_write = task.clone(); + let duplicate = std::sync::atomic::AtomicBool::new(false); + let missing_dependency = std::sync::Mutex::new(None::); + cluster + .update_configmap_data( + &format!("kars-team-tasks-{name}"), + &[("kars.azure.com/team-tasks", name.as_str())], + |data| { + let mut tasks = data + .get("tasks.json") + .map(|raw| read_task_list(raw)) + .unwrap_or_default(); + if tasks + .iter() + .any(|existing| existing.id == task_for_write.id) + { + duplicate.store(true, std::sync::atomic::Ordering::Relaxed); + return; + } + if let Some(dependency) = task_for_write.depends_on.iter().find(|dependency| { + !tasks + .iter() + .any(|task| task.id.as_str() == dependency.as_str()) + }) { + *missing_dependency.lock().expect("dependency lock") = Some(dependency.clone()); + return; + } + tasks.push(task_for_write.clone()); + data.insert( + "tasks.json".into(), + serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), + ); + }, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if duplicate.load(std::sync::atomic::Ordering::Relaxed) { + return Err(AppError::Conflict(format!( + "task id '{}' already exists", + task.id + ))); + } + if let Some(dependency) = missing_dependency.lock().expect("dependency lock").clone() { + return Err(AppError::Conflict(format!( + "task dependency '{dependency}' disappeared during update" + ))); + } + Ok(Json(task)) +} + +/// `DELETE /api/namespaces/:ns/teams/:name/tasks/:task_id` — remove a task from +/// the backlog (any status; removing an active task doesn't stop its run). +pub async fn delete_team_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name, task_id)): Path<(String, String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let removed = std::sync::atomic::AtomicBool::new(false); + let dependency_blocked = std::sync::atomic::AtomicBool::new(false); + cluster + .update_configmap_data( + &format!("kars-team-tasks-{name}"), + &[("kars.azure.com/team-tasks", name.as_str())], + |data| { + let mut tasks = data + .get("tasks.json") + .map(|raw| read_task_list(raw)) + .unwrap_or_default(); + if tasks.iter().any(|task| { + task.id != task_id && task.depends_on.iter().any(|id| id == &task_id) + }) { + dependency_blocked.store(true, std::sync::atomic::Ordering::Relaxed); + return; + } + let before = tasks.len(); + tasks.retain(|task| task.id != task_id); + removed.store(tasks.len() != before, std::sync::atomic::Ordering::Relaxed); + data.insert( + "tasks.json".into(), + serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), + ); + }, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if dependency_blocked.load(std::sync::atomic::Ordering::Relaxed) { + return Err(AppError::Conflict( + "cannot delete a milestone that is referenced by dependent work".into(), + )); + } + if !removed.load(std::sync::atomic::Ordering::Relaxed) { + return Err(AppError::NotFound); + } + + Ok(Json(serde_json::json!({ "removed": true }))) +} + +#[derive(Debug, Deserialize)] +pub struct ReviewTeamTaskRequest { + pub decision: String, + pub feedback: Option, +} + +pub async fn review_team_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name, task_id)): Path<(String, String, String)>, + Json(body): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + if !matches!(body.decision.as_str(), "approve" | "request_changes") { + return Err(AppError::BadRequest( + "decision must be approve or request_changes".into(), + )); + } + let current = read_task_list(&cluster.read_team_tasks(&name).await); + let existing = current + .iter() + .find(|task| task.id == task_id) + .cloned() + .ok_or(AppError::NotFound)?; + if existing.status != "awaiting_review" { + return Err(AppError::BadRequest( + "only an awaiting_review milestone can be decided".into(), + )); + } + let feedback = body + .feedback + .as_deref() + .map(str::trim) + .filter(|feedback| !feedback.is_empty()) + .map(str::to_string); + if body.decision == "request_changes" && feedback.is_none() { + return Err(AppError::BadRequest( + "request_changes requires written feedback".into(), + )); + } + let approvals = cluster.approvals(&ns); + let selector = format!("kars.azure.com/team={name},kars.azure.com/milestone={task_id}"); + let approval = approvals + .list(&ListParams::default().labels(&selector)) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .into_iter() + .find(|approval| { + approval.spec.action.kind == "checkpoint" + && approval.spec.decision.is_none() + && approval + .status + .as_ref() + .and_then(|status| status.phase.as_deref()) + .is_none_or(|phase| phase == "Pending") + }) + .ok_or_else(|| { + AppError::Conflict( + "checkpoint approval is not pending yet; refresh before deciding".into(), + ) + })?; + approvals + .patch( + &approval.name_any(), + &PatchParams::default(), + &Patch::Merge(serde_json::json!({ + "spec": { + "decision": { + "verdict": if body.decision == "approve" { "approve" } else { "deny" }, + "decider": principal.name, + "deciderSubject": principal.sub, + "deciderRoles": principal.roles, + "reason": feedback, + } + } + })), + ) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + + let updated = read_task_list(&cluster.read_team_tasks(&name).await) + .into_iter() + .find(|task| task.id == task_id) + .ok_or(AppError::NotFound)?; + Ok(Json(updated)) +} + +// ─── Communication channels (part of a team's envelope) ────────────────────── +// A standing team can report to its operator over Telegram / Slack / Discord / +// WhatsApp. Tokens live ONLY in the K8s Secret `kars-team-channel-`, +// propagated by the controller into each ephemeral run sandbox. SECURITY: the +// API is write-only for tokens — GET never returns a token, only which channels +// are enabled. + +/// Map a channel id → the env keys the sandbox entrypoint reads for it. +pub(crate) fn channel_env_keys(channel: &str) -> &'static [&'static str] { + match channel { + "telegram" => &["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOW_FROM"], + "slack" => &["SLACK_BOT_TOKEN"], + "discord" => &["DISCORD_BOT_TOKEN"], + "whatsapp" => &["WHATSAPP_ENABLED"], + // Teams uses a dedicated Secret (kars-bridge-teams), not workspace channels. + // Only a non-secret marker key goes in workspace-channels for enabled detection. + "teams" => &["TEAMS_ENABLED"], + _ => &[], + } +} + +/// Derive which channels are enabled from the present secret keys (no values). +pub(crate) const SUPPORTED_CHANNELS: &[&str] = + &["telegram", "slack", "discord", "whatsapp", "teams"]; + +pub(crate) fn channels_from_keys(keys: &[String]) -> Vec { + SUPPORTED_CHANNELS + .iter() + .copied() + .filter(|ch| { + // A channel is "enabled" if its primary token/flag key is present. + let primary = channel_env_keys(ch).first().copied().unwrap_or(""); + keys.iter().any(|k| k == primary) + }) + .map(String::from) + .collect() +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChannelQualificationDto { + pub channel: String, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub qualified: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Serialize)] +pub struct ChannelsDto { + /// Channel ids currently enabled (e.g. ["telegram","slack"]). + pub enabled: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub statuses: Vec, +} + +async fn effective_team_route( + cluster: &crate::kars::cluster::Cluster, + team: &KarsTeam, +) -> Option<(String, String, String)> { + let runtime = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.runtime.clone()) + .filter(|runtime| !runtime.is_empty()) + .unwrap_or_else(|| "OpenClaw".to_string()); + if let Some(model) = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.model.as_ref()) + { + return Some((runtime, model.provider.clone(), model.deployment.clone())); + } + let deployment = cluster.controller_default_model().await?; + let provider = cluster.controller_provider().await.map(|(id, _, _)| id); + Some(( + runtime, + crate::routes::options::provider_for(&deployment, None, provider.as_deref()), + deployment, + )) +} + +async fn channel_statuses_for_team( + cluster: &crate::kars::cluster::Cluster, + team: &KarsTeam, + enabled: &[String], +) -> Vec { + let route = effective_team_route(cluster, team).await; + SUPPORTED_CHANNELS + .iter() + .copied() + .map(|channel| { + let enabled = enabled.iter().any(|configured| configured == channel); + match route.as_ref() { + Some((runtime, provider, deployment)) => { + let qualification = crate::routes::options::channel_adapter_qualified_for_route( + runtime, + provider, + deployment, + channel, + ); + match qualification { + Ok(qualified) => ChannelQualificationDto { + channel: channel.to_string(), + enabled, + qualified: Some(qualified), + detail: Some(if qualified { + format!( + "Retained channel-adapter evidence exists for {}.", + crate::routes::options::route_label( + runtime, provider, deployment + ) + ) + } else { + format!( + "No retained channel-adapter qualification matches {}. Credentials can be configured later, but generic route records do not prove this channel adapter.", + crate::routes::options::route_label( + runtime, provider, deployment + ) + ) + }), + }, + Err(error) => ChannelQualificationDto { + channel: channel.to_string(), + enabled, + qualified: None, + detail: Some(format!( + "Channel qualification could not be evaluated: {error}" + )), + }, + } + } + None => ChannelQualificationDto { + channel: channel.to_string(), + enabled, + qualified: None, + detail: Some( + "The team has no effective runtime/model route yet, so channel qualification cannot be evaluated." + .into(), + ), + }, + } + }) + .collect() +} + +#[derive(Debug, Deserialize)] +pub struct SetChannelRequest { + /// Channel id: telegram | slack | discord | whatsapp. + pub channel: String, + /// The channel's bot token / OAuth token. For whatsapp send "true". + pub token: String, + /// Telegram only: comma-separated allowed numeric user IDs. + #[serde(default)] + pub allow_from: Option, +} + +/// `GET /api/namespaces/:ns/teams/:name/channels` — which channels are enabled. +pub async fn get_team_channels( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let keys = cluster + .team_channel_keys(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&keys); + Ok(Json(ChannelsDto { + statuses: channel_statuses_for_team(cluster, &team, &enabled).await, + enabled, + })) +} + +/// `POST /api/namespaces/:ns/teams/:name/channels` — enable/update a channel. +/// The token is written straight into the team's channel Secret and never +/// echoed back. +pub async fn set_team_channel( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(b): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let keys = channel_env_keys(b.channel.as_str()); + if keys.is_empty() { + return Err(AppError::BadRequest(format!( + "unknown channel '{}': use telegram|slack|discord|whatsapp", + b.channel + ))); + } + if b.token.trim().is_empty() { + return Err(AppError::BadRequest("token is required".into())); + } + let mut data = std::collections::BTreeMap::new(); + // whatsapp uses a presence flag, not a token. + let primary = keys[0]; + data.insert(primary.to_string(), b.token.trim().to_string()); + if b.channel == "telegram" + && let Some(allow) = b + .allow_from + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + data.insert("TELEGRAM_ALLOW_FROM".to_string(), allow.to_string()); + } + cluster + .merge_team_channel(&ns, &name, data) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let after = cluster + .team_channel_keys(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&after); + Ok(Json(ChannelsDto { + statuses: channel_statuses_for_team(cluster, &team, &enabled).await, + enabled, + })) +} + +/// `DELETE /api/namespaces/:ns/teams/:name/channels/:channel` — disable a channel. +pub async fn delete_team_channel( + State(state): State, + Extension(principal): Extension, + Path((ns, name, channel)): Path<(String, String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let keys: Vec = channel_env_keys(channel.as_str()) + .iter() + .map(|s| s.to_string()) + .collect(); + if keys.is_empty() { + return Err(AppError::BadRequest(format!("unknown channel '{channel}'"))); + } + cluster + .remove_team_channel_keys(&ns, &name, &keys) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let after = cluster + .team_channel_keys(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&after); + Ok(Json(ChannelsDto { + statuses: channel_statuses_for_team(cluster, &team, &enabled).await, + enabled, + })) +} + +pub async fn get_team( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + + // Resolve generated task-force tasks: KarsTasks in the namespace owned by + // this team whose name carries the `-run-` standing-operation prefix. + let tasks: Api = cluster.tasks(&ns); + let run_prefix = format!("{name}-run-"); + let mut generated_tasks: Vec = tasks + .list(&ListParams::default()) + .await + .map(|l| { + l.items + .iter() + .map(kube::ResourceExt::name_any) + .filter(|n| n.starts_with(&run_prefix)) + .collect() + }) + .unwrap_or_default(); + generated_tasks.sort(); + generated_tasks.reverse(); + let retained_run_count = generated_tasks.len() as i64; + let newest_retained_run = generated_tasks.first().cloned(); + let retained_run_names = generated_tasks + .iter() + .cloned() + .collect::>(); + let mut recent_outcomes = cluster + .list_mission_output_evidence() + .await + .into_iter() + .filter_map(|record| { + let run = record + .data + .get("assignmentNonce") + .cloned() + .unwrap_or(record.evidence_key); + (retained_run_names.contains(&run) || record.data.get("team") == Some(&name)) + .then(|| outcome_from_output(run, &record.data)) + }) + .collect::>(); + recent_outcomes.sort_by(|left, right| { + right + .finished_at + .cmp(&left.finished_at) + .then_with(|| right.run.cmp(&left.run)) + }); + let mut recent_outcome_summary = TeamOutcomeSummaryDto::default(); + for outcome in &recent_outcomes { + match outcome.disposition.as_str() { + "change_proposed" => recent_outcome_summary.change_proposed += 1, + "no_action_needed" => recent_outcome_summary.no_action_needed += 1, + "failed" => recent_outcome_summary.failed += 1, + _ => recent_outcome_summary.completed += 1, + } + } + + let st = team.status.as_ref(); + let member_names: Vec = st + .map(|s| s.member_refs.iter().map(|r| r.name.clone()).collect()) + .unwrap_or_default(); + + // Build the org chart: pair each roster role with its materialized member + // task (named `-` by the reconciler). + let roster: Vec = team + .spec + .roster + .iter() + .map(|role| { + let member_task = format!("{name}-{}", role.name); + let materialized = member_names.iter().any(|m| m == &member_task); + TeamRoleDto { + name: role.name.clone(), + system_prompt: role.system_prompt.clone(), + tier: role.envelope.as_ref().map(|e| e.tier), + member_task: materialized.then_some(member_task), + skills: role.skills.clone(), + runtime: role.blueprint.as_ref().and_then(|b| b.runtime.clone()), + model: role + .blueprint + .as_ref() + .and_then(|b| b.model.as_ref()) + .map(|m| format!("{}::{}", m.provider, m.deployment)), + } + }) + .collect(); + + let bp = team.spec.blueprint.as_ref(); + // Effective tool policy: explicit blueprint override, else the system + // default `kars-default` (applied to every run sandbox via the + // `system-default=true` sandbox selector). Never "none" — a run without a + // governing policy fails closed. + let bp_tool_policy = bp.and_then(|b| b.tool_policy.clone()); + let tool_policy_default = bp_tool_policy.is_none(); + let tool_policy = bp_tool_policy.or_else(|| Some("kars-default".to_string())); + // Effective model: explicit blueprint override, else the controller's + // KARS_TASK_DEFAULT_MODEL that every run actually inherits. + let bp_model = bp + .and_then(|b| b.model.as_ref()) + .map(|m| format!("{}::{}", m.provider, m.deployment)); + let model_default = bp_model.is_none(); + let model = match bp_model { + Some(m) => Some(m), + None => cluster.controller_default_model().await, + }; + // Effective harness: explicit blueprint override, else the sandbox default + // (OpenClaw). Team runs inherit this via launched_run_blueprint. + let bp_runtime = bp.and_then(|b| b.runtime.clone()).filter(|s| !s.is_empty()); + let runtime_default = bp_runtime.is_none(); + let runtime = bp_runtime.or_else(|| Some("OpenClaw".to_string())); + let egress: Vec = bp + .map(|b| { + b.egress + .iter() + .map(|e| { + if let Some(p) = e.port { + format!("{}:{}", e.host, p) + } else { + e.host.clone() + } + }) + .collect() + }) + .unwrap_or_default(); + + // Concrete "domains reached so far": aggregate the learn-mode observation + // buffers of the team's currently-running run sandboxes (`-run-` + // in kars-system). Per-run + best-effort — empty when no run is live. + let mut learned_egress: Vec = Vec::new(); + if let Ok(sandboxes) = cluster + .list_kind_labeled("KarsSandbox", &format!("kars.azure.com/team={name}")) + .await + { + let mut seen = std::collections::BTreeSet::new(); + for sb in sandboxes.iter().take(8) { + let sb_name = sb.metadata.name.clone().unwrap_or_default(); + let running = sb + .data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + == Some("Running"); + if !running || sb_name.is_empty() { + continue; + } + if let Ok(domains) = cluster.sandbox_learned_domains(&sb_name).await { + for d in domains { + seen.insert(d); + } + } + } + learned_egress = seen.into_iter().collect(); + } + + Ok(Json(TeamDetailDto { + name: team.name_any(), + display_name: team.spec.display_name.clone(), + charter: team.spec.charter.clone(), + phase: phase_of(&team), + reporting_to: team.spec.reporting_to.clone(), + knowledge_commons: team.spec.knowledge_commons.clone(), + tier: team.spec.envelope.tier, + authority_ceiling: team.spec.envelope.authority_ceiling, + delegation_depth: team.spec.envelope.delegation_depth, + paused: team.spec.paused, + every_minutes: team.spec.cadence.as_ref().and_then(|c| c.every_minutes), + lifecycle_mode: effective_lifecycle_mode(&team), + warm_idle_seconds: team.spec.warm_idle_seconds, + runtime_state: runtime_state(&team), + current_assignment_nonce: st.and_then(|s| s.current_assignment_nonce.clone()), + current_assignment_task: st.and_then(|s| s.current_assignment_task.clone()), + idle_deadline_at: st.and_then(|s| s.idle_deadline_at.clone()), + envelope_digest: st.and_then(|s| s.envelope_digest.clone()), + principal_task: st.and_then(|s| s.principal_ref.as_ref().map(|r| r.name.clone())), + roster, + member_count: st.and_then(|s| s.member_count).unwrap_or(0), + generated_task_count: st + .and_then(|s| s.generated_task_count) + .unwrap_or(0) + .max(retained_run_count), + last_generated_task: newest_retained_run + .or_else(|| st.and_then(|s| s.last_generated_task.clone())), + last_run_at: st.and_then(|s| s.last_run_at.clone()), + next_run_at: st.and_then(|s| s.next_run_at.clone()), + detail: st.and_then(|s| s.detail.clone()), + health: st.and_then(|s| s.health.clone()), + runs_succeeded: st.and_then(|s| s.runs_succeeded).unwrap_or(0), + tokens_spent_total: st.and_then(|s| s.tokens_spent_total).unwrap_or(0), + commons_entry_count: st.and_then(|s| s.commons_entry_count).unwrap_or(0), + last_success_at: st.and_then(|s| s.last_success_at.clone()), + created_at: team + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + last_activity_at: [ + st.and_then(|status| status.last_activity_at.clone()), + st.and_then(|status| status.last_run_at.clone()), + st.and_then(|status| status.last_success_at.clone()), + team.metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + ] + .into_iter() + .flatten() + .max(), + generated_tasks, + recent_outcomes, + recent_outcome_summary, + tool_policy, + tool_policy_default, + mcp_servers: bp.map(|b| b.mcp_servers.clone()).unwrap_or_default(), + git_write_repos: bp + .and_then(|b| b.git_write.as_ref()) + .map(|git_write| git_write.repos.clone()) + .unwrap_or_default(), + egress, + egress_mode: bp.and_then(|b| b.egress_mode.clone()), + learned_egress, + network_posture: "Default-deny egress (kernel-level). Only the inference router and AGT mesh relay are reachable; novel domains need an approved egress request.".to_string(), + model, + model_fallbacks: bp + .map(|blueprint| { + blueprint + .model_fallbacks + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .collect() + }) + .unwrap_or_default(), + model_default, + memory: bp.and_then(|blueprint| blueprint.memory.clone()), + runtime, + runtime_default, + isolation: bp.and_then(|b| b.isolation.clone()), + execution_plan: bp + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + .map(crate::routes::tasks::ExecutionPlanDto::from_crd), + tasks: read_task_list(&cluster.read_team_tasks(&name).await), + channels: channels_from_keys(&cluster.team_channel_keys(&ns,&name).await.map_err(|e|AppError::Upstream(e.to_string()))?), + })) +} + +/// One event in a team's continuous ledger. +#[derive(Debug, Serialize)] +pub struct LedgerEvent { + pub at: String, + pub kind: String, + pub summary: String, + pub task: Option, + pub tokens: Option, +} + +/// `GET /api/namespaces/:ns/teams/:name/ledger` — the team's continuous ledger +/// (§14): a streaming, append-only timeline of everything the standing +/// operation has done, composed from the durable records the controller already +/// writes (generated runs + their deliverables/tokens + harvested knowledge + +/// published digests). Newest first. +pub async fn get_team_ledger( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let mut events: Vec = Vec::new(); + + // Run deliverables (delivery events, with token cost). + let run_prefix = format!("{name}-run-"); + for record in cluster.list_mission_output_evidence().await { + let data = record.data; + let task = data + .get("assignmentNonce") + .cloned() + .unwrap_or(record.evidence_key); + let belongs_to_team = data.get("team") == Some(&name) + || task.starts_with(&run_prefix) + || task.starts_with(&format!("{name}-principal-assign-")); + if !belongs_to_team { + continue; + } + let at = data.get("finishedAt").cloned().unwrap_or_default(); + let tokens = data.get("totalTokens").and_then(|t| t.parse::().ok()); + let ok = data.get("status").map(String::as_str) == Some("ok"); + events.push(LedgerEvent { + at, + kind: if ok { + "delivery".into() + } else { + "delivery_error".into() + }, + summary: data + .get("output") + .map(|o| { + deliverable_text(o) + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .chars() + .take(140) + .collect::() + }) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "run completed".into()), + task: Some(task), + tokens, + }); + } + + // Harvested knowledge (commons entries). Heal the legacy charter-as-title so + // the ledger reads "Learned: " rather than the same charter + // line on every knowledge event. + if let Some(cm) = cluster.read_commons(&name).await + && let Some(idx) = cm.get("index.json") + && let Ok(entries) = serde_json::from_str::>(idx) + { + let charter_line = cluster + .teams(&ns) + .get_opt(&name) + .await + .ok() + .flatten() + .map(|t| { + t.spec + .charter + .lines() + .next() + .unwrap_or(&t.spec.charter) + .to_string() + }) + .unwrap_or_default(); + for e in entries { + let key = format!( + "entry-{}", + e.id.chars() + .map( + |c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + ) + .collect::() + ); + let title = match cm.get(&key) { + Some(c) => commons_title(&e.title, &deliverable_text(c), &charter_line), + None => e.title.clone(), + }; + events.push(LedgerEvent { + at: e.created_at, + kind: "knowledge".into(), + summary: format!("Learned: {title}"), + task: Some(e.source_task), + tokens: None, + }); + } + } + + // Published digests (report events). + for d in cluster.list_team_digests().await { + if d.get("team").and_then(|v| v.as_str()) != Some(name.as_str()) { + continue; + } + events.push(LedgerEvent { + at: d + .get("at") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + kind: "digest".into(), + summary: d + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + task: None, + tokens: None, + }); + } + + events.sort_by(|a, b| b.at.cmp(&a.at)); + events.truncate(100); + Ok(Json(events)) +} + +#[derive(Debug, Deserialize)] +pub struct CreateTeamRequest { + pub name: String, + pub charter: String, + #[serde(default)] + pub display_name: Option, + pub tier: Option, + pub authority_ceiling: Option, + pub delegation_depth: Option, + pub reporting_to: Option, + pub knowledge_commons: Option, + #[serde(default)] + pub memory: Option, + pub cadence_minutes: Option, + /// Runtime retention policy. Older clients omit this and retain ephemeral + /// behavior; the Bridge composer recommends resource-optimized operation. + #[serde(default)] + pub lifecycle_mode: Option, + /// Idle window before a resource-optimized runtime is suspended. + #[serde(default)] + pub warm_idle_seconds: Option, + /// Governance policy that bounds every run this team mints. When omitted the + /// Bridge assigns the cluster default (`kars-default`) so the team's + /// sandboxes are governed AND functional — an un-governed sandbox hangs + /// because the agent's AGT engine fails closed on an empty policy set. + pub tool_policy: Option, + /// The harness every run this team mints executes on (OpenClaw / Hermes / + /// BYO). Written onto the team's run blueprint; the controller inherits it + /// for each minted run. A bootstrap-only (non-autonomous) harness is + /// corrected to OpenClaw. Absent => the sandbox default (OpenClaw). + #[serde(default)] + pub runtime: Option, + /// Model route for the team principal and minted runs, encoded as + /// `provider::deployment` (for example + /// `github-copilot::claude-opus-4.8`). + #[serde(default)] + pub model: Option, + /// Ordered provider::deployment routes used only after the primary route + /// fails. Every entry must independently qualify the complete Team plan. + #[serde(default)] + pub model_fallbacks: Vec, + /// Network destinations inherited by every run. + #[serde(default)] + pub egress: Vec, + /// `learning` for discovery or `strict` for allowlist enforcement. + #[serde(default)] + pub egress_mode: Option, + /// Connected MCP servers every run this team mints receives. Each name must + /// resolve to an installed McpServer; preflight validates readiness before + /// launch and the controller inherits the list onto every task-force run. + #[serde(default)] + pub mcp_servers: Vec, + /// When false/omitted the team is created PAUSED (hibernating) so nothing + /// runs until the operator explicitly launches it ("Run now" / resume) — + /// the launch is a real human approval, not an automatic kickoff. Set true + /// to opt into launching immediately on create. + #[serde(default)] + pub launch: Option, + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub execution_plan: Option, + /// Repos every run may open PRs against, selected from the authenticated + /// principal's connection. The server derives the typed connection reference. + #[serde(default)] + pub git_write_repos: Option>, + /// The identity creating this team (stamped `kars.azure.com/created-by`) for + /// per-user budget attribution. Absent => "unattributed". + #[serde(default)] + pub created_by: Option, + /// Retention override, in seconds, for every task-force RUN this team + /// mints — auto-delete a run's record this long after its deliverable + /// lands. Does NOT apply to the standing principal/roster, which are + /// never auto-deleted. `0` disables retention for this team's runs even + /// if a cluster-wide default is set. Absent inherits the cluster default. + #[serde(default)] + pub run_retention_ttl_seconds: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateEgress { + pub host: String, + #[serde(default)] + pub port: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateRole { + pub name: String, + pub system_prompt: Option, + pub runtime: Option, + pub model: Option, + #[serde(default)] + pub skills: Vec, +} + +/// Build a `spec.roster` array from create/update role inputs, shared by team +/// creation and roster editing so both paths produce identical role shapes +/// (per-member systemPrompt + blueprint{runtime, model} + skills). +/// Reject roster role names that collide with the reserved task names the +/// controller derives from the team (`-principal`). A role named +/// "principal" would otherwise re-materialize the principal task as a member +/// parented to itself, deadlocking the whole team. The controller also skips +/// such a role defensively, but rejecting here gives the operator a clear error +/// instead of a silently dropped role. +fn reject_reserved_role_names(team: &str, roles: &[CreateRole]) -> AppResult<()> { + let _ = team; + for r in roles { + let n = r.name.trim().to_ascii_lowercase(); + if n == "principal" { + return Err(AppError::BadRequest( + "role name 'principal' is reserved for the team's authority root — rename this role".into(), + )); + } + } + Ok(()) +} + +fn build_roster(roles: &[CreateRole]) -> Vec { + roles + .iter() + .filter(|r| !r.name.trim().is_empty()) + .map(|r| { + let mut role = serde_json::json!({ "name": r.name.trim() }); + if let Some(sp) = &r.system_prompt + && !sp.trim().is_empty() + { + role["systemPrompt"] = serde_json::json!(sp.trim()); + } + let mut bp = serde_json::Map::new(); + if let Some(rt) = &r.runtime + && !rt.is_empty() + { + // Harness capability, defense-in-depth: a bootstrap-only adapter + // (no autonomous task loop) can't run a standing member — a + // hand-composed team could still name one, so correct it to + // OpenClaw here too. Hermes/BYO are autonomous and pass through. + let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { + "OpenClaw" + } else { + rt.as_str() + }; + bp.insert("runtime".into(), serde_json::json!(rt)); + } + if let Some(m) = &r.model + && let Some((provider, deployment)) = m.split_once("::") + { + bp.insert( + "model".into(), + serde_json::json!({ "provider": provider, "deployment": deployment }), + ); + } + if !bp.is_empty() { + role["blueprint"] = serde_json::Value::Object(bp); + } + if !r.skills.is_empty() { + role["skills"] = serde_json::json!(r.skills); + } + role + }) + .collect() +} + +fn normalize_mcp_servers(servers: &[String]) -> AppResult> { + let mut seen = std::collections::BTreeSet::new(); + let mut normalized = Vec::new(); + for server in servers { + let server = server.trim(); + if !server.is_empty() && seen.insert(server.to_string()) { + normalized.push(server.to_string()); + } + } + if normalized.len() > 8 { + return Err(AppError::BadRequest( + "a team may connect at most 8 MCP servers".into(), + )); + } + Ok(normalized) +} + +fn normalize_autonomous_runtime(runtime: &mut Option) { + if let Some(value) = runtime.as_deref() + && (value.trim().is_empty() || crate::routes::compose::is_non_autonomous_harness(value)) + { + *runtime = Some("OpenClaw".into()); + } +} + +fn normalize_model_fallback_routes( + routes: &[String], + primary: Option<&str>, +) -> AppResult> { + let mut seen = std::collections::BTreeSet::new(); + let mut normalized = Vec::new(); + for route in routes { + let route = route.trim(); + if route.is_empty() + || primary.is_some_and(|primary| route == primary) + || !seen.insert(route.to_string()) + { + continue; + } + let valid = route + .split_once("::") + .is_some_and(|(provider, deployment)| { + !provider.trim().is_empty() && !deployment.trim().is_empty() + }); + if !valid { + return Err(AppError::BadRequest( + "model_fallbacks entries must be encoded as provider::deployment".into(), + )); + } + normalized.push(route.to_string()); + } + if normalized.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 unique routes".into(), + )); + } + Ok(normalized) +} + +fn validate_model_route(models: &[ModelOption], route: &str) -> AppResult<()> { + let route = route.trim(); + if route.is_empty() { + return Ok(()); + } + let valid = route + .split_once("::") + .is_some_and(|(provider, deployment)| { + models + .iter() + .any(|model| model.provider == provider && model.deployment == deployment) + }); + if valid { + Ok(()) + } else { + Err(AppError::BadRequest(format!( + "model route `{route}` is not present in the live model catalogue" + ))) + } +} + +fn option_named<'a>(items: &'a [RefOption], namespace: &str, name: &str) -> Option<&'a RefOption> { + items + .iter() + .find(|option| option.name == name && option.namespace == namespace) +} + +fn team_role_qualification_requirements( + plan: &crate::routes::tasks::ExecutionPlanDto, + role_name: &str, +) -> std::collections::BTreeSet { + let mut required = + std::collections::BTreeSet::from(["team".to_string(), "telemetry".to_string()]); + if let Some(role) = plan.roles.iter().find(|role| role.name == role_name) { + for phase in &role.phases { + required.extend(phase.capabilities.iter().cloned()); + } + } + required +} + +struct TeamModelRoutes<'a> { + namespace: &'a str, + runtime: Option<&'a str>, + model: Option<&'a str>, + model_fallbacks: &'a [String], + roles: &'a [CreateRole], + execution_plan: &'a crate::routes::tasks::ExecutionPlanDto, + mcp_servers: &'a [String], + memory: Option<&'a str>, +} + +fn validate_team_model_routes(options: &Options, routes: TeamModelRoutes<'_>) -> AppResult<()> { + let TeamModelRoutes { + namespace, + runtime, + model, + model_fallbacks, + roles, + execution_plan, + mcp_servers, + memory, + } = routes; + let memory = memory.map(str::trim).filter(|memory| !memory.is_empty()); + let default_route = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .unwrap_or_default(); + let principal_route = model + .map(str::trim) + .filter(|model| !model.is_empty()) + .unwrap_or(default_route.as_str()); + validate_model_route(&options.models, principal_route)?; + let principal_runtime = runtime + .map(str::trim) + .filter(|runtime| !runtime.is_empty()) + .unwrap_or("OpenClaw"); + let principal_model = principal_route.split_once("::").ok_or_else(|| { + AppError::BadRequest(format!( + "model route `{principal_route}` must use provider::deployment" + )) + })?; + let principal_blueprint = crate::routes::tasks::BlueprintDto { + runtime: Some(principal_runtime.to_string()), + model: Some(crate::routes::tasks::ModelDto { + provider: principal_model.0.to_string(), + deployment: principal_model.1.to_string(), + }), + model_fallbacks: Vec::new(), + instructions: None, + tool_policy: None, + mcp_servers: mcp_servers.to_vec(), + egress: Vec::new(), + egress_mode: None, + isolation: None, + memory: memory.map(str::to_string), + skills: roles + .iter() + .flat_map(|role| role.skills.iter().cloned()) + .collect(), + execution_plan: Some(execution_plan.clone()), + }; + let (principal_required, principal_parallel) = + crate::routes::validate::qualification_requirements(&principal_blueprint, Some("team")); + validate_qualified_model_route( + principal_runtime, + principal_route, + &principal_required, + principal_parallel, + )?; + let principal_route_label = crate::routes::options::route_label( + principal_runtime, + principal_model.0, + principal_model.1, + ); + for server in mcp_servers { + let option = option_named(&options.mcp_servers, namespace, server).ok_or_else(|| { + AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::mcp_server_qualified_for_route( + principal_runtime, + principal_model.0, + principal_model.1, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` lacks retained resource qualification for {principal_route_label} at current schema {}", + option.tool_schema_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + if let Some(memory) = memory.map(str::trim).filter(|memory| !memory.is_empty()) { + let option = option_named(&options.memories, namespace, memory).ok_or_else(|| { + AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::memory_binding_qualified_for_route( + principal_runtime, + principal_model.0, + principal_model.1, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "memory `{memory}` lacks retained resource qualification for {principal_route_label} at backend {} / compiled digest {}", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + for role in roles { + let role_route = role + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + .unwrap_or(principal_route); + validate_model_route(&options.models, role_route)?; + let role_runtime = role + .runtime + .as_deref() + .map(str::trim) + .filter(|runtime| !runtime.is_empty()) + .unwrap_or(principal_runtime); + let role_required = team_role_qualification_requirements(execution_plan, &role.name); + validate_qualified_model_route(role_runtime, role_route, &role_required, 1)?; + let (provider, deployment) = role_route.split_once("::").ok_or_else(|| { + AppError::BadRequest(format!( + "model route `{role_route}` must use provider::deployment" + )) + })?; + let role_route_label = + crate::routes::options::route_label(role_runtime, provider, deployment); + if role_required.contains("mcp") { + for server in mcp_servers { + let option = + option_named(&options.mcp_servers, namespace, server).ok_or_else(|| { + AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::mcp_server_qualified_for_route( + role_runtime, + provider, + deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "role `{}` MCP server `{server}` lacks retained resource qualification for {role_route_label} at current schema {}", + role.name, + option.tool_schema_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + } + if role_required.contains("memory") + && let Some(memory) = memory.map(str::trim).filter(|memory| !memory.is_empty()) + { + let option = option_named(&options.memories, namespace, memory).ok_or_else(|| { + AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::memory_binding_qualified_for_route( + role_runtime, + provider, + deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "role `{}` memory `{memory}` lacks retained resource qualification for {role_route_label} at backend {} / compiled digest {}", + role.name, + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + for skill in &role.skills { + let option = option_named(&options.skills, namespace, skill).ok_or_else(|| { + AppError::BadRequest(format!( + "skill `{skill}` is not present in the approved live catalogue" + )) + })?; + match crate::routes::options::skill_version_qualified_for_route( + role_runtime, + provider, + deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "role `{}` skill `{skill}` lacks retained resource qualification for {role_route_label} at current version digest {}", + role.name, + option.version_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + } + let mut seen_fallbacks = std::collections::BTreeSet::new(); + for fallback in model_fallbacks { + let fallback = fallback.trim(); + if fallback.is_empty() { + continue; + } + if !seen_fallbacks.insert(fallback.to_string()) { + continue; + } + if seen_fallbacks.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 unique routes".into(), + )); + } + validate_model_route(&options.models, fallback)?; + let fallback_roles = roles + .iter() + .cloned() + .map(|mut role| { + role.model = Some(fallback.to_string()); + role + }) + .collect::>(); + validate_team_model_routes( + options, + TeamModelRoutes { + namespace, + runtime, + model: Some(fallback), + model_fallbacks: &[], + roles: &fallback_roles, + execution_plan, + mcp_servers, + memory, + }, + )?; + } + Ok(()) +} + +fn validate_qualified_model_route( + runtime: &str, + route: &str, + required_capabilities: &std::collections::BTreeSet, + max_parallel: i32, +) -> AppResult<()> { + let Some((provider, deployment)) = route.split_once("::") else { + return Err(AppError::BadRequest(format!( + "model route `{route}` must use provider::deployment" + ))); + }; + match crate::routes::options::route_qualification( + runtime, + provider, + deployment, + required_capabilities, + max_parallel, + None, + ) { + Ok(true) => Ok(()), + Ok(false) => Err(AppError::BadRequest(format!( + "runtime/model route `{runtime} · {provider}::{deployment}` has not passed the fresh E2E qualification matrix" + ))), + Err(error) => Err(AppError::Upstream(format!( + "route qualification configuration error: {error}" + ))), + } +} + +fn normalize_lifecycle_mode(mode: Option<&str>) -> AppResult> { + let Some(mode) = mode.map(str::trim).filter(|mode| !mode.is_empty()) else { + return Ok(None); + }; + match mode.to_ascii_lowercase().replace(['-', '_'], "").as_str() { + "ephemeral" => Ok(Some("ephemeral")), + "resourceoptimized" => Ok(Some("resourceOptimized")), + "persistent" => Ok(Some("persistent")), + _ => Err(AppError::BadRequest( + "lifecycle_mode must be 'ephemeral', 'resourceOptimized', or 'persistent'".into(), + )), + } +} + +fn validate_warm_idle_seconds(seconds: Option) -> AppResult> { + match seconds { + Some(seconds) if seconds < 0 => Err(AppError::BadRequest( + "warm_idle_seconds must be non-negative".into(), + )), + value => Ok(value), + } +} + +fn apply_team_git_write( + spec: &mut serde_json::Value, + git_write: Option<&crate::kars::task::GitWriteConfig>, +) -> AppResult<()> { + let Some(git_write) = git_write else { + return Ok(()); + }; + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["gitWrite"] = + serde_json::to_value(git_write).map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(()) +} + +async fn validate_mcp_servers( + cluster: &crate::kars::cluster::Cluster, + namespace: &str, + servers: &[String], +) -> AppResult<()> { + for server in servers { + let Some(resource) = cluster + .get_kind(namespace, "McpServer", server) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + else { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` is not installed in namespace `{namespace}`" + ))); + }; + let phase = resource + .data + .get("status") + .and_then(|status| status.get("phase")) + .and_then(|phase| phase.as_str()); + let observed_generation = resource + .data + .get("status") + .and_then(|status| status.get("observedGeneration")) + .and_then(|generation| generation.as_i64()); + if phase != Some("Ready") || observed_generation != resource.metadata.generation { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` is not Ready for its current generation in namespace `{namespace}`" + ))); + } + } + Ok(()) +} + +/// `POST /api/namespaces/:ns/teams` — create a standing team. The controller +/// validates the envelope; cadence drives the autonomous tick. Defaults are +/// conservative (tier 3, ceiling=tier, depth 1) so a team can't self-amplify. +pub async fn create_team( + State(state): State, + Extension(principal): Extension, + Path(ns): Path, + Json(mut b): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + cluster + .credential_grant(&ns) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if b.name.trim().is_empty() || b.charter.trim().len() < 8 { + return Err(AppError::BadRequest( + "name and a real charter are required".into(), + )); + } + reject_reserved_role_names(&b.name, &b.roles)?; + let execution_plan = b + .execution_plan + .as_ref() + .ok_or_else(|| AppError::BadRequest("a typed execution_plan is required".into()))?; + crate::routes::compose::validate_execution_plan(execution_plan) + .map_err(AppError::BadRequest)?; + let roster_names = b + .roles + .iter() + .map(|role| role.name.trim()) + .collect::>(); + let plan_names = execution_plan + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(); + if roster_names != plan_names { + return Err(AppError::BadRequest( + "execution_plan role names must exactly match the team roster".into(), + )); + } + b.mcp_servers = normalize_mcp_servers(&b.mcp_servers)?; + validate_mcp_servers(cluster, &ns, &b.mcp_servers).await?; + normalize_autonomous_runtime(&mut b.runtime); + for role in &mut b.roles { + normalize_autonomous_runtime(&mut role.runtime); + } + let options = build_options(cluster).await?; + if b.model + .as_deref() + .is_none_or(|model| model.trim().is_empty()) + { + b.model = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)); + } + b.model_fallbacks = normalize_model_fallback_routes(&b.model_fallbacks, b.model.as_deref())?; + validate_team_model_routes( + &options, + TeamModelRoutes { + namespace: &ns, + runtime: b.runtime.as_deref(), + model: b.model.as_deref(), + model_fallbacks: &b.model_fallbacks, + roles: &b.roles, + execution_plan, + mcp_servers: &b.mcp_servers, + memory: b.memory.as_deref(), + }, + )?; + b.created_by = Some(principal.name.clone()); + let created_by = principal.name.clone(); + let git_write = crate::routes::github::authorize_git_write( + cluster, + &ns, + &principal, + b.git_write_repos.as_deref(), + ) + .await?; + // Aggregate inference-budget gate (cluster + workspace + user): a launched + // team immediately kicks off a run (token spend), so block starting new work + // when a budget at any tier is strict/over-buffer. A paused team passes. + if b.launch.unwrap_or(false) { + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; + } + let tier = b.tier.unwrap_or(3).clamp(1, 5); + let ceiling = b.authority_ceiling.unwrap_or(tier).clamp(1, tier); + // Governance: create PAUSED unless the operator explicitly opts into + // launching. A paused team does not auto-kickoff (the controller mints the + // initial run only when `!paused`), so "Launch" is a genuine human approval + // — clicking Run now / Resume — not an automatic side-effect of Create. + let paused = !b.launch.unwrap_or(false); + let mut spec = serde_json::json!({ + "charter": b.charter, "paused": paused, "envelope": { "tier": tier, "authorityCeiling": ceiling, "delegationDepth": b.delegation_depth.unwrap_or(1) }, + }); + if let Some(mode) = normalize_lifecycle_mode(b.lifecycle_mode.as_deref())? { + spec["lifecycleMode"] = serde_json::json!(mode); + } + if let Some(seconds) = validate_warm_idle_seconds(b.warm_idle_seconds)? { + spec["warmIdleSeconds"] = serde_json::json!(seconds); + } + if let Some(r) = &b.reporting_to { + spec["reportingTo"] = serde_json::json!(r); + } + if let Some(d) = b + .display_name + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + { + spec["displayName"] = serde_json::json!(d); + } + if let Some(c) = &b.knowledge_commons { + spec["knowledgeCommons"] = serde_json::json!(c); + } + // cadence_minutes == 0 (or absent) means a cadence-LESS "run on demand" team: + // the CRD requires everyMinutes >= 1 when the cadence field is present, so we + // OMIT it entirely rather than write an invalid everyMinutes: 0 (which the + // apiserver rejects 422). A cadence-less team is minted once on creation + // (kickoff) and thereafter only runs via "Run now". + if let Some(m) = b.cadence_minutes + && m >= 1 + { + spec["cadence"] = serde_json::json!({ "everyMinutes": m }); + } + // Every team run must be governed by a real ToolPolicy. Without one the run + // sandbox is created with governance disabled, the agent's AGT engine starts + // with an empty policy set and fails closed, and the run hangs until the + // dispatch times out. Resolve the requested policy (or the cluster default + // `kars-default`) and pin it on the team's run blueprint. + let tool_policy = resolve_team_tool_policy(cluster, &ns, b.tool_policy.as_deref()).await; + if let Some(tp) = &tool_policy { + spec["blueprint"] = serde_json::json!({ "toolPolicy": tp }); + } + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["executionPlan"] = serde_json::to_value(execution_plan.clone().into_crd()) + .map_err(|error| { + AppError::BadRequest(format!("execution_plan could not be serialized: {error}")) + })?; + // Team-level harness: the runtime every minted run executes on. Correct a + // bootstrap-only adapter (no autonomous task loop) to OpenClaw — a standing + // run must be able to run autonomously. Hermes/BYO are autonomous and pass + // through. The controller inherits this via the team's run blueprint. + if let Some(rt) = b + .runtime + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { + "OpenClaw" + } else { + rt + }; + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["runtime"] = serde_json::json!(rt); + } + if let Some(model) = b.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) + && let Some((provider, deployment)) = model.split_once("::") + { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["model"] = + serde_json::json!({"provider": provider, "deployment": deployment}); + } + let model_fallbacks = b + .model_fallbacks + .iter() + .map(|route| route.trim()) + .filter(|route| !route.is_empty()) + .filter_map(|route| route.split_once("::")) + .map(|(provider, deployment)| { + serde_json::json!({"provider": provider, "deployment": deployment}) + }) + .collect::>(); + spec["blueprint"]["modelFallbacks"] = serde_json::json!(model_fallbacks); + if let Some(memory) = b.memory.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["memory"] = serde_json::json!(memory); + } + if !b.egress.is_empty() { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["egress"] = serde_json::json!( + b.egress + .iter() + .filter_map(|entry| { + let host = entry.host.trim(); + (!host.is_empty()) + .then(|| serde_json::json!({"host": host, "port": entry.port})) + }) + .collect::>() + ); + } + if let Some(mode) = b.egress_mode.as_deref().map(str::trim) { + let mode = match mode.to_ascii_lowercase().as_str() { + "strict" => "Strict", + "learning" | "learn" => "Learn", + _ => { + return Err(AppError::BadRequest( + "egress_mode must be 'learning' or 'strict'".into(), + )); + } + }; + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["egressMode"] = serde_json::json!(mode); + } + apply_team_git_write(&mut spec, git_write.as_ref().map(|(config, _)| config))?; + if let Some((_, binding)) = &git_write { + spec["blueprint"]["githubBinding"] = + serde_json::to_value(binding).map_err(|error| AppError::Upstream(error.to_string()))?; + } + if !b.mcp_servers.is_empty() { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["mcpServers"] = serde_json::json!( + b.mcp_servers + .iter() + .map(|server| server.trim()) + .filter(|server| !server.is_empty()) + .collect::>() + ); + } + if !b.roles.is_empty() { + spec["roster"] = serde_json::json!(build_roster(&b.roles)); + } + if let Some(ttl) = b.run_retention_ttl_seconds { + spec["runRetentionTtlSeconds"] = serde_json::json!(ttl); + } + let body = serde_json::json!({ "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTeam", "metadata": {"name": b.name.trim(), "namespace": ns}, "spec": spec }); + let mut body = body; + // Stamp the creator for per-user budget attribution (propagated onto runs). + if body["metadata"]["annotations"].is_null() { + body["metadata"]["annotations"] = serde_json::json!({}); + } + body["metadata"]["annotations"]["kars.azure.com/created-by"] = serde_json::json!(created_by); + body["metadata"]["annotations"]["kars.azure.com/owner-sub"] = serde_json::json!(principal.sub); + body["metadata"]["annotations"]["kars.azure.com/owner-name"] = + serde_json::json!(principal.name); + let active = !body["spec"]["paused"].as_bool().unwrap_or(false); + body["spec"]["paused"] = serde_json::json!(true); + let captured = cluster + .create_kind(&ns, "KarsTeam", body) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + cluster + .finish_created_credentials( + &crate::kars::credentials::Target { + kind: "KarsTeam".into(), + namespace: ns.clone(), + name: captured.name_any(), + uid: captured + .uid() + .ok_or_else(|| AppError::Upstream("Team CREATE omitted UID".into()))?, + }, + active, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json( + serde_json::json!({"created": true, "name": b.name.trim()}), + )) +} + +/// Resolve the governance policy to pin on a team's run blueprint: the +/// requested policy when it exists, else the cluster default (`kars-default`), +/// else the first installed policy. Returns `None` only when the cluster has no +/// ToolPolicy at all (nothing we can assign). +async fn resolve_team_tool_policy( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + requested: Option<&str>, +) -> Option { + if let Some(r) = requested.map(str::trim).filter(|r| !r.is_empty()) + && cluster + .get_kind(ns, "ToolPolicy", r) + .await + .ok() + .flatten() + .is_some() + { + return Some(r.to_string()); + } + if cluster + .get_kind( + ns, + "ToolPolicy", + crate::routes::compose::DEFAULT_TOOL_POLICY, + ) + .await + .ok() + .flatten() + .is_some() + { + return Some(crate::routes::compose::DEFAULT_TOOL_POLICY.to_string()); + } + cluster + .list_kind_all("ToolPolicy") + .await + .ok()? + .into_iter() + .find(|policy| policy.namespace().as_deref() == Some(ns)) + .map(|policy| policy.name_any()) +} + +#[derive(Debug, Deserialize)] +pub struct UpdateTeamRequest { + pub charter: Option, + pub paused: Option, + pub cadence_minutes: Option, + pub reporting_to: Option, + /// Change how the standing runtime is retained between assignments. + #[serde(default)] + pub lifecycle_mode: Option, + /// Change the resource-optimized warm idle window. + #[serde(default)] + pub warm_idle_seconds: Option, + /// When present, replaces the team's roster — editing the org post-create + /// (add/remove roles, change per-member prompt/harness/model/skills). The + /// controller reconciles member tasks to match. + pub roles: Option>, + /// Change the harness every run this team mints executes on (OpenClaw / + /// Hermes / BYO). A non-autonomous adapter is corrected to OpenClaw. + #[serde(default)] + pub runtime: Option, + /// Change the principal/default model inherited by future runs. + #[serde(default)] + pub model: Option, + /// Replace the ordered fallback routes inherited by future runs. + #[serde(default)] + pub model_fallbacks: Option>, + /// Replace or clear the shared memory binding inherited by future runs. + #[serde(default)] + pub memory: Option, + /// Replace the connected MCP servers inherited by future team runs. + /// `Some([])` explicitly clears the list; `None` leaves it unchanged. + #[serde(default)] + pub mcp_servers: Option>, + /// Replace the keyless GitHub write scope inherited by future team runs. + /// `Some([])` revokes PR-write access; `None` leaves it unchanged. + #[serde(default)] + pub git_write_repos: Option>, + /// Replace the declared egress destinations for future runs. + #[serde(default)] + pub egress: Option>, + /// Change future runs between learning and strict egress modes. + #[serde(default)] + pub egress_mode: Option, + /// Change the retention override for this team's future task-force runs. + /// `0` disables retention; absent leaves the current setting unchanged. + #[serde(default)] + pub run_retention_ttl_seconds: Option, + /// Replace the typed execution plan while preserving the team roster. The + /// server validates structure, exact role-name parity, and route qualification. + #[serde(default)] + pub execution_plan: Option, +} + +/// `PATCH /api/namespaces/:ns/teams/:name` — edit charter, cadence, reporting, +/// or pause. Envelope-raising fields are out of scope here (promote handles +/// governed tier changes); this is the non-amplifying day-to-day edit. +pub async fn update_team( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, + Json(mut b): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + normalize_autonomous_runtime(&mut b.runtime); + if let Some(roles) = &mut b.roles { + for role in roles { + normalize_autonomous_runtime(&mut role.runtime); + } + } + let execution_plan_changed = b.execution_plan.is_some(); + if b.runtime.is_some() + || b.model.is_some() + || b.model_fallbacks.is_some() + || b.memory.is_some() + || b.roles.is_some() + || b.mcp_servers.is_some() + || b.execution_plan.is_some() + { + let options = build_options(cluster).await?; + if b.model + .as_deref() + .is_some_and(|model| model.trim().is_empty()) + { + b.model = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)); + } + let existing_runtime = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.runtime.as_deref()); + let existing_model = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.model.as_ref()) + .map(|model| format!("{}::{}", model.provider, model.deployment)); + let existing_model_fallbacks = team + .spec + .blueprint + .as_ref() + .map(|blueprint| { + blueprint + .model_fallbacks + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .collect::>() + }) + .unwrap_or_default(); + if b.model.is_some() || b.model_fallbacks.is_some() { + b.model_fallbacks = Some(normalize_model_fallback_routes( + b.model_fallbacks + .as_deref() + .unwrap_or(existing_model_fallbacks.as_slice()), + b.model.as_deref().or(existing_model.as_deref()), + )?); + } + let existing_roles = team + .spec + .roster + .iter() + .map(|role| CreateRole { + name: role.name.clone(), + system_prompt: role.system_prompt.clone(), + runtime: role + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.runtime.clone()), + model: role + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.model.as_ref()) + .map(|model| format!("{}::{}", model.provider, model.deployment)), + skills: role.skills.clone(), + }) + .collect::>(); + let existing_execution_plan = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + .map(crate::routes::tasks::ExecutionPlanDto::from_crd); + let existing_mcp_servers = team + .spec + .blueprint + .as_ref() + .map(|blueprint| blueprint.mcp_servers.clone()) + .unwrap_or_default(); + let existing_memory = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.memory.as_deref()); + let execution_plan = b + .execution_plan + .as_ref() + .or(existing_execution_plan.as_ref()) + .ok_or_else(|| { + AppError::BadRequest( + "this team cannot change runtime/model/roles/MCP until it has a typed execution_plan" + .into(), + ) + })?; + crate::routes::compose::validate_execution_plan(execution_plan) + .map_err(AppError::BadRequest)?; + let effective_roles = b.roles.as_deref().unwrap_or(existing_roles.as_slice()); + let roster_names = effective_roles + .iter() + .map(|role| role.name.trim()) + .collect::>(); + let plan_names = execution_plan + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::>(); + if roster_names != plan_names { + return Err(AppError::BadRequest( + "execution_plan role names must exactly match the team roster".into(), + )); + } + validate_team_model_routes( + &options, + TeamModelRoutes { + namespace: &ns, + runtime: b.runtime.as_deref().or(existing_runtime), + model: b.model.as_deref().or(existing_model.as_deref()), + model_fallbacks: b + .model_fallbacks + .as_deref() + .unwrap_or(existing_model_fallbacks.as_slice()), + roles: effective_roles, + execution_plan, + mcp_servers: b + .mcp_servers + .as_deref() + .unwrap_or(existing_mcp_servers.as_slice()), + memory: b.memory.as_deref().or(existing_memory), + }, + )?; + } + if b.paused == Some(false) { + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &principal.name).await?; + } + let mut spec = serde_json::Map::new(); + if let Some(c) = &b.charter { + spec.insert("charter".into(), serde_json::json!(c)); + } + if let Some(p) = b.paused { + spec.insert("paused".into(), serde_json::json!(p)); + } + if let Some(r) = &b.reporting_to { + spec.insert("reportingTo".into(), serde_json::json!(r)); + } + if let Some(mode) = normalize_lifecycle_mode(b.lifecycle_mode.as_deref())? { + spec.insert("lifecycleMode".into(), serde_json::json!(mode)); + } + if let Some(seconds) = validate_warm_idle_seconds(b.warm_idle_seconds)? { + spec.insert("warmIdleSeconds".into(), serde_json::json!(seconds)); + } + if let Some(m) = b.cadence_minutes { + if m >= 1 { + spec.insert("cadence".into(), serde_json::json!({"everyMinutes": m})); + } else { + // 0 = passive / run-on-demand: clear the cadence entirely (a merge + // patch null removes the field) so the team actually stops auto- + // running, honouring the "0 = passive" label instead of silently + // leaving the previous cadence in place. + spec.insert("cadence".into(), serde_json::Value::Null); + } + } + if let Some(roles) = &b.roles { + reject_reserved_role_names(&name, roles)?; + spec.insert("roster".into(), serde_json::json!(build_roster(roles))); + } + let mut blueprint = serde_json::Map::new(); + if let Some(rt) = b + .runtime + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { + "OpenClaw" + } else { + rt + }; + blueprint.insert("runtime".into(), serde_json::json!(rt)); + } + if let Some(model) = b.model.as_deref().map(str::trim) { + if model.is_empty() { + blueprint.insert("model".into(), serde_json::Value::Null); + } else if let Some((provider, deployment)) = model.split_once("::") { + blueprint.insert( + "model".into(), + serde_json::json!({"provider": provider, "deployment": deployment}), + ); + } else { + return Err(AppError::BadRequest( + "model must be encoded as provider::deployment".into(), + )); + } + } + if let Some(fallbacks) = &b.model_fallbacks { + let mut seen = std::collections::BTreeSet::new(); + let mut routes = Vec::new(); + for fallback in fallbacks { + let fallback = fallback.trim(); + if fallback.is_empty() || !seen.insert(fallback.to_string()) { + continue; + } + let Some((provider, deployment)) = fallback.split_once("::") else { + return Err(AppError::BadRequest( + "model_fallbacks entries must be encoded as provider::deployment".into(), + )); + }; + routes.push(serde_json::json!({ + "provider": provider, + "deployment": deployment, + })); + } + if routes.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 unique routes".into(), + )); + } + blueprint.insert("modelFallbacks".into(), serde_json::json!(routes)); + } + if let Some(memory) = b.memory.as_deref() { + blueprint.insert( + "memory".into(), + if memory.trim().is_empty() { + serde_json::Value::Null + } else { + serde_json::json!(memory.trim()) + }, + ); + } + if let Some(servers) = &b.mcp_servers { + let servers = normalize_mcp_servers(servers)?; + validate_mcp_servers(cluster, &ns, &servers).await?; + blueprint.insert("mcpServers".into(), serde_json::json!(servers)); + } + if let Some(repos) = &b.git_write_repos { + let git_write = + crate::routes::github::authorize_git_write(cluster, &ns, &principal, Some(repos)) + .await?; + blueprint.insert( + "githubBinding".into(), + git_write + .as_ref() + .map(|(_, binding)| serde_json::to_value(binding)) + .transpose() + .map_err(|error| AppError::Upstream(error.to_string()))? + .unwrap_or(serde_json::Value::Null), + ); + blueprint.insert( + "gitWrite".into(), + git_write + .map(|(grant, _)| { + serde_json::to_value(grant).map_err(|e| AppError::Upstream(e.to_string())) + }) + .transpose()? + .unwrap_or(serde_json::Value::Null), + ); + } + if let Some(egress) = &b.egress { + let entries = egress + .iter() + .filter_map(|entry| { + let host = entry.host.trim(); + (!host.is_empty()).then(|| serde_json::json!({"host": host, "port": entry.port})) + }) + .collect::>(); + blueprint.insert("egress".into(), serde_json::json!(entries)); + } + if let Some(mode) = b.egress_mode.as_deref().map(str::trim) { + let mode = match mode.to_ascii_lowercase().as_str() { + "strict" => "Strict", + "learning" | "learn" => "Learn", + _ => { + return Err(AppError::BadRequest( + "egress_mode must be 'learning' or 'strict'".into(), + )); + } + }; + blueprint.insert("egressMode".into(), serde_json::json!(mode)); + } + if let Some(execution_plan) = &b.execution_plan { + blueprint.insert( + "executionPlan".into(), + serde_json::to_value(execution_plan.clone().into_crd()).map_err(|error| { + AppError::BadRequest(format!("execution_plan could not be serialized: {error}")) + })?, + ); + } + if !blueprint.is_empty() { + // Merge-patch the nested blueprint so runtime/MCP edits preserve the + // team's existing toolPolicy/model and can be changed together. + spec.insert("blueprint".into(), serde_json::Value::Object(blueprint)); + } + if let Some(ttl) = b.run_retention_ttl_seconds { + spec.insert("runRetentionTtlSeconds".into(), serde_json::json!(ttl)); + } + let api: Api = cluster.teams(&ns); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(serde_json::json!({"spec": spec})), + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if execution_plan_changed { + cluster + .merge_patch_kind( + &ns, + "KarsTask", + &format!("{name}-principal"), + serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/retry-not-before": null + } + } + }), + ) + .await + .map_err(|error| { + AppError::Upstream(format!( + "team plan was updated but its retry park could not be cleared: {error}" + )) + })?; + } + Ok(Json(serde_json::json!({"updated": true}))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_team_visibility_requires_exact_owner_even_for_operators() { + let team: KarsTeam = serde_json::from_value(serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTeam", + "metadata": { + "name": "private-team", + "annotations": {"kars.azure.com/owner-sub": "subject-a"} + }, + "spec": { + "charter": "Maintain private work.", + "envelope": {"tier": 2, "authorityCeiling": 1}, + "paused": true + } + })) + .expect("team"); + let owner = Principal { + sub: "subject-a".into(), + name: "owner".into(), + roles: vec!["user".into()], + }; + let other_operator = Principal { + sub: "subject-b".into(), + name: "operator".into(), + roles: vec!["operator".into()], + }; + + assert!(is_team_owner(&team, &owner)); + assert!(!is_team_owner(&team, &other_operator)); + } + + #[test] + fn team_create_and_update_accept_mcp_servers() { + let create: CreateTeamRequest = serde_json::from_value(serde_json::json!({ + "name": "review-board", + "charter": "Review the release", + "model": "github-copilot::claude-opus-4.8", + "model_fallbacks": [ + "local-inference::gpt-oss-120b", + "foundry::gpt-5.4-pro" + ], + "mcp_servers": ["playwright", "everything"], + "memory": "foundry-default", + "lifecycle_mode": "resourceOptimized", + "warm_idle_seconds": 900, + "egress_mode": "strict", + "egress": [{"host":"docs.example.com","port":443}] + })) + .expect("create request"); + assert_eq!(create.mcp_servers, vec!["playwright", "everything"]); + assert_eq!(create.memory.as_deref(), Some("foundry-default")); + assert_eq!( + create.model.as_deref(), + Some("github-copilot::claude-opus-4.8") + ); + assert_eq!( + create.model_fallbacks, + vec!["local-inference::gpt-oss-120b", "foundry::gpt-5.4-pro"] + ); + assert_eq!(create.egress_mode.as_deref(), Some("strict")); + assert_eq!(create.egress[0].host, "docs.example.com"); + assert_eq!(create.egress[0].port, Some(443)); + assert_eq!(create.lifecycle_mode.as_deref(), Some("resourceOptimized")); + assert_eq!(create.warm_idle_seconds, Some(900)); + + let update: UpdateTeamRequest = serde_json::from_value(serde_json::json!({ + "mcp_servers": [], + "model": "local-inference::gpt-oss-120b", + "model_fallbacks": ["github-copilot::gpt-5.6-sol"], + "lifecycle_mode": "persistent", + "warm_idle_seconds": 1800, + "egress_mode": "learning", + "egress": [] + })) + .expect("update request"); + assert_eq!(update.mcp_servers, Some(Vec::new())); + assert_eq!( + update.model.as_deref(), + Some("local-inference::gpt-oss-120b") + ); + assert_eq!( + update.model_fallbacks, + Some(vec!["github-copilot::gpt-5.6-sol".into()]) + ); + assert_eq!(update.egress_mode.as_deref(), Some("learning")); + assert_eq!(update.egress.map(|entries| entries.len()), Some(0)); + assert_eq!(update.lifecycle_mode.as_deref(), Some("persistent")); + assert_eq!(update.warm_idle_seconds, Some(1800)); + } + + #[test] + fn team_lifecycle_modes_are_normalized_and_idle_window_is_bounded() { + assert_eq!( + normalize_lifecycle_mode(Some("resource-optimized")).expect("mode"), + Some("resourceOptimized") + ); + assert_eq!( + normalize_lifecycle_mode(Some("persistent")).expect("mode"), + Some("persistent") + ); + assert!(normalize_lifecycle_mode(Some("always-on")).is_err()); + assert_eq!( + validate_warm_idle_seconds(Some(900)).expect("idle"), + Some(900) + ); + assert_eq!(validate_warm_idle_seconds(Some(0)).expect("idle"), Some(0)); + assert!(validate_warm_idle_seconds(Some(-1)).is_err()); + } + + #[test] + fn team_models_require_exact_live_catalogue_pairs() { + let models = vec![ModelOption { + provider: "github-copilot".into(), + deployment: "shared-name".into(), + is_default: true, + detail: None, + }]; + assert!(validate_model_route(&models, "github-copilot::shared-name").is_ok()); + assert!(validate_model_route(&models, "").is_ok()); + assert!(validate_model_route(&models, "local-inference::shared-name").is_err()); + assert!(validate_model_route(&models, "shared-name").is_err()); + } + + #[test] + fn team_mcp_servers_are_deduplicated_and_bounded() { + assert_eq!( + normalize_mcp_servers(&[ + " playwright ".into(), + "playwright".into(), + "everything".into() + ]) + .expect("normalize"), + vec!["playwright", "everything"] + ); + let too_many = (0..9).map(|i| format!("mcp-{i}")).collect::>(); + assert!(normalize_mcp_servers(&too_many).is_err()); + } + + #[test] + fn team_git_write_is_applied_without_mcp_servers() { + let mut spec = serde_json::json!({"charter": "Deliver a feature"}); + let git_write = crate::kars::task::GitWriteConfig { + connection_config_map_ref: crate::kars::task::LocalObjectRef { + name: "kars-github-connection-0123456789abcdef".into(), + }, + repos: vec!["owner/repo".into()], + }; + apply_team_git_write(&mut spec, Some(&git_write)).expect("git write applies"); + assert_eq!( + spec["blueprint"]["gitWrite"]["connectionConfigMapRef"]["name"], + "kars-github-connection-0123456789abcdef" + ); + assert_eq!(spec["blueprint"]["gitWrite"]["repos"][0], "owner/repo"); + assert!(spec["blueprint"].get("mcpServers").is_none()); + } + + #[test] + fn team_outcomes_distinguish_change_no_action_and_failure() { + let output = |status: &str, text: &str| { + std::collections::BTreeMap::from([ + ("status".to_string(), status.to_string()), + ("output".to_string(), text.to_string()), + ("finishedAt".to_string(), "2026-07-22T20:12:27Z".to_string()), + ]) + }; + let change = outcome_from_output( + "team-run-1784750586".into(), + &output( + "ok", + "Opened https://github.com/example/repo/pull/42 with the dependency fix.", + ), + ); + assert_eq!(change.disposition, "change_proposed"); + assert!(change.headline.contains("PR #42")); + let change_with_old_sentinel = outcome_from_output( + "team-run-1784750586".into(), + &output( + "ok", + "Opened https://github.com/example/repo/pull/43.\nPrior run: [[NO_MATERIAL_CHANGE]]", + ), + ); + assert_eq!(change_with_old_sentinel.disposition, "change_proposed"); + + let no_action = outcome_from_output( + "team-run-1784750586".into(), + &output( + "ok", + "[[NO_MATERIAL_CHANGE]] The dependency is already fixed on main.", + ), + ); + assert_eq!(no_action.disposition, "no_action_needed"); + assert!(no_action.headline.contains("already fixed")); + + let failed = outcome_from_output( + "team-run-1784750586".into(), + &output("error", "Parser failed before a handback was produced."), + ); + assert_eq!(failed.disposition, "failed"); + } + + #[test] + fn team_outcome_uses_assigned_task_as_work_item() { + assert_eq!( + outcome_work_item( + "Assigned task for team 'maintenance'.\nTASK: [Dependabot alert] owner/repo #7: tar DETAILS: internal scaffolding" + ), + "[Dependabot alert] owner/repo #7: tar" + ); + } +} diff --git a/bridge/bff/src/routes/teams_internal.rs b/bridge/bff/src/routes/teams_internal.rs new file mode 100644 index 000000000..c97d2cf34 --- /dev/null +++ b/bridge/bff/src/routes/teams_internal.rs @@ -0,0 +1,455 @@ +// kars Bridge BFF — Internal Teams gateway endpoints. +// +// Authenticated by X-Teams-Internal-Secret. The gateway sends the Entra subject; +// the BFF resolves the principal from its own server-side role map +// (BRIDGE_TEAMS_ENTRA_ROLE_MAP). Roles from the request body are IGNORED. +// +// Decision endpoint applies identical authorization + stale-protection as the +// browser path (approvals.rs). Command endpoint delegates to shared team helpers. + +use axum::Json; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::approval::{ApprovalDecision, KarsApproval}; +use crate::state::AppState; +use kube::ResourceExt; +use kube::api::{Api, Patch, PatchParams}; + +const INTERNAL_SECRET_HEADER: &str = "x-teams-internal-secret"; + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +fn verify_internal_auth(state: &AppState, headers: &HeaderMap) -> AppResult<()> { + let expected = state.teams_internal_secret().ok_or(AppError::Forbidden( + "teams integration not configured".into(), + ))?; + let provided = headers + .get(INTERNAL_SECRET_HEADER) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if provided.is_empty() || provided != expected { + return Err(AppError::Forbidden("invalid internal teams secret".into())); + } + Ok(()) +} + +/// Resolve a principal from the BFF's own Entra role map. +/// The gateway sends only the Entra subject; roles come from server-side config. +fn resolve_teams_principal( + state: &AppState, + entra_subject: &str, + _entra_name: &str, +) -> AppResult { + if entra_subject.is_empty() { + return Err(AppError::BadRequest("entra_subject is required".into())); + } + let role_map = state.teams_entra_role_map(); + let entry = role_map.iter().find(|(sub, _, _, _)| sub == entra_subject); + match entry { + Some((_, bridge_subject, roles, display_name)) => Ok(Principal { + sub: bridge_subject.clone(), + name: display_name.clone(), + roles: roles.clone(), + }), + None => Err(AppError::Forbidden(format!( + "Entra subject {entra_subject} is not in the BFF role map" + ))), + } +} + +// ─── Decision endpoint ──────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct TeamsDecisionRequest { + pub approval_name: String, + pub approval_namespace: String, + pub verdict: String, + pub reason: Option, + pub resource_version: String, + pub bound_envelope_digest: Option, + pub entra_subject: String, + pub entra_name: String, +} + +#[derive(Debug, Serialize)] +pub struct TeamsDecisionResponse { + pub phase: String, +} + +fn owner_subject(a: &KarsApproval) -> Option<&str> { + a.annotations() + .get("kars.azure.com/owner-sub") + .map(String::as_str) +} + +fn is_owner(a: &KarsApproval, principal: &Principal) -> bool { + owner_subject(a).is_some_and(|subject| subject == principal.sub) +} + +fn can_expand_authority(principal: &Principal) -> bool { + principal + .roles + .iter() + .any(|role| role == "operator" || role == "admin") +} + +fn is_team_milestone_review(approval: &KarsApproval) -> bool { + approval.spec.action.kind == "checkpoint" + && (approval.annotations().contains_key("kars.azure.com/team") + || approval.labels().contains_key("kars.azure.com/team")) + && (approval + .annotations() + .contains_key("kars.azure.com/milestone") + || approval.labels().contains_key("kars.azure.com/milestone")) +} + +pub async fn teams_decision( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + verify_internal_auth(&state, &headers)?; + + if !matches!(req.verdict.as_str(), "approve" | "request-changes" | "deny") { + return Err(AppError::BadRequest(format!( + "verdict must be 'approve', 'request-changes', or 'deny', got '{}'", + req.verdict + ))); + } + + // Resolve principal from BFF's own role map — never trust gateway's role claim + let principal = resolve_teams_principal(&state, &req.entra_subject, &req.entra_name)?; + + let cluster = require_cluster(&state)?; + let api: Api = cluster.approvals(&req.approval_namespace); + let current = api + .get(&req.approval_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + + // ── Authorization (identical to browser approvals.rs) ────────────────────── + if req.verdict == "request-changes" + && is_team_milestone_review(¤t) + && req + .reason + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + return Err(AppError::Rejected( + "requesting changes for a Team milestone requires written feedback".into(), + )); + } + + let owned_by_principal = is_owner(¤t, &principal); + let owner_may_decide = owned_by_principal + && matches!( + current.spec.action.kind.as_str(), + "clarification" | "egress" + ); + if !owner_may_decide && !can_expand_authority(&principal) { + return Err(AppError::Forbidden( + "operator or admin role required for this approval decision".into(), + )); + } + + if req.verdict == "approve" + && current.spec.action.kind != "clarification" + && current + .spec + .requested_by + .as_ref() + .is_some_and(|actor| actor.subject == principal.sub) + { + return Err(AppError::Forbidden( + "requester cannot approve their own authority expansion".into(), + )); + } + + // ── Stale-protection ────────────────────────────────────────────────────── + let phase = current + .status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .unwrap_or("Pending"); + if phase != "Pending" || current.spec.decision.is_some() { + return Err(AppError::Conflict(format!( + "approval is already terminal ({phase})" + ))); + } + let current_rv = current + .metadata + .resource_version + .clone() + .unwrap_or_default(); + if req.resource_version != current_rv { + return Err(AppError::Conflict( + "approval changed since the card was sent; stale resourceVersion".into(), + )); + } + let current_digest = current + .status + .as_ref() + .and_then(|s| s.bound_envelope_digest.clone()); + if req.bound_envelope_digest != current_digest { + return Err(AppError::Conflict( + "the governed envelope changed since the card was sent".into(), + )); + } + + // ── Apply decision ──────────────────────────────────────────────────────── + let decision = ApprovalDecision { + verdict: if req.verdict == "request-changes" { + "deny".to_string() + } else { + req.verdict.clone() + }, + decider: principal.name.clone(), + decider_subject: Some(principal.sub), + decider_roles: principal.roles, + reason: req.reason.filter(|r| !r.trim().is_empty()), + }; + let patch = json_patch::Patch(vec![ + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["metadata", "resourceVersion"]), + value: serde_json::Value::String(current_rv), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "metadata", + "annotations", + "kars.azure.com/review-decision-kind", + ]), + value: serde_json::Value::String(req.verdict), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["spec", "decision"]), + value: serde_json::to_value(decision).map_err(|e| AppError::Internal(e.into()))?, + }), + ]); + let patched = api + .patch( + &req.approval_name, + &PatchParams::default(), + &Patch::Json::(patch), + ) + .await + .map_err(|e| { + if matches!(e, kube::Error::Api(ref ae) if ae.code == 409 || ae.code == 422) { + AppError::Conflict("approval was decided concurrently; stale".into()) + } else { + AppError::Upstream(e.to_string()) + } + })?; + + let result_phase = patched + .status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_else(|| "Pending".to_string()); + + Ok(( + StatusCode::OK, + Json(TeamsDecisionResponse { + phase: result_phase, + }), + )) +} + +// ─── Team command endpoint ──────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct TeamsCommandRequest { + pub team_name: String, + pub namespace: String, + pub command: String, + pub args: String, + pub entra_subject: String, + pub entra_name: String, +} + +#[derive(Debug, Serialize)] +pub struct TeamsCommandResponse { + pub message: String, +} + +pub async fn teams_command( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + verify_internal_auth(&state, &headers)?; + + let principal = resolve_teams_principal(&state, &req.entra_subject, &req.entra_name)?; + if !principal + .roles + .iter() + .any(|r| r == "user" || r == "operator" || r == "admin") + { + return Err(AppError::Forbidden( + "user, operator, or admin role required".into(), + )); + } + + let cluster = require_cluster(&state)?; + let ns = &req.namespace; + let team_name = &req.team_name; + + // Enforce team ownership — same as browser handlers + crate::routes::teams::require_owned_team(cluster, ns, team_name, &principal).await?; + + match req.command.as_str() { + "status" => { + let team = cluster + .teams(ns) + .get_opt(team_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or(AppError::NotFound)?; + let phase = team + .status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + let health = team + .status + .as_ref() + .and_then(|s| s.health.clone()) + .unwrap_or_else(|| "–".to_string()); + Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: format!( + "**{}** — Phase: `{}` | Health: `{}`", + team.name_any(), + phase, + health + ), + }), + )) + } + "list-tasks" => { + let raw = cluster.read_team_tasks(team_name).await; + let tasks: Vec = serde_json::from_str(&raw).unwrap_or_default(); + if tasks.is_empty() { + return Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: format!("No tasks in team **{team_name}** backlog."), + }), + )); + } + let mut lines = vec![format!("**{team_name}** backlog ({} tasks):", tasks.len())]; + for task in tasks.iter().take(10) { + let id = task.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let title = task.get("title").and_then(|v| v.as_str()).unwrap_or("?"); + let status = task.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + lines.push(format!("• `{id}` — {title} [{status}]")); + } + if tasks.len() > 10 { + lines.push(format!(" …and {} more", tasks.len() - 10)); + } + Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: lines.join("\n"), + }), + )) + } + "add-task" => { + if req.args.trim().is_empty() { + return Err(AppError::BadRequest("add-task requires a title".into())); + } + let title = req.args.trim().to_string(); + let task_id = format!("t-{}", chrono::Utc::now().timestamp_micros()); + let new_task = serde_json::json!({ + "id": task_id, + "title": title, + "description": "", + "depends_on": [], + "acceptance_criteria": [], + "review_required": false, + "status": "pending", + "created_at": chrono::Utc::now().to_rfc3339(), + }); + cluster + .update_configmap_data( + &format!("kars-team-tasks-{team_name}"), + &[("kars.azure.com/team-tasks", team_name.as_str())], + |data| { + let mut tasks: Vec = data + .get("tasks.json") + .and_then(|raw| serde_json::from_str(raw).ok()) + .unwrap_or_default(); + tasks.push(new_task.clone()); + data.insert( + "tasks.json".into(), + serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), + ); + }, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: format!("✅ Task `{task_id}` added: {title}"), + }), + )) + } + "run" => { + crate::routes::teams::request_team_run(cluster, ns, team_name, &principal).await?; + Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: format!("▶️ Run requested for team **{team_name}**."), + }), + )) + } + "halt" => { + let mut parts = req.args.trim().splitn(2, char::is_whitespace); + let run = parts + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::BadRequest("halt requires: /halt [reason]".into()) + })?; + let reason = parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()); + crate::routes::teams::request_team_run_halt( + cluster, ns, team_name, run, reason, &principal, + ) + .await?; + Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: format!("⏸️ Run `{run}` halted and team **{team_name}** paused."), + }), + )) + } + "bind" => { + // Validate team ownership — if ownership check passes, the team exists + // and is owned by this principal. Return success so the gateway can store + // the binding. + Ok(( + StatusCode::OK, + Json(TeamsCommandResponse { + message: format!("✅ Ownership verified for team **{team_name}**."), + }), + )) + } + _ => Err(AppError::BadRequest(format!( + "unknown command '{}': use bind, add-task, list-tasks, status, run, halt", + req.command + ))), + } +} diff --git a/bridge/bff/src/routes/telemetry.rs b/bridge/bff/src/routes/telemetry.rs new file mode 100644 index 000000000..211232bd9 --- /dev/null +++ b/bridge/bff/src/routes/telemetry.rs @@ -0,0 +1,191 @@ +// kars Bridge BFF — live telemetry stream (§8). Streams the WHOLE agent tree's +// real per-round / per-tool activity as it happens, so the activity stream and +// the expanding flow graph tick in flight instead of only at delivery. +// +// Source of truth: each sandbox's router exposes its live execution trace at the +// PUBLIC `GET /telemetry/trace` endpoint (derived from the model traffic it +// proxies — honest, never fabricated). This SSE endpoint tails that endpoint for +// the mission's PRINCIPAL sandbox AND every sub-agent the principal spawned +// (KarsSandboxes labelled `kars.azure.com/parent=`), tags each event +// with the agent that emitted it, and emits new events as they land. It falls +// back to the persisted `kars-mission-trace-` ConfigMap when no live pod +// is reachable (e.g. after the run is retired), and closes when the deliverable +// is captured. + +use axum::extract::{Extension, Path, State}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use std::collections::HashMap; +use std::convert::Infallible; +use std::time::Duration; +use tokio_stream::Stream; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::require_owned_task_or_output; +use crate::state::AppState; + +/// Tag events with `seq` strictly greater than `watermark` (the router stamps a +/// monotonic `seq` per event) with the agent that emitted them and its role in +/// the tree. Returns the tagged new events AND the new watermark (max seq seen). +/// Keying on `seq` — not array length — is correct even when the router's +/// bounded trace buffer evicts old events (length stops growing while seq keeps +/// climbing), so events are never silently dropped or re-emitted. +fn tag_new( + events: &[serde_json::Value], + watermark: u64, + agent: &str, + role: &str, +) -> (Vec, u64) { + let mut max_seq = watermark; + let out = events + .iter() + .filter_map(|e| { + let seq = e.get("seq").and_then(|s| s.as_u64())?; + if seq <= watermark { + return None; + } + if seq > max_seq { + max_seq = seq; + } + let mut ev = e.clone(); + if let Some(obj) = ev.as_object_mut() { + obj.insert("agent".into(), serde_json::json!(agent)); + obj.insert("agentInstance".into(), serde_json::json!(agent)); + obj.insert("agentRole".into(), serde_json::json!(role)); + } + Some(ev) + }) + .collect(); + (out, max_seq) +} + +/// `GET /api/namespaces/:ns/tasks/:name/stream` — Server-Sent Events of the +/// mission's LIVE execution trace across the whole agent tree. Each event is one +/// real per-tool / per-round record, tagged with `agent` + `agentRole`. Sends a +/// terminal `done` event when the deliverable is captured. +pub async fn stream_mission( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult>>> { + let live_cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + require_owned_task_or_output(live_cluster, &ns, &name, &principal).await?; + let cluster = state.cluster().cloned(); + let s = async_stream::stream! { + // Per-agent high-water mark = the max event `seq` already emitted. + let mut emitted: HashMap = HashMap::new(); + let mut emitted_count: usize = 0; + let mut saw_live = false; + let mut cm_backfilled = false; + let mut idle = 0u32; + + while let Some(c) = &cluster { + + // Resolve the principal sandbox for this task (its status.sandboxRef). + let principal_sandbox = c + .tasks(&ns) + .get_opt(&name) + .await + .ok() + .flatten() + .and_then(|t| t.status.and_then(|s| s.sandbox_ref).map(|r| r.name)); + + let mut batch: Vec = Vec::new(); + + if let Some(principal) = principal_sandbox.as_deref() { + // Principal live trace. + let p_events = c.sandbox_live_trace(principal).await; + if !p_events.is_empty() { + saw_live = true; + let seen = emitted.entry(principal.to_string()).or_insert(0); + let (fresh, new_max) = tag_new(&p_events, *seen, principal, "principal"); + *seen = new_max; + batch.extend(fresh); + } + + // Every spawned sub-agent's live trace (the folding graph fan-out). + let mut descendants = c + .sub_agent_sandbox_names(&ns, principal) + .await + .into_iter(); + loop { + let sub_batch = descendants.by_ref().take(8).collect::>(); + if sub_batch.is_empty() { + break; + } + let mut polling = tokio::task::JoinSet::new(); + for sub in sub_batch { + let cluster = c.clone(); + polling.spawn(async move { + let events = cluster.sandbox_live_trace(&sub).await; + (sub, events) + }); + } + while let Some(result) = polling.join_next().await { + let Ok((sub, s_events)) = result else { + continue; + }; + if s_events.is_empty() { + continue; + } + saw_live = true; + let seen = emitted.entry(sub.clone()).or_insert(0); + let (fresh, new_max) = tag_new(&s_events, *seen, &sub, "subagent"); + *seen = new_max; + batch.extend(fresh); + } + } + } + + // Fallback: no live pod reachable (pre-launch or post-retire) — tail + // the persisted trace CM ONCE so the surface still shows the run. The + // persisted (agent-self-reported) trace may lack the router's `seq`, + // so we emit it wholesale here rather than via the seq-dedup path; + // `cm_backfilled` guards the single emission. + if !saw_live && !cm_backfilled + && let Some(raw) = c.read_mission_trace(&name).await + && let Ok(events) = serde_json::from_str::>(&raw) { + for e in &events { + let mut ev = e.clone(); + if let Some(obj) = ev.as_object_mut() { + obj.insert("agent".into(), serde_json::json!(name)); + obj.insert("agentRole".into(), serde_json::json!("principal")); + } + batch.push(ev); + } + if !events.is_empty() { + cm_backfilled = true; + } + } + + if !batch.is_empty() { + idle = 0; + emitted_count += batch.len(); + for ev in batch { + if let Ok(sse) = Event::default().json_data(&ev) { + yield Ok(sse); + } + } + } + + // Deliverable captured → run finished; emit done + close. + if c.read_mission_artifacts(&name).await.is_some() { + let total: usize = emitted_count; + if let Ok(done) = Event::default() + .event("done") + .json_data(serde_json::json!({ "events": total })) + { + yield Ok(done); + } + break; + } + + idle += 1; + if idle > 150 { + break; // ~5 min ceiling without progress + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + Ok(Sse::new(s).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))) +} diff --git a/bridge/bff/src/routes/validate.rs b/bridge/bff/src/routes/validate.rs new file mode 100644 index 000000000..52d3cee25 --- /dev/null +++ b/bridge/bff/src/routes/validate.rs @@ -0,0 +1,1273 @@ +// kars Bridge BFF — pre-flight validation gate (design note §20). +// +// The riskiest moment is the handoff from an edited package to a running agent +// with real tools, network, and authority. This endpoint validates the package +// against the LIVE cluster before a single agent starts, and returns an +// itemised pass/fail — never a black-box "go". It checks what can be checked +// honestly from the BFF today: the referenced ToolPolicy / McpServer / +// KarsMemory exist, the model is one the cluster serves, and each egress host +// resolves. Deeper in-sandbox usability probes (a live MCP handshake, a +// tool-invocation probe, RBAC-delegation) are a named next step and are +// reported as such rather than faked. + +use std::time::Duration; + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::require_owned_task; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct ValidateRequest { + #[serde(default)] + pub blueprint: Option, + /// The autonomy tier the mission will run at (1..5). Validated so the launch + /// gate actually covers the envelope the UI shows, not just the blueprint. + #[serde(default)] + pub tier: Option, + /// The token budget cap, when set. Validated for sanity (positive, not + /// absurdly small) so a mis-typed cap is caught before launch. + #[serde(default)] + pub budget_tokens: Option, + /// Structural launch surface. Team composition requires retained Team E2E + /// evidence in addition to generic mission execution evidence. + #[serde(default)] + pub workload: Option, +} + +#[derive(Debug, Serialize, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CheckStatus { + Pass, + Fail, + Warn, +} + +#[derive(Debug, Serialize)] +pub struct Check { + pub id: String, + pub label: String, + pub status: CheckStatus, + pub detail: String, +} + +#[derive(Debug, Serialize)] +pub struct ValidateResponse { + /// True only when there are no failing checks — the launch gate. + pub ok: bool, + pub checks: Vec, +} + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +fn names(items: &[kube::core::DynamicObject]) -> Vec { + items + .iter() + .filter_map(|o| o.metadata.name.clone()) + .collect() +} + +/// Readiness facts read from a CRD object's status: the `phase`, whether a +/// `Ready` condition is `True`, and that condition's message (the controller's +/// own honest reason). This is the §19 "provisioned ≠ usable" signal — the +/// controller reconciles + (for MCP) probes these, so we report its real +/// verdict rather than a fabricated one. +struct Readiness { + phase: Option, + ready: Option, + message: Option, +} + +fn readiness_of(items: &[kube::core::DynamicObject], name: &str) -> Option { + let obj = items + .iter() + .find(|o| o.metadata.name.as_deref() == Some(name))?; + let status = obj.data.get("status"); + let phase = status + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()); + let ready_cond = status + .and_then(|s| s.get("conditions")) + .and_then(|c| c.as_array()) + .and_then(|arr| { + arr.iter() + .find(|c| c.get("type").and_then(|t| t.as_str()) == Some("Ready")) + }); + let ready = ready_cond + .and_then(|c| c.get("status")) + .and_then(|s| s.as_str()) + .map(|s| s == "True"); + let message = ready_cond + .and_then(|c| c.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()); + Some(Readiness { + phase, + ready, + message, + }) +} + +fn status_observes_current_generation(resource: &kube::core::DynamicObject) -> bool { + resource + .data + .get("status") + .and_then(|status| status.get("observedGeneration")) + .and_then(|generation| generation.as_i64()) + == resource.metadata.generation +} + +pub(crate) fn qualification_requirements( + bp: &crate::routes::tasks::BlueprintDto, + workload: Option<&str>, +) -> (std::collections::BTreeSet, i32) { + let mut capabilities = std::collections::BTreeSet::new(); + if !bp.egress.is_empty() { + capabilities.insert("network".into()); + } + if !bp.mcp_servers.is_empty() { + capabilities.insert("mcp".into()); + } + if bp.memory.is_some() { + capabilities.insert("memory".into()); + } + let max_parallel = if let Some(plan) = bp.execution_plan.as_ref() { + capabilities.insert("delegation".into()); + if !plan.deliverables.is_empty() { + capabilities.insert("artifacts".into()); + } + for role in &plan.roles { + for phase in &role.phases { + capabilities.extend(phase.capabilities.iter().cloned()); + } + } + capabilities.extend(plan.synthesis.capabilities.iter().cloned()); + plan.max_parallel + } else { + capabilities.insert("single-agent".into()); + 1 + }; + if workload.is_some_and(|value| value.eq_ignore_ascii_case("team")) { + capabilities.insert("team".into()); + } + capabilities.insert("telemetry".into()); + (capabilities, max_parallel) +} + +/// `POST /api/namespaces/:ns/validate` — validate a launch package against live +/// cluster state. Pure read + DNS; never mutates anything. +pub async fn validate_package( + State(state): State, + Path(ns): Path, + Json(req): Json, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let bp = req.blueprint.unwrap_or_default(); + Ok(Json( + run_checks( + cluster, + &ns, + &bp, + req.tier, + req.budget_tokens, + req.workload.as_deref(), + ) + .await, + )) +} + +/// `POST /api/namespaces/:ns/tasks/:name/validate` — validate a task's *own* +/// stored blueprint. This protects the launch gate for a draft created +/// earlier: launching it from the mission detail re-runs the same §20 checks +/// against the package the controller would actually materialize. +pub async fn validate_task( + State(state): State, + Extension(principal): Extension, + Path((ns, name)): Path<(String, String)>, +) -> AppResult> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let bp = task + .spec + .blueprint + .as_ref() + .map(blueprint_to_dto) + .unwrap_or_default(); + let tier = Some(task.spec.envelope.tier); + let budget_tokens = task.spec.envelope.budget.as_ref().and_then(|b| b.tokens); + Ok(Json( + run_checks(cluster, &ns, &bp, tier, budget_tokens, None).await, + )) +} + +/// Project the typed task blueprint into the validation DTO (the checks operate +/// on the same shape the create path accepts). +fn blueprint_to_dto(b: &crate::kars::task::TaskBlueprint) -> crate::routes::tasks::BlueprintDto { + use crate::routes::tasks::{BlueprintDto, EgressDto, ModelDto}; + BlueprintDto { + runtime: b.runtime.clone(), + model: b.model.as_ref().map(|m| ModelDto { + provider: m.provider.clone(), + deployment: m.deployment.clone(), + }), + model_fallbacks: b + .model_fallbacks + .iter() + .map(|m| ModelDto { + provider: m.provider.clone(), + deployment: m.deployment.clone(), + }) + .collect(), + instructions: b.instructions.clone(), + tool_policy: b.tool_policy.clone(), + mcp_servers: b.mcp_servers.clone(), + egress: b + .egress + .iter() + .map(|e| EgressDto { + host: e.host.clone(), + port: e.port, + }) + .collect(), + egress_mode: b.egress_mode.clone(), + isolation: b.isolation.clone(), + memory: b.memory.clone(), + skills: b.skills.clone(), + execution_plan: b + .execution_plan + .as_ref() + .map(crate::routes::tasks::ExecutionPlanDto::from_crd), + } +} + +/// Run the full §20 check suite against a package. Pure read + DNS. +async fn run_checks( + cluster: &crate::kars::cluster::Cluster, + namespace: &str, + bp: &crate::routes::tasks::BlueprintDto, + tier: Option, + budget_tokens: Option, + workload: Option<&str>, +) -> ValidateResponse { + let mut checks: Vec = Vec::new(); + + // 0. Envelope sanity — the autonomy tier and budget the operator will launch + // with. The UI presents validation as covering the whole package, so the + // gate must actually check the envelope, not only the blueprint. + if let Some(t) = tier { + checks.push(if (1..=5).contains(&t) { + Check { + id: "tier".into(), + label: format!("Autonomy tier {t}"), + status: CheckStatus::Pass, + detail: "Within the valid range (1–5). Sub-roles are capped one tier below.".into(), + } + } else { + Check { + id: "tier".into(), + label: "Autonomy tier".into(), + status: CheckStatus::Fail, + detail: format!("Tier {t} is out of range — must be 1–5."), + } + }); + } + let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); + match budget_tokens { + Some(b) if b <= 0 => checks.push(Check { + id: "budget".into(), + label: "Token budget".into(), + status: CheckStatus::Fail, + detail: "The budget cap must be a positive number of tokens.".into(), + }), + Some(b) if b < 500 => checks.push(Check { + id: "budget".into(), + label: format!("Token budget {b}"), + status: CheckStatus::Warn, + detail: "This cap is very low — a real run may stop before producing a deliverable.".into(), + }), + Some(b) => checks.push(Check { + id: "budget".into(), + label: format!("Token budget {b}"), + status: CheckStatus::Pass, + detail: "A per-run token cap is set — the router stops the run at this ceiling.".into(), + }), + None => checks.push(Check { + id: "budget".into(), + label: "Token budget".into(), + status: CheckStatus::Warn, + detail: "No token cap set — the run is bounded only by the mission's autonomy and the cluster defaults.".into(), + }), + } + if let Some(plan) = bp.execution_plan.as_ref() { + checks.push(match crate::routes::compose::validate_execution_plan(plan) { + Ok(()) => Check { + id: "execution_plan".into(), + label: format!( + "Execution plan · {} role{} · up to {} in parallel", + plan.roles.len(), + if plan.roles.len() == 1 { "" } else { "s" }, + plan.max_parallel + ), + status: CheckStatus::Pass, + detail: + "Role dependencies, phases, capabilities, tool-call bounds, synthesis, and deliverables are valid." + .into(), + }, + Err(error) => Check { + id: "execution_plan".into(), + label: "Execution plan is invalid".into(), + status: CheckStatus::Fail, + detail: error, + }, + }); + } + + // 0b. Runtime image and registry credentials. This is a real launch blocker: + // allowing a configured private image without a matching pull secret produces + // an immediate ImagePullBackOff before the agent can execute anything. + let runnable_runtimes = cluster.runnable_runtimes().await; + checks.push(if runnable_runtimes.contains(runtime) { + Check { + id: "runtime".into(), + label: format!("Runtime “{runtime}” can start on this cluster"), + status: CheckStatus::Pass, + detail: "The runtime image is configured and its registry is covered by the controller's pull credentials.".into(), + } + } else { + Check { + id: "runtime".into(), + label: format!("Runtime “{runtime}” cannot start on this cluster"), + status: CheckStatus::Fail, + detail: "The runtime image is missing or its private registry is not covered by the controller's pull credentials. Launch is blocked to prevent ImagePullBackOff.".into(), + } + }); + if runtime != "OpenClaw" && !bp.skills.is_empty() { + checks.push(Check { + id: "runtime_skills".into(), + label: format!("Runtime “{runtime}” cannot mount file skills"), + status: CheckStatus::Fail, + detail: "Controller-mounted file skills are currently supported only by OpenClaw; remove them or use OpenClaw instead.".into(), + }); + } + + // 1. Tool policy — provisioned AND usable (compiled). A ToolPolicy that + // exists but hasn't compiled its AGT profile can't actually govern tools. + if let Some(tp) = bp.tool_policy.as_deref().filter(|s| !s.is_empty()) { + let found = cluster + .get_kind(namespace, "ToolPolicy", tp) + .await + .ok() + .flatten(); + checks.push(match found.as_ref() { + None => Check { + id: "tool_policy".into(), + label: format!("Tool policy “{tp}” not found"), + status: CheckStatus::Fail, + detail: "No ToolPolicy by that name exists. Pick an existing policy or create it in the Operator Console.".into(), + }, + Some(resource) => { + let r = readiness_of(std::slice::from_ref(resource), tp) + .expect("resource was supplied"); + // The policy compiles its AGT profile to a `Compiled` phase; a + // `Ready=True` condition only appears once a sandbox references + // it, so `Compiled` (or Ready) is the usable signal here. + let compiled = r + .phase + .as_deref() + .map(|p| p == "Compiled" || p == "Ready") + .unwrap_or(false); + if compiled && status_observes_current_generation(resource) { + Check { + id: "tool_policy".into(), + label: format!("Tool policy “{tp}” is compiled and usable"), + status: CheckStatus::Pass, + detail: "The ToolPolicy exists and its governance profile compiled — it can bound tool calls.".into(), + } + } else { + Check { + id: "tool_policy".into(), + label: format!("Tool policy “{tp}” isn't compiled yet"), + status: CheckStatus::Warn, + detail: format!( + "The ToolPolicy exists but its current generation is not compiled (phase {}).", + r.phase.as_deref().unwrap_or("unknown") + ), + } + } + } + }); + } else { + // No tool policy pinned in the blueprint. The controller applies a + // cluster-default ToolPolicy at launch, but this package doesn't specify + // one — surface it as a Warn rather than silently passing, so "launch- + // ready" never hides an unpinned governance boundary. + checks.push(Check { + id: "tool_policy".into(), + label: "No tool policy pinned in this package".into(), + status: CheckStatus::Warn, + detail: "The cluster's default ToolPolicy will be applied at launch. Pin an explicit policy here if you need this package's tool-governance boundary to be reviewable and reproducible.".into(), + }); + } + + // 2. MCP servers — managed Ready means the controller completed a real MCP + // initialize/tools-list probe and recorded a schema digest. External + // registrations remain explicitly registration-only until a sandbox call. + if !bp.mcp_servers.is_empty() { + for m in &bp.mcp_servers { + let found = cluster + .get_kind(namespace, "McpServer", m) + .await + .ok() + .flatten(); + let url = found + .as_ref() + .and_then(|resource| { + resource + .data + .get("status") + .and_then(|status| status.get("endpoint")) + .or_else(|| resource.data.get("spec").and_then(|spec| spec.get("url"))) + }) + .and_then(|url| url.as_str()); + checks.push(match found.as_ref() { + None => Check { + id: format!("mcp:{m}"), + label: format!("Connected service “{m}” not found"), + status: CheckStatus::Fail, + detail: format!( + "No McpServer by that name exists in namespace `{namespace}`." + ), + }, + Some(resource) => { + let r = readiness_of(std::slice::from_ref(resource), m) + .expect("resource was supplied"); + let ready = (r.ready.unwrap_or(false) + || r.phase.as_deref() == Some("Ready")) + && status_observes_current_generation(resource); + if ready { + let verified_tools = resource + .data + .get("status") + .and_then(|s| s.get("discoveredTools")) + .and_then(|v| v.as_array()) + .map_or(0, Vec::len); + Check { + id: format!("mcp:{m}"), + label: format!("Connected service “{m}” is registered and reconciled"), + status: CheckStatus::Pass, + detail: if verified_tools > 0 { + format!( + "The managed MCP workload is Ready and its live initialize/tools-list probe verified {verified_tools} tools. Mission launch still proves the sandbox-router call path." + ) + } else { + "The external endpoint is registered and reconciled. Its credentials and real tool call are verified from the launched sandbox, not assumed here.".into() + }, + } + } else { + Check { + id: format!("mcp:{m}"), + label: format!("Connected service “{m}” isn't reconciled"), + status: CheckStatus::Fail, + detail: format!( + "The McpServer exists but the controller hasn't reconciled it to Ready ({}). Tool calls to it would likely be denied.", + r.message.or(r.phase).unwrap_or_else(|| "no status".into()) + ), + } + } + } + }); + + // Real endpoint-reachability signal: resolve the MCP server's URL + // host. Catches a misconfigured/typo'd endpoint honestly. + if let Some(host) = url.and_then(url_host) { + let resolved = resolves(&host, 443).await; + checks.push(Check { + id: format!("mcp_endpoint:{m}"), + label: if resolved { + format!("“{m}” endpoint {host} resolves") + } else { + format!("“{m}” endpoint {host} does not resolve") + }, + status: if resolved { CheckStatus::Pass } else { CheckStatus::Warn }, + detail: if resolved { + "The MCP server's URL host resolves in DNS. A full handshake is a deeper probe.".into() + } else { + "The MCP server's URL host did not resolve from the gateway. Check the endpoint URL; it may still be reachable from inside the cluster.".into() + }, + }); + } + } + if bp + .tool_policy + .as_deref() + .filter(|s| !s.is_empty()) + .is_none() + { + checks.push(Check { + id: "mcp_needs_policy".into(), + label: "Connected services need a tool policy".into(), + status: CheckStatus::Fail, + detail: "Governed MCP access must be bounded by a tool policy (admission enforces this).".into(), + }); + } + } + + // 3. Shared memory exists. + if let Some(mem) = bp.memory.as_deref().filter(|s| !s.is_empty()) { + let existing = cluster + .list_kind_all("KarsMemory") + .await + .map(|v| names(&v)) + .unwrap_or_default(); + checks.push(if existing.iter().any(|n| n == mem) { + Check { + id: "memory".into(), + label: format!("Shared memory “{mem}” exists"), + status: CheckStatus::Pass, + detail: "The KarsMemory store is present.".into(), + } + } else { + Check { + id: "memory".into(), + label: format!("Shared memory “{mem}” not found"), + status: CheckStatus::Fail, + detail: "No KarsMemory by that name exists.".into(), + } + }); + } + + // 3b. (Removed) Harness-suitability check that flagged Hermes as a + // chat-gateway which would sit idle on a one-shot autonomous mission. + // Hermes now runs the agent in-process like OpenClaw and executes + // autonomous missions + genuine mesh delegation (verified E2E), so the + // warning was a stale false-positive. Both wired harnesses are valid + // mission runners; there is no longer a harness-suitability failure to + // surface here. + + // 3c. Skills — each required capability bundle (KarsSkill) must exist AND be + // APPROVED, because the controller's trust gate refuses to mount an + // unapproved skill into the sandbox. Pre-flighting this turns a silent + // "the agent never got the skill it needed" runtime gap into an explicit, + // fixable launch check (get the operator to approve it first). + if !bp.skills.is_empty() { + for s in &bp.skills { + let found = cluster + .get_kind(namespace, "KarsSkill", s) + .await + .ok() + .flatten(); + let approved = found.as_ref().is_some_and(|o| { + o.metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/skill-review")) + .is_some_and(|review| review == "approved") + }); + if approved { + let required_mcp = found + .as_ref() + .and_then(|o| o.data.get("spec")) + .and_then(|spec| spec.get("mcpServers")) + .and_then(|servers| servers.as_array()) + .cloned() + .unwrap_or_default(); + for server in required_mcp.iter().filter_map(|value| value.as_str()) { + let dependency = cluster + .get_kind(namespace, "McpServer", server) + .await + .ok() + .flatten(); + let ready = dependency.as_ref().is_some_and(|resource| { + readiness_of(std::slice::from_ref(resource), server).is_some_and(|status| { + (status.ready.unwrap_or(false) + || status.phase.as_deref() == Some("Ready")) + && status_observes_current_generation(resource) + }) + }); + checks.push(Check { + id: format!("skill_mcp:{s}:{server}"), + label: format!("Skill “{s}” dependency “{server}”"), + status: if ready { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if ready { + "The skill's required MCP server is Ready in this namespace.".into() + } else { + "The skill requires an MCP server that is missing, stale, or not Ready in this namespace.".into() + }, + }); + } + } + checks.push(match found.as_ref() { + None => Check { + id: format!("skill:{s}"), + label: format!("Skill “{s}” not found"), + status: CheckStatus::Fail, + detail: "No KarsSkill by that name exists — upload it in Skills, then have an operator approve it.".into(), + }, + Some(_) => { + if approved { + Check { + id: format!("skill:{s}"), + label: format!("Skill “{s}” is approved and mountable"), + status: CheckStatus::Pass, + detail: "The skill package is approved; the controller will mount it into the sandbox.".into(), + } + } else { + Check { + id: format!("skill:{s}"), + label: format!("Skill “{s}” is not approved"), + status: CheckStatus::Fail, + detail: "The skill exists but hasn't been approved — the sandbox trust gate will refuse to mount it. An operator must approve it before the agent can use it.".into(), + } + } + } + }); + } + } + + // 4. Model is one the cluster serves. Use the exact same provider-aware + // catalogue as the composer/picker; checking only the controller default + // catalogue incorrectly rejects additional providers such as Foundry. + let effective_default_model = if bp.model.is_none() { + crate::routes::options::build_options(cluster) + .await + .ok() + .and_then(|options| { + options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| crate::routes::tasks::ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }) + }) + } else { + None + }; + if let Some(model) = bp.model.as_ref().or(effective_default_model.as_ref()) { + match crate::routes::options::build_options(cluster).await { + Ok(options) => { + let known = options.models.iter().any(|option| { + option.provider == model.provider && option.deployment == model.deployment + }); + checks.push(Check { + id: "model".into(), + label: if known { + format!( + "Model “{}” is served through {}", + model.deployment, model.provider + ) + } else { + format!( + "Model route “{}::{}” is not served by this cluster", + model.provider, model.deployment + ) + }, + status: if known { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if known { + "This exact provider and deployment pair is present in the live model catalogue used by the picker.".into() + } else { + "This exact provider and deployment pair is absent from the live model catalogue. Select a listed route before launch.".into() + }, + }); + if known { + let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); + let (required_capabilities, max_parallel) = + qualification_requirements(bp, workload); + let qualification = crate::routes::options::route_qualification( + runtime, + &model.provider, + &model.deployment, + &required_capabilities, + max_parallel, + budget_tokens, + ); + let qualified = qualification.as_ref().copied().unwrap_or(false); + checks.push(Check { + id: "route_qualification".into(), + label: if qualified { + format!( + "{runtime} · {}::{} is qualified", + model.provider, model.deployment + ) + } else { + format!( + "{runtime} · {}::{} is not qualified", + model.provider, model.deployment + ) + }, + status: if qualified { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if let Err(error) = qualification { + format!("Route qualification configuration error: {error}") + } else if qualified { + format!( + "This route has verified evidence for capabilities: {}.", + required_capabilities.iter().cloned().collect::>().join(", ") + ) + } else { + let missing = crate::routes::options::route_qualification_gap( + runtime, + &model.provider, + &model.deployment, + &required_capabilities, + max_parallel, + budget_tokens, + ) + .unwrap_or_else(|_| required_capabilities.clone()); + format!( + "This route lacks verified evidence for: {}. Existing retained evidence covers the other required capabilities, but qualification records are atomic and cannot be combined.", + missing.iter().cloned().collect::>().join(", ") + ) + }, + }); + fn find_resource<'a>( + items: &'a [crate::routes::options::RefOption], + name: &str, + ) -> Option<&'a crate::routes::options::RefOption> { + items.iter().find(|option| option.name == name) + } + for server in &bp.mcp_servers { + let qualification = find_resource(&options.mcp_servers, server) + .map(|option| { + crate::routes::options::mcp_server_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map(|qualified| (qualified, option)) + }) + .transpose(); + checks.push(match qualification { + Ok(Some((true, option))) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” has retained resource qualification" + ), + status: CheckStatus::Pass, + detail: format!( + "Retained evidence matches the current tool schema digest {} on {}.", + option.tool_schema_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(Some((false, option))) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” lacks retained resource qualification" + ), + status: CheckStatus::Fail, + detail: format!( + "No retained resource-scoped qualification record matches the current tool schema digest {} on {}. Generic route records do not prove this MCP server.", + option.tool_schema_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(None) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” could not be matched to live metadata" + ), + status: CheckStatus::Fail, + detail: + "The live MCP catalogue has no current schema digest for this server, so resource-scoped qualification cannot be proven." + .into(), + }, + Err(error) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” qualification could not be evaluated" + ), + status: CheckStatus::Fail, + detail: format!( + "Resource qualification configuration error: {error}" + ), + }, + }); + } + if let Some(memory) = bp.memory.as_deref().filter(|memory| !memory.is_empty()) { + let qualification = find_resource(&options.memories, memory) + .map(|option| { + crate::routes::options::memory_binding_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map(|qualified| (qualified, option)) + }) + .transpose(); + checks.push(match qualification { + Ok(Some((true, option))) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” has retained resource qualification" + ), + status: CheckStatus::Pass, + detail: format!( + "Retained evidence matches backend {} and compiled digest {} on {}.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(Some((false, option))) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” lacks retained resource qualification" + ), + status: CheckStatus::Fail, + detail: format!( + "No retained resource-scoped qualification record matches backend {} and compiled digest {} on {}. Generic route records do not prove this memory binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(None) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” could not be matched to live metadata" + ), + status: CheckStatus::Fail, + detail: + "The live memory catalogue has no current backend and compiled digest for this binding, so resource-scoped qualification cannot be proven." + .into(), + }, + Err(error) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” qualification could not be evaluated" + ), + status: CheckStatus::Fail, + detail: format!( + "Resource qualification configuration error: {error}" + ), + }, + }); + } + for skill in &bp.skills { + let qualification = find_resource(&options.skills, skill) + .map(|option| { + crate::routes::options::skill_version_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map(|qualified| (qualified, option)) + }) + .transpose(); + checks.push(match qualification { + Ok(Some((true, option))) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” has retained resource qualification" + ), + status: CheckStatus::Pass, + detail: format!( + "Retained evidence matches the current approved version digest {} on {}.", + option.version_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(Some((false, option))) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” lacks retained resource qualification" + ), + status: CheckStatus::Fail, + detail: format!( + "No retained resource-scoped qualification record matches the current approved version digest {} on {}. Generic route records do not prove this skill version.", + option.version_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(None) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” could not be matched to live metadata" + ), + status: CheckStatus::Fail, + detail: + "The live skill catalogue has no current approved version digest for this skill, so resource-scoped qualification cannot be proven." + .into(), + }, + Err(error) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” qualification could not be evaluated" + ), + status: CheckStatus::Fail, + detail: format!( + "Resource qualification configuration error: {error}" + ), + }, + }); + } + } + let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); + let (required_capabilities, max_parallel) = + qualification_requirements(bp, workload); + if bp.model_fallbacks.len() > 8 { + checks.push(Check { + id: "model_fallback_count".into(), + label: "Too many fallback model routes".into(), + status: CheckStatus::Fail, + detail: "A blueprint may declare at most 8 ordered fallback routes.".into(), + }); + } + for (index, fallback) in bp.model_fallbacks.iter().enumerate() { + let route = crate::routes::options::route_label( + runtime, + &fallback.provider, + &fallback.deployment, + ); + let known = options.models.iter().any(|option| { + option.provider == fallback.provider + && option.deployment == fallback.deployment + }); + let route_qualified = known + && crate::routes::options::route_qualification( + runtime, + &fallback.provider, + &fallback.deployment, + &required_capabilities, + max_parallel, + budget_tokens, + ) + .unwrap_or(false); + let mcp_qualified = bp.mcp_servers.iter().all(|server| { + options + .mcp_servers + .iter() + .find(|option| option.name == *server) + .is_some_and(|option| { + crate::routes::options::mcp_server_qualified_for_route( + runtime, + &fallback.provider, + &fallback.deployment, + option, + ) + .unwrap_or(false) + }) + }); + let memory_qualified = bp.memory.as_ref().is_none_or(|memory| { + options + .memories + .iter() + .find(|option| option.name == *memory) + .is_some_and(|option| { + crate::routes::options::memory_binding_qualified_for_route( + runtime, + &fallback.provider, + &fallback.deployment, + option, + ) + .unwrap_or(false) + }) + }); + let skills_qualified = bp.skills.iter().all(|skill| { + options + .skills + .iter() + .find(|option| option.name == *skill) + .is_some_and(|option| { + crate::routes::options::skill_version_qualified_for_route( + runtime, + &fallback.provider, + &fallback.deployment, + option, + ) + .unwrap_or(false) + }) + }); + let qualified = + route_qualified && mcp_qualified && memory_qualified && skills_qualified; + checks.push(Check { + id: format!("model_fallback:{index}"), + label: if qualified { + format!("Fallback {route} is qualified") + } else { + format!("Fallback {route} is not qualified") + }, + status: if qualified { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if qualified { + "Retained evidence proves the complete capability and selected-resource contract for this fallback route.".into() + } else if !known { + "This fallback is absent from the live model catalogue.".into() + } else if !route_qualified { + "No atomic qualification record proves the complete capability contract for this fallback.".into() + } else { + "The route is generally qualified, but at least one selected MCP server, memory binding, or skill version lacks current resource-scoped evidence on it.".into() + }, + }); + } + } + Err(error) => checks.push(Check { + id: "model".into(), + label: "Live model catalogue could not be verified".into(), + status: CheckStatus::Fail, + detail: format!( + "Pre-flight could not confirm the requested provider/model route: {error}" + ), + }), + } + } + + // 5. Egress hosts resolve (DNS) — a real, honest reachability signal from + // the BFF (not the full in-sandbox egress path, which is a deeper probe). + for e in &bp.egress { + let port = e.port.unwrap_or(443) as u16; + let resolved = resolves(&e.host, port).await; + checks.push(Check { + id: format!("egress:{}", e.host), + label: if resolved { + format!("Egress host {} resolves", e.host) + } else { + format!("Egress host {} does not resolve", e.host) + }, + status: if resolved { CheckStatus::Pass } else { CheckStatus::Warn }, + detail: if resolved { + "The host resolves in DNS. Full reachability from the sandbox egress path is a deeper probe (named next step).".into() + } else { + "The host did not resolve from the gateway. Check the spelling; it may still be reachable from inside the cluster.".into() + }, + }); + } + + if checks.is_empty() { + checks.push(Check { + id: "baseline".into(), + label: "Package is launch-ready".into(), + status: CheckStatus::Pass, + detail: "No external tools, services, memory, or custom egress to validate — the mission runs on the model alone within its envelope.".into(), + }); + } + + let ok = !checks.iter().any(|c| c.status == CheckStatus::Fail); + ValidateResponse { ok, checks } +} + +/// Extract the host from a URL string for a reachability check. Best-effort: +/// strips a scheme and any path/port. Returns `None` for an empty host. +fn url_host(url: &str) -> Option { + let after_scheme = url.split("://").nth(1).unwrap_or(url); + let host = after_scheme + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or("") + .trim(); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// True when `host:port` resolves in DNS within a short timeout. A real, +/// honest reachability signal from the gateway — not a full connection. +async fn resolves(host: &str, port: u16) -> bool { + let addr = format!("{host}:{port}"); + tokio::time::timeout(Duration::from_secs(3), tokio::net::lookup_host(&addr)) + .await + .ok() + .and_then(|r| r.ok()) + .map(|mut it| it.next().is_some()) + .unwrap_or(false) +} + +#[cfg(test)] +mod qualification_requirement_tests { + use super::{blueprint_to_dto, qualification_requirements}; + + #[test] + fn persisted_draft_plan_preserves_create_preflight_requirements_and_all_plan_fields() { + use crate::kars::task::TaskBlueprint; + use crate::routes::tasks::{BlueprintDto, ExecutionPlanDto}; + let plan: ExecutionPlanDto = serde_json::from_value(serde_json::json!({ + "schema": "kars.execution-plan/v1", + "roles": [ + { + "name": "evidence", + "objective": "Read public evidence.", + "depends_on": [], + "phases": [{ + "name": "inspect", + "objective": "Read current check logs.", + "capabilities": ["network", "mcp"], + "required_tool_calls": [{ + "name": "github_actions_job_logs", + "arguments": {"owner": "owner", "repo": "repo", "job_id": "42"} + }], + "min_tool_calls": 1, + "max_tool_calls": 4, + "fresh_context": true + }], + "budget_tokens": 1500 + }, + { + "name": "review", + "objective": "Review the evidence.", + "depends_on": ["evidence"], + "phases": [{ + "name": "assess", + "objective": "Assess the handback.", + "capabilities": [], + "required_tool_calls": [], + "min_tool_calls": 0, + "max_tool_calls": 0, + "fresh_context": false + }], + "budget_tokens": 500 + } + ], + "max_parallel": 2, + "synthesis": { + "objective": "Produce the review.", + "capabilities": ["network"], + "max_tool_calls": 1 + }, + "deliverables": [ + {"name": "review.md", "media_type": "text/markdown"}, + {"name": "evidence.json", "media_type": null} + ] + })) + .unwrap(); + let create = BlueprintDto { + execution_plan: Some(plan.clone()), + ..Default::default() + }; + let stored = serde_json::to_value(TaskBlueprint { + execution_plan: Some(plan.into_crd()), + ..Default::default() + }) + .unwrap(); + assert_eq!(stored["executionPlan"]["maxParallel"], 2); + let persisted: TaskBlueprint = serde_json::from_value(stored).unwrap(); + let launch = blueprint_to_dto(&persisted); + assert_eq!( + serde_json::to_value(&launch.execution_plan).unwrap(), + serde_json::to_value(&create.execution_plan).unwrap() + ); + let required = qualification_requirements(&launch, Some("mission")); + assert_eq!( + required, + qualification_requirements(&create, Some("mission")) + ); + assert_eq!(required.1, 2); + for capability in ["artifacts", "delegation", "mcp", "network", "telemetry"] { + assert!(required.0.contains(capability)); + } + assert!(!required.0.contains("single-agent")); + } + + #[test] + fn legacy_draft_without_a_plan_still_requires_single_agent_qualification() { + let launch = blueprint_to_dto(&crate::kars::task::TaskBlueprint::default()); + assert!(launch.execution_plan.is_none()); + let (required, parallel) = qualification_requirements(&launch, Some("mission")); + assert_eq!(parallel, 1); + assert!(required.contains("single-agent")); + assert!(!required.contains("delegation")); + } + + #[test] + fn team_preflight_requires_retained_team_evidence() { + let blueprint = crate::routes::tasks::BlueprintDto::default(); + let (mission, _) = qualification_requirements(&blueprint, Some("mission")); + let (team, _) = qualification_requirements(&blueprint, Some("team")); + + assert!(!mission.contains("team")); + assert!(team.contains("team")); + } + + #[test] + fn execution_plan_capabilities_flow_into_atomic_qualification_requirements() { + let blueprint = crate::routes::tasks::BlueprintDto { + execution_plan: Some(crate::routes::tasks::ExecutionPlanDto { + schema: "kars.execution-plan/v1".into(), + roles: vec![crate::routes::tasks::ExecutionRoleDto { + name: "source-scout".into(), + objective: "Discover exact URLs and fetch the source evidence.".into(), + depends_on: Vec::new(), + phases: vec![crate::routes::tasks::ExecutionPhaseDto { + name: "discover".into(), + objective: "Search and fetch authoritative sources.".into(), + capabilities: vec!["web-search".into(), "network".into()], + required_tool_calls: Vec::new(), + min_tool_calls: 1, + max_tool_calls: 4, + fresh_context: true, + }], + budget_tokens: None, + }], + max_parallel: 1, + synthesis: crate::routes::tasks::ExecutionSynthesisDto { + objective: "Return the verified answer.".into(), + capabilities: Vec::new(), + max_tool_calls: 0, + }, + deliverables: Vec::new(), + }), + ..Default::default() + }; + + let (required, max_parallel) = qualification_requirements(&blueprint, Some("mission")); + assert_eq!(max_parallel, 1); + assert!(required.contains("delegation")); + assert!(required.contains("web-search")); + assert!(required.contains("network")); + } +} diff --git a/bridge/bff/src/state.rs b/bridge/bff/src/state.rs new file mode 100644 index 000000000..37a91d704 --- /dev/null +++ b/bridge/bff/src/state.rs @@ -0,0 +1,174 @@ +// kars Bridge BFF — shared application state. +// +// Holds the optional cluster handle. Cluster connectivity is *optional* at +// startup: the BFF serves health immediately and reports cluster wiring +// honestly via readiness, rather than crash-looping when no cluster is +// reachable (e.g. local web-only development). + +use std::sync::Arc; + +use crate::kars::cluster::Cluster; + +/// Axum shared state, cheaply cloneable. +#[derive(Clone)] +pub struct AppState { + inner: Arc, +} + +struct Inner { + cluster: Option, + default_namespace: String, + api_token: Option, + principal_secret: Option, + teams_internal_secret: Option, + /// Entra subject → Bridge roles mapping loaded from BRIDGE_TEAMS_ENTRA_ROLE_MAP. + /// The BFF resolves principals from this map; it never trusts roles from the + /// gateway request body. + teams_entra_role_map: Vec<(String, String, Vec, String)>, +} + +impl AppState { + #[cfg(test)] + pub(crate) fn for_test_client(client: kube::Client, namespace: &str) -> Self { + let mut state = Self::web_only(namespace.to_string()); + Arc::get_mut(&mut state.inner).unwrap().cluster = Some(Cluster::for_test_client(client)); + state + } + + /// Build state, attempting a cluster connection. A failed connection is + /// not fatal — `cluster()` returns `None` and readiness reports it. + pub async fn new(default_namespace: String) -> Self { + let cluster = match Cluster::connect().await { + Ok(c) => { + tracing::info!("connected to cluster"); + Some(c) + } + Err(e) => { + tracing::warn!(error = %e, "no cluster connection — running web-only"); + None + } + }; + Self { + inner: Arc::new(Inner { + cluster, + default_namespace, + api_token: None, + principal_secret: None, + teams_internal_secret: None, + teams_entra_role_map: Vec::new(), + }), + } + } + + /// Attach the mutating-endpoint bearer token (from BRIDGE_API_TOKEN). + #[must_use] + pub fn with_api_token(self, token: Option) -> Self { + let inner = self.inner; + Self { + inner: Arc::new(Inner { + cluster: inner.cluster.clone(), + default_namespace: inner.default_namespace.clone(), + api_token: token, + principal_secret: inner.principal_secret.clone(), + teams_internal_secret: inner.teams_internal_secret.clone(), + teams_entra_role_map: inner.teams_entra_role_map.clone(), + }), + } + } + + #[must_use] + pub fn with_principal_secret(self, secret: Option) -> Self { + let inner = self.inner; + Self { + inner: Arc::new(Inner { + cluster: inner.cluster.clone(), + default_namespace: inner.default_namespace.clone(), + api_token: inner.api_token.clone(), + principal_secret: secret, + teams_internal_secret: inner.teams_internal_secret.clone(), + teams_entra_role_map: inner.teams_entra_role_map.clone(), + }), + } + } + + /// Attach the Teams gateway internal shared secret (from BRIDGE_TEAMS_INTERNAL_SECRET). + #[must_use] + pub fn with_teams_internal_secret(self, secret: Option) -> Self { + let inner = self.inner; + Self { + inner: Arc::new(Inner { + cluster: inner.cluster.clone(), + default_namespace: inner.default_namespace.clone(), + api_token: inner.api_token.clone(), + principal_secret: inner.principal_secret.clone(), + teams_internal_secret: secret, + teams_entra_role_map: inner.teams_entra_role_map.clone(), + }), + } + } + + /// Attach the Entra→Bridge role map for Teams principal resolution. + /// Parsed from BRIDGE_TEAMS_ENTRA_ROLE_MAP JSON. + #[must_use] + pub fn with_teams_entra_role_map( + self, + map: Vec<(String, String, Vec, String)>, + ) -> Self { + let inner = self.inner; + Self { + inner: Arc::new(Inner { + cluster: inner.cluster.clone(), + default_namespace: inner.default_namespace.clone(), + api_token: inner.api_token.clone(), + principal_secret: inner.principal_secret.clone(), + teams_internal_secret: inner.teams_internal_secret.clone(), + teams_entra_role_map: map, + }), + } + } + + /// The bearer token guarding mutating endpoints, if configured. + pub fn api_token(&self) -> Option<&str> { + self.inner.api_token.as_deref() + } + + pub fn principal_secret(&self) -> Option<&str> { + self.inner.principal_secret.as_deref() + } + + /// The shared secret for the Teams gateway internal decision endpoint. + pub fn teams_internal_secret(&self) -> Option<&str> { + self.inner.teams_internal_secret.as_deref() + } + + /// The Entra→Bridge role map for Teams principal resolution. + /// Each entry is (entra_subject, bridge_roles, display_name). + pub fn teams_entra_role_map(&self) -> &[(String, String, Vec, String)] { + &self.inner.teams_entra_role_map + } + + /// The cluster handle, if one was established. + pub fn cluster(&self) -> Option<&Cluster> { + self.inner.cluster.as_ref() + } + + /// The namespace used for readiness probing + default scoping. + pub fn default_namespace(&self) -> &str { + &self.inner.default_namespace + } + + /// Build state with no cluster connection — for tests and explicit + /// web-only mode. Deterministic regardless of ambient kubeconfig. + pub fn web_only(default_namespace: String) -> Self { + Self { + inner: Arc::new(Inner { + cluster: None, + default_namespace, + api_token: None, + principal_secret: None, + teams_internal_secret: None, + teams_entra_role_map: Vec::new(), + }), + } + } +} diff --git a/bridge/bff/tests/health.rs b/bridge/bff/tests/health.rs new file mode 100644 index 000000000..6d9dd1bc6 --- /dev/null +++ b/bridge/bff/tests/health.rs @@ -0,0 +1,90 @@ +// Copyright (c) Pal Lakatos-Toth. +// Integration tests for the kars Bridge BFF router. Exercises the public +// HTTP surface in-process (no socket bind) via tower's oneshot. These run +// without a cluster — AppState falls back to web-only mode. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use kars_bridge_bff::routes; +use kars_bridge_bff::state::AppState; +use tower::ServiceExt; + +fn test_router() -> axum::Router { + let state = AppState::web_only("default".to_string()); + routes::router(state) +} + +#[tokio::test] +async fn healthz_returns_ok() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn readyz_fails_closed_without_a_compatible_cluster() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/readyz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + json, + serde_json::json!({"status": "unavailable", "cluster_configured": false}) + ); +} + +#[tokio::test] +async fn unknown_route_returns_structured_404() { + let app = test_router(); + let resp = app + .oneshot(Request::builder().uri("/nope").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "not_found"); +} + +#[tokio::test] +async fn list_tasks_without_cluster_reports_unavailable() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/api/namespaces/default/tasks") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // No cluster wired in the test env → honest 503, structured body. + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "cluster_unavailable"); +} diff --git a/bridge/bff/tests/jwt_backend.rs b/bridge/bff/tests/jwt_backend.rs new file mode 100644 index 000000000..5db84d2dc --- /dev/null +++ b/bridge/bff/tests/jwt_backend.rs @@ -0,0 +1,117 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde_json::{Value, json}; + +fn openssl(args: &[&str], input: Option<&[u8]>) -> Vec { + let mut child = Command::new("openssl") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("OpenSSL is required for JWT backend interoperability tests"); + if let Some(input) = input { + child.stdin.as_mut().unwrap().write_all(input).unwrap(); + } + drop(child.stdin.take()); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "OpenSSL failed: {}", + String::from_utf8_lossy(&output.stderr), + ); + output.stdout +} + +fn claims() -> Value { + let now = chrono::Utc::now().timestamp(); + json!({ + "iss": "bridge-backend-test", + "aud": "bridge-test", + "iat": now, + "exp": now + 300, + "sub": "test-principal", + }) +} + +fn validation(algorithm: Algorithm) -> Validation { + let mut validation = Validation::new(algorithm); + validation.set_issuer(&["bridge-backend-test"]); + validation.set_audience(&["bridge-test"]); + validation +} + +#[test] +fn rsa_app_jwts_support_pkcs1_and_pkcs8_keys_without_the_rustcrypto_rsa_dependency() { + let pkcs8 = openssl( + &[ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + ], + None, + ); + let pkcs1 = openssl(&["pkey", "-traditional"], Some(&pkcs8)); + let public = openssl(&["pkey", "-pubout"], Some(&pkcs8)); + let claims = claims(); + for private in [&pkcs1, &pkcs8] { + let token = encode( + &Header::new(Algorithm::RS256), + &claims, + &EncodingKey::from_rsa_pem(private).unwrap(), + ) + .unwrap(); + let decoded = decode::( + &token, + &DecodingKey::from_rsa_pem(&public).unwrap(), + &validation(Algorithm::RS256), + ) + .unwrap(); + assert_eq!(decoded.claims, claims); + let mut wrong_audience = validation(Algorithm::RS256); + wrong_audience.set_audience(&["different-audience"]); + assert!( + decode::( + &token, + &DecodingKey::from_rsa_pem(&public).unwrap(), + &wrong_audience, + ) + .is_err() + ); + } +} + +#[test] +fn hmac_principal_jwts_still_verify_and_reject_a_different_key() { + let key = openssl(&["rand", "32"], None); + let claims = claims(); + let token = encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(&key), + ) + .unwrap(); + assert_eq!( + decode::( + &token, + &DecodingKey::from_secret(&key), + &validation(Algorithm::HS256), + ) + .unwrap() + .claims, + claims + ); + let other_key = openssl(&["rand", "32"], None); + assert!( + decode::( + &token, + &DecodingKey::from_secret(&other_key), + &validation(Algorithm::HS256), + ) + .is_err() + ); +} diff --git a/bridge/deploy/helm/kars-bridge/Chart.yaml b/bridge/deploy/helm/kars-bridge/Chart.yaml new file mode 100644 index 000000000..c2695e12e --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v2 +name: kars-bridge +description: >- + kars Bridge — the command center (operator console + workspace) that runs + ADDITIVELY on top of a kars install. Deploys the BFF and web surfaces plus the + least-privilege RBAC the BFF needs. kars can run on its own; the Bridge layers + on top and never replaces any kars component. +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - kars + - openclaw + - ai + - agent + - console + - bridge +home: https://github.com/Azure/kars/tree/kars-bridge/bridge +sources: + - https://github.com/Azure/kars/tree/kars-bridge/bridge +maintainers: + - name: kars Bridge +annotations: + # Additive layer: requires a kars install (the kars CRDs + controller) already + # present in the target cluster. Install kars first (or alongside), then this. + kars.azure.com/requires: "Bridge/core compatibility matrix" + kars.azure.com/additive: "true" diff --git a/bridge/deploy/helm/kars-bridge/README.md b/bridge/deploy/helm/kars-bridge/README.md new file mode 100644 index 000000000..860939543 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/README.md @@ -0,0 +1,140 @@ +# Deploying kars Bridge + +kars Bridge is an **additive** layer on top of [kars](https://github.com/Azure/kars): +kars runs on its own; the Bridge deploys the operator console + workspace (BFF + web) +and the least-privilege RBAC the BFF needs — it never replaces any kars component. + +**Private preview:** there is no public Bridge image/release matrix yet. The +[compatibility document](../../../docs/compatibility.md) records the full private +Kars candidate and historical qualification separately. The public Kars +foundation PRs alone do not provide the full runtime required by Bridge. + +The chart uses standard Kubernetes workloads, but only **AKS** and **local +kind** are live-qualified today. EKS and GKE require environment-specific +identity, registry, ingress, CNI, inference, and compatibility validation. + +## Prerequisites + +- A Kubernetes cluster (new or existing) with the **kars CRDs + controller** installed: + ```bash + helm install kars ../kars/deploy/helm/kars -n kars-system --create-namespace + ``` +- The Bridge images, pushed to a registry your cluster can pull (or loaded into kind). + +## Build the images + +```bash +# BFF (Rust) — build context is bff/ +docker build -f bff/Dockerfile -t /kars-bridge-bff: bff +# Web (Next.js standalone) — build context is web/ +docker build -f web/Dockerfile -t /kars-bridge-web: web +docker push /kars-bridge-bff: +docker push /kars-bridge-web: +``` + +## Install modes + +The defaults join the existing `kars-system` namespace with +`createNamespace: false`. New installs cannot claim `kars-system`; upgrades +preserve a legacy chart-owned namespace so retention can be applied safely. +An existing custom namespace must also use `createNamespace: false`. For a new +dedicated namespace, keep it false and use Helm's `--create-namespace` with +matching Helm `--namespace` and chart `namespace` values. Helm creates its +storage namespace before saving the release, without owning it as a chart +resource. Advanced deployments may use `createNamespace: true` for a fresh +workload namespace only when Helm stores the release in a separate existing +namespace; the chart annotates that workload namespace +`helm.sh/resource-policy: keep` to prevent cascading deletion on uninstall. +See [upgrade/uninstall caveats](../../../docs/deployment.md#uninstall) before +removing an older release. + +Microsoft Teams is optional: the gateway defaults to zero replicas and the +BFF's Teams Secret references are optional. Missing tenant credentials do not +block a Kars-backed web-only deployment. + +### Bridge alone, on an EXISTING kars cluster (the common case) + +```bash +helm install kars-bridge deploy/helm/kars-bridge -n kars-system \ + --set bff.image.repository=/kars-bridge-bff \ + --set web.image.repository=/kars-bridge-web \ + --set bff.image.tag= --set web.image.tag= +``` + +### kars + Bridge together, on a NEW cluster + +```bash +helm install kars ../kars/deploy/helm/kars -n kars-system --create-namespace +helm install kars-bridge deploy/helm/kars-bridge -n kars-system # additive +``` +(or `make helm-install` — see the Makefile.) + +## Cloud-specific values + +| Cloud | Image registry | Ingress class | Notes | +|-------|----------------|---------------|-------| +| **AKS** | `.azurecr.io` | `webapprouting.kubernetes.azure.com` or `nginx` | `az acr login`; Workload Identity for the controller. | +| **EKS** | `.dkr.ecr..amazonaws.com` | `alb` or `nginx` | Template guidance only; not live-qualified. | +| **GKE** | `-docker.pkg.dev//` | `gce` or `nginx` | Template guidance only; not live-qualified. | +| **kind** | locally loaded (`kind load docker-image`) | `nginx` (ingress-nginx) | `--set *.image.pullPolicy=IfNotPresent`; reach via `kubectl port-forward`. | + +Enable ingress with, e.g. on EKS: +```bash +helm install kars-bridge deploy/helm/kars-bridge -n kars-system \ + --set ingress.enabled=true --set ingress.className=alb \ + --set ingress.host=bridge.example.com +``` + +## Reach the surfaces + +With no ingress, port-forward the web Service (it proxies `/api/*` to the BFF): +```bash +kubectl -n kars-system port-forward svc/kars-bridge-web 3000:3000 +# http://localhost:3000/workspace (users) +# http://localhost:3000/console (operators / admins) +``` + +## Validate before installing + +```bash +helm lint deploy/helm/kars-bridge +make helm-test +helm template kars-bridge deploy/helm/kars-bridge | kubectl apply --dry-run=client -f - +helm install kars-bridge deploy/helm/kars-bridge -n kars-system --dry-run=server +``` + +`make helm-test` uses the existing `teams-gateway` Vitest runner and requires +Helm and that package's development dependencies; it does not contact a +cluster. BFF `/readyz` also requires all fourteen documented Kars APIs and +list permission, with a five-second total budget. The chart uses a ten-second +readiness timeout and keeps `/healthz` for liveness. Neither test proves +controller behavior or replaces a qualified source/image matrix. + +The PR workflow additionally runs an actual Helm install, upgrade and uninstall +against disposable Kind. It checks resource UIDs and retained data for both an +existing shared namespace and a chart-owned workload namespace. It does not +launch Kars or Bridge images and is not full runtime acceptance. + +The separate native credential workflow exercises real core/Bridge images and +credential rebind. Its continuity contract preserves Task/Sandbox/namespace +identities, namespace-owned resources and current authorization/receipts. +The existing core mounts `/sandbox` as `emptyDir`: files survive a container +restart in the same Pod, but not replacement of that Pod. This release does not +add persistent workspaces or PVC migration. The native case checks that storage +mode explicitly rather than claiming filesystem durability from namespace +retention. Credential revocation, old-consumer retirement, TLS/CNI and all +required outcomes remain mandatory. +After recording Team rebind continuity, the harness explicitly unlaunches that +UID-pinned test Task and waits for normal controller cleanup before creating +the independent observer fixture. This releases the completed fixture's CPU +reservation on the bounded Kind worker without changing production requests, +node taints, scheduling policy or observer readiness requirements. + +## Security + +The BFF verifies the authenticated Bridge principal and enforces persona routes. +The `kars-bridge` ServiceAccount then limits the aggregate Kubernetes operations +the product can perform. Both layers are required. Most credential Secrets are +write-only. The BFF has narrowly scoped `get` access to the canonical provider +Secrets so it can report connection metadata; credential values are never +returned to browsers or agents. diff --git a/bridge/deploy/helm/kars-bridge/templates/NOTES.txt b/bridge/deploy/helm/kars-bridge/templates/NOTES.txt new file mode 100644 index 000000000..004ee3bf1 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/NOTES.txt @@ -0,0 +1,35 @@ +kars Bridge {{ .Chart.AppVersion }} installed in namespace {{ include "kars-bridge.namespace" . }} (release {{ .Release.Name }}). + +This is an ADDITIVE layer on top of kars. It expects the kars CRDs + controller to +already be present in the cluster. Install a compatible full Kars runtime from +its separate checkout first; see docs/compatibility.md. The public foundation +PRs alone are not the full Bridge runtime. Images remain private preview. + +Verify the required API group before enabling user traffic: + kubectl api-resources --api-group=kars.azure.com + kubectl -n {{ include "kars-bridge.namespace" . }} get karstasks,karsteams,karsapprovals + +The BFF /readyz probe requires all documented Kars APIs and list permission. +API availability does not prove source/image or controller compatibility. +Microsoft Teams is optional and has zero gateway replicas by default. +New installs cannot claim kars-system. Any chart-owned namespace, including +legacy shared namespaces, is kept on uninstall to avoid cascading deletion of +Kars resources. Preserve createNamespace when upgrading an unprotected legacy +namespace until the installed Helm manifest contains the keep annotation. + +Reach the console + workspace: +{{- if .Values.ingress.enabled }} + https://{{ .Values.ingress.host }}/workspace (user) + https://{{ .Values.ingress.host }}/console (operator/admin) +{{- else }} + # No ingress configured — port-forward the web Service: + kubectl -n {{ include "kars-bridge.namespace" . }} port-forward svc/kars-bridge-web {{ .Values.web.port }}:{{ .Values.web.port }} + # then open http://localhost:{{ .Values.web.port }}/workspace +{{- end }} + +The BFF runs as ServiceAccount "{{ include "kars-bridge.serviceAccountName" . }}" under a +least-privilege ClusterRole. Persona checks in the BFF govern individual users; +the ClusterRole is the aggregate Kubernetes permission ceiling. + +AKS and local kind are live-qualified. EKS/GKE require environment-specific +identity, registry, ingress, CNI, inference, and compatibility validation. diff --git a/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl b/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl new file mode 100644 index 000000000..248e02d4d --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl @@ -0,0 +1,21 @@ +{{/* Common labels + names for the kars Bridge chart. */}} +{{- define "kars-bridge.labels" -}} +app.kubernetes.io/name: kars-bridge +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: kars +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "kars-bridge.namespace" -}} +{{- .Values.namespace | default "kars-system" -}} +{{- end -}} + +{{- define "kars-bridge.coreNamespace" -}} +{{- $core := .Values.core | default dict -}} +{{- $core.namespace | default "kars-system" -}} +{{- end -}} + +{{- define "kars-bridge.serviceAccountName" -}} +{{- .Values.rbac.serviceAccountName | default "kars-bridge" -}} +{{- end -}} diff --git a/bridge/deploy/helm/kars-bridge/templates/bff.yaml b/bridge/deploy/helm/kars-bridge/templates/bff.yaml new file mode 100644 index 000000000..dddb68a6b --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/bff.yaml @@ -0,0 +1,122 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kars-bridge-bff + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: bff +spec: + replicas: {{ .Values.bff.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + template: + metadata: + labels: + {{- include "kars-bridge.labels" . | nindent 8 }} + app.kubernetes.io/component: bff + spec: + serviceAccountName: {{ include "kars-bridge.serviceAccountName" . }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: bff + image: "{{ .Values.bff.image.repository }}:{{ .Values.bff.image.tag }}" + imagePullPolicy: {{ .Values.bff.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.bff.port }} + env: + - name: BRIDGE_BFF_HOST + value: "0.0.0.0" + - name: BRIDGE_BFF_PORT + value: "{{ .Values.bff.port }}" + - name: BRIDGE_DEFAULT_NAMESPACE + value: {{ include "kars-bridge.coreNamespace" . | quote }} + - name: BRIDGE_CORE_NAMESPACE + value: {{ include "kars-bridge.coreNamespace" . | quote }} + - name: BRIDGE_INSTALL_NAMESPACE + value: {{ include "kars-bridge.namespace" . | quote }} + - name: BRIDGE_LOG_JSON + value: "true" + {{- if or .Values.idp.enabled .Values.auth.principalSecretName }} + # Same HS256 key the web uses to verify bridge-session cookies. The + # web forwards the signed session; the BFF independently verifies it + # and enforces persona authorization on every API route. + - name: BRIDGE_PRINCIPAL_SECRET + valueFrom: + secretKeyRef: + name: {{ ternary .Values.idp.secretName .Values.auth.principalSecretName .Values.idp.enabled }} + key: {{ ternary "session-secret" .Values.auth.principalSecretKey .Values.idp.enabled }} + {{- end }} + # Shared secret for the Teams gateway internal decision endpoint. + # Optional until an admin configures Teams from Connections. + - name: BRIDGE_TEAMS_INTERNAL_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: bff-internal-secret + optional: true + # Entra→Bridge role map for server-side principal resolution. + - name: BRIDGE_TEAMS_ENTRA_ROLE_MAP + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: entra-role-map + optional: true + - name: BRIDGE_TEAMS_SECRET_NAME + value: {{ .Values.teamsGateway.secretName | quote }} + - name: BRIDGE_TEAMS_GATEWAY_DEPLOYMENT + value: "kars-bridge-teams-gateway" + - name: BRIDGE_BFF_DEPLOYMENT + value: "kars-bridge-bff" + {{- with .Values.bff.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: { path: /readyz, port: http } + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 10 + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 10 + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + resources: + {{- toYaml .Values.bff.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: kars-bridge-bff + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: bff +spec: + selector: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + ports: + - name: http + port: {{ .Values.bff.port }} + targetPort: http diff --git a/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml b/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml new file mode 100644 index 000000000..79715d48d --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml @@ -0,0 +1,28 @@ +{{- if .Values.idp.enabled }} +{{- $ns := include "kars-bridge.namespace" . }} +{{- $existing := (lookup "v1" "Secret" $ns .Values.idp.secretName) }} +{{- $client := .Values.idp.clientSecret }} +{{- $session := .Values.idp.sessionSecret }} +{{- if and (not $client) $existing }}{{ $client = (index $existing.data "client-secret" | b64dec) }}{{ end }} +{{- if and (not $session) $existing }}{{ $session = (index $existing.data "session-secret" | b64dec) }}{{ end }} +{{- if not $client }}{{ $client = randAlphaNum 64 }}{{ end }} +{{- if not $session }}{{ $session = randAlphaNum 64 }}{{ end }} +# kars Bridge — OIDC secrets (Dex client secret + Bridge session-signing secret). +# +# Both are 64-char values. When `idp.clientSecret` / `idp.sessionSecret` are set +# in values they are used verbatim; otherwise a stable per-release random value +# is generated (and preserved across upgrades via lookup) so a `helm upgrade` +# does not silently invalidate every live session. For anything beyond a private +# alpha, set them explicitly from a real secret manager and never commit them. +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.idp.secretName }} + namespace: {{ $ns }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +type: Opaque +stringData: + client-secret: {{ $client | quote }} + session-secret: {{ $session | quote }} +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/idp.yaml b/bridge/deploy/helm/kars-bridge/templates/idp.yaml new file mode 100644 index 000000000..cf1262cc2 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/idp.yaml @@ -0,0 +1,144 @@ +{{- if .Values.idp.enabled }} +# kars Bridge — in-cluster IdP (Dex) for multi-user SSO. +# +# WHY THIS EXISTS: colleagues reach the Bridge over a single +# `kubectl port-forward svc/kars-bridge-web 3000:3000` +# with NO public ingress and NO LoadBalancer. The OIDC login flow redirects the +# *browser* to the IdP's authorization endpoint, so the IdP must be reachable at +# an origin the browser can resolve. Rather than force every colleague to edit +# /etc/hosts and run a second port-forward for Dex, the web pod proxies /dex/* +# to this in-cluster Dex Service (see web/src/app/dex/[...path]/route.ts) and +# Dex's issuer is set to `{web origin}/dex`. One port-forward, zero host hacks. +# +# Dex runs entirely in-cluster; it is never exposed publicly. Static passwords +# are for a private alpha only — point `idp.dex.connectors` at a real upstream +# (Entra ID, Okta, GitHub, ...) for anything beyond a colleague test ring. +apiVersion: v1 +kind: ConfigMap +metadata: + name: dex + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app: dex +data: + config.yaml: | + # Issuer is the browser-facing origin + /dex (served by the web same-origin + # proxy), NOT the in-cluster Service DNS — otherwise the browser could not + # resolve the authorize/login redirects. + issuer: {{ .Values.idp.issuer | quote }} + storage: { type: memory } + web: { http: 0.0.0.0:5556 } + oauth2: { skipApprovalScreen: true } + staticClients: + - id: {{ .Values.idp.clientId | quote }} + name: kars Bridge + secretEnv: DEX_CLIENT_SECRET + redirectURIs: {{ .Values.idp.redirectURIs | toJson }} + enablePasswordDB: {{ .Values.idp.dex.enablePasswordDB }} + {{- with .Values.idp.dex.staticPasswords }} + staticPasswords: + {{- toYaml . | nindent 6 }} + {{- end }} + {{- with .Values.idp.dex.connectors }} + connectors: + {{- toYaml . | nindent 6 }} + {{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dex + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app: dex +spec: + replicas: 1 + selector: + matchLabels: + app: dex + template: + metadata: + labels: + {{- include "kars-bridge.labels" . | nindent 8 }} + app: dex + annotations: + # Roll the pod when the config changes so issuer/user edits take effect. + checksum/config: {{ .Values.idp | toJson | sha256sum }} + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1001 + seccompProfile: + type: RuntimeDefault + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: dex + image: {{ .Values.idp.dex.image | quote }} + imagePullPolicy: IfNotPresent + command: ["/usr/local/bin/dex", "serve", "/etc/dex/config.yaml"] + env: + - name: DEX_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.idp.secretName }} + key: client-secret + ports: + - containerPort: 5556 + securityContext: + allowPrivilegeEscalation: false + capabilities: { drop: ["ALL"] } + volumeMounts: + - name: config + mountPath: /etc/dex + readOnly: true + volumes: + - name: config + configMap: + name: dex +--- +apiVersion: v1 +kind: Service +metadata: + name: dex + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app: dex +spec: + selector: + app: dex + ports: + - name: http + port: 5556 + targetPort: 5556 +{{- if .Values.networkPolicy.create }} +--- +# kars ships a kars-system-default-deny that drops pod-to-pod on non-allowlisted +# ports; the web pod's /dex proxy hop is not on that allowlist. This additive +# policy opens ingress to Dex on 5556 from in-cluster callers so the same-origin +# proxy (and OIDC discovery/token/JWKS server-side calls) can reach it. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-dex + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: dex + policyTypes: ["Ingress"] + ingress: + - from: + - namespaceSelector: {} + ports: + - port: 5556 + protocol: TCP +{{- end }} +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/ingress.yaml b/bridge/deploy/helm/kars-bridge/templates/ingress.yaml new file mode 100644 index 000000000..10706b037 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/ingress.yaml @@ -0,0 +1,33 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: kars-bridge + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + # The web app owns the origin and proxies /api/* to the BFF Service. + - path: / + pathType: Prefix + backend: + service: + name: kars-bridge-web + port: + number: {{ .Values.web.port }} +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/namespace.yaml b/bridge/deploy/helm/kars-bridge/templates/namespace.yaml new file mode 100644 index 000000000..aea69115a --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/namespace.yaml @@ -0,0 +1,41 @@ +{{- $namespace := include "kars-bridge.namespace" . }} +{{- $retainOwnedNamespace := false }} +{{- $existing := dict }} +{{- if .Release.IsUpgrade }} +{{- $existing = lookup "v1" "Namespace" "" $namespace }} +{{- if $existing }} +{{- $annotations := default dict $existing.metadata.annotations }} +{{- $retainOwnedNamespace = and + (eq (index $annotations "meta.helm.sh/release-name") .Release.Name) + (eq (index $annotations "meta.helm.sh/release-namespace") .Release.Namespace) }} +{{- end }} +{{- end }} +{{- if or .Values.createNamespace $retainOwnedNamespace }} +{{- if and (eq (include "kars-bridge.namespace" .) "kars-system") (not .Release.IsUpgrade) }} +{{- fail "Bridge must not own the shared kars-system namespace on a new install; set createNamespace=false and install Kars first" }} +{{- end }} +{{- if and (eq (include "kars-bridge.namespace" .) .Release.Namespace) (not .Release.IsUpgrade) }} +{{- fail "A chart cannot bootstrap its own Helm release storage namespace; set createNamespace=false and use helm --create-namespace, or store the release in a separate existing namespace" }} +{{- end }} +# Keep legacy chart-owned namespaces in upgrade manifests so retention can be +# applied before an operator changes ownership or uninstalls the release. +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "kars-bridge.namespace" . }} + annotations: + # A Bridge uninstall must never cascade-delete Kars CRs that happen to + # live in the same namespace. Leave an empty chart-created namespace + # behind for the operator to remove explicitly. + {{- if $retainOwnedNamespace }} + {{- toYaml (mergeOverwrite (deepCopy (default dict $existing.metadata.annotations)) (dict "helm.sh/resource-policy" "keep")) | nindent 4 }} + {{- else }} + helm.sh/resource-policy: keep + {{- end }} + labels: + {{- if $retainOwnedNamespace }} + {{- toYaml (default dict $existing.metadata.labels) | nindent 4 }} + {{- else }} + {{- include "kars-bridge.labels" . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml b/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml new file mode 100644 index 000000000..3fc5858cd --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml @@ -0,0 +1,81 @@ +{{- if .Values.networkPolicy.create }} +# kars Bridge — additive NetworkPolicies. +# +# kars applies a `kars-system-default-deny` policy that selects EVERY pod in the +# namespace and drops all pod-to-pod traffic except a fixed allow-list (DNS, the +# API server, AgentMesh, sandbox routers, IMDS). That allow-list does NOT include +# the Bridge web -> BFF hop, so without these policies the console cannot reach the +# BFF under a policy-enforcing CNI. NetworkPolicies are additive (their rules are +# OR-ed with the default-deny), so these only OPEN the Bridge's own ports. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-web + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: web + policyTypes: ["Ingress", "Egress"] + ingress: + # Reach the console from an ingress controller / load balancer / port-forward. + - ports: + - port: {{ .Values.web.port }} + protocol: TCP + egress: + # Resolve Service DNS names (also granted by the default-deny; repeated here so + # the policy is self-sufficient on clusters with a different default-deny). + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + # The same-origin /api/* proxy to the BFF. + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + ports: + - port: {{ .Values.bff.port }} + protocol: TCP + {{- if .Values.idp.enabled }} + # Same-origin /dex proxy and server-side OIDC discovery/token/JWKS. + - to: + - podSelector: + matchLabels: + app: dex + ports: + - port: 5556 + protocol: TCP + {{- end }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-bff + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + policyTypes: ["Ingress"] + ingress: + # The web pod's same-origin proxy is the only ingress to the BFF; the K8s API, + # sandbox-router egress and DNS the BFF itself needs are already granted to all + # pods by the kars-system default-deny. + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: web + ports: + - port: {{ .Values.bff.port }} + protocol: TCP +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml b/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml new file mode 100644 index 000000000..69702f46e --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml @@ -0,0 +1,38 @@ +{{- $networkPolicy := .Values.networkPolicy | default dict }} +{{- $observations := $networkPolicy.observations | default dict }} +{{- if ($observations.enabled | default false) }} +{{- if not ($observations.existingIsolationConfirmed | default false) }} +{{- fail "Observation egress requires explicit confirmation that the BFF is already egress-isolated; preserve its API/provider/OIDC/GitHub baseline policy" }} +{{- end }} +{{- if not ($observations.targetNamespaces | default list) }} +{{- fail "Observation egress requires exact reviewed targetNamespaces" }} +{{- end }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-observation-egress + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + policyTypes: ["Egress"] + egress: + - to: + - namespaceSelector: + matchExpressions: + - key: kubernetes.io/metadata.name + operator: In + values: + {{- toYaml $observations.targetNamespaces | nindent 18 }} + podSelector: + matchExpressions: + - key: kars.azure.com/sandbox + operator: Exists + ports: + - port: 9447 + protocol: TCP +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/rbac.yaml b/bridge/deploy/helm/kars-bridge/templates/rbac.yaml new file mode 100644 index 000000000..b7ed2f6f3 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/rbac.yaml @@ -0,0 +1,164 @@ +{{- if .Values.rbac.create }} +# kars Bridge — least-privilege RBAC for the BFF ServiceAccount. The REAL +# authorization boundary includes Kubernetes RBAC and the core grant admission +# policies, not only BFF code. This ClusterRole has no Secret mutation or +# Deployment patch rule; core delegates exact enrolled stores separately. +# See deploy/rbac.yaml for the annotated source. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kars-bridge.serviceAccountName" . }} + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: bff +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-kars-bridge + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +rules: + # Read the full kars CRD surface (status + envelope objects). + - apiGroups: ["kars.azure.com"] + resources: + - karstasks + - karsteams + - karssandboxes + - toolpolicies + - mcpservers + - karsskills + - inferencepolicies + - karsapprovals + - egressapprovals + - karsreceipts + - karsprofiles + - karssreactions + - karsmemories + - karsevals + - karscredentialgrants + verbs: ["get", "list", "watch"] + # Author/edit the envelope objects the Bridge owns (Server-Side Apply). + # karstasks/karsteams additionally support delete (mission/team "Delete" + # action → delete_task/delete_team → delete_kind, foreground cascade) — + # real gap found live (2026-07-09): every mission/team delete silently + # 403'd against the real ServiceAccount (prior verification in this + # session's history had only ever run through a developer's cluster-admin + # kubeconfig). karssandboxes doesn't need it — the standing + # bridge-orchestrator is only ever halted (patch, un-launch), never + # CR-deleted by the BFF. + - apiGroups: ["kars.azure.com"] + resources: + - karstasks + - karsteams + verbs: ["create", "patch", "delete"] + - apiGroups: ["kars.azure.com"] + resources: + - karssandboxes + verbs: ["create", "patch"] + # Operator-authored governance objects additionally support delete. + # karsprofiles: real gap found live (2026-07-09) — had a full visual editor + # + apply/delete BFF routes wired, but was never granted write RBAC here + # (only get/list/watch above) — every save/create/delete silently 403'd. + - apiGroups: ["kars.azure.com"] + resources: + - toolpolicies + - mcpservers + - karsskills + - inferencepolicies + - egressapprovals + - karsprofiles + verbs: ["create", "patch", "delete"] + # KarsEval supports create (launch a run) but has no delete route in the BFF. + - apiGroups: ["kars.azure.com"] + resources: + - karsevals + verbs: ["create", "patch"] + # SRE self-remediation proposals: approve/reject only flips spec.approval.state. + - apiGroups: ["kars.azure.com"] + resources: + - karssreactions + verbs: ["patch"] + # Steering inbox: recording a human decision only ever patches spec.decision + # (the controller is the sole author of the KarsApproval object itself). + - apiGroups: ["kars.azure.com"] + resources: + - karsapprovals + verbs: ["patch"] + # Read CRD schemas (client-side form hints / validation). + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list"] + # Mission deliverables, status, Bridge-owned config, and isolated + # per-principal GitHub connection records live in ConfigMaps. + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "patch", "update", "delete"] + # Marker only; core admission constrains the adapter's remaining namespace + # creation to its dedicated local-inference namespace. It grants no source use. + - apiGroups: ["kars.azure.com"] + resources: ["karscredentialgrants"] + resourceNames: ["workspace"] + verbs: ["bridge-adapter"] + # Agent runtime namespaces and all credential projections are core-owned. + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "create"] + # Live sandbox data via the pods/proxy subresource (router :8443). + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/proxy"] + verbs: ["get", "create"] + # Read-only AGT registry discovery so retained Mission/Team views can attach + # the live signed DID registration to their cryptographic proof summary. + - apiGroups: [""] + resources: ["services/proxy"] + verbs: ["get"] + # Live deploy activity: real Kubernetes events (image pull, scheduling, + # container start/fail) surfaced in the local-model deploy progress tracker. + - apiGroups: [""] + resources: ["events"] + verbs: ["get", "list"] + # Local (in-cluster) inference — AI Runway's ModelDeployment CRD. kars does + # NOT install AI Runway/KAITO itself (see docs/local-inference.md) -- an + # operator does that once via their own helm/kubectl, same as the GitHub + # App. This grant is a no-op on a cluster where that hasn't happened. + - apiGroups: ["airunway.ai"] + resources: ["modeldeployments"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] + # Read-only Node capacity scan so the local-inference wizard only offers + # GPU-tier models when the cluster can actually schedule them. + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "list"] + - apiGroups: ["metrics.k8s.io"] + resources: ["nodes", "pods"] + verbs: ["get", "list"] + # Provider settings and Teams rollouts are applied by core under explicit UIDs. + - apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list"] + - apiGroups: ["authentication.k8s.io"] + resources: ["selfsubjectreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-kars-bridge + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-kars-bridge +subjects: + - kind: ServiceAccount + name: {{ include "kars-bridge.serviceAccountName" . }} + namespace: {{ include "kars-bridge.namespace" . }} +# Core owns purpose/UID-bound credential Roles and bindings. Bridge cannot +# author grants or bootstrap/adopt integration stores with its ServiceAccount. +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml new file mode 100644 index 000000000..c7e21972b --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml @@ -0,0 +1,297 @@ +# kars Bridge — Teams Gateway: dedicated ServiceAccount, RBAC, Deployment, +# Service, NetworkPolicy, and optional Ingress for Teams webhook callbacks. +# Credentials live in a DEDICATED Secret (kars-bridge-teams), NEVER in the +# workspace-channels Secret that gets propagated to sandbox pods. +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.teamsGateway.conversationConfigMapName }} + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: teams-gateway +data: + bindings.json: "[]" + approval-messages.json: "[]" + resource-versions.json: "{}" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kars-bridge-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: teams-gateway +--- +# Least-privilege Role: watch KarsApprovals, read/write the conversation ConfigMap. +# No cluster-wide write privileges. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +rules: + - apiGroups: ["kars.azure.com"] + resources: ["karsapprovals", "karstasks", "karsteams"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: ["{{ .Values.teamsGateway.conversationConfigMapName }}"] + verbs: ["get", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Release.Name }}-teams-gateway +subjects: + - kind: ServiceAccount + name: kars-bridge-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kars-bridge-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: teams-gateway +spec: + replicas: {{ ternary .Values.teamsGateway.replicas 0 .Values.teamsGateway.enabled }} + selector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: teams-gateway + template: + metadata: + labels: + {{- include "kars-bridge.labels" . | nindent 8 }} + app.kubernetes.io/component: teams-gateway + spec: + serviceAccountName: kars-bridge-teams-gateway + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: teams-gateway + image: "{{ .Values.teamsGateway.image.repository }}:{{ .Values.teamsGateway.image.tag }}" + imagePullPolicy: {{ .Values.teamsGateway.image.pullPolicy }} + ports: + - name: teams + containerPort: {{ .Values.teamsGateway.port }} + - name: internal + containerPort: {{ add .Values.teamsGateway.port 1 }} + env: + - name: TEAMS_GATEWAY_PORT + value: "{{ .Values.teamsGateway.port }}" + - name: TEAMS_BFF_BASE_URL + value: "http://kars-bridge-bff.{{ include "kars-bridge.namespace" . }}.svc.cluster.local:{{ .Values.bff.port }}" + - name: TEAMS_CONFIGMAP_NAMESPACE + value: {{ include "kars-bridge.namespace" . | quote }} + - name: TEAMS_CONFIGMAP_NAME + value: {{ .Values.teamsGateway.conversationConfigMapName | quote }} + - name: TEAMS_WATCH_NAMESPACE + value: {{ include "kars-bridge.namespace" . | quote }} + # All secrets from the dedicated kars-bridge-teams Secret + - name: TEAMS_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: client-id + optional: true + - name: TEAMS_TENANT_ID + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: tenant-id + optional: true + - name: TEAMS_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: client-secret + optional: true + - name: TEAMS_ENTRA_ROLE_MAP + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: entra-role-map + optional: true + - name: TEAMS_BFF_INTERNAL_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.teamsGateway.secretName }} + key: bff-internal-secret + optional: true + {{- with .Values.teamsGateway.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: { path: /healthz, port: internal } + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: { path: /healthz, port: internal } + initialDelaySeconds: 10 + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + resources: + {{- toYaml .Values.teamsGateway.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: kars-bridge-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: teams-gateway +spec: + selector: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: teams-gateway + ports: + - name: teams + port: {{ .Values.teamsGateway.port }} + targetPort: teams + - name: internal + port: {{ add .Values.teamsGateway.port 1 }} + targetPort: internal +--- +{{- if .Values.networkPolicy.create }} +# NetworkPolicy: ingress from internet (Teams webhooks) + BFF (proactive notify). +# Egress: DNS + K8s API + BFF + Teams Bot Framework service (HTTPS). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: teams-gateway + policyTypes: ["Ingress", "Egress"] + ingress: + - ports: + - port: {{ .Values.teamsGateway.port }} + protocol: TCP + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + ports: + - port: {{ add .Values.teamsGateway.port 1 }} + protocol: TCP + egress: + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - to: + - ipBlock: + cidr: 0.0.0.0/0 + ports: + - port: 443 + protocol: TCP + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + ports: + - port: {{ .Values.bff.port }} + protocol: TCP +--- +# Allow the Teams gateway to reach the BFF (add gateway as ingress source on BFF policy) +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-bff-teams-gateway-ingress + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff + policyTypes: ["Ingress"] + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: teams-gateway + ports: + - port: {{ .Values.bff.port }} + protocol: TCP +{{- end }} +{{- if .Values.teamsGateway.ingress.enabled }} +--- +# TLS Ingress for Teams webhook callbacks — routes directly to the gateway, +# not through the web pod. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Release.Name }}-teams-webhook + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: teams-gateway + annotations: + {{- with .Values.teamsGateway.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.teamsGateway.ingress.className }} + ingressClassName: {{ .Values.teamsGateway.ingress.className | quote }} + {{- end }} + tls: + - hosts: + - {{ .Values.teamsGateway.ingress.host | quote }} + {{- if .Values.teamsGateway.ingress.tlsSecretName }} + secretName: {{ .Values.teamsGateway.ingress.tlsSecretName | quote }} + {{- end }} + rules: + - host: {{ .Values.teamsGateway.ingress.host | quote }} + http: + paths: + - path: {{ .Values.teamsGateway.ingress.path | default "/api/messages" }} + pathType: Prefix + backend: + service: + name: kars-bridge-teams-gateway + port: + name: teams +{{- end }} diff --git a/bridge/deploy/helm/kars-bridge/templates/web.yaml b/bridge/deploy/helm/kars-bridge/templates/web.yaml new file mode 100644 index 000000000..90bfc05cf --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/templates/web.yaml @@ -0,0 +1,135 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kars-bridge-web + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + replicas: {{ .Values.web.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: web + template: + metadata: + labels: + {{- include "kars-bridge.labels" . | nindent 8 }} + app.kubernetes.io/component: web + spec: + securityContext: + {{- toYaml (mergeOverwrite (dict "fsGroup" 10001) .Values.podSecurityContext) | nindent 8 }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: web + image: "{{ .Values.web.image.repository }}:{{ .Values.web.image.tag }}" + imagePullPolicy: {{ .Values.web.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.web.port }} + env: + - name: PORT + value: "{{ .Values.web.port }}" + # Next.js standalone server binds to $HOSTNAME; Kubernetes sets + # HOSTNAME to the pod name, so without this it listens only on the + # pod-name interface and the readiness probe (pod IP) never succeeds. + - name: HOSTNAME + value: "0.0.0.0" + # Server components + the /api rewrite target the in-cluster BFF Service. + - name: BRIDGE_BFF_URL + value: "http://kars-bridge-bff.{{ include "kars-bridge.namespace" . }}.svc.cluster.local:{{ .Values.bff.port }}" + - name: BRIDGE_DEFAULT_NAMESPACE + value: {{ include "kars-bridge.coreNamespace" . | quote }} + {{- if .Values.idp.enabled }} + # ── In-cluster IdP (Dex) SSO wiring ────────────────────────────── + # Issuer is the browser-facing {web origin}/dex, reached via the web + # pod's same-origin /dex proxy (DEX_UPSTREAM_URL) both server-side + # (discovery/token/JWKS) and browser-side (authorize/login). One + # port-forward, no /etc/hosts. See templates/idp.yaml. + - name: BRIDGE_OIDC_ISSUER + value: {{ .Values.idp.issuer | quote }} + - name: BRIDGE_OIDC_CLIENT_ID + value: {{ .Values.idp.clientId | quote }} + - name: BRIDGE_OIDC_REDIRECT_URI + value: {{ index .Values.idp.redirectURIs 0 | quote }} + - name: BRIDGE_OIDC_SCOPES + value: {{ .Values.idp.scopes | quote }} + - name: BRIDGE_OIDC_ROLE_CLAIM + value: {{ .Values.idp.roleClaim | quote }} + - name: BRIDGE_OIDC_ROLE_MAP + value: {{ .Values.idp.roleMap | toJson | quote }} + - name: BRIDGE_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.idp.secretName }} + key: client-secret + - name: BRIDGE_SESSION_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.idp.secretName }} + key: session-secret + - name: DEX_UPSTREAM_URL + value: {{ default (printf "http://dex.%s.svc.cluster.local:5556" (include "kars-bridge.namespace" .)) .Values.idp.dexUpstreamUrl | quote }} + {{- end }} + {{- if and (not .Values.idp.enabled) .Values.auth.principalSecretName }} + - name: BRIDGE_SESSION_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.auth.principalSecretName }} + key: {{ .Values.auth.principalSecretKey }} + {{- end }} + {{- with .Values.web.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + readinessProbe: + httpGet: { path: /api/health, port: http } + initialDelaySeconds: 5 + periodSeconds: 10 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: ["ALL"] } + volumeMounts: + - name: web-cache + mountPath: /app/.next/cache + - name: web-tmp + mountPath: /tmp + resources: + {{- toYaml .Values.web.resources | nindent 12 }} + volumes: + - name: web-cache + emptyDir: + sizeLimit: 256Mi + - name: web-tmp + emptyDir: + sizeLimit: 128Mi + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: kars-bridge-web + namespace: {{ include "kars-bridge.namespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + selector: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: web + ports: + - name: http + port: {{ .Values.web.port }} + targetPort: http diff --git a/bridge/deploy/helm/kars-bridge/values-kind.yaml b/bridge/deploy/helm/kars-bridge/values-kind.yaml new file mode 100644 index 000000000..29e3c61b7 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/values-kind.yaml @@ -0,0 +1,16 @@ +# kars Bridge — values overlay for local kind clusters. +# helm install kars-bridge deploy/helm/kars-bridge -n kars-system -f deploy/helm/kars-bridge/values-kind.yaml +# Load the images into kind first: +# kind load docker-image kars-bridge-bff:dev kars-bridge-web:dev --name +bff: + image: + repository: kars-bridge-bff + tag: dev + pullPolicy: IfNotPresent +web: + image: + repository: kars-bridge-web + tag: dev + pullPolicy: IfNotPresent +ingress: + enabled: false diff --git a/bridge/deploy/helm/kars-bridge/values.yaml b/bridge/deploy/helm/kars-bridge/values.yaml new file mode 100644 index 000000000..de9795a10 --- /dev/null +++ b/bridge/deploy/helm/kars-bridge/values.yaml @@ -0,0 +1,234 @@ +# kars Bridge Helm values. +# +# The Bridge is ADDITIVE: it deploys on top of an existing kars install (kars +# CRDs + controller in `namespace`). It never creates or replaces kars components. +# Kubernetes-native templates. Each deployment requires environment-specific +# identity, registry, ingress, CNI, inference, and compatibility validation. +# Historical preview image defaults are retained for existing installations. +# Source publication does not publish those images: new installs must supply +# repositories built in an operator-controlled registry. + +# The namespace the Bridge runs in. The kars chart creates `kars-system`; the +# Bridge joins it. Set createNamespace: true only when installing the Bridge into +# a fresh, dedicated namespace. New installs cannot claim kars-system. Legacy +# chart-owned namespaces remain in upgrades and are retained on uninstall; +# keep createNamespace unchanged until the safe upgrade has been applied. +# New release storage namespaces need Helm's --create-namespace with this +# value false; a chart-owned workload namespace needs separate release storage. +namespace: kars-system +createNamespace: false + +# Core control/default workspace is independent of the private add-on namespace. +core: + namespace: kars-system + +# imagePullSecrets: list of {name: } references to pre-created +# `kubernetes.io/dockerconfigjson` Secrets in `namespace`, for pulling the +# private BFF/web images. Empty ⇒ public images / node-identity pull. +global: + imagePullSecrets: [] + # e.g. + # - name: myregistry-pull + +# Shared signing secret for external OIDC deployments. The web signs the +# principal assertion with this key and the BFF verifies it independently. +# idp.enabled uses idp.secretName automatically. When idp.enabled=false and +# external OIDC is configured through web.extraEnv, set this Secret name. +auth: + principalSecretName: "" + principalSecretKey: "session-secret" + +# ── BFF (Rust API) ─────────────────────────────────────────────────────────── +bff: + image: + # Override per environment: AKS -> .azurecr.io, EKS -> .dkr.ecr..amazonaws.com, + # GKE -> -docker.pkg.dev//, kind -> a locally loaded image. + repository: ghcr.io/pallakatos/kars-bridge-bff + tag: "latest" + pullPolicy: IfNotPresent + replicas: 1 + port: 8081 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "500m", memory: "256Mi" } + # Extra env merged into the BFF container (e.g. BRIDGE_OPERATOR, BRIDGE_ROLES, + # BRIDGE_ENGINEERING_POLLER_SECONDS; default 60, minimum 15). + extraEnv: [] + +# ── Web (Next.js UI) ───────────────────────────────────────────────────────── +web: + image: + repository: ghcr.io/pallakatos/kars-bridge-web + tag: "latest" + pullPolicy: IfNotPresent + replicas: 1 + port: 3000 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + # Extra env merged into the web container. Notably: + # BRIDGE_HEADLAMP_URL - deep-links the Console home + Cluster tools card to a + # Kubernetes dashboard (Headlamp or any other) for deep pod/node/event + # inspection. Point it at whatever URL your operators can actually reach + # it at (an Ingress host, a NodePort, or a `kubectl port-forward` target + # for local/kind demos) — the Bridge only deep-links, it doesn't proxy. + # Example: --set web.extraEnv[0].name=BRIDGE_HEADLAMP_URL \ + # --set web.extraEnv[0].value=http://localhost:3903 + # Left unset, the card shows an honest "not linked" state instead of a + # broken link. + # + # BRIDGE_OIDC_ISSUER / BRIDGE_OIDC_CLIENT_ID / BRIDGE_OIDC_CLIENT_SECRET / + # BRIDGE_SESSION_SECRET - connect a real OIDC identity provider (Entra + # ID, Okta, Auth0, Keycloak, Dex, ...) for genuine per-user SSO: a real + # Authorization Code + PKCE flow at /auth/login, ID-token signature/ + # issuer/audience/nonce verified against the provider's live JWKS, and a + # signed Bridge session issued on success. Put BRIDGE_OIDC_CLIENT_SECRET + # and BRIDGE_SESSION_SECRET in a K8s Secret (valueFrom.secretKeyRef), + # never inline in values.yaml. Optional: BRIDGE_OIDC_REDIRECT_URI + # (defaults to `{origin}/auth/callback`), BRIDGE_OIDC_SCOPES (default + # "profile email"), BRIDGE_OIDC_ROLE_CLAIM (the ID-token claim carrying + # group/role membership, default "roles"), and BRIDGE_OIDC_ROLE_MAP + # (JSON mapping an IdP group/role name to a Bridge role, e.g. + # '{"kars-admins":"admin","kars-operators":"operator"}'). Unmapped + # claims grant zero roles (fail-closed) — see /console/access. + # Left unset (the default — no IdP is registered anywhere in this + # project), /auth/login returns an honest "SSO not configured" response + # and the header's dev role-switch remains the way to preview roles. + extraEnv: [] + +# ── Ingress (optional) ─────────────────────────────────────────────────────── +# Off by default so a bare install just exposes ClusterIP Services (reach them via +# `kubectl port-forward`). Enable + set className/host per cloud: +# AKS: application-gateway | nginx EKS: alb | nginx GKE: gce | nginx kind: nginx +ingress: + enabled: false + className: "" + host: "" + annotations: {} + tls: [] + +# The ServiceAccount + least-privilege ClusterRole the BFF runs under. The REAL +# authorization boundary is this Role — keep it least-privilege. +rbac: + create: true + serviceAccountName: kars-bridge + +# NetworkPolicies so the Bridge works under a namespace default-deny (kars ships +# a kars-system-default-deny that drops pod-to-pod on the Bridge ports). These are +# additive allow-rules: web -> BFF on the BFF port, and ingress to web + BFF on +# their service ports. Disable only on clusters with no NetworkPolicy enforcement +# or no default-deny (they are harmless there, but off by request is supported). +networkPolicy: + create: true + observations: + enabled: false + # Explicit opt-in only: this additive policy must not accidentally become + # the first Egress policy selecting an otherwise unrestricted BFF. + existingIsolationConfirmed: false + # Exact, operator-reviewed runtime namespace names (for example kars-agent). + targetNamespaces: [] + +# ── In-cluster IdP (Dex) for multi-user SSO ────────────────────────────────── +# OFF by default: a bare install uses the dev role-switcher (no login). Turn ON +# for a multi-user colleague ring reachable over a single +# kubectl port-forward svc/kars-bridge-web 3000:3000 +# with no public ingress. The web pod proxies /dex/* to the in-cluster Dex +# Service, so the issuer is the browser origin + /dex — one port-forward, no +# /etc/hosts. See templates/idp.yaml + web/src/app/dex/[...path]/route.ts. +idp: + enabled: false + # Browser-facing issuer = {web origin}/dex. For the port-forward path this is + # localhost:3000/dex; behind a real ingress set it to https:///dex. + issuer: "http://localhost:3000/dex" + clientId: "kars-bridge" + # Must match the browser origin the user actually loads the Bridge at. + redirectURIs: + - "http://localhost:3000/auth/callback" + scopes: "profile email groups" + # The ID-token claim Dex puts group membership in, mapped to Bridge roles. + roleClaim: "groups" + roleMap: + kars-employees: "user" + kars-operators: "operator" + kars-auditors: "auditor" + # In-cluster Service the web /dex proxy forwards to (NOT browser-facing). + # Empty derives `http://dex..svc.cluster.local:5556`. + dexUpstreamUrl: "" + # Secret holding client-secret + session-secret (64 chars each). Left empty, + # the chart generates stable random values and preserves them across upgrades. + secretName: "kars-bridge-oidc" + clientSecret: "" + sessionSecret: "" + dex: + image: "ghcr.io/dexidp/dex:v2.45.1" + enablePasswordDB: true + # Private-alpha seed users (bcrypt hash below = "password"). Replace with + # real per-colleague accounts, or drop these and wire `connectors` to a real + # upstream IdP. Passwords are only ever inside the cluster. + staticPasswords: + - email: "employee@kars.test" + username: "employee" + userID: "11111111-1111-4111-8111-111111111111" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + groups: ["kars-employees"] + - email: "operator@kars.test" + username: "operator" + userID: "22222222-2222-4222-8222-222222222222" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + groups: ["kars-operators"] + - email: "auditor@kars.test" + username: "auditor" + userID: "33333333-3333-4333-8333-333333333333" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + groups: ["kars-auditors"] + # Upstream OIDC/social connectors (Entra ID, GitHub, ...). When set, prefer + # these over static passwords for real deployments. + connectors: [] + +# Pod-level security context (rootless, read-only-friendly). Overridable per cloud +# if a platform requires different fsGroup/seccomp defaults. +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +nodeSelector: {} +tolerations: [] +affinity: {} + +# ── Teams Gateway (optional Microsoft Teams HITL integration) ───────────────── +# The gateway resources are installed at zero replicas by default so an admin can +# bootstrap credentials from Connections. `enabled` controls the initial +# replica count; the BFF scales it after secure configuration. +# 1. An Entra App Registration with Bot Channel enabled + admin consent +# 2. A Secret named `kars-bridge-teams` containing keys: +# client-id, tenant-id, client-secret, entra-role-map (JSON), bff-internal-secret +# 3. The BFF must also mount bff-internal-secret via BRIDGE_TEAMS_INTERNAL_SECRET +# +# The dedicated Secret is NEVER propagated to sandbox pods. +teamsGateway: + enabled: false + image: + repository: ghcr.io/pallakatos/kars-bridge-freeze-teams-gateway + tag: "latest" + pullPolicy: Always + replicas: 1 + port: 3978 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "200m", memory: "256Mi" } + # Name of the dedicated K8s Secret holding Teams credentials. Keys: + # client-id, tenant-id, client-secret, entra-role-map, bff-internal-secret + # entra-role-map is JSON: + # [{"entra_subject":"","bridge_subject":"","roles":["operator"],"name":"Alice"}] + secretName: "kars-bridge-teams" + conversationConfigMapName: "kars-teams-conversations" + extraEnv: [] + # TLS Ingress for Teams webhook callbacks (routes to gateway, not web). + ingress: + enabled: false + className: "" + host: "" + path: "/api/messages" + tlsSecretName: "" + annotations: {} diff --git a/bridge/deploy/rbac.yaml b/bridge/deploy/rbac.yaml new file mode 100644 index 000000000..19e1d1de0 --- /dev/null +++ b/bridge/deploy/rbac.yaml @@ -0,0 +1,183 @@ +# kars Bridge — least-privilege RBAC for the BFF ServiceAccount. +# +# The Bridge is a privileged Kubernetes API client: it authors envelope CRDs +# (operator pattern) and reads back status/telemetry. The REAL authorization +# boundary is this Role — not the BFF code. In dev the BFF runs locally with the +# developer's kubeconfig; in-cluster it MUST run as this ServiceAccount so the +# API server enforces exactly these verbs and nothing more. +# +# Security notes: +# - Writes are scoped to the kinds the Bridge authors; everything else is read-only. +# - Core supplies source-only and enrolled-store Roles after explicit operator +# grants. This manifest grants no Secret or Deployment mutation authority. +# - For audit fidelity, run the BFF behind user impersonation (Impersonate-User) +# or per-user tokens so the K8s audit log records the human, not this SA. +# - Governance CRDs ARE the security posture; gate privilege-WIDENING applies +# (broader egress / new tool / new MCP) behind the approval flow before apply. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kars-bridge + namespace: kars-system + labels: + app.kubernetes.io/name: kars-bridge + app.kubernetes.io/component: bff +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kars-bridge + labels: + app.kubernetes.io/name: kars-bridge +rules: + # ── Read the full kars CRD surface (status + envelope objects) ────────────── + - apiGroups: ["kars.azure.com"] + resources: + - karstasks + - karsteams + - karssandboxes + - toolpolicies + - mcpservers + - karsskills + - inferencepolicies + - karsapprovals + - egressapprovals + - karsreceipts + - karsprofiles + - karssreactions + - karsmemories + - karsevals + - karscredentialgrants + verbs: ["get", "list", "watch"] + # ── Author/edit the envelope objects the Bridge owns (Server-Side Apply) ──── + # `patch` covers apply (create-or-update); `create` covers first-author on + # APIs that route a fresh object through create. `delete` covers the + # explicit mission/team "Delete" action (delete_task/delete_team → + # delete_kind, foreground cascade) — found missing live (2026-07-09): + # every mission/team delete silently 403'd against the real + # ServiceAccount (verification earlier in this file's history had only + # ever run through a developer's cluster-admin kubeconfig). karssandboxes + # doesn't need it — the standing bridge-orchestrator is only ever + # halted (patch, un-launch), never CR-deleted by the BFF. + - apiGroups: ["kars.azure.com"] + resources: + - karstasks # missions + - karsteams # standing teams + verbs: ["create", "patch", "delete"] + - apiGroups: ["kars.azure.com"] + resources: + - karssandboxes # standing bridge-orchestrator + halt/un-launch + verbs: ["create", "patch"] + # Operator-authored governance objects additionally support delete — the + # console offers an explicit Remove/Revoke. For egressapprovals, delete IS the + # revoke action (create-to-grant / delete-to-revoke). + # + # inferencepolicies + karsprofiles: real gap found live (2026-07-09) — both + # had a real visual editor + full apply/delete BFF routes (applyGovernance/ + # deleteGovernance) wired since their respective critique items, but were + # NEVER granted write RBAC — only get/list/watch above. Every save/create/ + # delete attempt against either kind has been silently 403ing since day one. + - apiGroups: ["kars.azure.com"] + resources: + - toolpolicies # operator-authored governance + - mcpservers # operator-authored connected services + - karsskills # operator-authored skills + - egressapprovals # HITL egress grants + - inferencepolicies # operator-authored inference policies + - karsprofiles # operator-authored team profile templates + verbs: ["create", "patch", "delete"] + # KarsEval supports create (launch a run) but has no delete route in the BFF. + - apiGroups: ["kars.azure.com"] + resources: + - karsevals + verbs: ["create", "patch"] + # ── SRE self-remediation proposals: approve/reject flips spec.approval.state ─ + - apiGroups: ["kars.azure.com"] + resources: + - karssreactions + verbs: ["patch"] + # ── Steering inbox: recording a human decision only ever patches spec.decision ─ + # (the controller is the sole author of the KarsApproval object itself). + - apiGroups: ["kars.azure.com"] + resources: + - karsapprovals + verbs: ["patch"] + # ── Read CRD schemas (for client-side form hints / validation) ────────────── + - apiGroups: ["apiextensions.k8s.io"] + resources: ["customresourcedefinitions"] + verbs: ["get", "list"] + # ── Mission deliverables, status, and per-principal GitHub connection records + # live in ConfigMaps (read + Bridge-owned + # sweep-on-delete: mission/team delete removes the output/artifacts/trace/ + # review/commons/team-tasks ConfigMaps it authored — found missing live + # (2026-07-09) alongside the karstasks/karsteams delete gap above) ──────── + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "patch", "update", "delete"] + - apiGroups: ["kars.azure.com"] + resources: ["karscredentialgrants"] + resourceNames: ["workspace"] + verbs: ["bridge-adapter"] + # Core admission limits adapter namespace creation to kars-local-inference. + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "create"] + # ── Live sandbox data via the pods/proxy subresource (router :8443) ───────── + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/proxy"] + verbs: ["get", "create"] + # Read-only AGT registry discovery for the retained mesh DID proof. + - apiGroups: [""] + resources: ["services/proxy"] + verbs: ["get"] + # ── Live deploy activity: real Kubernetes events (image pull, scheduling, + # container start/fail) surfaced in the local-model deploy progress + # tracker so the operator sees what's actually happening ─────────────── + - apiGroups: [""] + resources: ["events"] + verbs: ["get", "list"] + # ── Local (in-cluster) inference — AI Runway's ModelDeployment CRD ────────── + # kars does NOT install AI Runway/KAITO itself (see docs/local-inference. + # md) — an operator does that once via their own helm/kubectl, exactly + # like the GitHub App. The Bridge only manages ModelDeployment objects + # it creates in its own `kars-local-inference` namespace on top of + # whatever's already installed; this grant is a no-op (the CRD simply + # won't exist) on a cluster where the operator hasn't set it up. + - apiGroups: ["airunway.ai"] + resources: ["modeldeployments"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] + # ── Read-only Node capacity scan so the local-inference wizard only offers + # GPU-tier models when the cluster can actually schedule them ─────────── + - apiGroups: [""] + resources: ["nodes"] + verbs: ["get", "list"] + # ── Live utilization for Operator Fleet capacity monitoring ──────────────── + - apiGroups: ["metrics.k8s.io"] + resources: ["nodes", "pods"] + verbs: ["get", "list"] + # Provider settings and Teams rollout/scale operations are core-owned. + - apiGroups: ["apps"] + resources: ["deployments", "replicasets"] + verbs: ["get", "list"] + - apiGroups: ["authentication.k8s.io"] + resources: ["selfsubjectreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kars-bridge + labels: + app.kubernetes.io/name: kars-bridge +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kars-bridge +subjects: + - kind: ServiceAccount + name: kars-bridge + namespace: kars-system +# Operator-enrolled, UID-bound credential access is reconciled by Kars core. diff --git a/bridge/docs/README.md b/bridge/docs/README.md new file mode 100644 index 000000000..95219dc3b --- /dev/null +++ b/bridge/docs/README.md @@ -0,0 +1,49 @@ +# Kars Bridge documentation + +Kars Bridge turns Kars APIs into a governed human workflow for agent missions +and teams. This documentation is organized by task and persona rather than by +repository component. + +## Get started + +1. [Compatibility and prerequisites](compatibility.md) +2. [Private-preview quickstart](quickstart.md) +3. [Identity and sign-in](identity.md) +4. [First mission and first team](missions-and-teams.md) +5. [Glossary](glossary.md) + +## Employee and team workflows + +- [Missions and teams](missions-and-teams.md) +- [Team workflows: intent to reviewed outcome](team-workflows.md) +- [Connections](connections.md) +- [Skills](skills.md) +- [Approvals and egress](approvals-egress.md) + +## Operator workflows + +- [MCP servers](mcp-servers.md) +- [Providers and model routing](providers.md) +- [Providers and local inference](local-inference.md) +- [Inference budgets](inference-budgets.md) +- [Access and roles](rbac.md) +- [Observability and evidence](observability.md) +- [Evidence and compliance](evidence-compliance.md) + +## Deploy and operate + +- [Deployment](deployment.md) +- [Compatibility](compatibility.md) +- [Troubleshooting](troubleshooting.md) +- [Operations](operations.md) +- [Architecture](architecture.md) + +## Product model + +| Surface | Primary user | Responsibility | +|---|---|---| +| Workspace | Employee | Create and review missions/teams, connections, and deliverables | +| Operator Console | Operator/admin | Govern the fleet and integrations | +| Audit | Auditor/admin | Verify retained evidence without mutation rights | + +Bridge is additive. Kars remains independently installable and usable. diff --git a/bridge/docs/SUMMARY.md b/bridge/docs/SUMMARY.md new file mode 100644 index 000000000..d6ca6a98a --- /dev/null +++ b/bridge/docs/SUMMARY.md @@ -0,0 +1,36 @@ +# Kars Bridge documentation + +- [Documentation home](README.md) + +## Get started + +- [Compatibility](compatibility.md) +- [Private-preview quickstart](quickstart.md) +- [Deployment](deployment.md) +- [Identity](identity.md) +- [Glossary](glossary.md) + +## Concepts and workflows + +- [Architecture](architecture.md) +- [Missions and teams](missions-and-teams.md) +- [Team workflows: intent to reviewed outcome](team-workflows.md) +- [Connections](connections.md) +- [MCP servers](mcp-servers.md) +- [Providers and model routing](providers.md) +- [Skills](skills.md) +- [Approvals and egress](approvals-egress.md) + +## Operate and govern + +- [Access and roles](rbac.md) +- [Local inference](local-inference.md) +- [Inference budgets](inference-budgets.md) +- [Observability](observability.md) +- [Evidence and compliance](evidence-compliance.md) +- [Operations](operations.md) +- [Troubleshooting](troubleshooting.md) + +## Contribute + +- [Documentation guide](contributing.md) diff --git a/bridge/docs/approvals-egress.md b/bridge/docs/approvals-egress.md new file mode 100644 index 000000000..22f52531b --- /dev/null +++ b/bridge/docs/approvals-egress.md @@ -0,0 +1,45 @@ +# Approvals and egress + +Bridge presents governance requests in a shared inbox. Approval is a +resource transition, not a client-side button state. + +## Approval integrity + +- Actor and roles come from the verified session. +- Decisions use resource-version compare-and-swap. +- Terminal decisions are immutable. +- Replays and stale generations return conflicts. +- Approval binds the current envelope or package digest. +- Self-approval is rejected where separation of duties applies. + +## Egress modes + +| Mode | Behavior | +|---|---| +| Learning | Records destinations and supports discovery; it is not equivalent to strict deny-all | +| Strict | Denies destinations not present in the signed baseline or an active approval | + +The router is the L7 enforcement point. Kubernetes NetworkPolicy and the +egress-guard contain the agent but do not replace host-level policy. + +## Request flow + +1. A mission attempts an unapproved destination or explicitly requests access. +2. Kars records a pending `KarsApproval`. +3. The request includes destination, port, task, reason, and bounded authority. +4. An operator approves or denies through Bridge. +5. The controller materializes the `EgressApproval`. +6. The router reloads the active grant. +7. Expiry or revocation restores denial. + +Approving one host must not allow adjacent domains or wildcard expansion. + +## Verification + +For a high-confidence test: + +- prove the destination did not receive traffic before approval; +- approve one exact host; +- verify that host succeeds; +- verify an adjacent host remains denied; +- expire or revoke the grant and verify denial returns. diff --git a/bridge/docs/architecture.md b/bridge/docs/architecture.md new file mode 100644 index 000000000..8793854dc --- /dev/null +++ b/bridge/docs/architecture.md @@ -0,0 +1,245 @@ +# Architecture + +kars Bridge is a **thin product layer** over the kars substrate. It owns the +*experience*; kars owns the *security and governance primitives*. + +```mermaid +flowchart LR + Browser["Browser"] -->|"same-origin /api/* rewrite"| Web["web (Next.js)"] + Web -->|"server-side BRIDGE_BFF_URL"| BFF["BFF (Rust/axum)"] + BFF -->|"privileged kube client"| Cluster["kars cluster\n(CRDs, ConfigMaps, router :8443)"] + Web -.->|"renders"| Surfaces["Workspace + Operator Console + Auditor"] +``` + +| Component | Stack | Role | +|-----------|-------|------| +| `web/` | Next.js (React, TS, Tailwind) | The product UI. Server components call the BFF directly (`BRIDGE_BFF_URL`); the browser talks same-origin and the `/api/*` rewrite proxies to the BFF (needed for the SSE telemetry stream). | +| `bff/` | Rust (axum) | Backend-for-frontend — the **only** server-side path between the browser and the cluster. Holds privileged cluster access; the browser never sees kube credentials or signing keys. | + +## Hard rule — one-way dependency + +**Bridge depends on kars; kars never depends on Bridge.** Every kars primitive +Bridge uses is usable on a plain kars cluster with no Bridge installed. Bridge +only *composes and presents* them. A handful of features required small, +general-purpose additions to the core router/controller (see +[Kars `docs/git-write.md`](https://github.com/Azure/kars) and the channel/budget +notes below) — all of them stand on their own. Private +preview releases must publish the exact compatible Kars commit; see +[Compatibility](compatibility.md). + +## How the BFF talks to the cluster + +The BFF is a Kubernetes API client. In-cluster it runs as the `kars-bridge` +ServiceAccount under a **least-privilege ClusterRole** — that Role, not the BFF +code, is the real authorization boundary: + +- **Reads** the full kars CRD surface (tasks, teams, sandboxes, policies, + receipts, …) + mission-output/trace/artifact ConfigMaps. +- **Writes** only the envelope objects it authors (KarsTask, KarsTeam) and + operator-authored governance (ToolPolicy, McpServer, KarsSkill, InferencePolicy, + KarsProfile, EgressApproval) via Server-Side Apply. +- **Secrets are write-only** — create/patch, never `get`/`list`. The Bridge never + reads a credential value back. +- **ConfigMaps** it owns (inference budgets, team task backlog, gitconfig) are + create/patch; everything else is read-only. + +See `deploy/rbac.yaml` (or the Helm chart's `templates/rbac.yaml`). + +## What Bridge stores where + +Bridge is **stateless** — its source of truth is the cluster: + +| State | Where | +|-------|-------| +| Missions / teams | `KarsTask` / `KarsTeam` CRs | +| Deliverables, traces, artifacts | mission-output / trace / artifact ConfigMaps (controller-written) | +| Inference budgets (cluster/workspace) | `kars-inference-budgets` ConfigMap | +| Team task backlog | `kars-team-tasks-` ConfigMap | +| Workspace channels | `kars-workspace-channels` secret (write-only) | +| GitHub connection | `kars-github-connection-` ConfigMap (per principal; installation/account/repos only) | +| Receipts / inclusion log | `KarsReceipt` CRs (controller-signed) | + +## Live telemetry + +The Activity view and agent graph consume ONE SSE stream per run +(`/api/namespaces/{ns}/tasks/{name}/stream`), which the BFF aggregates across the +principal sandbox **and** every sub-agent it spawned, tagging each event with the +emitting agent. See [Observability](observability.md). + +## The whole system, end to end + +Bridge sits on top of a real Kubernetes operator (kars core, `Azure/kars`) — +this section is the map that ties the pieces together: what the **controller** +does, how agents talk over the **mesh**, how **communication channels** work, +and what the **compose orchestrator** actually is (a term this product +overloads for two different things — see below). + +```mermaid +flowchart TB + Browser["Browser"] --> Web["web (Next.js)"] + Web --> BFF["BFF (Rust)"] + BFF --> API["kube-apiserver"] + API --> Controller["controller reconciles CRDs"] + Controller --> Sandbox["KarsSandbox (per agent)"] + Controller --> Team["KarsTeam (standing org)"] + Team -->|"mints on cadence / kickoff"| SandboxTask["KarsTask children"] + Sandbox --> NS["namespace + Deployment\n+ inference-router sidecar"] + NS --> Inference["Inference calls\n(via router, governed by InferencePolicy)"] + NS --> Tools["Tool/MCP calls\n(via router, governed by ToolPolicy)"] + NS --> Mesh["Mesh (agent<->agent)\n(AGT client in the agent process —\nthe router never sees plaintext)"] +``` + +### The controller — what it actually does + +`kars-controller` is a standard Kubernetes operator: it watches every kars CRD +(`KarsTask`, `KarsTeam`, `KarsSandbox`, `ToolPolicy`, `InferencePolicy`, +`McpServer`, `KarsSkill`, `KarsReceipt`, `KarsSREAction`, …) and reconciles each +towards its declared spec. Concretely, for a mission: a `KarsTask` reconcile +validates the trust envelope, then (once `execution.launch=true`) materializes +a `KarsSandbox` — which itself reconciles into a real namespace, a Deployment +(the `openclaw`/`hermes` agent container **plus** an `inference-router` +sidecar), a NetworkPolicy default-denying egress, and the governance +ConfigMaps (ToolPolicy → AGT profile, egress allowlist). Bridge never talks to +a sandbox directly — it only ever reads/writes CRDs; the controller is the +only thing that touches Deployments, NetworkPolicies, and Secrets. + +Full CRD reference + the "why ten (now more) CRDs, not one" rationale: +[kars docs → Architecture § CRDs as the API](https://github.com/Azure/kars/blob/main/docs/architecture.md#crds-as-the-api). + +### Multi-provider inference routing — one sandbox, several providers + +Every sandbox's `inference-router` sidecar holds credentials for **one +default** provider (from cluster setup) but can ALSO carry any number of +**additional** providers — connected from the same Configuration → Inference +provider wizard (its "Where it applies" step asks whether a given connection +is the cluster default or an additional one; e.g. GitHub Copilot as the +default, Azure AI Foundry and GitHub Models also connected). All of them +reach every sandbox — which one a specific request actually uses is decided +**per request** by that sandbox's `InferencePolicy.modelPreference.primary. +provider`, never by what's merely present in the environment. A +cross-provider fallback chain (`modelPreference.fallback[]`) also works — a +5xx/429 on the primary provider's model retries the next entry, on ITS OWN +provider if named. + +```mermaid +flowchart LR + Secret["kars-inference-providers Secret\n(kars-system — one shared, cluster-wide)"] -->|"mirrored (same as kars-github-app)"| RouterEnv["every sandbox's own namespace\n→ router envFrom"] + RouterEnv --> Router["inference-router"] + Policy["InferencePolicy.modelPreference\n(per-sandbox)"] -->|"primary.provider"| Router + Router -->|"provider resolved"| Foundry["Azure AI Foundry\n(Workload Identity / IMDS / dev key)"] + Router -->|"provider resolved"| Copilot["GitHub Copilot\n(GH token → exchanged Copilot JWT)"] + Router -->|"provider resolved"| Models["GitHub Models / custom\n(direct token)"] +``` + +The Secret's keys ARE the literal env var names the router parses +(`KARS_PROVIDER__ENDPOINT`/`_API_KEY`/`_TOKEN`, or the well-known +`COPILOT_GITHUB_TOKEN` for the Copilot special case) — connecting a new +provider from the Bridge UI needs no router or controller code change. +Credentials live ONLY in the router container (UID 1001); the agent never +receives them. + +**Known limitation — no live rollout trigger.** Environment variables sourced +from a Secret (`envFrom`) are read once at container start; Kubernetes does +not hot-reload them into a running process. Connecting/removing an +additional provider updates the shared `kars-inference-providers` Secret +immediately, but an **already-running** sandbox's router won't see the change +until its pod restarts (a fresh mission/team launch always gets the current +Secret, since it's a brand-new pod). There's no automatic rollout-restart of +in-flight sandboxes today — if you need an existing long-running sandbox to +pick up a newly connected provider immediately, restart its pod +(`kubectl delete pod` — the Deployment recreates it) rather than waiting. + +### The mesh — how agents actually talk to each other + +When a mission spawns a sub-agent (or a team's members need to hand off work), +the two agents do **not** talk through the controller or the BFF. Each agent +process runs an AGT mesh client (TypeScript for OpenClaw, Python for Hermes) +that registers identity, runs X3DH key exchange, and opens a **Signal-Protocol, +end-to-end-encrypted** session over the AgentMesh relay. The +`inference-router` sidecar is **not** part of this path for message content — +it only proxies inference/tool calls; the relay and the router both see +opaque ciphertext, never plaintext. This is why the Bridge's mesh topology +graph (Operator Console → Sandboxes) can show *that* two agents are linked +(a proven parent→child delegation) but never *what* they said. + +```mermaid +sequenceDiagram + participant P as Parent agent process + participant Relay as AgentMesh relay + participant Registry as AgentMesh registry + participant C as Child agent process + P->>Registry: register identity + prekeys + C->>Registry: register identity + prekeys + P->>Registry: fetch child's prekey bundle + P->>P: X3DH key agreement (Double Ratchet init) + P->>Relay: KNOCK (opaque ciphertext) + Relay->>C: forward KNOCK (relay never decrypts) + C->>C: complete X3DH, derive shared session + C->>Relay: session accepted (ciphertext) + Relay->>P: forward accept + Note over P,C: Every further message is Double-Ratchet-encrypted.
Relay + inference-router both see ciphertext only. + P->>Relay: task_request (ciphertext) + Relay->>C: forward + C->>Relay: task_response (ciphertext) + Relay->>P: forward +``` + +Full mechanics (KNOCK, Double Ratchet, trust thresholds): +[kars docs → Architecture § The mesh](https://github.com/Azure/kars/blob/main/docs/architecture.md#the-mesh). + +### Communication channels — proactive reporting to humans + +Separate from the mesh (agent↔agent) and A2A (cross-org), **channels** +(Telegram, Slack, Discord, WhatsApp) are how an agent reports to a **person**. +Wired once per workspace (Workspace → Connections), the controller copies the +resulting write-only secret into every run sandbox's credentials before the +pod starts, so any harness's entrypoint picks it up — agent-agnostic by +design, not per-harness plumbing. Today Telegram is wired for **proactive** +push (the agent decides to send an update); Slack/Discord/WhatsApp are +**inbound conversational** (you message the agent, it replies). Full +mechanics + the exact env keys each channel reads: +[Connections](connections.md). + +### The compose orchestrator — two different things, one confusing name + +"Orchestrator" means two unrelated things in this product, and that overload +is itself a common source of confusion: + +1. **Team charter loop (kars core, `KarsTeam` reconciler).** A standing team + is long-lived governance over short-lived work: the controller mints a + fresh `KarsTask` from the team's charter either on a configured cadence, on + an explicit "Run now", or — for a **cadence-less** team — exactly once as a + kickoff run when the team is first created (so standing up a team always + produces work, never sits silently idle). This has nothing to do with LLM + calls the Bridge itself makes; it is the controller autonomously running + your team's mandate on a schedule. + +2. **The compose orchestrator (Bridge-side, "intent → package").** The + Workspace's "What do you want done?" composer turns a plain-language + objective into a *proposed* governed launch package (model, tools, budget, + isolation) by making its own LLM call — constrained to only reference real + building blocks this cluster actually offers (`/api/options`); nothing is + provisioned until you review and click Launch. That LLM call needs + somewhere to run, and there are exactly two paths, health-checked live on + the Console home page: + - **Direct** — `BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL}` set, calling + your inference provider straight from the BFF. Scales independently of + any sandbox; recommended once you have concurrent teams. + - **Sandbox fallback** — routes through the standing `bridge-orchestrator` + sandbox's own inference-router. This is what a fresh install gets for + free with zero extra config, at the cost of a single-sandbox dependency. + + Neither path is "the orchestration engine" in the sense of running your + missions — composing a package and launching it are two separate steps; + compose only ever proposes. + +```mermaid +flowchart TD + Intent["What do you want done?\n(plain-language objective)"] --> Compose["Compose orchestrator\n(Bridge-side LLM call)"] + Compose -->|"constrained to /api/options"| Package["Proposed launch package\n(model, tools, budget, isolation)"] + Package -->|"you review + click Launch"| Provision["KarsTask created\n(nothing provisioned before this)"] + + Compose -.->|"needs an LLM call to run"| Decide{"BRIDGE_ORCHESTRATOR_*\nenv set?"} + Decide -->|"yes"| Direct["Direct path\ncalls your inference provider\nstraight from the BFF"] + Decide -->|"no"| Fallback["Sandbox fallback\nroutes through the standing\nbridge-orchestrator sandbox's router"] +``` diff --git a/bridge/docs/compatibility.md b/bridge/docs/compatibility.md new file mode 100644 index 000000000..fcf39fa43 --- /dev/null +++ b/bridge/docs/compatibility.md @@ -0,0 +1,110 @@ +# Compatibility + +Bridge is version-coupled to Kars APIs, but is not a required core component. +Deployments must record the qualified Kars and Bridge source revisions and +resolved image digests. + +## Current publication boundary + +The complete application lives under `bridge/` in **Azure/kars**, with +publication PRs targeting **`kars-bridge`**. This is an integration preview, +not a release or a recommendation to install an unqualified candidate. +Publishing source does not publish images or qualify all product workflows. + +Kars core builds, installs, runs and upgrades without Bridge. Bridge retains +its own Rust/npm packages, lockfiles, container images and Helm release. +Installing or removing Bridge must preserve core resources and customer data. +The existing `/sandbox` filesystem is ephemeral (`emptyDir`); source publication +does not introduce persistent agent workspaces. + +Normal core CI remains independent of application builds. Separate Bridge CI +qualifies its components and add-on lifecycle. Native qualification builds core +and BFF from the **same immutable monorepo commit**; `CORE_REVISION` must equal +the checked-out commit. Both real API/admission and runtime/TLS/CNI jobs must +pass. A green component build or API readiness probe cannot replace them. + +## Qualification status + +The import is not yet release-qualified. Historical private-preview runs used +different source and images; they are not acceptance evidence for this +monorepo candidate. + +| Platform | Current candidate | +|---|---| +| AKS | No same-candidate end-to-end qualification yet | +| Local kind | Component/chart regressions available; native acceptance required | +| EKS | No same-candidate end-to-end qualification | +| GKE | No same-candidate end-to-end qualification | +| Other Kubernetes | Untested | + +## Release requirements + +Application source can be reviewed while dependent core PRs are qualifying. +Before merging the application candidate, its core dependencies, application +checks, security reviews and same-candidate native gates must all be complete. +Do not bypass failed gates to make the import appear release-ready. + +The complete standing-Team journey additionally requires the compatible +governed-delivery producer and authenticated runtime transport: proposal, +approved launch, recurring work, per-agent evidence, review, revision and +restart recovery. Those behaviors are not established merely by importing +their Bridge consumers. See [Team workflows](team-workflows.md) for the product +contract, not a claim that every path is qualified on this integration branch. + +Each release must record the exact qualified source commit, resolved component +image digests, runtime/provider configuration, platform and acceptance outcomes. +The broad chart version alone is not a compatibility guarantee. Registry +publication, deployment and promotion to `main` are separate from integration +source publication. + +The chart retains legacy preview image repository defaults for configuration +compatibility. These are not advertised public artifacts. Build the application +images and set operator-controlled repositories explicitly as described in +[Deployment](deployment.md). + +## Required Kars capabilities + +Bridge requires the Kars APIs and controller behavior for: + +- `KarsSandbox`, `KarsTask`, `KarsTeam`, `KarsProfile`, `KarsSkill`; +- `KarsApproval`, `EgressApproval`, `KarsReceipt`; +- managed and external `McpServer`; +- `InferencePolicy`, `ToolPolicy`, `KarsMemory`, `KarsEval`, and `KarsSREAction`; +- team commons, task retention, runtime selection, and parent-scoped spawn; +- router telemetry and task artifacts. + +Installing against an older public Kars release may render successfully but +fail at runtime if those APIs or fields are absent. The BFF readiness probe +therefore performs a read-only, limited list against each required +`kars.azure.com/v1alpha1` API in the configured namespace before the BFF pod +becomes Ready. Missing APIs, missing list permission, authentication errors, +throttling, server/transport errors, invalid responses, and a five-second total +timeout all produce HTTP 503. Kubernetes gives this probe ten seconds; +`/healthz` remains independent process liveness. Detailed failures are logged +server-side, not exposed in the readiness body. + +This is a necessary API/RBAC guard, **not proof of controller health, schema +fields, or behavioral compatibility**. Every release still needs the exact +qualified Kars commit and image digests; offline chart and readiness regressions +do not replace that runtime qualification. + +Microsoft Teams is optional: its gateway defaults to zero replicas and missing +tenant credentials do not block a compatible Kars-backed web deployment. +Teams credentials are not readiness prerequisites. Local development without +any Kars connection can serve `/healthz`, but correctly remains unready. + +## What “portable” means + +The Bridge workloads themselves use standard Deployments, Services, +NetworkPolicies, Secrets, RBAC, and optional Ingress. Cloud integration is +still environment-specific: + +- image registry and pull identity; +- ingress controller and TLS; +- Kars inference identity; +- CNI NetworkPolicy behavior; +- local-inference stack; +- storage and observability. + +Do not claim EKS/GKE support until the full identity, MCP, mission, team, and +failure-recovery matrix has run live on those platforms. diff --git a/bridge/docs/connections.md b/bridge/docs/connections.md new file mode 100644 index 000000000..c589fb2e6 --- /dev/null +++ b/bridge/docs/connections.md @@ -0,0 +1,85 @@ +# Connections + +The **Connections** tab is where a signed-in user connects services for work +they create. GitHub connections are principal-scoped; channels remain +workspace-scoped. No agent ever handles a raw credential. + +## GitHub (keyless pull requests) + +Install the kars GitHub App on the repositories you want your agents to work on, +then **Connect**. The admin-configured App is shared, but every authenticated +principal gets an isolated ConfigMap named from a SHA-256 hash of their immutable +subject. It stores only the installation id, account, and reachable repos. + +When a mission or team needs to push, the router mints a short-lived, +repo-scoped GitHub App token and injects it at a loopback proxy; the agent uses +ordinary `git` and `github.com` URLs and never sees a token. Disconnect revokes +instantly. Granting write access to a mission/team is done from the composer's +**Pull request access** control. The BFF rejects any requested repo outside the +signed-in principal's grant and derives the connection reference server-side. + +Engineering intake can also read Dependabot vulnerability, code scanning, and +secret scanning alerts. Configure the shared GitHub App with read access to all +three alert families in addition to Metadata, Checks, and Commit statuses. +Existing installations must approve newly added permissions. A repository must +also have the corresponding GitHub security product enabled. Bridge reports +missing permission/feature access as partial or unavailable; it never treats a +403/404 as a successful scan with zero findings. Secret values are never stored +in backlog tasks or status. + +Full mechanics are documented in `docs/git-write.md` in the compatible Kars +checkout. The page is not yet available on the public Kars `main` branch. + +## Channels (agent-agnostic) + +Wire **Telegram, Slack, Discord, WhatsApp, or Microsoft Teams** once for the workspace. Any +mission or team can then report progress and deliverables over them — regardless +of harness. + +> **Proactive vs inbound.** Today an agent can **proactively** push status/ +> deliverable updates over **Telegram** (the `telegram_status` tool). Slack, +> Discord, and WhatsApp are wired as **inbound conversational** channels (the +> agent replies to messages you send it), not proactive push. Telegram proactive +> reporting also requires the **allowed chat IDs** (`TELEGRAM_ALLOW_FROM`) — set +> them when connecting, or the agent has no one to send to. + +Microsoft Teams uses a dedicated gateway rather than exposing bot credentials +to agent sandboxes. It supports inbound team commands, proactive progress and +approval cards, and distinct Approve / Request changes / Deny decisions. + +- **Write-only tokens.** The token is typed into a password field and written + straight into the `kars-workspace-channels` secret (`kars-system`); the API + never echoes it back. `GET` only reveals which channels are *enabled*. +- **Agent-agnostic propagation.** The controller copies `kars-workspace-channels` + into **every** run sandbox's `-credentials` secret (mounted via + `envFrom optional`) before the pod starts, so any agent's entrypoint wires up + the channel from it. A standing team may still layer its own + `kars-team-channel-` secret on top (team keys win). +- **Teams isolation.** Teams client credentials and the Entra-to-Bridge identity + map live only in `kars-bridge-teams`; they are mounted by the gateway and BFF, + never copied into sandbox credentials. An admin maps each Teams Entra object + ID to that person's immutable Bridge OIDC subject. + +| Channel | Env key(s) the entrypoint reads | +|---------|---------------------------------| +| Telegram | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOW_FROM` | +| Slack | `SLACK_BOT_TOKEN` | +| Discord | `DISCORD_BOT_TOKEN` | +| WhatsApp | `WHATSAPP_ENABLED` | +| Microsoft Teams | Dedicated gateway secret; no agent-visible credential | + +### Microsoft Teams prerequisites + +1. Entra App Registration and Azure Bot resource for the tenant. +2. Bot installed in the target Teams chat/channel. +3. TLS ingress for `/api/messages`. +4. Admin-provided identity map: + `[{"entra_subject":"","bridge_subject":"","roles":["operator"],"name":"Alice"}]`. + +### API + +| Method | Path | +|--------|------| +| GET | `/api/namespaces/{ns}/channels` — which channels are enabled | +| POST | `/api/namespaces/{ns}/channels` — enable/update a channel (write-only token) | +| DELETE | `/api/namespaces/{ns}/channels/{channel}` — disable a channel | diff --git a/bridge/docs/contributing.md b/bridge/docs/contributing.md new file mode 100644 index 000000000..0c149666a --- /dev/null +++ b/bridge/docs/contributing.md @@ -0,0 +1,38 @@ +# Contributing documentation + +Bridge documentation should be usable by someone without access to session +history, private design notes, or a developer kubeconfig. + +## Page types + +- **Tutorial**: a complete learning journey. +- **How-to**: one operational task. +- **Concept**: architecture, boundaries, and rationale. +- **Reference**: exact configuration, APIs, roles, and compatibility. + +## Rules + +- State that `Azure/kars:kars-bridge` is an integration preview where availability + matters; do not infer release readiness from source publication. +- State the required Kars version/commit for deployment instructions. +- Separate persona authorization from Kubernetes ServiceAccount RBAC. +- Distinguish live-qualified support from Helm template portability. +- Never link public readers to personal fork branches. +- Never place credentials, cookies, identity seeds, private keys, or customer + resource names in examples. +- Add new pages to `docs/SUMMARY.md`. + +## Validation + +```bash +cd bridge +make check +(cd bff && cargo fmt --all -- --check) +helm lint deploy/helm/kars-bridge +helm template kars-bridge deploy/helm/kars-bridge \ + | kubectl apply --dry-run=client -f - +``` + +Any documented write path must also be tested through the deployed +`kars-bridge` ServiceAccount rather than only through a cluster-admin +kubeconfig. diff --git a/bridge/docs/deployment.md b/bridge/docs/deployment.md new file mode 100644 index 000000000..be52d9a39 --- /dev/null +++ b/bridge/docs/deployment.md @@ -0,0 +1,212 @@ +# Deployment + +Kars Bridge is an additive Helm release installed into a compatible Kars +cluster. The chart deploys: + +- the Rust BFF; +- the Next.js web application; +- the BFF ServiceAccount and ClusterRole; +- NetworkPolicies; +- optional ingress; +- optional in-cluster Dex for private-preview testing; +- optional Microsoft Teams gateway resources, with zero replicas by default. + +It does not install Kars or provision a Kubernetes cluster. +Kars remains usable without Bridge. Install the complete compatible Kars +runtime first; the public foundation PRs alone are insufficient. This is +integration-preview deployment guidance, not a public image-availability or +release-readiness claim. + +All commands below run from `bridge/` at the repository root (`cd bridge`). + +## Support statement + +| Environment | Status | +|---|---| +| AKS | Historical private-preview evidence only; current candidate not qualified | +| Local kind | Development/acceptance harness; current native gates must pass | +| EKS | Templates render; not live-qualified end to end | +| GKE | Templates render; not live-qualified end to end | +| Other Kubernetes | No blanket support claim | + +“Cloud-agnostic templates” means the workloads use standard Kubernetes APIs. +It does not mean identity, ingress, registry, inference, CNI behavior, or all +Kars features work unchanged on every distribution. + +## Prerequisites + +- A [compatible Kars installation](compatibility.md). +- Kubernetes 1.30+ when using the default Kars admission controls. +- Operator-built Bridge images and registry pull access. Legacy chart repository + defaults do not imply published public images. +- DNS and NetworkPolicy connectivity from web → BFF and BFF → Kubernetes API. +- An authentication mode selected deliberately. + +## Install + +Build the standalone application images with `make images REGISTRY=`. +The optional Teams image has a separate `make image-gateway REGISTRY=` +target. Build targets do not push images or change the cluster. Publish to your +chosen registry separately before installation. + +The default `namespace: kars-system` and `createNamespace: false` join the +Kars-owned namespace without adding it to the Bridge release. Setting +`createNamespace: true` for `kars-system` is rejected on new installs, rather +than attempting to claim or replace the core namespace. Legacy chart-owned +namespaces remain in upgrade manifests so retention can be applied safely. + +```bash +helm upgrade --install kars-bridge deploy/helm/kars-bridge \ + --namespace kars-system \ + --values my-values.yaml +``` + +Example values: + +```yaml +namespace: kars-system + +bff: + image: + repository: /kars-bridge-bff + tag: latest + pullPolicy: Always + +web: + image: + repository: /kars-bridge-web + tag: latest + pullPolicy: Always + +global: + imagePullSecrets: + - name: registry-pull +``` + +For an existing custom namespace, also leave `createNamespace: false`. For a +fresh dedicated namespace, keep it false and use Helm's `--create-namespace` +with matching chart `namespace` and Helm `--namespace` values. The namespace +then remains outside the release's resource ownership. + +`createNamespace: true` is supported for a fresh workload namespace when the +Helm release is stored in a separate existing namespace, and for upgrades of +legacy chart-owned namespaces. A new release cannot bootstrap its own storage +namespace from a chart template: Helm must store release history before applying +that template. Chart-owned workload namespaces are retained on uninstall. Chart +placement in a custom namespace does not imply every multi-workspace workflow +is qualified. + +Microsoft Teams is disabled by default (`teamsGateway.enabled: false`, rendered +replicas `0`). Its Secret references in the BFF are optional, so absent Entra +tenant/bot credentials do not block the web surface. Enable the gateway only +after its dedicated credentials and role mapping are configured; web OIDC +authentication is a separate requirement. + +## Private-preview Dex + +Dex is useful for a colleague test ring without a public ingress: + +```yaml +idp: + enabled: true + issuer: http://localhost:3000/dex + redirectURIs: + - http://localhost:3000/auth/callback +``` + +```bash +kubectl -n kars-system port-forward svc/kars-bridge-web 3000:3000 +``` + +The web application proxies `/dex/*` to the in-cluster Dex Service. Seed +password users are for private preview only. Replace them with real users or an +upstream IdP before broader use. + +For ingress, set the externally reachable HTTPS issuer and callback URI. The +issuer is a security boundary and must match exactly. + +## Production identity + +Use a real OIDC provider such as Entra ID, Okta, Auth0, Keycloak, or an +enterprise Dex deployment. Store client and session secrets in a Kubernetes +Secret; never commit them to values. + +Set `auth.principalSecretName` so the web session signing key and the BFF +principal-verification key are the same secret. External OIDC configuration +without BFF principal verification is not a production multi-user deployment. + +See [Identity](identity.md). + +## Validate + +```bash +helm lint deploy/helm/kars-bridge +make helm-test +helm template kars-bridge deploy/helm/kars-bridge --values my-values.yaml \ + | kubectl apply --dry-run=client -f - +helm upgrade --install kars-bridge deploy/helm/kars-bridge \ + --namespace kars-system \ + --values my-values.yaml \ + --dry-run=server +``` + +After installation: + +```bash +kubectl -n kars-system rollout status deploy/kars-bridge-bff +kubectl -n kars-system rollout status deploy/kars-bridge-web +kubectl auth can-i list karstasks.kars.azure.com \ + --as system:serviceaccount:kars-system:kars-bridge +``` + +Exercise at least one real mutation through the deployed BFF ServiceAccount. +Testing with a developer’s cluster-admin kubeconfig does not validate Bridge +RBAC. + +The BFF readiness probe calls `/readyz`, not `/healthz`. A 503 means at least one +required Kars API cannot be read within five seconds; inspect BFF logs for the +kind and failure. Do not bypass readiness, weaken RBAC, or remove guardrail APIs +to make an incomplete core installation appear compatible. API readiness does +not replace the [source/image and workflow qualification](compatibility.md). + +## Upgrade and rollback + +```bash +helm upgrade kars-bridge deploy/helm/kars-bridge \ + --namespace kars-system \ + --values my-values.yaml + +helm history kars-bridge -n kars-system +helm rollback kars-bridge -n kars-system +``` + +Upgrade Kars first when a Bridge release requires new CRD fields or controller +behavior. The compatibility matrix must identify the required order. + +## Uninstall + +```bash +helm uninstall kars-bridge -n kars-system +``` + +This removes Bridge workloads and chart-owned configuration without removing +Kars CRDs, missions, teams, receipts, or sandboxes. The recommended +`createNamespace: false` joins an existing namespace without owning it. For a +dedicated namespace with `createNamespace: true`, the namespace is annotated with +`helm.sh/resource-policy: keep`, so Helm leaves it behind rather than +cascade-deleting namespaced Kars resources. Remove an empty retained namespace +explicitly only after inspecting its contents. + +The retention annotation must be present in the **installed release manifest** +before uninstalling. Upgrades inspect the configured namespace's Helm ownership +and retain it if this release already owns it, even when `createNamespace` +changes to false. The Helm caller therefore needs permission to get that +Namespace. Confirm `helm get manifest` contains `helm.sh/resource-policy: keep` +before uninstalling an upgraded legacy release. Do not move a legacy release +to a different workload namespace before applying retention to its old one: +removing an unprotected Namespace from an upgrade manifest can delete it too. +Do not uninstall an old revision or roll back to it and assume the new retention +behavior applies. Helm ownership checks still +apply; never use `--take-ownership` to transfer Kars-owned +resources to Bridge. Bridge-created runtime CRs and their evidence are not Helm +resources and remain for explicit operator lifecycle management. diff --git a/bridge/docs/evidence-compliance.md b/bridge/docs/evidence-compliance.md new file mode 100644 index 000000000..4345182d1 --- /dev/null +++ b/bridge/docs/evidence-compliance.md @@ -0,0 +1,66 @@ +# Evidence, receipts, and compliance views + +Bridge presents Kars evidence; it does not turn evidence mappings into a +certification. + +## Evidence model + +A delivered task may retain: + +- mission output; +- text and binary artifacts; +- router trace and token telemetry; +- envelope digest and runtime/model identity; +- approvals and egress decisions; +- a `KarsReceipt`; +- inclusion-log metadata and verification result. + +## Audit surface + +The Audit product is read-only and self-contained. Auditors can inspect +receipts and evidence but cannot use Workspace or Console mutation APIs. + +Insights, System and Operator Audit use the same typed receipt-log snapshot +as receipt verification. The reader includes the legacy `kars-receipt-log` +head and every numbered overflow segment in `BRIDGE_CORE_NAMESPACE` (default +`kars-system`), with checkpoint, witness and published-key data from that same +API snapshot. It checks object identities, contiguous segment indices, +previous-root links, entry sequence numbers and the complete hash chain. + +A genuinely absent log or a valid empty legacy head has count zero. API errors, +malformed data, foreign responses, missing segments and broken chains return +the existing sanitized upstream-error response instead of an empty or healthy +summary. Owner-scoped Insights counts only logged inclusions for the visible +receipts, not the fleet-wide history. Cryptographic verification still requires +the signed payload/subject binding and checkpoint; a missing checkpoint cannot +produce a successful receipt-verification result. + +Unlabelled legacy heads and valid opaque legacy payload-digest strings remain +readable. Overflow follows the current canonical names and explicit index/root +metadata; arbitrary renamed segments or missing index metadata are rejected, +not guessed. A paginated/incomplete snapshot is also rejected. No storage, +writer, permissions, trust anchor or external witness is created by these reads. + +## Verification + +Receipt verification checks the recorded payload, signature scheme/key +metadata, and inclusion evidence supported by the Kars release. Hash chaining +provides tamper detection. It is not equivalent to third-party notarization or +regulatory certification. + +## Compliance mappings + +Bridge may map evidence to NIST AI RMF, EU AI Act, or other control language. +These are implementation-evidence aids, not legal conclusions or an attestation +by Microsoft. + +## Retention and export + +Operators must define: + +- mission/team retention TTLs; +- receipt and log retention; +- export to a durable evidence store or SIEM; +- key custody and rotation; +- incident preservation and legal hold; +- deletion and tenant-offboarding behavior. diff --git a/bridge/docs/glossary.md b/bridge/docs/glossary.md new file mode 100644 index 000000000..f4b78baf4 --- /dev/null +++ b/bridge/docs/glossary.md @@ -0,0 +1,18 @@ +# Glossary + +| Term | Meaning | +|---|---| +| Bridge | Private human-facing command center layered on Kars | +| Kars | Open-source Kubernetes runtime and governance substrate | +| Mission | One governed `KarsTask` | +| Team | Standing `KarsTeam` that mints task-force runs | +| Principal | The parent agent responsible for a mission/team run | +| Harness/runtime | Agent framework such as OpenClaw or Hermes | +| Trust envelope | Tier, authority, budget, delegation, tool, and egress bounds | +| MCP server | Tool provider registered through `McpServer` | +| Everything MCP | MCP reference/conformance server, not a production integration | +| Skill | Versioned, approval-bound package mounted into an agent | +| Team commons | Retained, untrusted reference data harvested from prior team runs | +| Receipt | Durable governance and delivery evidence | +| Learning egress | Observe/record mode; not strict deny-all | +| Strict egress | Exact baseline plus active approved grants | diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md new file mode 100644 index 000000000..4ba5cf928 --- /dev/null +++ b/bridge/docs/governed-credentials.md @@ -0,0 +1,418 @@ +# Private Bridge governed credential adapter + +Bridge stays private while the core contract is integrated and qualified. +This change neither publishes app source/images nor changes repository visibility. +Existing audit gates and pending review evidence remain required. + +The BFF consumes `KarsCredentialGrant/workspace`; it cannot create or expand +that operator grant. Operators enroll the actual BFF ServiceAccount UID and +existing purpose-specific store UIDs using the core CLI. Bootstrap missing +stores as explicitly selected empty Opaque objects before enrollment, never by +adopting a racing existing object. + +Set `core.namespace` independently from the chart's `namespace`. BFF/web +default workspace and provider operations use the configured core namespace; +the optional Teams Secret remains in the private Bridge integration namespace. + +The credential form now requires a target kind and workspace, and accepts an +explicit reviewed target UID. It stores a governed source without precreating +an agent namespace. Source key changes are UID/resourceVersion-fenced. Existing +legacy collections require metadata-only operator review before migration; +unsupported keys and ambiguous ownership are not silently dropped. +Agent key deletion records persistent metadata-only removal intent under the +same UID/resourceVersion fence as the value update. A new source or pending +legacy import therefore cannot reconnect a removed channel. Core applies those +tombstones after import; explicitly setting the key again removes its tombstone. + +Workspace and Team channel views use observed key-name metadata and surface +permission/identity errors rather than reporting empty/disabled configuration. +Microsoft Teams configuration remains separate from agent channels; no tenant +secret is projected into an agent. The gateway stays at zero replicas until +its enrolled configuration is complete. + +Provider, Foundry, GitHub App and dynamic provider updates use enrolled store +UIDs. Disconnect clears the owned key collection without deleting the enrolled +store. Core applies typed controller settings and Teams rollout/scale changes; +the BFF no longer patches Deployments. Learned-domain observations use only the +new declared read-only observation capability and its separate observer token. +There is no legacy admin/control-token or unauthenticated fallback. The BFF +verifies the target/namespace/Secret UID, recipient identity and Pod ownership +chain, pins the controller-issued TLS CA and UID hostname on port 9447, and +uses scope discovery followed by a scope-fenced read. + +GitHub launch bindings additionally require an operator-reviewed connection +ConfigMap UID, App-store UID/App ID, owner subject, installation and repository/ +write subset. The BFF attaches `githubBinding`; core alone issues the private +runtime App projection. An absent review is an explicit error, not a downgrade +to raw `GITHUB_TOKEN`. Existing bare-Sandbox credentials remain distinct from +repository-enforced keyless mode. Newly created keyless consumers are inactive +until an explicit empty or populated governed agent source is bound. + +Tasks and Teams are created inactive, bound to their actual CREATE UID and +source selections, then activated. Team deletion is UID/RV-fenced and leaves +core ownership/retention in charge of sources and evidence instead of sweeping +other workspaces' same-name records. + +The public workspace channel-write entrypoint preflights the complete consumer +plan **before creating or patching the source Secret**, not only before changing +consumer references. A later known consumer conflict therefore leaves source +values/UIDs and all consumers unchanged and sends no mutating API request. +New-source plans carry only a canonical name until the actual CREATE response +supplies its UID; no empty UID is synthesized and no missing/recreated source +is adopted. They do not require or attempt a Secret GET before CREATE: +uninventoried source names are intentionally unreadable to the BFF. After +complete consumer preflight, CREATE is exclusive; an existing object produces +409 and is preserved, without GET/adoption/PATCH fallback. A 403 is never +interpreted as absence and reader permissions are not widened. The BFF waits +for core enrollment/metadata acknowledgement before reading the new source. +After the source write and controller metadata acknowledgement, +current source UID/RV and all captured consumer UID/RV fences are rechecked +before binding. Concurrent changes after preflight remain explicit CAS errors; +this is not a Kubernetes multi-object transaction or rollback promise. + +`POST /api/operator/credentials` preserves a typed Kubernetes 409 as HTTP 409 +with error code `conflict`, rather than reporting a generic 502. This includes +a status-only resourceVersion conflict between the final target read and PATCH: +the captured version is not silently refreshed. The handler does not retry a +partly written transaction, adopt a colliding/replaced source, or delete a source +after an ambiguous CREATE/PATCH acknowledgement. A source may already be stored; +refresh the operator-reviewed target and core source metadata before explicitly +resubmitting through the existing form. Preflight conflicts still perform zero +mutations, and the existing retention/cleanup contract is unchanged. Other API +failures and transport/serialization failures remain errors, not conflicts or +successes. Responses and logs omit Kubernetes messages and credential values. + +## Explicit metadata review and continuation + +The credential form on **Console -> Agent capabilities** uses +`POST /api/operator/credentials/review` before storing. This operator-only read +returns metadata and an authenticated review ticket, never Secret values. It +captures target UID/generation/resourceVersion and a complete spec/identity +fingerprint, grant UID/generation/version/policy and legacy-review fingerprints, +workspace UID, key scope, and the canonical source's acknowledged UID/version +and metadata fingerprint. An uninventoried name remains CREATE-only: review +does not attempt a Secret GET to discover or adopt it. + +The existing credential POST accepts the ticket in `review`. It rechecks the +captured metadata before any source mutation and retains the reviewed target RV +at binding; it never automatically retries or rebases that PATCH. Legacy +callers without a ticket keep their existing contract and cannot obtain a +continuation receipt. + +Core enrollment may attach ownership metadata after exclusive source CREATE. +The adapter accepts that version change only when controller-owned grant status +provides `ownershipFromResourceVersion` matching its acknowledged write, with +the same source UID and target and the exact current successor version. It +rechecks live metadata and the reviewed authority before retaining the successor +acknowledgement. No source value is read or rewritten, and the target's reviewed +RV is still enforced immediately before binding. Missing, stale or unrelated +transition evidence remains a conflict; there is no compatibility fallback +that guesses a version change was harmless. + +A reviewed 409 can carry one of two signed outcomes: + +- `no-write-attempted`: no source mutation was attempted. Explicit re-review + may advance status-only target/grant versions, but the entire source review + must remain identical. +- `source-stored`: this handler obtained the real source write's UID/version. + Re-review waits for core acknowledgement, reads only source metadata, and + rejects any source UID/version/metadata/key-scope change. A confirmed resume + binds this source without another source CREATE/PATCH or value readback. + +Both outcomes require the same operator, target UID/generation/full intent, +grant policy/identity, workspace, key, and submitted value. Tickets use a +domain-separated signing key derived from the existing principal secret; +signed operator sessions are required, with no alternate credential fallback. +Only a keyed signature commitment to the value is carried, not the value or an +unkeyed value hash. Tickets expire after five minutes, never extend that expiry +on refresh, and authorize at most three separately confirmed submissions. +Each reviewed read/write operation has a 30-second bound. + +The browser does not resubmit in a catch block. It displays the outcome, requires +an explicit **Refresh and review current metadata**, displays the new versions +and unchanged intent fingerprints, and requires another confirmation and +re-entry of the same value. Changes invalidate the review. CREATE collisions, +403/422, missing receipts, tampering, expiry, lost acknowledgements and transport +failures do not authorize recovery. A completed binding and the retained source +are verified before reporting success; the operation is still not an atomic +multi-object Kubernetes transaction, and no deletion/rollback is invented. + +The native `create_delivery` case follows the same review -> submit -> explicit +re-review -> resubmit protocol rather than requiring first-attempt 200 or +retrying arbitrary 409/502 responses. It emits only fixed stage/status/boolean +facts. Its original real projection, runtime-value, ownership and subsequent +revocation assertions remain mandatory. Local orchestration tests are not +native delivery evidence: + +```bash +node --experimental-strip-types --test web/tests/credential-review.test.mjs +PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=tests/native-credentials python3 -m unittest discover -s tests/native-credentials -p test_credential_review.py +cargo test --locked --manifest-path bff/Cargo.toml --lib credential +``` + +Supported v1 `credentialsRef` Sandboxes and existing unbounded +standalone Sandboxes are not implicitly migrated; fresh or already opted-in v2 +Sandboxes remain eligible. Foreign/mixed internal references and conflicting +grant/source identities fail explicitly before conversion. Team-owned Tasks +follow core's non-destructive rebind protocol. Widening an active standalone +Task's key authority requires explicit governed rebinding, not a workspace-save +side effect. Each applied plan entry remains UID/resourceVersion fenced. + +Templates supply the new parent-map defaults themselves: old reused release +values keep observations off and core workspace `kars-system`, even if `core` or +`networkPolicy.observations` is absent. Regression tests replace chart defaults +with the exact BASE105 values and exercise real Helm `lookup` against a local +read-only API fixture. The private Next 16.3.3 manifests and verified lock remain +unchanged by these credential repairs. + +Both Helm and standalone RBAC remove broad Secret and Deployment mutation +permissions. Credential access comes from core-generated, purpose-bound Roles. +Bridge uninstall removes only add-on resources, not the grants, core workloads, +legacy stores or operator configuration. Writer retirement is separate from +source delivery: core publishes `WriterReady=False`, revokes owned read Roles, +and retains valid source/GitHub consumers. Enrolled ServiceAccount/namespace +name holds prevent reuse until revocation is complete. An operator may review +`spec.writers: []` without disabling the grant. The core controller must remain +running during add-on uninstall; do not force-remove its guards. Continuity and +revocation under uninstall/reinstall still need actual Kubernetes qualification, +not just retained-object Helm tests. + +For an already egress-isolated BFF, explicitly configure: + +```yaml +networkPolicy: + observations: + enabled: true + existingIsolationConfirmed: true + targetNamespaces: [kars-reviewed-agent] +``` + +Leave this off for an unrestricted BFF; enabling an Egress policy there would +introduce isolation. Preserve the existing Kubernetes/provider/OIDC/GitHub +baseline policy. The additive rule opens only TCP 9447 to reviewed runtime +namespaces and Sandbox Pods. It is installed in the configured **Bridge** +namespace, not the core workspace. Core checks actual sender Pods and selected +NetworkPolicies before issuance and reports a clear unavailable state when the +approved path is absent. + +The candidate needs coordinated Rust and actual Kubernetes permission/lifecycle +qualification before it is ready for use. Source/configuration checks have not +been waived. + +The approved active-SRE verifier now runs in the existing core controller. +Enable core chart `observationPrivacyRpc.enabled=true`; its private TLS port is +9448, separate from metrics. Every router observation obtains a fresh, +target/version/identity/recipient/scope/nonce-bound proof from that controller, +which validates the current canonical observer Secret and executes the full +privacy helper. No raw Secret inventory permissions, broad private Kubernetes +credential, additional sidecar, full control token or App key is given to Bridge. + +The BFF requires an unexpired observation binding with the declared verifier +capability and checks the TLS scope response's `privacy_verifier` marker. Old +controllers/routers are explicitly unavailable; there is no admin or unauthenticated +fallback. Core uses revision-selected controller Pods, pinned CA/UID hostnames +and per-target network policies. The BFF still uses only its existing private +9447 observation path, never the controller RPC as a general API. + +Core RPC/TLS/API regressions pass locally. The new BFF compatibility assertions +remain subject to the separate private Rust plan, and real Kind/CNI plus private +adapter TLS/API lifecycle acceptance are still required. Native Kubernetes GET +is name-authorized RBAC; no complete raw-GET UID-bound claim is made. + +The native observer enablement failure records `observationReadiness` alongside +`metadataAtFailure` in `native.json`. Collection is read-only and bounded to +the core controller and observation-target routers. Only the fixed core +readiness stage vocabulary, numeric HTTP status (`0` means none recorded), +timeout/connect booleans, and collection-availability booleans survive parsing. +Raw logs, span fields, exception text, tokens, identities, and response bodies +are never written to this evidence. Collection cannot qualify any assertion. +TLS negatives, 9447/9448 paths, CNI peer denial, and credential rotation remain +required unchanged. + +Labels and ServiceAccount names are only prefilters, never diagnostic +provenance. The collector anchors the canonical controller Deployment and the +specific native observation target's published namespace/Deployment UIDs, +checks actual ReplicaSet and Pod controller-owner UID chains, and requires +the current observer version on the runtime template and Pod. It rechecks +source, namespace, Deployment, ReplicaSet, and Pod UID/resourceVersion after +reading bounded logs; changes discard the sample. Missing identities, +unlinked Pods, read failures, and empty/unrecognized records remain explicitly +`available: false`. The aggregate is available only when both expected +components supply verified records; this still conveys no readiness verdict. + +This collector needs the reviewed core readiness-diagnostic candidate. Update +all existing core pins together only after core source review; an older core +can yield empty stage records, not a successful readiness verdict. The source +review should examine ordinary observer metadata API egress separately from +SRE-only egress and the disposable Cilium fixture's BFF-only API entity rule. +Neither network policy nor a publication/workflow pin is changed here. + +Run the dependency-free projection tests with: +`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests/native-credentials -p 'test_observation_diagnostics.py'`. + +### Failure-only observer API reachability experiment + +Only after the original observer-capability deadline has failed and that failure +has been saved, the disposable private `bridge-native` job may collect +`observerApiReachability`. It snapshots the installed runtime NetworkPolicies, +exact `default/kubernetes` Service/Endpoints identities and address/port pairs, +and the current UID-proven observer Pod set. Namespace ownership, Deployment and +ReplicaSet lineage, observer version, ServiceAccount UID, configured router UID +1001, container identity and restart count are checked. Unknown selector consumers +or changed provenance prevent or abort the experiment. + +The diagnostic's final Sandbox read permits status-only Prepared/Ready progress +and its resourceVersion/status-managedFields bookkeeping. Full spec, generation, +metadata (including owners, labels and annotations), and all observer binding +fields except phase/reason remain frozen, both within and across snapshots. +The final status is retained so an in-flight Ready transition is not discarded. +Other snapshot anchors and the shared fresh RPC/audit provenance readers retain +their strict UID/resourceVersion checks. + +Before policy creation, the redacted, minified kubeconfig must select the unique +`kind-bridge-native` context/cluster/user binding and an HTTPS literal-loopback +origin matching both the captured setup cluster and the actual admin client's +host/port. Proxies, TLS overrides, non-loopback endpoints and changed origins are +refused. Raw kubeconfig and credentials are never emitted; cleanup remains bound +to the same validated admin origin. + +The current experiment does **not** repeat the IP-only exception. Cilium 1.18.5 +[excludes in-cluster node identities from CIDR selectors by default](https://github.com/cilium/cilium/blob/v1.18.5/Documentation/security/policy/language.rst#L443-L458); +`policyCIDRMatchMode: nodes` is an agent/Helm setting, not a per-policy option. +The preceding exact-IP experiment's lack of progress therefore does not +exonerate CNI egress. + +The reader captures allowlisted fields from the installed `cilium-config` and +the actual UID/DaemonSet-linked Cilium agent on the observer's node. Fixed +JSONPath projections of read-only `cilium-dbg config --read-only` and +`endpoint get` return only CIDR mode/policy enablement, endpoint/security +identities, workload names, and desired/realized policy revisions. The +CiliumEndpoint CR must belong to the exact Pod UID and match its Pod/node +addresses. Agent recreation, restart, endpoint rebinding or configuration +changes abort the comparison. Contradictory installed/effective configuration +prevents policy creation rather than guessing how a non-default cluster works. +Only baseline namespace CNP identities/spec digests are retained, not arbitrary +policy descriptions or status messages. Baseline KNPs and CNPs remain unchanged; +CNP status bookkeeping may advance without changing identity or authority. + +If the baseline cannot complete, `baselineStoppingStage` and `baselineSnapshot` +identify the exact read, identity check, CLI execution/framing, field parse or +pin comparison that stopped it. Only fixed stage names, allowlisted shape +classes, actual HTTP/exit status, bounded counts and boolean comparisons are +retained. Already validated network/Pod facts survive a later Cilium failure, +but are explicitly historical: `networkValidated: true` does not imply +`complete: true` or authorize an intervention. Unknown exception text, API +bodies, CLI stderr and raw configuration/projection values are not retained. + +The mode-format witness distinguishes JSON `null`/`[]`, empty output, +``/``, and malformed values without converting unknown/missing +output into a default. A credential-free offline control exercises the existing +kubectl JSONPath engine when available; it is not execution of the pinned +Cilium CLI or proof of the failed native run's cause. No policy is created +unless the complete, unchanged provenance/configuration fences succeed. + +The tagged implementation distinguishes map presence from rendering: +[`evalField`](https://github.com/cilium/cilium/blob/v1.18.5/vendor/k8s.io/client-go/util/jsonpath/jsonpath.go#L392-L430) +retains a valid map entry even when its value is nil, but produces no result +for a missing key under `AllowMissingKeys(true)`. For the declared config-map +value types, [`PrintResults` and `evalToText`](https://github.com/cilium/cilium/blob/v1.18.5/vendor/k8s.io/client-go/util/jsonpath/jsonpath.go#L145-L182) +render a nil interface as `null` (the explicit scalar-printer branch at lines +570–578) and an empty slice as `[]`; this is not a claim that the outer Cilium +printer JSON-marshals every scalar. The offline control also omits each required +field with missing-key tolerance enabled: delimiters remain, but the missing +field's token is empty and is rejected. Bare ``, empty tokens and unknown +values remain unaccepted; `requiredTokensPresent` is a format fact, not a +substitute for the fixed-field schema or the agent/config identity fences. + +One CREATE-only, Deployment-owned **namespace-scoped CiliumNetworkPolicy** +permits only `toEntities: [kube-apiserver]` and the observed TCP HTTPS ports +443/6443, as described by the +[tagged entity semantics](https://github.com/cilium/cilium/blob/v1.18.5/Documentation/security/policy/language.rst#L247-L288). +It uses the actual Sandbox and Pod-template-hash labels, never an empty/global +selector, `host`, `remote-node`, `cluster`, `world`, `toServices`, or a global +Cilium configuration change. An observer without existing matching Kubernetes +egress isolation gets no new policy. A 60-second observation deadline bounds +the loop; API/agent reads and cleanup retain their own transport bounds. +The existing metadata-audit projector observes only the same Pod's first +Sandbox GET. Any observed API response, including 403, is diagnostic transport +progress; observer readiness is not required. The policy is removed with +its captured UID and current resourceVersion, and absence is verified. Replaced +namespaces or policy objects are not deleted; unverified cleanup is explicit. + +This is correlation evidence, not a production fix or CNI acceptance. Policy +revision observations alone are not proof that a particular rule was realized, +and Kubernetes object removal is not a claim about datapath convergence. No RBAC, TLS, +iptables, admission or existing policy is changed. Raw bodies, environment +values, headers, query URLs and arbitrary log data are not retained. The original +case remains failed and its dependent TLS/CNI/rotation cases remain blocked, +even if the same Pod advances during this probe. The original acceptance +deadline and core pin are unchanged. + +Run the dependency-free provenance, fencing and cleanup tests with: +`PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests/native-credentials -p 'test_*.py'`. + +### Actor-scoped native API outcomes + +Only the two failing native cases collect `actorApiOutcomes`: credential +delivery selects the BFF writer and its captured Sandbox CREATE UID; observer +enablement selects the actual observer router and captured target UID. Both +reuse Deployment→ReplicaSet→Pod ownership checks and additionally bind the +current ServiceAccount UID. All source UID/resourceVersions are checked again +after collection; a change discards the evidence. + +The reader consumes at most the last 4 MiB of the already configured, +Metadata-only disposable audit file. It does not change audit policy or save +the raw tail. It retains only case-window `ResponseComplete` events whose +ServiceAccount name/UID **and bound Pod UID** match the resolved actor. +Impersonated requests, other Pods sharing the account, missing Pod binding, +foreign targets, subresources, and non-allowlisted GVRs/verbs are excluded. +For the router, only its first Sandbox GET is selected. For the BFF, only the +captured delivery workspace's grant/namespace reads, target GET/PATCH, and +canonical source CREATE/GET/PATCH are selected. A CREATE denied before its +name is decoded may yield an unnamed Secret CREATE outcome in that workspace; +that is not evidence of source creation, source UID, or persisted values. + +Output is limited to actor/category/availability, fixed GVR and verb, actual +numeric HTTP status, status category, and occurrence count. Request/response +bodies, headers, tokens, raw URLs, actor/resource names and UIDs, and diagnostic +messages are not exported. `source-unavailable`, `audit-unavailable`, and +`no-matching-evidence` remain distinct from retained successful or denied +responses. A bounded tail, missing authentication identity, or missing bound +Pod claim can hide events; no-match is **not** proof of network denial or of +HTTP success. These facts cannot qualify a test or replace TLS/CNI negatives. + +### Bounded reachability conclusion at core `cb38649` + +The native artifact identifies a current observer Pod/Deployment with Ready +containers, and repeated router failure/cancellation at the first Sandbox +metadata GET. It records no HTTP status for that GET. Health probes do not +exercise this metadata path. + +* `controller/src/reconciler/mod.rs` configures the router as UID 1001 with the + runtime `sandbox` ServiceAccount. The guard in `reconciler/pod_spec.rs` applies + its redirect/drop rules only to UID 1000. Consequently, the generated guard + does not redirect the router's Kubernetes client through agent port 8444. + Actual installed rule state and process identity were not retained. +* `inference-router/src/service_observation.rs` builds the Kubernetes client + using `kube::Config::incluster()`. This metadata request uses Kubernetes + in-cluster authentication, not the separate observation bearer used on + private 9447/9448. Reaching the GET stage proves local observer binding checks + and client construction completed, not successful API TLS/token acceptance. +* The ordinary runtime's pod-level NetworkPolicy permits external HTTPS while + excluding private ranges. Exact API Service/endpoint egress is added only + when `sre_projection.is_some()`. Observation-specific additions in + `credential_grants/observer_metadata.rs` permit 9447/9448, not API HTTPS. + The native fixture uses Cilium 1.18.5 with normal kube-proxy; its explicit + `kube-apiserver` entity rule selects the **BFF**, not the runtime. +* Source therefore does not establish an ordinary observer API path. The + retained artifact nevertheless contains neither effective CNI policy/flow + verdicts nor installed UID-rule state, and cannot prove which layer caused + the GET failure/cancellation. Actor-bound API outcomes can establish that a + specific request reached and completed at the API server; their absence + cannot isolate networking from pre-HTTP credential/client failure. + +No egress, TLS, audience, readiness, or authorization exception is introduced. +The separate BFF credential POST failure occurs before observation enablement +installs its BFF Cilium rule, so that later rule cannot explain the earlier +502. The operator handler maps upstream errors to 502; the new scoped outcomes +are intended to distinguish actual API failures without exporting error bodies. diff --git a/bridge/docs/identity.md b/bridge/docs/identity.md new file mode 100644 index 000000000..6ee3cedf1 --- /dev/null +++ b/bridge/docs/identity.md @@ -0,0 +1,88 @@ +# Identity and sign-in + +Bridge supports standards-based OIDC and an optional in-cluster Dex deployment. + +## OIDC flow + +1. The browser starts Authorization Code + PKCE. +2. The provider authenticates the user. +3. Bridge verifies issuer, audience, signature, nonce, and callback state. +4. Provider groups/roles map to Bridge roles. +5. Bridge issues a signed session cookie. +6. The web layer propagates a signed principal assertion to the BFF. +7. The BFF independently verifies that assertion before authorizing routes. + +## Required configuration + +| Variable | Purpose | +|---|---| +| `BRIDGE_OIDC_ISSUER` | Provider issuer URL | +| `BRIDGE_OIDC_CLIENT_ID` | OIDC client | +| `BRIDGE_OIDC_CLIENT_SECRET` | Confidential-client secret | +| `BRIDGE_SESSION_SECRET` | Signs Bridge sessions | +| `BRIDGE_PRINCIPAL_SECRET` | BFF verification key; must equal the web session secret | +| `BRIDGE_OIDC_ROLE_CLAIM` | Claim containing groups/roles | +| `BRIDGE_OIDC_ROLE_MAP` | Provider claim to Bridge role mapping | + +Use Kubernetes Secrets or an external-secret controller. Never place secrets in +Helm values committed to source control. + +For external OIDC, create one shared signing Secret and wire it to both +components: + +```yaml +auth: + principalSecretName: kars-bridge-principal + principalSecretKey: session-secret + +web: + extraEnv: + - name: BRIDGE_OIDC_ISSUER + value: https://id.example.com + - name: BRIDGE_OIDC_CLIENT_ID + value: kars-bridge + - name: BRIDGE_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: kars-bridge-oidc-client + key: client-secret +``` + +The chart injects `BRIDGE_SESSION_SECRET` into web and +`BRIDGE_PRINCIPAL_SECRET` into the BFF from `auth.principalSecretName`. +Without the BFF secret, the BFF intentionally falls back to local-development +authorization and the deployment is not a valid multi-user configuration. + +## Dex private preview + +The chart can deploy Dex with static password users. The web proxies `/dex/*` +so a single port-forward is enough: + +```bash +kubectl -n kars-system port-forward svc/kars-bridge-web 3000:3000 +``` + +The default issuer is `http://localhost:3000/dex`. This split-horizon design is +for colleague testing over port-forward. In-cluster browsers cannot follow that +localhost redirect because localhost refers to the browser pod itself. + +For ingress, set an HTTPS issuer and callback URI reachable by users and Dex. + +## Sessions and logout + +- Session cookies are signed. +- Secure cookies are used when the forwarded scheme is HTTPS. +- Logout uses a non-preserving redirect so the browser does not repeat the POST. +- Unauthenticated protected routes redirect to login; they do not fall back to + a privileged local persona. + +## Production checklist + +- Replace seed passwords. +- Configure real groups and fail-closed role mapping. +- Rotate client and session secrets. +- Set HTTPS issuer and redirect URIs. +- Define session lifetime and emergency revocation. +- Test each persona using direct deep links, not only navigation menus. +- Test direct BFF API denial: an auditor token must receive 403 for + `/api/namespaces//tasks`. diff --git a/bridge/docs/inference-budgets.md b/bridge/docs/inference-budgets.md new file mode 100644 index 000000000..852810108 --- /dev/null +++ b/bridge/docs/inference-budgets.md @@ -0,0 +1,68 @@ +# Inference budgets + +Bridge adds a **hierarchy** over inference token spend, above the per-sandbox +`InferencePolicy` the kars controller already compiles and the router already +enforces (429 per sandbox): + +```mermaid +flowchart LR + Cluster["Cluster\n(aggregate cap)"] --> Workspace["Workspace (namespace)\n(per-namespace cap)"] + Workspace --> User["User (creator)\n(per-user cap)"] + User --> Sandbox["Sandbox\n(router 429)"] +``` + +Configure it in **Operator Console → Policies → Inference budgets**. +The UI reserves cluster/workspace increases for admins. The current BFF groups +many mutation routes under the operator persona, so deployments that require a +hard admin boundary must verify server-side enforcement for their release. See +[RBAC](rbac.md). + +## Enforcement modes + +Each level has one of three modes: + +| Mode | Behavior | +|------|----------| +| **passive** | Never blocks. Raises an alert when over budget (the meter turns amber). | +| **buffer** | Allows up to `limit × (1 + bufferPercent/100)`, then blocks. An admin must raise it to proceed. | +| **strict** | Blocks at 100% of the limit. Only an admin can raise it. | + +## How it's measured & enforced + +- **Measured** daily (UTC) from completed run outputs — the same `totalTokens` the + efficiency engine reads — aggregated per cluster and per namespace. +- **Enforced** by the Bridge at the point work is launched: `create_task` (on + launch), `create_team` (on launch), and `run_mission` call + `enforce_launch_budget(cluster, ns)`. A `strict`/over-buffer level returns + `422` with an admin-raise message; `passive` always passes (and surfaces as an + alert). The per-sandbox cap is separately enforced by the router. +- **Stored** in the `kars-inference-budgets` ConfigMap (`kars-system`), key + `budgets.json`: + ```json + { + "cluster": { "daily_tokens": 1000000, "mode": "buffer", "buffer_percent": 20 }, + "workspaces": { + "kars-system": { "daily_tokens": 300000, "mode": "strict", "buffer_percent": 0 } + } + } + ``` + +## Editing policies + +The **per-sandbox** policies (`InferencePolicy` CRs) are mostly +controller-generated from each mission's budget. On the same page an operator can: + +- **author** a standalone `InferencePolicy` (a selector + token budget + + content-safety floor) — `PUT /api/operator/inferencepolicies`; +- **edit** a policy's daily budget in place — `PATCH …/inferencepolicies/{name}`; +- **remove** an authored one. + +## API + +| Method | Path | +|--------|------| +| GET | `/api/operator/inference-budgets` — hierarchy + live measured usage | +| PUT | `/api/operator/inference-budgets/cluster` — set/clear the cluster cap | +| PUT | `/api/operator/inference-budgets/workspaces/{ns}` — set/clear a workspace cap | + +Utilization also surfaces on **Insights → Budget utilization** (live meters). diff --git a/bridge/docs/local-inference.md b/bridge/docs/local-inference.md new file mode 100644 index 000000000..31f94710d --- /dev/null +++ b/bridge/docs/local-inference.md @@ -0,0 +1,51 @@ +# Local inference with AI Runway + +Bridge discovers and manages Kars-compatible in-cluster model deployments +through AI Runway. Bridge does not install AI Runway or KAITO. + +## Responsibilities + +| Component | Responsibility | +|---|---| +| AI Runway / KAITO | ModelDeployment CRDs, engine selection, GPU/CPU workload | +| Kars | Router path, identity, NetworkPolicy, model policy, agent runtime | +| Bridge | Discovery, deployment UX, progress, catalogue, default selection | + +## Prerequisites + +- AI Runway installed by the cluster operator. +- KAITO or another supported provider/engine. +- GPU nodes and drivers for GPU models. +- Registry and model artifact access. +- A Kars local-inference target matching the model namespace, labels, and ports. + +## Workflow + +1. Install AI Runway/KAITO using their supported installation process. +2. In Console → Configuration, select **AI Runway (in-cluster)**. +3. Bridge detects the API and lists supported models. +4. Deploy a model from the catalogue. +5. Follow live status until the endpoint is ready. +6. Set the model as default or select it in a launch package. +7. Run a mission and correlate router logs with model-server/GPU telemetry. + +## Security + +The agent calls only its loopback router. The controller emits a precise +router-to-model NetworkPolicy target. The model service does not require a +credential to be present in the agent container. + +## Operations + +Plan for: + +- model cold-start time; +- GPU capacity and taints/tolerations; +- model cache/storage; +- node and model cost; +- rollout and deletion; +- model provenance and image pinning; +- health and queue saturation. + +Bridge’s catalogue UX is not a replacement for operating the underlying model +platform. diff --git a/bridge/docs/mcp-servers.md b/bridge/docs/mcp-servers.md new file mode 100644 index 000000000..2b75c099e --- /dev/null +++ b/bridge/docs/mcp-servers.md @@ -0,0 +1,75 @@ +# MCP servers + +Bridge manages the Kars `McpServer` catalog and exposes two distinct modes. + +## Managed MCP + +Bridge creates a typed `McpServer` that selects a controller-owned preset. Kars +deploys the workload, Service, probes, NetworkPolicy, and registry credentials. +The controller does not accept an arbitrary image from the user-facing CR. + +### Playwright + +The managed Playwright preset provides isolated browser automation. It is a +meaningful integration for navigation, interaction, screenshots, and page +evaluation. + +### Everything + +Everything is the MCP reference server. It exposes deterministic protocol +features such as: + +- echo and sum; +- structured content and annotations; +- resources and links; +- logging and subscriptions; +- long-running operations. + +It exists to prove that generic MCP deployment, discovery, schemas, +namespacing, forwarding, and session recovery work. It is **not** a production +business integration. + +## External MCP + +External mode registers an existing Streamable HTTP endpoint. Bridge does not +deploy it. The operator configures: + +- URL and production mode; +- OAuth or router-held bearer authentication; +- allowed tools; +- allowed sandbox scope; +- optional cross-namespace access. + +## Readiness + +An MCP is ready only when: + +1. the workload and Service are ready, if managed; +2. `initialize` succeeds; +3. `notifications/initialized` succeeds; +4. `tools/list` succeeds; +5. the observed generation is current; +6. the tool count and schema digest are recorded. + +A running pod is not sufficient. + +## Mission and team use + +The composer lists only current, namespace-compatible MCP resources. Required +MCP readiness participates in preflight; a missing or stale required server +blocks launch rather than silently dropping tools. + +Tools are namespaced as `.`. + +## Security + +- Agents do not receive MCP credentials. +- ToolPolicy and MCP allow-lists both apply. +- Server-scoped AGT action verbs are used for governance. +- Strict sandbox egress does not require a broad destination grant; the + controller derives the router-to-MCP NetworkPolicy path. +- Tool results remain untrusted input to the model. + +## Troubleshooting + +See [Troubleshooting](troubleshooting.md#managed-mcp). diff --git a/bridge/docs/missions-and-teams.md b/bridge/docs/missions-and-teams.md new file mode 100644 index 000000000..25d9af2d8 --- /dev/null +++ b/bridge/docs/missions-and-teams.md @@ -0,0 +1,60 @@ +# Missions & teams + +Bridge runs work as either a one-shot **mission** or a standing **team**, both on +the same governed kars substrate. + +## Missions (one-shot) + +A mission is a `KarsTask` with a trust envelope (tier, budget, tools, egress, +delegation depth). The composer turns a plain-language objective into an editable +launch package; on launch the controller materializes a sandbox, and the run +delivers the objective into the agent's loop over the AGT mesh. The deliverable + +a signed receipt come back. A mission can delegate to sub-agents (bounded by its +envelope) — see [Observability → agent graph](observability.md). + +## Teams (standing) + +A `KarsTeam` is a standing org with a charter and an optional cadence. Each +cadence tick (or "Run now") mints one governed task-force run; the run can spawn +sub-agents just like a mission's principal. + +### Operating mode — always-on vs on/off + +A standing team does **not** hold an always-on sandbox. It materializes a fresh, +governed sandbox per run and **tears it down on completion** to stay lean. The +team page surfaces this honestly with a live operating-mode badge: + +| Mode | Meaning | +|------|---------| +| **Working now** | A run sandbox is live and executing the charter. | +| **Idle — spins up on demand** | No sandbox is running between runs; it will spin up on the next tick/task/Run now and **rebuild from memory**. | +| **Hibernating** | Paused — nothing runs until resumed. | + +"Always-on in intent, on-demand in cost." + +### Memory across runs + +Because runs are ephemeral, continuity comes from the team's **knowledge commons** +(`kars-commons-`). Each delivered run harvests its deliverable into the +commons (when it did real work), and the next run injects recent commons entries +as prior knowledge — so a team resumes from what it has learned rather than a +cold start. The operating-mode explainer shows the count of carried-forward +memories. + +### Task backlog + +Beyond the always-on charter, a team can hold a **task backlog** (discrete tasks, +one per run). Each run claims the oldest pending task, works it, and marks it done +on delivery. + +## Deliverables + +Both surfaces render deliverables via the shared, typed deliverable view (report / +recommendation / action / note, with an "in brief" summary). A **pull request** the +agent opened is a first-class delivery type, shown as an artifact chip (repo + +number + link) — see [Connections → GitHub](connections.md). + +For the detailed relationship between engineering intake, backlog milestones, +runs, activity, role artifacts, principal deliverables, review gates, +checkpoints, and team memory, see +[Team workflows: from intent to reviewed outcome](team-workflows.md). diff --git a/bridge/docs/observability.md b/bridge/docs/observability.md new file mode 100644 index 000000000..75692c43e --- /dev/null +++ b/bridge/docs/observability.md @@ -0,0 +1,55 @@ +# Observability + +Bridge surfaces three complementary views of a run and the fleet, all from real +telemetry — never fabricated. + +## Efficiency engine (Insights) + +**Operator Console → Insights** is a live (8s auto-refresh), rich efficiency + +governance view computed from real runs: + +- **Outcome funnel** — attempted → delivered → human-accepted (the honest signal + is *accepted*, not merely "tokens were spent"). +- **Efficiency frontier** — a Pareto scatter of routes by cost (tokens per + delivered outcome) vs delivery success, with the recommended route ringed. Plus + an A/B head-to-head of the top two routes on the same outcome metrics. +- **Reliability** — `pass^k` across packages run more than once (honest about `k` + and sample size), and top-fault attribution. +- **Latency** — p95 wall-clock + mean time-to-first-action. +- **Governance integrity** — receipts issued, tamper-evident log size, over-reach + attempts blocked. +- **Budget utilization** — today's measured cluster spend vs the + [inference-budget](inference-budgets.md) caps, with live meters. + +## Agent / org graph + +The Activity tab renders a radial graph of a run: the principal hub, the tools it +called, the external hosts it reached, and — when it delegates — each **sub-agent** +it spawned as a branch node with its own live activity. + +When a run has sub-agents, an **Org activity** roster makes the delegation tree +legible: every agent (principal + subs) with its role badge, live phase, per-agent +tool-call count, and hosts reached — the "who did what". + +## Proofs & attestations + +The graph also visualizes **where and when** a run's cryptographic proofs are +made, from real data: + +| Proof point | When | From | +|-------------|------|------| +| **Trust envelope signed** | at admission | the envelope digest (`status.envelopeDigest`) | +| **Agent mesh identity (DID)** | at registration | the AGT registry DID + reputation | +| **Sub-agent attenuation verified** | at spawn | each sub-agent's envelope proven a strict SUBSET of the principal's | +| **Governance receipt (DSSE)** | at delivery | the receipt scheme + key id + inclusion-log sequence | + +Each point shows ✓ when produced in the run. This makes the otherwise-invisible +signing points concrete — how trust is established, end to end. + +## Live stream + +One SSE stream per run (`/api/namespaces/{ns}/tasks/{name}/stream`) feeds both the +graph and the per-round/per-tool feed. The BFF aggregates the principal's trace +and every spawned sub-agent's, tagging each event with the emitting agent and its +role; the client de-dupes on the router-stamped `seq` so an event never +double-counts across the persisted seed and the live tail. diff --git a/bridge/docs/operations.md b/bridge/docs/operations.md new file mode 100644 index 000000000..fb6256abb --- /dev/null +++ b/bridge/docs/operations.md @@ -0,0 +1,49 @@ +# Operations + +Bridge is stateless application code over Kubernetes-resident Kars resources. +Production operation still requires explicit SLOs, scaling, backup, and +incident procedures. + +## Operate these components + +- web Deployment and Service; +- BFF Deployment, ServiceAccount, and RBAC; +- OIDC provider and signing secrets; +- ingress and TLS; +- Kars controller/router/runtime compatibility; +- mission/team resources and retained ConfigMaps; +- provider, GitHub, channel, and MCP credentials. + +## Minimum operational controls + +- availability alerts for web, BFF, controller, AgentMesh, and required MCPs; +- latency/error dashboards for BFF and mission launch; +- audit of RBAC and Secret access; +- image and dependency vulnerability monitoring; +- secret rotation runbooks; +- Helm upgrade and rollback rehearsal; +- evidence export and retention; +- capacity planning for sandboxes, browsers, and GPU models. + +## Scaling + +The preview chart defaults to one web and one BFF replica. Before increasing +replicas, verify: + +- session signing keys are shared; +- BFF operations remain idempotent/CAS-protected; +- ingress health and readiness are correct; +- topology spread and disruption budgets fit the cluster. + +## Backup + +Bridge source-of-truth data lives in Kars CRDs, Secrets, and ConfigMaps. Back up +the Kubernetes API objects and any external evidence store according to your +cluster’s supported backup mechanism. Never export credential Secret values +into support bundles. + +## Incident response + +Use [Troubleshooting](troubleshooting.md), preserve trace IDs and receipts, +rotate affected credentials, and use Kars emergency-stop/break-glass controls +only through the documented audited path. diff --git a/bridge/docs/providers.md b/bridge/docs/providers.md new file mode 100644 index 000000000..758c0ca42 --- /dev/null +++ b/bridge/docs/providers.md @@ -0,0 +1,42 @@ +# Providers and model routing + +Bridge separates provider connections from per-mission model policy. + +## Connection model + +- One provider/model can be the cluster default. +- Additional providers are mirrored to sandbox routers. +- `InferencePolicy.modelPreference.primary.provider` selects the provider for a + specific sandbox or task. +- Fallbacks are explicit; a missing model must not silently change provider. + +Supported private-preview paths include Azure AI Foundry/Azure OpenAI, GitHub +Copilot, GitHub Models, OpenAI-compatible endpoints, and AI Runway local +inference. + +## Credential handling + +Provider credentials are stored in canonical Kubernetes Secrets and read by the +BFF/router as required. The UI reports connection metadata such as provider, +endpoint, and whether a key exists; it does not return credential values. + +## Operator workflow + +1. Open Console → Configuration. +2. Select a provider type. +3. Authenticate or provide the required endpoint/secret. +4. Run live model discovery. +5. Choose default or additional scope. +6. Set the default model. +7. Run a mission and verify the actual provider/model in telemetry. + +## Failure behavior + +- Missing credentials fail preflight or the router request. +- Responses-only models use the router’s Responses API path. +- An unavailable model may use only the declared fallback/default behavior. +- Provider Secrets are mirrored at sandbox creation; connection changes may + require new sandbox pods. + +See [Local inference](local-inference.md) and +[Inference budgets](inference-budgets.md). diff --git a/bridge/docs/quickstart.md b/bridge/docs/quickstart.md new file mode 100644 index 000000000..500536d38 --- /dev/null +++ b/bridge/docs/quickstart.md @@ -0,0 +1,91 @@ +# Private-preview quickstart + +This quickstart assumes access to the private Bridge images and a compatible +Kars cluster. + +## 1. Confirm Kars + +```bash +kubectl -n kars-system get deploy kars-controller +kubectl -n kars-system get crd karstasks.kars.azure.com karsteams.kars.azure.com +``` + +Review [Compatibility](compatibility.md) before continuing. + +## 2. Install Bridge with Dex + +Build and load the `dev` images referenced by `values-kind.yaml`: + +```bash +docker build -f bff/Dockerfile -t kars-bridge-bff:dev bff +docker build -f web/Dockerfile -t kars-bridge-web:dev web +kind load docker-image kars-bridge-bff:dev kars-bridge-web:dev --name + +helm upgrade --install kars-bridge deploy/helm/kars-bridge \ + --namespace kars-system \ + --values deploy/helm/kars-bridge/values-kind.yaml \ + --set idp.enabled=true +``` + +For a private remote registry, create an image-pull Secret and configure +`global.imagePullSecrets`; do not use the kind overlay’s local image names. + +```bash +kubectl -n kars-system rollout status deploy/kars-bridge-bff +kubectl -n kars-system rollout status deploy/kars-bridge-web +kubectl -n kars-system port-forward svc/kars-bridge-web 3000:3000 +``` + +Open `http://localhost:3000`. + +## 3. Sign in by persona + +Obtain the preview accounts from the deployment operator through a secure +channel. The chart’s sample static users are not a credential-distribution +mechanism and must be replaced or rotated for a real colleague ring. + +Use one employee, operator, and auditor account. Verify: + +- employee lands in Workspace; +- operator can open Workspace and Console; +- auditor lands in Audit and cannot enter Workspace or Console. + +## 4. Configure a provider + +In **Console → Configuration**, connect a supported provider and set a default +model. Provider credentials are operator-managed and are never returned to the +browser or agent. + +## 5. Install managed MCPs + +Confirm Kars has the default ToolPolicy and configured managed-MCP images, then +in **Console → Capabilities**, install: + +- Playwright for a real browser integration; +- Everything for deterministic MCP conformance checks. + +Everything is not a production integration. See [MCP servers](mcp-servers.md). + +## 6. Run the first mission + +Create a mission that: + +1. uses `everything.echo` and `everything.get-sum`; +2. uses Playwright to navigate to a page and read a heading; +3. returns a structured result. + +Confirm the mission page shows a deliverable, activity, MCP calls, token usage, +and retained artifacts. + +## 7. Run the first team + +Create a team with two differentiated roles and an objective that requires +their outputs to be combined. Run it twice and confirm the second run references +the team commons rather than starting cold. + +## Next steps + +- [Identity](identity.md) +- [Missions and teams](missions-and-teams.md) +- [Approvals and egress](approvals-egress.md) +- [Troubleshooting](troubleshooting.md) diff --git a/bridge/docs/rbac.md b/bridge/docs/rbac.md new file mode 100644 index 000000000..da39a0662 --- /dev/null +++ b/bridge/docs/rbac.md @@ -0,0 +1,86 @@ +# Access and roles + +Bridge has two authorization layers: + +1. **Persona authorization** in the web application and BFF controls what an + authenticated human may do. +2. The **BFF ServiceAccount ClusterRole** limits the aggregate Kubernetes + actions Bridge can perform. + +Neither layer replaces the other. + +## Roles + +| Capability | User | Auditor | Operator | Admin | +|---|:---:|:---:|:---:|:---:| +| Use Workspace missions and teams | Yes | No | Yes | Yes | +| Manage user/workspace connections | Yes | No | Yes | Yes | +| Read Audit receipts and evidence | No | Yes | No | Yes | +| Use Operator Console | No | No | Yes | Yes | +| Manage policies, MCP, skills, and approvals | No | No | Yes | Yes | +| Administrative configuration | No | No | Limited | Yes | + +Role implication: + +- admin implies operator and auditor; +- operator implies user; +- auditor does **not** imply user. + +Workspace, Console, and Audit are self-contained persona surfaces. + +## Authentication modes + +### OIDC + +The production-capable mode. The web application performs Authorization Code + +PKCE, verifies issuer, audience, nonce, and JWKS signature, and issues a signed +Bridge session. Group/role claims map to Bridge roles. + +### In-cluster Dex + +An optional Helm-managed IdP for private-preview testing. It uses the same OIDC +flow and role mapping as an external provider. Seed users and passwords are not +a production identity source. + +### Local development + +When OIDC and signed principal propagation are not configured, development +role switching may be available. This is for one trusted developer and must not +be exposed as a multi-user deployment. + +See [Identity](identity.md). + +## Important limitation + +The BFF enforces a dedicated User persona for every non-operator API route. +Auditors therefore cannot bypass the UI with direct Workspace API calls. + +The BFF still groups many Console mutation routes under the operator persona. +The UI reserves some configuration actions for admins, but do not treat UI +hiding as a hard admin boundary unless the BFF route itself requires admin. +Before public release, every admin-only operation must have explicit +server-side enforcement and direct API tests. + +## Kubernetes RBAC + +The `kars-bridge` ServiceAccount defines Bridge’s maximum cluster permissions. +Every new BFF write path must update the Helm ClusterRole and be exercised +through the deployed ServiceAccount. + +```bash +kubectl auth can-i delete karstasks.kars.azure.com \ + --as system:serviceaccount:kars-system:kars-bridge +``` + +Do not validate Bridge writes using only a cluster-admin kubeconfig; that hides +missing verbs. + +## Separation of duties + +- Users submit missions, teams, skills, and requests. +- Operators review operational and governance requests. +- Auditors remain read-only. +- Self-approval is rejected for governed resources where separation is + required. +- Approval actors are derived from the verified session, never trusted from the + request body. diff --git a/bridge/docs/skills.md b/bridge/docs/skills.md new file mode 100644 index 000000000..752350984 --- /dev/null +++ b/bridge/docs/skills.md @@ -0,0 +1,50 @@ +# Skills + +Skills are versioned packages that extend an agent with instructions, +references, and executable helpers. + +## Lifecycle + +1. A user submits a skill package. +2. Bridge records the package bytes and version digest. +3. An operator reviews the exact version. +4. Approval binds the current generation and package digest. +5. A mission or team selects the approved skill. +6. Kars mirrors and mounts the verified bytes into the sandbox. + +Changing the package invalidates approval; approval is not a mutable “trusted +name” flag. + +## Package shape + +A package may include: + +```text +SKILL.md +scripts/ +references/ +``` + +Executable helpers must be complete, non-root, deterministic where possible, +and must not read ambient credentials. + +## Separation of duties + +Self-approval is rejected where the configured governance policy requires a +distinct approver. The actor comes from the authenticated session, not the +request payload. + +## Mission use + +Approved skills appear in the mission and team composers. Preflight binds the +selected skill generation into the launch-package fingerprint so a package +change cannot race the review. + +## Operator review checklist + +- Purpose and expected outputs are clear. +- Scripts are complete and have no placeholders. +- Network and filesystem requirements are explicit. +- No secrets are embedded. +- Version digest matches the package under review. +- Upgrade and revocation behavior is understood. diff --git a/bridge/docs/team-workflows.md b/bridge/docs/team-workflows.md new file mode 100644 index 000000000..853abe71c --- /dev/null +++ b/bridge/docs/team-workflows.md @@ -0,0 +1,261 @@ +# Team workflows: from intent to reviewed outcome + +This guide ties together the Bridge concepts that appear across the Workspace: +teams, engineering intake, work queue, runs, activity, artifacts, deliverables, +approvals, outcomes, and memory. + +Bridge presents these states; Kars owns and persists them. This page describes +the product contract. The monorepo integration candidate has not yet qualified +the complete controller/runtime journey; see [Compatibility](compatibility.md). +In particular, importing the UI does not provide missing governed task delivery, +authenticated runtime transport, or restart-recovery behavior in an older core. + +## The short version + +```text +Intent + -> proposed org + milestone graph + -> human review + preflight + -> standing team + -> one eligible milestone + -> one governed run + -> principal + selected specialists + -> activity, artifacts, and structured handbacks + -> principal deliverable + truthfulness gate + -> optional human review + -> approved team memory + next milestone +``` + +## What each Workspace concept means + +| Workspace concept | What it is | What it is not | +|---|---|---| +| Team | A persistent charter, org chart, authority envelope, backlog, memory identity, and explicit runtime lifecycle policy. | A single run or an implicitly permanent pod. | +| Work queue | Durable milestones/tasks the team will execute. | A live event stream. | +| Run | One attempt to execute one milestone or charter tick. | The whole lifetime of the team. | +| Execution flow / activity | The chronological signal inside one run. | A separate work item. | +| Agent | A principal or specialist worker selected for the run. | The durable team itself. | +| Artifact | A file produced by an individual agent or the principal. | The final outcome classification. | +| Deliverable | The principal's final synthesis for the run. | Every raw agent file. | +| Outcome | Delivered, delivered with issues, no action, incomplete, or failed. | A model's self-reported confidence. | +| Approval | A typed human decision that changes workflow state. | Free-form feedback with no controller effect. | +| Memory | Approved retained knowledge injected into later runs. | A replay of every raw conversation. | +| Engineering intake | Repository signal discovery and backlog creation. | The activity timeline or the agents doing the work. | + +## 1. Compose a team + +From **Workspace -> Teams -> Set up a team**, enter a standing charter. The +Bridge composer reads the live cluster palette and proposes: + +- a principal/default model; +- 2-4 focused evidence roles; +- per-role harness and model overrides; +- MCP services, egress mode, and public hosts; +- autonomy tier and cadence; +- runtime lifecycle and warm-idle policy; +- engineering intake settings for repository maintenance; +- a 2-8 milestone DAG for finite work. + +The proposal is editable. It is not execution. + +```mermaid +flowchart LR + Intent["Plain-language charter"] --> Composer["Bridge compose orchestrator"] + Palette["Live cluster options"] --> Composer + Composer --> Proposal["Editable org + model routes + milestone DAG"] + Proposal --> Preflight["Preflight validation"] + Preflight --> Create["Create team paused"] + Create --> Launch["Explicit launch"] +``` + +If the model response is malformed, Bridge performs one bounded repair. It does +not present generic fallback roles as a successful AI recommendation. + +### Runtime lifecycle modes + +Lifecycle mode controls runtime retention, not workflow durability. The charter, +queue, approvals, receipts, and approved memory remain durable in every mode. + +| Mode | Runtime behavior | Use when | +|---|---|---| +| **Resource optimized** | Reuses the same principal while warm, then suspends it after the configured idle window. New work resumes the same team identity. | Default for most standing teams. | +| **Persistent** | Keeps the principal runtime ready until an operator explicitly pauses the team. | Low-latency work justifies continuous resource use. | +| **Ephemeral** | Creates a clean isolated runtime for each assignment and tears it down after evidence is retained. | Maximum isolation or compatibility with earlier teams. | + +The controller never auto-suspends a runtime while an assignment is active or a +milestone is awaiting review. Pausing a team explicitly suspends retained +runtimes in all modes. + +## 2. Understand the milestone graph + +Each milestone contains: + +- stable ID; +- title and description; +- preferred owner role; +- dependencies; +- acceptance criteria; +- optional review gate. + +Only a milestone whose dependencies are `done` may run. A milestone in +`active` or `awaiting_review` blocks later assignments. This is why a team can +have many queued milestones but only one current execution cell. + +## 3. Launch and watch a run + +**Run now** creates a one-shot request. The BFF rejects a second request while +one is pending or active, and the button changes to **Run in progress**. + +The run page has four views: + +1. **Overview:** role selection and durable handback state. +2. **Execution flow:** searchable lifecycle, tool calls, reasoning cost, and + truthfulness decision. +3. **Deliverables:** principal output plus retained role artifacts. +4. **Research & egress:** network attempts, remote HTTP results, and actual + network denials. + +The signal path summarizes the expected order: + +```text +controller assignment -> principal -> specialists -> tools -> handbacks -> truthfulness gate +``` + +An in-flight page never calls work "verified." Verification appears only after +the terminal evidence gate. + +## 4. Activity, artifacts, and deliverable + +These answer different questions: + +- **Activity:** What happened, in what order, and where did it fail? +- **Artifacts:** What files did each agent produce? +- **Deliverable:** What did the principal conclude after reviewing handbacks? + +Examples: + +| Evidence | Typical location | +|---|---| +| Model round and tool result | Execution flow | +| `application-engineer` report | Role artifact | +| `task-checkpoint.json` | Deliverables and checkpoint panel | +| Principal readiness report | Deliverable | +| Missing handback | Role delivery and truthfulness failure | + +Internal evidence files such as `collaboration.jsonl` and +`subagent-telemetry.jsonl` remain durable even after child sandboxes are gone. + +## 5. Checkpoint and restart + +Every milestone receives a controller-owned, nonce-scoped checkpoint before +task delivery. The run page shows it as **Durable milestone checkpoint**. + +If the principal pod restarts, the controller: + +1. discovers the new worker identity; +2. preserves the same task nonce; +3. rereads the checkpoint; +4. reroutes the verified contract; +5. records **Worker restarted; assignment rerouted** in Execution flow. + +The replacement does not consume a checkpoint from an older run. + +## 6. Review and request changes + +When a review-required milestone delivers, its work-queue state becomes +**Awaiting review**. + +- **Approve milestone:** the milestone becomes `done`, dependent work unlocks, + and the approved output is promoted to team memory. +- **Request changes:** feedback is required, appended with the source run, and + the milestone returns to `pending`. + +The source run remains immutable evidence in both cases. + +Approval is a typed `KarsApproval`; Bridge is not required for Kars to resolve +the decision. + +## 7. Memory continuity + +Runtime lifetime depends on the selected lifecycle mode. Team continuity never +depends on pod lifetime; it is persisted through: + +- its `KarsTeam` charter and roster; +- its durable work queue; +- approved entries in team commons. + +The team page displays the count of **carried-forward memories**. Later runs +receive recent approved entries in an untrusted reference-data frame. Memory +may inform work, but it cannot override the current charter or contract. + +Review-required output is not promoted before approval. + +## 8. Engineering intake + +Engineering intake continuously observes configured repository signals such as +Dependabot PRs, vulnerability alerts, code scanning, and secret scanning. + +It belongs before the work queue: + +```mermaid +flowchart LR + Signal["GitHub signal"] --> Intake["Engineering intake item"] + Intake --> Queue["Durable team task"] + Queue --> Run["Governed run"] + Run --> Evidence["Activity + artifacts + deliverable"] + Evidence --> Readiness["CI and merge readiness"] + Readiness --> Human["Review / feedback / merge decision"] +``` + +The intake item links to the exact run that handled it. Opening the run shows +the activity and agents; opening its deliverables shows what they produced. + +## 9. Egress decisions + +The UI separates two failure classes: + +- **Network denied:** the sandbox boundary blocked an unapproved host. This is + eligible for an `EgressApproval`. +- **Remote error:** the request reached the host and received an HTTP response + such as 404 or 500. Approving egress cannot fix it. + +Research teams should prefer governed search and a bounded source list. Do not +approve speculative host fan-out simply to make a run green. + +## 10. Failure and recovery guide + +| Symptom | Read first | Typical action | +|---|---|---| +| Run never reaches Working | Deploy timeline and latest lifecycle event | Check sandbox materialization, image pull, gateway, and mesh registration. | +| Role says Handback failed | Role delivery, then matching execution event | Read the retained failure preview; retry only after correcting the cause. | +| Missing handback | Collaboration events and child lease | Confirm the child received the task and sent progress; do not trust the principal narrative alone. | +| Repeated role work | Role plan and assignment IDs | Confirm completed assignments are idempotent and recovery uses a fresh worker generation. | +| Egress request | Research & egress | Approve only a task-specific host; deny optional metadata or speculative sources. | +| Run claims success but is rejected | Truthfulness panel | Use the listed missing role/artifact/acceptance evidence as the source of truth. | +| Need to stop a runaway run | Emergency stop | Enter a reason; Bridge pauses the team and preserves evidence. | + +## 11. Relationship map + +```mermaid +flowchart TB + Team["KarsTeam"] --> Queue["team task ConfigMap"] + Queue --> Milestone["milestone ID"] + Milestone --> Run["task-force KarsTask"] + Run --> Assignment["assignment ledger + nonce"] + Assignment --> Activity["mission trace"] + Assignment --> Agents["principal + child DIDs"] + Agents --> Artifacts["mission artifacts"] + Agents --> Deliverable["mission output"] + Deliverable --> Approval["KarsApproval"] + Approval --> Commons["team commons"] + Commons --> NextRun["next run contract"] +``` + +This is the simplest way to answer "where did this come from?": + +1. start from the team; +2. find the milestone in Work queue; +3. follow its `run`; +4. inspect role activity and artifacts; +5. read the principal deliverable and truthfulness result; +6. inspect the approval and resulting memory entry. diff --git a/bridge/docs/troubleshooting.md b/bridge/docs/troubleshooting.md new file mode 100644 index 000000000..020280815 --- /dev/null +++ b/bridge/docs/troubleshooting.md @@ -0,0 +1,39 @@ +# Troubleshooting + +## First checks + +```bash +kubectl -n kars-system get pods +kubectl -n kars-system logs deploy/kars-bridge-bff --since=15m +kubectl -n kars-system logs deploy/kars-bridge-web --since=15m +kubectl -n kars-system logs deploy/kars-controller --since=15m +``` + +## Common symptoms + +| Symptom | Likely cause | Action | +|---|---|---| +| `/workspace` redirects unexpectedly | Missing/incorrect OIDC role mapping | Inspect verified claims and `BRIDGE_OIDC_ROLE_MAP` | +| Auditor can enter Workspace | Incorrect role implication | Auditor must not imply user | +| Login follows localhost and fails in managed Playwright | Port-forward Dex split horizon | Use redirect-manual evidence or configure ingress issuer | +| BFF returns Kubernetes 403 | Missing ServiceAccount verb | Test `kubectl auth can-i` as `kars-bridge` | +| Mission launch returns 422 | Budget or preflight failure | Read the structured error and Console budget/MCP status | +| MCP appears Ready but tools fail | Stale generation/session or auth | Inspect `McpServer.status` and sandbox router logs | +| Playwright opens a blank page mid-run | Session reaped or non-isolated server | Verify managed preset and router keepalive | +| Team reuses another team’s role | Outdated parent-scoped spawn implementation | Upgrade Kars controller/router/runtime | +| Team forgets earlier work | No harvested substantive deliverable | Inspect team commons and run health | +| Delete works as admin but fails in Bridge | Developer kubeconfig hid RBAC gap | Test through deployed BFF ServiceAccount | + +## Managed MCP + +```bash +kubectl -n kars-system get mcpserver -o yaml +kubectl -n kars-mcp get deploy,svc,networkpolicy +kubectl -n kars- logs deploy/ -c inference-router --since=15m +``` + +## Evidence bundle + +Collect resource names and UIDs, trace IDs, the affected output/receipt, pod +events, BFF/controller/router logs, and exact timestamps. Remove credentials, +cookies, identity seeds, and private keys before sharing. diff --git a/bridge/start-bff.sh b/bridge/start-bff.sh new file mode 100755 index 000000000..14647b0f2 --- /dev/null +++ b/bridge/start-bff.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run the BFF in the foreground. Existing listeners are never stopped or adopted. +set -euo pipefail +cd "$(dirname "$0")/bff" + +exec cargo run --locked --bin kars-bridge-bff diff --git a/bridge/teams-gateway/.dockerignore b/bridge/teams-gateway/.dockerignore new file mode 100644 index 000000000..9f0e441c2 --- /dev/null +++ b/bridge/teams-gateway/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.md +tests +vitest.config.ts diff --git a/bridge/teams-gateway/.gitignore b/bridge/teams-gateway/.gitignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/bridge/teams-gateway/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/bridge/teams-gateway/Dockerfile b/bridge/teams-gateway/Dockerfile new file mode 100644 index 000000000..409c0ecae --- /dev/null +++ b/bridge/teams-gateway/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-slim AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts +COPY tsconfig.json ./ +COPY src/ src/ +RUN npx tsc + +FROM node:22-slim +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force +COPY --from=builder /app/dist ./dist +USER 1000 +EXPOSE 3978 3979 +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s \ + CMD node -e "fetch('http://localhost:3979/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node", "dist/main.js"] diff --git a/bridge/teams-gateway/package-lock.json b/bridge/teams-gateway/package-lock.json new file mode 100644 index 000000000..508510337 --- /dev/null +++ b/bridge/teams-gateway/package-lock.json @@ -0,0 +1,3622 @@ +{ + "name": "@kars-bridge/teams-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kars-bridge/teams-gateway", + "version": "0.1.0", + "dependencies": { + "@kubernetes/client-node": "^1.4.0", + "@microsoft/teams.apps": "^2.0.15", + "@microsoft/teams.cards": "^2.0.15" + }, + "devDependencies": { + "@types/node": "^22", + "oxlint": "0.16.0", + "tsx": "^4", + "typescript": "^5.8", + "vitest": "^3.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.11.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-common/-/msal-common-16.11.3.tgz", + "integrity": "sha1-0UdOH5A8P8gT1nNVII8KFZtzuNU=", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.4.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/msal-node/-/msal-node-5.4.3.tgz", + "integrity": "sha1-Wfd4U3BIyFWtmp3F/hlC1Y0XYtI=", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.11.3", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha1-egGo0uwvuy2seK2tCbD6eB5Agr4=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha1-cEvSl95tdi3lTqu+r79V9nVqvi8=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha1-tUCifRTkr9BYSWpNvsTT9BTbEQo=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha1-0csWbTSw+/D+irRgpVlPJKN4cB4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha1-EDSyZFf8iGNo/mG70J9lP2r6jlQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha1-ZVVqQyoeTXIDLYIYwZMvzKGkl3I=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha1-LmHgWS+QMNfj2uGO4l68U1kYrvY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha1-yV7CiZWe+AecTcqBeh4sS+Zrm9M=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha1-wJoPZ5F1kqwN6JKpvk04FN69Kmw=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha1-QLIhdd2gYYLz7oFBGGxf8wTEpxc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha1-pYD5xnZ5eDOJHlGfx6EzfIr9jbM=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha1-RkUs8yHcf56Rwvp4Cla7Vuec1os=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha1-QhGzGE3WYI9T3LIuOfXTTuCIUsg=", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha1-aXhXwqYcubC2u2ZS5AwdxeHKjl0=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha1-0ZKUPrFGpArExkl9DPe+NbmGvwg=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha1-rOoDVtoODrwI+Xz3ucLkAeHmSNw=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha1-bww84MtkxTS3DExF7LLBbTTjXf0=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha1-i813B3oNzjN4tXT+2ybSolO3PTY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha1-5/sqAemcgwyU5mI82f77TI+1g0c=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha1-xSkJNy24uG4sVeBaiUADO1Zgo7I=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha1-xCe5vlpkwmL/mn63C1+7qt9EbGw=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha1-3JsUe6yi5sSzyFVxdB70hgpIkJc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha1-zoZtEt8TwV5MmfBzo9Rm9uBkmzo=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha1-dGjjaS0B1inVlB5dg4F7uA+eObQ=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha1-pbwAY/sryrbQ7WPyoVN5WLwmnsY=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha1-EAZO5E9DR7kMmgK0Rrv4CpFjKxI=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha1-aRKwDSxjHA0Vzhp6tXzWV/Ko+Lo=", + "dev": true, + "license": "MIT" + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha1-/PxUF6BJM/fO7nhuirSYqjziskI=", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha1-yy/EIyIPpxxgkyO5un99NEp1X8w=", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@kubernetes/client-node": { + "version": "1.4.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@kubernetes/client-node/-/client-node-1.4.0.tgz", + "integrity": "sha1-NbctMfZivv2aKGnhcrMVOjRD42g=", + "license": "Apache-2.0", + "dependencies": { + "@types/js-yaml": "^4.0.1", + "@types/node": "^24.0.0", + "@types/node-fetch": "^2.6.13", + "@types/stream-buffers": "^3.0.3", + "form-data": "^4.0.0", + "hpagent": "^1.2.0", + "isomorphic-ws": "^5.0.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "node-fetch": "^2.7.0", + "openid-client": "^6.1.3", + "rfc4648": "^1.3.0", + "socks-proxy-agent": "^8.0.4", + "stream-buffers": "^3.0.2", + "tar-fs": "^3.0.9", + "ws": "^8.18.2" + } + }, + "node_modules/@kubernetes/client-node/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-24.13.3.tgz", + "integrity": "sha1-SfGL08ZHhm3NpRoHVsFF4UWQzhY=", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@kubernetes/client-node/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha1-KTV6iee3ykrvO/D9P9DNc4hCKek=", + "license": "MIT" + }, + "node_modules/@microsoft/teams.api": { + "version": "2.0.15", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/teams.api/-/teams.api-2.0.15.tgz", + "integrity": "sha1-bC8MYd8gVEqZU8FmYl6Gk5jJwj0=", + "license": "MIT", + "dependencies": { + "@microsoft/teams.cards": "2.0.15", + "@microsoft/teams.common": "2.0.15", + "jwt-decode": "^4.0.0", + "qs": "^6.15.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.apps": { + "version": "2.0.15", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/teams.apps/-/teams.apps-2.0.15.tgz", + "integrity": "sha1-IiY0Vqjy1pEVlJ2jAT1Vye26bdc=", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^5.2.2", + "@microsoft/teams.api": "2.0.15", + "@microsoft/teams.common": "2.0.15", + "@microsoft/teams.graph": "2.0.15", + "axios": "^1.18.1", + "cors": "^2.8.5", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "jwks-rsa": "^3.2.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.cards": { + "version": "2.0.15", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/teams.cards/-/teams.cards-2.0.15.tgz", + "integrity": "sha1-WcrZM6/62rOaJNMmUX3khbfU2W4=", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.common": { + "version": "2.0.15", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/teams.common/-/teams.common-2.0.15.tgz", + "integrity": "sha1-RqMPTCoWqwPNOcwEYhHn3vso6lk=", + "license": "MIT", + "dependencies": { + "axios": "^1.18.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/teams.graph": { + "version": "2.0.15", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@microsoft/teams.graph/-/teams.graph-2.0.15.tgz", + "integrity": "sha1-6a7jKDIy/zrqhdlPX2p4KTV5x5U=", + "license": "MIT", + "dependencies": { + "@microsoft/teams.common": "2.0.15", + "qs": "^6.15.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxlint/darwin-arm64": { + "version": "0.16.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/darwin-arm64/-/darwin-arm64-0.16.0.tgz", + "integrity": "sha1-p9adC9KajZnBp0St2n1WNYIvQI0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/darwin-x64": { + "version": "0.16.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/darwin-x64/-/darwin-x64-0.16.0.tgz", + "integrity": "sha1-TawAXoygyhFZLZBhFu+oZL+N0zk=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/linux-arm64-gnu": { + "version": "0.16.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-0.16.0.tgz", + "integrity": "sha1-3YqHmKFqQ90xAzaNGqY5mutrJvY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-arm64-musl": { + "version": "0.16.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/linux-arm64-musl/-/linux-arm64-musl-0.16.0.tgz", + "integrity": "sha1-VkXlK6ooTepSIhvmD/vZ31Zv/Ro=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-gnu": { + "version": "0.16.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/linux-x64-gnu/-/linux-x64-gnu-0.16.0.tgz", + "integrity": "sha1-sby6lkT8tPi6KobQOJMGjFm2zP4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-musl": { + "version": "0.16.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/linux-x64-musl/-/linux-x64-musl-0.16.0.tgz", + "integrity": "sha1-+DIKmkDrZwXUuvPeKEmxXxVTCto=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/win32-arm64": { + "version": "0.16.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/win32-arm64/-/win32-arm64-0.16.0.tgz", + "integrity": "sha1-e0xbyE6nSO23uCs5kqNTmGQ7mE4=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/win32-x64": { + "version": "0.16.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxlint/win32-x64/-/win32-x64-0.16.0.tgz", + "integrity": "sha1-oPV5/8hol8o/sBTwvTwZ3at1esw=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha1-sKtCL7YPNYPIx4noVtZKMlB/KZ4=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha1-BH6WfutECimfGrx3NXm1wlFvpI0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha1-GDaeZ9DT/8sBqVVhIXRHt5Fydpc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha1-vyOuTFwPhBsSO8eeOyRhdsbQj9c=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha1-ySobB9AkMYHybZ3rJ4w+8J4B8GU=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha1-tp2gQH6gZJfTlb4OeZzGAQBLNy4=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha1-G0tjxX/CDm6kdIqOqGTYZRMyjsQ=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha1-ZDNPWlF4yrsV59KpH7WS2x+GIdM=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha1-H/KZ94JfD1Ko4Qfax/Fc5zAJhIs=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha1-mQUjgOepT6RAuRZsZ6kJqjVy8Ik=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha1-X1i1drNmjt9irPDFJlgmJnt9hY8=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha1-qM0DN+P/OgyV6tZ3FcUa93xa/zY=", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha1-YzXOwVtVprBj40y36WhRX6UA/9Q=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha1-zytv0jjwkoxWV7uKXKd1HfwubOQ=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha1-35UIyHIUN/flGZDKqmHS8423PL8=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha1-DP6TBy9MOYu51mbllTNxoiq6f0o=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha1-+Oe989QZhmtoiwnZNIJTklIy0cs=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha1-V+9/A5xPfeDpE/HyaAOFRnuGuxU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha1-b1tWk9S+sVK/UYxIfSWTYtvs5Ek=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha1-mQkBAJPtf7JIDHO8GTqPTUT/ueM=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha1-q6kLV3Jfz0xAcslabb+891AKdw0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha1-Es7isubTfbuDYCaGgS5LuikMmqM=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha1-ORoNb4RYpnWtcgzMrpvLmkgQkBY=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha1-Pzi7GA/PHPqRl1xLNElB6YWm7RM=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha1-DMr9RKjLyzP3/qqePoUDPnpSKVw=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha1-jpzZ4cNYH6azQaWu1ViOsoW+C0o=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha1-M0MRlx06BxIefrkbaEpgXn7qnL0=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha1-zz8Oh2177hWpOrkluCv1cKOQSiQ=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha1-zYI4LE+QL+2WkaLteexoxYmK9MI=", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha1-p5MqRxd9zUKDthRvO9XCbYJkfwk=", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha1-BSqmekjszEMJ1/AZG35BQ0uQu3g=", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.1.tgz", + "integrity": "sha1-hOfN9jzaogwTSqMXzMkBqiHhbw4=", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha1-4Mm3te29sbUM4ywSfoXogIctVu4=", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/stream-buffers": { + "version": "3.0.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/stream-buffers/-/stream-buffers-3.0.8.tgz", + "integrity": "sha1-mYHAkrcsfhiJ43JNtAM49QOurGI=", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha1-cKNBWDg9AIw79dgC4mQzF/Cd9tg=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha1-MxvpRMt4PGQt1CvXQ0EayiTqBGY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha1-KntZP44Afp2O9+c0OqMOxz/eryk=", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha1-wMCAIoGJ8fps2kD1m+CddGsKylE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha1-o6fhlQzpnsTPAjleIN3KQDtsgY4=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha1-yn++5EAZUjykUDldmiKEzp7OHzE=", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha1-MCyBJiEaxN/qh7O1CFwJjW0i6J4=", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha1-u89LpQdUZ/PyEx6rPP/HPC9deJU=", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha1-Sf/1hXfP7j83F2/qtMIuAPhtf3c=", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha1-JG9Q88p4oyQPbJl+ipvR6sSeSzg=", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha1-9kGhlrM1aQsQcL8AtudZP+wZC/c=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/axios/-/axios-1.18.1.tgz", + "integrity": "sha1-1j+YY7zYk4gVyG+eKr04AYnZbf4=", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha1-fxYzTKgBJ66yYGSiiEGsvxdIQKQ=", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha1-XIZhaWY0O8sDobMVX+qyU+rb80k=", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha1-Q1CH1CR38Gft3zwsdG506dthd7w=", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha1-1KIHwIhgm0Zjp1VqRqlzQjmL9+I=", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha1-9hhsfLtLv1OkVg815IsWNzulHOY=", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha1-Yo8iMWDgPno6ClzXYfYcZETRcps=", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha1-bYZi9NjDNgKLismqJCUbDKZLpDc=", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha1-L7Pt5p3/oK94ynxM51iWgGOLVt8=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha1-iwvuuYYFrfGxKPpDhkA8AJ4CIaU=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cac/-/cac-6.7.14.tgz", + "integrity": "sha1-gE4eb1Bu42PLDjzLsJytXdmHCVk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha1-I43pNdKippKSjFOMfM+pEGf9Bio=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chai/-/chai-5.3.3.tgz", + "integrity": "sha1-3T2pVeJwkWpL0/Yl9LkZmWrafgY=", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha1-JCc2ERe3DMqNyJaA6tMrFXAZyvU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha1-w9RaizT9cwYxoRCoolIGgrMdWn8=", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha1-89t4nHUtRVZMx+nh4LMXkNSjjhc=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha1-i3cxYmVtHRCGeEyPI6VM5tc9eRg=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha1-VWNpxHKiupEPKXmJG1JrNDYjftc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha1-V8f8PMKTrKuf7FTXPhVpDr5KF5M=", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cors/-/cors-2.8.6.tgz", + "integrity": "sha1-/13Wm9leVHUDgg0pq6T4+vjf7JY=", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha1-S3VtjXcKklcwCCXVKiws/5nDo0E=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/depd/-/depd-2.0.0.tgz", + "integrity": "sha1-tpYWPMdXVg0JzyLMj60Vcbeedt8=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha1-rg8PothQRe8UqBfao86azQSJ5b8=", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha1-e46omAd9fkCdOsRUdOo46vCFelg=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha1-c0TXEd6kDgt0q8LtSXeHQ8ztsIw=", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha1-kVlgFWGICoXyc0VgqQmbLDHlNyo=", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha1-8x274MGDsAptJutjJcgQwP0YvU0=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha1-70W0Y0ycnZeilq6kEUpfmED5VXg=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha1-Z8PlSexAKkh7T8GT0ZU6UkdSNA0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha1-tWqE/WEbZhDgotDwn4D9+THi3+Y=", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha1-JO338MxppE0AhWe6RZSrlvPDo9Y=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/express/-/express-5.2.1.tgz", + "integrity": "sha1-jyHRW20yf5K0eU7PjLCKcvlWrAQ=", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha1-KG4x3pbrltOKl4mYFXQLoqTzZAw=", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha1-osUXplWYUrzbBtH4vX9Rto+tgJk=", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha1-KEdKFZ07nRHvYgUKFO1g5N9tYbw=", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha1-KOhk4beG2+u2jbH0UvljUnhmWCc=", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha1-u6vNwChZ9JhzAchW4zh85exDv3A=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha1-OBqHG2KnNEUGYK497uRIE/cNlZo=", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha1-ImmTZCiq1MFcfr6XeahL8LKoGBE=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha1-jdffahs6Gzpc8YbAWl3SZ2ImNaQ=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha1-LNxC1AvvLltO6rfAGnPFTOerWrw=", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha1-CuQXiVQw6zdwwDRDRWuNkMpGSQM=", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha1-NtL2W8kJyHkAGN02+02T2myq4Gs=", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha1-xZ7yJKBP6LdU89sAY6Jeow0ABdY=", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha1-hO4S+WPn3lC8AaE+FgoHizsPQV8=", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha1-kp+WKdFyT34bdIXOiXUvNnUzahA=", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha1-v/OFQ+64mEglB5/zoqjmy9RngbM=", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha1-Qv+fhCBsGZHSbev1IN1cAQQt0vM=", + "license": "MIT" + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha1-5VKRSJEuy5tFG0btRNU9rhzgS78=", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-4.15.9.tgz", + "integrity": "sha1-m2jtop6aBhTAQvopOHGWx92AAQA=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha1-LsQ5ZGWENSlvZ2GzThBnHC2VJ/Q=", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha1-Gf7Mv6Udinn3JIC0uOQM4uFxUvA=", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha1-c89UXCMa/aIUUhULeipY5I4QlwI=", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha1-bNV6sB6bCsB8uEfVPTybbuMfeuI=", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha1-v4F20a0M1y4PP1gzhZWhPhELyAQ=", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha1-9tUoMGvvrNvGLIwCcnYfqsVf+7M=", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jws/-/jws-4.0.1.tgz", + "integrity": "sha1-B+3Bvo+sIOZ3soPs4mFJi9OPBpA=", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha1-InA1JCX9QTeFsvrxH251XFFRvUs=", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha1-j5KiWzsWxhMSk6DMg0tKg4oqp8I=" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha1-AJXPVtxbepp8CP9bGoeW7IrRfnY=", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha1-bW/mVw69lqr5D8rR2vo7JWbbOpQ=", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha1-7w+8AhvOtmZ5SxRe76xr5J3EfzE=", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha1-VnY+wJoPqAkd8nh5/ZTRkHjADZE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha1-bwNUAN/jq51WB7x3VGzjDML5xrg=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha1-6pIvZgY1oiSe5WXgRJ+VHmtgOAg=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha1-zds+5PnGRTDf9kAjZmHULLajFPU=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha1-OQAtQYJXXVrwNv+hGBAPJSSy4qs=", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha1-tskbtHFy1p+Tz9fDV7u1KQGbX2o=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha1-0PD6bj4twdJ+/NitmdVQvalNGH0=", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha1-Dt5GbYvod02zhVipBhLIthhqu6Q=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha1-g3UmXiG8IND6WCwi4bE0hdbgAhM=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha1-WMjEQRblSEWtV/FKsQsDUzGErD8=", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha1-Vz6FKpxuo/z+GAlW2mqvl5xORyQ=", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "6.2.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.4.tgz", + "integrity": "sha1-ad5zRnYc0ElCxlnlJNmI/rFqSm4=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oxlint": { + "version": "0.16.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/oxlint/-/oxlint-0.16.0.tgz", + "integrity": "sha1-qlZK/dcaMA0OYI36CjRbV2N0350=", + "dev": true, + "license": "MIT", + "bin": { + "oxc_language_server": "bin/oxc_language_server", + "oxlint": "bin/oxlint" + }, + "engines": { + "node": ">=8.*" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/darwin-arm64": "0.16.0", + "@oxlint/darwin-x64": "0.16.0", + "@oxlint/linux-arm64-gnu": "0.16.0", + "@oxlint/linux-arm64-musl": "0.16.0", + "@oxlint/linux-x64-gnu": "0.16.0", + "@oxlint/linux-x64-musl": "0.16.0", + "@oxlint/win32-arm64": "0.16.0", + "@oxlint/win32-x64": "0.16.0" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha1-eVxCDE98pFxbiHNm9iLuDJhSzM0=", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha1-PsvsVUIWhbcKnahyss/z4cvtFxY=", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha1-iFXFooma8HLWrAXRHkYEWtDcYF0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha1-UepXoX2G9gX4EDlZX7xA7QalX6s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.24", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha1-AdiwMkUeG57EGuZurwKEP0KnINI=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha1-8Z/mnOqzEe65S0LnDowgcPm6ECU=", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha1-p0h1aK2tV3z6qn6IxJyrOrMIGro=", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pump/-/pump-3.0.4.tgz", + "integrity": "sha1-HzE0MFJ/qLkFYi69Iv4UROdXqzw=", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/qs/-/qs-6.15.3.tgz", + "integrity": "sha1-doUhMqWO1cfA72fkRBubtdYGGzs=", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha1-1/Gb6BK7YnIUcrRdO+IZ7wlXK0c=", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha1-PjraWuVWj5CV2EN2/TpJuPsAClE=", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha1-QAyEW2y6h6IfLGXErrFY9PpNnFs=", + "license": "Apache-2.0" + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha1-EXTAr7pyQjoLcMOG7P64CqYbBco=", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha1-A+6Z4rWwdE3Zm+bUI4svzUCHtPY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/router/-/router-2.2.0.tgz", + "integrity": "sha1-AZvmILcRyHZBFnzHm5kJDwCxRu8=", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/semver/-/semver-7.8.5.tgz", + "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/send/-/send-1.2.1.tgz", + "integrity": "sha1-nqt0O4dPNVD0CiaGe/KGrWDT8+0=", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha1-fxhqSk5fW2Y616QpT/G/N88OmKk=", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha1-ZsmiSnP5/CjL5msJ/tPTPcrxtCQ=", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha1-6gLGLgXcS+pn1EQvD7ce4ZL44Ks=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha1-wuC1oUpUCuvuO7xsP4ZmzJtQkSc=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha1-1rtrN5Asb+9RdOX1M/q0xzKib0I=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha1-Ed2hnVNo5Azp7CvcH7DsvAeQ7Oo=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha1-MudscLeXJOO7Vny51UPrhYzPrzA=", + "dev": true, + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha1-bh1x+k8YwF99D/IW3RakgdDo2a4=", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/socks/-/socks-2.8.9.tgz", + "integrity": "sha1-ql8TDKD4ikP6RPr0hpxQ0iqid1I=", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha1-uc205+mYUJ12WdaJznaXrCFkW+4=", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha1-48121MVI7oldPD/Y3B9sW5Ay56g=", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha1-j3XuzvdlteHPzcCA2llAntQk44I=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha1-2BCyfjoHMEeyteQANIgfXqb5yDs=", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha1-n8auJn2cTfEZCngeARY0ysWK880=", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha1-A1q1YFe37SIRtR1TLmlz8PmfvxE=", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha1-IiskPdLUnAvNDeiQatvYQXcZYDI=", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha1-BWaMxoowdBw4E/nBZZO43sfcvNE=", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha1-DQBk2bZ+o8n1q94VXjX6qw3zdZE=", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/teex/-/teex-1.0.1.tgz", + "integrity": "sha1-uPpyRe+Ojv+oB4KBlGyFq3gKCxI=", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha1-XQc6mnS5wKnSjfrcq5a2BK9X2Lo=", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha1-EDyfi6bXI3pHq23R3P93JRhjQms=", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha1-lBeU5leoXklld5lcbu9m9T9Cs9I=", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha1-BZ8tBCvTdWf7wBfT1Ca90qJhJZE=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha1-lQmyFiQ2MV6A4+7g/M5EdNJEQpQ=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha1-13oAL7U6iKoUKbQZwckkkuDIH3g=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha1-O+NDIaiKgg7RvYDfqjPkefu43TU=", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha1-FwPaAC7kQyuaBTkXQx+RRi/KI1s=", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha1-cdGnBTKTWC4WrJ8+uvGrmqSeVXA=", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha1-L7Pt5p3/oK94ynxM51iWgGOLVt8=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-7.3.5.tgz", + "integrity": "sha1-kMLQt7lKIk5+fc8i0pEv8LUpEWU=", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha1-82dtlMSvHnaJjBYsknKLymX3uwc=", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha1-GUS27QE6Jf0mpz0Y4a+SwQpXr2w=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha1-o/aalxB/SUs83Dvd3Yg6fWXOvwQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ws/-/ws-8.21.1.tgz", + "integrity": "sha1-BFZQzUsSB4CedUcUYiPDgUqa9YY=", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha1-m7knkNnA7/7GO+c1GeEaNQGaOnI=", + "license": "ISC" + } + } +} diff --git a/bridge/teams-gateway/package.json b/bridge/teams-gateway/package.json new file mode 100644 index 000000000..283e4ec6a --- /dev/null +++ b/bridge/teams-gateway/package.json @@ -0,0 +1,35 @@ +{ + "name": "@kars-bridge/teams-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx src/main.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "oxlint src/ tests/", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@kubernetes/client-node": "^1.4.0", + "@microsoft/teams.apps": "^2.0.15", + "@microsoft/teams.cards": "^2.0.15" + }, + "devDependencies": { + "@types/node": "^22", + "oxlint": "0.16.0", + "tsx": "^4", + "typescript": "^5.8", + "vitest": "^3.2" + }, + "overrides": { + "esbuild": "^0.28.1", + "js-yaml@^4": "^4.3.1", + "nanoid@^3": "^3.3.18" + } +} diff --git a/bridge/teams-gateway/src/bff-client.ts b/bridge/teams-gateway/src/bff-client.ts new file mode 100644 index 000000000..592ebb2fc --- /dev/null +++ b/bridge/teams-gateway/src/bff-client.ts @@ -0,0 +1,163 @@ +import type { TeamsGatewayConfig } from "./config.js"; +import type { CardVerdict } from "./cards.js"; +import type { ResolvedPrincipal } from "./identity.js"; +import { log } from "./log.js"; + +export interface DecisionRequest { + readonly approvalName: string; + readonly approvalNamespace: string; + readonly verdict: CardVerdict; + readonly reason?: string | undefined; + readonly resourceVersion: string; + readonly boundEnvelopeDigest?: string | undefined; + readonly principal: ResolvedPrincipal; +} + +export interface DecisionResponse { + readonly success: boolean; + readonly phase?: string | undefined; + readonly error?: string | undefined; +} + +export type TeamCommandName = + | "bind" + | "status" + | "list-tasks" + | "add-task" + | "run" + | "halt"; + +export interface TeamCommandRequest { + readonly teamName: string; + readonly namespace: string; + readonly command: TeamCommandName; + readonly args: string; + readonly principal: ResolvedPrincipal; +} + +export interface TeamCommandResponse { + readonly success: boolean; + readonly message: string; + readonly error?: string | undefined; +} + +export class BffClient { + private readonly baseUrl: string; + private readonly secret: string; + + public constructor(config: TeamsGatewayConfig) { + this.baseUrl = config.bffBaseUrl.replace(/\/$/, ""); + this.secret = config.bffInternalSecret; + } + + public async submitDecision( + request: DecisionRequest + ): Promise { + const response = await this.postJson( + "/api/internal/teams/decision", + { + approval_name: request.approvalName, + approval_namespace: request.approvalNamespace, + verdict: request.verdict, + resource_version: request.resourceVersion, + ...(request.reason && request.reason.trim().length > 0 + ? { reason: request.reason.trim() } + : {}), + ...(request.boundEnvelopeDigest !== undefined + ? { bound_envelope_digest: request.boundEnvelopeDigest } + : {}), + entra_subject: request.principal.entraSubject, + entra_name: request.principal.name, + } + ); + if (!response.success) { + return response; + } + const payload = response.json as { phase?: unknown }; + return { + success: true, + phase: + typeof payload.phase === "string" ? payload.phase : undefined, + }; + } + + public async sendTeamCommand( + request: TeamCommandRequest + ): Promise { + const response = await this.postJson( + "/api/internal/teams/command", + { + team_name: request.teamName, + namespace: request.namespace, + command: request.command, + args: request.args, + entra_subject: request.principal.entraSubject, + entra_name: request.principal.name, + } + ); + if (!response.success) { + return { + success: false, + message: "", + error: response.error, + }; + } + const payload = response.json as { message?: unknown }; + return { + success: true, + message: + typeof payload.message === "string" + ? payload.message + : "Command completed.", + }; + } + + private async postJson( + path: string, + body: Record + ): Promise< + | { + readonly success: true; + readonly json: unknown; + } + | { + readonly success: false; + readonly error: string; + } + > { + const url = `${this.baseUrl}${path}`; + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Teams-Internal-Secret": this.secret, + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const error = await response.text(); + log("error", "BFF request failed", { + url, + status: String(response.status), + error, + }); + return { + success: false, + error, + }; + } + return { + success: true, + json: (await response.json()) as unknown, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log("error", "BFF request failed", { url, error: message }); + return { + success: false, + error: message, + }; + } + } +} diff --git a/bridge/teams-gateway/src/cards.ts b/bridge/teams-gateway/src/cards.ts new file mode 100644 index 000000000..033f5b587 --- /dev/null +++ b/bridge/teams-gateway/src/cards.ts @@ -0,0 +1,291 @@ +import type { IAdaptiveCard } from "@microsoft/teams.cards"; + +export interface ApprovalEvent { + readonly name: string; + readonly namespace: string; + readonly task: string; + readonly team?: string | undefined; + readonly actionKind: string; + readonly summary: string; + readonly detail?: string | undefined; + readonly requestedTier?: number | undefined; + readonly resourceVersion: string; + readonly boundEnvelopeDigest?: string | undefined; +} + +export type CardVerdict = "approve" | "request-changes" | "deny"; + +export interface CardActionPayload { + readonly action: "kars.approval.decision"; + readonly approvalName: string; + readonly approvalNamespace: string; + readonly verdict: CardVerdict; + readonly resourceVersion: string; + readonly boundEnvelopeDigest?: string | undefined; + readonly requestChangesReason?: string | undefined; +} + +export interface CardFact { + readonly title: string; + readonly value: string; +} + +export interface ProgressEvent { + readonly kind: "team" | "task"; + readonly teamName: string; + readonly resourceName: string; + readonly title: string; + readonly status: string; + readonly summary: string; + readonly detail?: string | undefined; + readonly stage?: string | undefined; + readonly facts?: readonly CardFact[] | undefined; +} + +type CardElement = Record; +type CardAction = Record; + +const ROUTE_ACTION = "kars.approval.decision"; + +function textBlock( + text: string, + options?: { + readonly weight?: "Bolder" | "Default" | undefined; + readonly size?: "Medium" | "Default" | "Small" | undefined; + readonly color?: "Attention" | "Default" | "Good" | "Warning" | undefined; + readonly isSubtle?: boolean | undefined; + } +): CardElement { + return { + type: "TextBlock", + text, + wrap: true, + ...(options?.weight !== undefined ? { weight: options.weight } : {}), + ...(options?.size !== undefined ? { size: options.size } : {}), + ...(options?.color !== undefined ? { color: options.color } : {}), + ...(options?.isSubtle !== undefined ? { isSubtle: options.isSubtle } : {}), + }; +} + +function buildCard( + body: readonly CardElement[], + actions?: readonly CardAction[] | undefined +): IAdaptiveCard { + return { + type: "AdaptiveCard", + version: "1.4", + body: [...body], + ...(actions && actions.length > 0 ? { actions: [...actions] } : {}), + } as unknown as IAdaptiveCard; +} + +function factSet(facts: readonly CardFact[]): CardElement { + return { + type: "FactSet", + facts: facts.map((fact) => ({ title: fact.title, value: fact.value })), + }; +} + +function verdictLabel(verdict: CardVerdict): string { + switch (verdict) { + case "approve": + return "Approved"; + case "request-changes": + return "Changes Requested"; + case "deny": + return "Denied"; + } +} + +function verdictIcon(verdict: CardVerdict): string { + switch (verdict) { + case "approve": + return "✅"; + case "request-changes": + return "↩️"; + case "deny": + return "❌"; + } +} + +function progressIcon(kind: ProgressEvent["kind"], status: string): string { + const normalized = status.toLowerCase(); + if (normalized.includes("ready") || normalized.includes("success")) { + return "✅"; + } + if (normalized.includes("degraded") || normalized.includes("error")) { + return "⚠️"; + } + if (kind === "team") { + return "👥"; + } + return "🚀"; +} + +function buildApprovalAction( + title: string, + verb: string, + verdict: CardVerdict, + event: ApprovalEvent, + style?: "positive" | "destructive" | undefined +): CardAction { + return { + type: "Action.Execute", + title, + verb, + ...(style !== undefined ? { style } : {}), + data: { + action: ROUTE_ACTION, + approvalName: event.name, + approvalNamespace: event.namespace, + verdict, + resourceVersion: event.resourceVersion, + ...(event.boundEnvelopeDigest !== undefined + ? { boundEnvelopeDigest: event.boundEnvelopeDigest } + : {}), + } satisfies CardActionPayload, + }; +} + +export function buildApprovalCard(event: ApprovalEvent): IAdaptiveCard { + const facts: CardFact[] = [ + { title: "Task", value: event.task }, + { title: "Kind", value: event.actionKind }, + { title: "Namespace", value: event.namespace }, + ]; + if (event.team) { + facts.push({ title: "Team", value: event.team }); + } + if (event.requestedTier !== undefined) { + facts.push({ title: "Requested Tier", value: String(event.requestedTier) }); + } + + const body: CardElement[] = [ + textBlock(`🔐 Approval Required: ${event.summary}`, { + weight: "Bolder", + size: "Medium", + }), + factSet(facts), + ]; + if (event.detail) { + body.push(textBlock(event.detail, { size: "Small" })); + } + body.push({ + type: "Input.Text", + id: "requestChangesReason", + label: "Feedback", + isMultiline: true, + placeholder: "Describe the requested changes", + }); + + return buildCard(body, [ + buildApprovalAction("Approve", "kars.approve", "approve", event, "positive"), + buildApprovalAction( + "Request Changes", + "kars.request-changes", + "request-changes", + event + ), + buildApprovalAction("Deny", "kars.deny", "deny", event, "destructive"), + ]); +} + +export function buildDecidedCard( + event: ApprovalEvent, + verdict: CardVerdict, + deciderName: string, + reason?: string | undefined +): IAdaptiveCard { + const facts: CardFact[] = [ + { title: "Task", value: event.task }, + { title: "Decision", value: verdictLabel(verdict) }, + { title: "By", value: deciderName }, + ]; + if (event.team) { + facts.push({ title: "Team", value: event.team }); + } + if (reason) { + facts.push({ title: "Reason", value: reason }); + } + + const body: CardElement[] = [ + textBlock(`${verdictIcon(verdict)} ${verdictLabel(verdict)}: ${event.summary}`, { + weight: "Bolder", + size: "Medium", + color: verdict === "approve" ? "Good" : verdict === "deny" ? "Attention" : "Warning", + }), + factSet(facts), + ]; + if (event.detail) { + body.push(textBlock(event.detail, { size: "Small", isSubtle: true })); + } + return buildCard(body); +} + +export function buildProgressCard(event: ProgressEvent): IAdaptiveCard { + const facts: CardFact[] = [ + { title: "Team", value: event.teamName }, + { title: event.kind === "team" ? "Standing Team" : "Task", value: event.resourceName }, + ]; + if (event.stage) { + facts.push({ title: "Stage", value: event.stage }); + } + for (const fact of event.facts ?? []) { + facts.push(fact); + } + + const body: CardElement[] = [ + textBlock(`${progressIcon(event.kind, event.status)} ${event.title}`, { + weight: "Bolder", + size: "Medium", + }), + textBlock(event.status, { color: "Default", weight: "Bolder" }), + factSet(facts), + textBlock(event.summary), + ]; + if (event.detail) { + body.push(textBlock(event.detail, { size: "Small", isSubtle: true })); + } + return buildCard(body); +} + +export function isCardActionPayload(data: unknown): data is CardActionPayload { + if (typeof data !== "object" || data === null) { + return false; + } + const payload = data as Record; + if (payload["action"] !== ROUTE_ACTION) { + return false; + } + if ( + typeof payload["approvalName"] !== "string" || + payload["approvalName"].trim().length === 0 || + typeof payload["approvalNamespace"] !== "string" || + payload["approvalNamespace"].trim().length === 0 || + typeof payload["resourceVersion"] !== "string" || + payload["resourceVersion"].trim().length === 0 || + typeof payload["verdict"] !== "string" + ) { + return false; + } + if ( + payload["verdict"] !== "approve" && + payload["verdict"] !== "request-changes" && + payload["verdict"] !== "deny" + ) { + return false; + } + if ( + payload["boundEnvelopeDigest"] !== undefined && + typeof payload["boundEnvelopeDigest"] !== "string" + ) { + return false; + } + if ( + payload["requestChangesReason"] !== undefined && + typeof payload["requestChangesReason"] !== "string" + ) { + return false; + } + return true; +} diff --git a/bridge/teams-gateway/src/config.ts b/bridge/teams-gateway/src/config.ts new file mode 100644 index 000000000..d3ddde82a --- /dev/null +++ b/bridge/teams-gateway/src/config.ts @@ -0,0 +1,128 @@ +// kars Bridge — Teams Gateway: configuration (fail-closed). +// +// All identity and routing configuration is required. The gateway refuses to +// start if any field is missing or malformed. + +export interface EntraRoleMapping { + readonly entraSubject: string; + readonly bridgeSubject: string; + readonly bridgeRoles: readonly string[]; + readonly displayName: string; +} + +export interface TeamsGatewayConfig { + readonly clientId: string; + readonly clientSecret: string; + readonly tenantId: string; + readonly entraRoleMappings: readonly EntraRoleMapping[]; + readonly bffBaseUrl: string; + readonly bffInternalSecret: string; + readonly port: number; + readonly internalPort: number; + readonly conversationConfigMapNamespace: string; + readonly conversationConfigMapName: string; + readonly watchNamespace: string; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value || value.trim().length === 0) { + throw new Error( + `FATAL: required environment variable ${name} is not set. ` + + `The Teams gateway refuses to start without complete identity configuration (fail-closed).` + ); + } + return value.trim(); +} + +/** + * Parse TEAMS_ENTRA_ROLE_MAP JSON: + * [{"entra_subject":"","bridge_subject":"","roles":["operator","user"],"name":"Alice"}] + * + * Every subject must have at least one role. Fail closed if empty or malformed. + */ +function parseRoleMappings(raw: string): EntraRoleMapping[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + "FATAL: TEAMS_ENTRA_ROLE_MAP is not valid JSON. Expected an array of {subject, roles, name}." + ); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error( + "FATAL: TEAMS_ENTRA_ROLE_MAP must be a non-empty JSON array." + ); + } + const mappings: EntraRoleMapping[] = []; + for (const entry of parsed) { + if ( + typeof entry !== "object" || + entry === null || + typeof entry.entra_subject !== "string" || + !entry.entra_subject.trim() || + typeof entry.bridge_subject !== "string" || + !entry.bridge_subject.trim() || + !Array.isArray(entry.roles) || + entry.roles.length === 0 || + typeof entry.name !== "string" || + !entry.name.trim() + ) { + throw new Error( + `FATAL: TEAMS_ENTRA_ROLE_MAP entry is malformed: ${JSON.stringify(entry)}. ` + + `Each entry must have non-empty "entra_subject", "bridge_subject", "roles" (array), and "name".` + ); + } + for (const role of entry.roles) { + if (typeof role !== "string" || !role.trim()) { + throw new Error( + `FATAL: TEAMS_ENTRA_ROLE_MAP entry for ${entry.entra_subject} has invalid role.` + ); + } + } + mappings.push({ + entraSubject: entry.entra_subject.trim(), + bridgeSubject: entry.bridge_subject.trim(), + bridgeRoles: entry.roles.map((r: string) => r.trim()), + displayName: entry.name.trim(), + }); + } + return mappings; +} + +export function loadConfig(): TeamsGatewayConfig { + const clientId = requireEnv("TEAMS_CLIENT_ID"); + const clientSecret = requireEnv("TEAMS_CLIENT_SECRET"); + const tenantId = requireEnv("TEAMS_TENANT_ID"); + const bffBaseUrl = requireEnv("TEAMS_BFF_BASE_URL"); + const bffInternalSecret = requireEnv("TEAMS_BFF_INTERNAL_SECRET"); + + const rawRoleMap = requireEnv("TEAMS_ENTRA_ROLE_MAP"); + const entraRoleMappings = parseRoleMappings(rawRoleMap); + + const port = parseInt(process.env["TEAMS_GATEWAY_PORT"] ?? "3978", 10); + const internalPort = port + 1; + const conversationConfigMapNamespace = + process.env["TEAMS_CONFIGMAP_NAMESPACE"]?.trim() || "kars-system"; + const conversationConfigMapName = + process.env["TEAMS_CONFIGMAP_NAME"]?.trim() || "kars-teams-conversations"; + const watchNamespace = + process.env["TEAMS_WATCH_NAMESPACE"]?.trim() || "kars-system"; + + return { + clientId, + clientSecret, + tenantId, + entraRoleMappings, + bffBaseUrl, + bffInternalSecret, + port, + internalPort, + conversationConfigMapNamespace, + conversationConfigMapName, + watchNamespace, + }; +} + +export { parseRoleMappings }; diff --git a/bridge/teams-gateway/src/conversation-store.ts b/bridge/teams-gateway/src/conversation-store.ts new file mode 100644 index 000000000..7c00d50fe --- /dev/null +++ b/bridge/teams-gateway/src/conversation-store.ts @@ -0,0 +1,516 @@ +import { + CoreV1Api, + KubeConfig, + type V1ConfigMap, +} from "@kubernetes/client-node"; +import { log } from "./log.js"; + +export interface ConversationBinding { + readonly conversationId: string; + readonly serviceUrl: string; + readonly tenantId: string; + readonly teamName: string; + readonly namespace: string; + readonly boundAt: string; + readonly rootMessageId?: string | undefined; +} + +export interface ApprovalMessageRecord { + readonly approvalName: string; + readonly approvalNamespace: string; + readonly conversationId: string; + readonly teamName: string; + readonly messageId: string; + readonly resourceVersion: string; + readonly boundEnvelopeDigest: string; + readonly sentAt: string; +} + +export interface ConversationStore { + getByTeam(teamName: string): Promise; + getByConversation( + conversationId: string + ): Promise; + bind(binding: ConversationBinding): Promise; + listBindings(): Promise; + getApprovalMessage( + approvalNamespace: string, + approvalName: string + ): Promise; + recordApprovalMessage(record: ApprovalMessageRecord): Promise; + getLastResourceVersion(stream: string): Promise; + setLastResourceVersion(stream: string, value: string): Promise; +} + +interface CoreV1ApiLike { + readNamespacedConfigMap( + name: string, + namespace: string + ): Promise; + createNamespacedConfigMap( + namespace: string, + body: V1ConfigMap + ): Promise; + replaceNamespacedConfigMap( + name: string, + namespace: string, + body: V1ConfigMap + ): Promise; +} + +export interface KubernetesConversationStoreOptions { + readonly namespace: string; + readonly configMapName: string; + readonly api?: CoreV1ApiLike | undefined; + readonly kubeConfig?: KubeConfig | undefined; +} + +interface PersistedStore { + readonly bindings: readonly ConversationBinding[]; + readonly approvalMessages: readonly ApprovalMessageRecord[]; + readonly resourceVersions: Readonly>; +} + +const BINDINGS_KEY = "bindings.json"; +const APPROVAL_MESSAGES_KEY = "approval-messages.json"; +const RESOURCE_VERSIONS_KEY = "resource-versions.json"; + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function unwrapResponse(response: unknown): T { + if ( + typeof response === "object" && + response !== null && + "body" in response + ) { + return (response as { body: T }).body; + } + return response as T; +} + +function statusCodeOf(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + const statusCode = (error as { statusCode?: unknown }).statusCode; + if (typeof statusCode === "number") { + return statusCode; + } + const code = (error as { code?: unknown }).code; + return typeof code === "number" ? code : undefined; +} + +function asObject(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function normalizeBinding(value: unknown): ConversationBinding | undefined { + const object = asObject(value); + if (!object) { + return undefined; + } + const conversationId = normalizeString(object.conversationId); + const serviceUrl = normalizeString(object.serviceUrl); + const tenantId = normalizeString(object.tenantId); + const teamName = (object.teamName as string | undefined)?.trim() ?? ""; + const namespace = (object.namespace as string | undefined)?.trim() ?? ""; + const boundAt = normalizeString(object.boundAt); + if (!conversationId || !serviceUrl || !tenantId || !boundAt) { + return undefined; + } + const rootMessageId = normalizeString(object.rootMessageId); + return { + conversationId, + serviceUrl, + tenantId, + teamName, + namespace, + boundAt, + ...(rootMessageId !== undefined ? { rootMessageId } : {}), + }; +} + +function normalizeApprovalRecord( + value: unknown +): ApprovalMessageRecord | undefined { + const object = asObject(value); + if (!object) { + return undefined; + } + const approvalName = normalizeString(object.approvalName); + const approvalNamespace = normalizeString(object.approvalNamespace); + const conversationId = normalizeString(object.conversationId); + const teamName = normalizeString(object.teamName); + const messageId = typeof object.messageId === "string" ? object.messageId : undefined; + const resourceVersion = normalizeString(object.resourceVersion); + const boundEnvelopeDigest = normalizeString(object.boundEnvelopeDigest); + const sentAt = normalizeString(object.sentAt); + if ( + !approvalName || + !approvalNamespace || + !conversationId || + !teamName || + messageId === undefined || + !resourceVersion || + !boundEnvelopeDigest || + !sentAt + ) { + return undefined; + } + return { + approvalName, + approvalNamespace, + conversationId, + teamName, + messageId, + resourceVersion, + boundEnvelopeDigest, + sentAt, + }; +} + +export function approvalMessageKey( + approvalNamespace: string, + approvalName: string +): string { + return `${approvalNamespace}/${approvalName}`; +} + +export class InMemoryConversationStore implements ConversationStore { + private readonly byConversation = new Map(); + private readonly byTeam = new Map(); + private readonly approvalMessages = new Map(); + private readonly resourceVersions = new Map(); + + public async getByTeam( + teamName: string + ): Promise { + return this.byTeam.get(teamName.trim()); + } + + public async getByConversation( + conversationId: string + ): Promise { + return this.byConversation.get(conversationId.trim()); + } + + public async bind(binding: ConversationBinding): Promise { + const normalized = normalizeBinding(binding); + if (!normalized) { + throw new Error("invalid conversation binding"); + } + const previous = this.byConversation.get(normalized.conversationId); + if (previous?.teamName && previous.teamName !== normalized.teamName) { + this.byTeam.delete(previous.teamName); + } + this.byConversation.set(normalized.conversationId, normalized); + if (normalized.teamName) { + this.byTeam.set(normalized.teamName, normalized); + } + log("info", "stored Teams conversation binding", { + conversationId: normalized.conversationId, + teamName: normalized.teamName || "(unbound)", + namespace: normalized.namespace || "(default)", + }); + } + + public async listBindings(): Promise { + return [...this.byConversation.values()]; + } + + public async getApprovalMessage( + approvalNamespace: string, + approvalName: string + ): Promise { + return this.approvalMessages.get( + approvalMessageKey(approvalNamespace, approvalName) + ); + } + + public async recordApprovalMessage( + record: ApprovalMessageRecord + ): Promise { + this.approvalMessages.set( + approvalMessageKey(record.approvalNamespace, record.approvalName), + record + ); + } + + public async getLastResourceVersion( + stream: string + ): Promise { + return this.resourceVersions.get(stream); + } + + public async setLastResourceVersion( + stream: string, + value: string + ): Promise { + const normalizedStream = stream.trim(); + const normalizedValue = value.trim(); + if (!normalizedStream || !normalizedValue) { + return; + } + this.resourceVersions.set(normalizedStream, normalizedValue); + } +} + +export class KubernetesConversationStore implements ConversationStore { + private readonly namespace: string; + private readonly configMapName: string; + private readonly api: CoreV1ApiLike; + private readonly byConversation = new Map(); + private readonly byTeam = new Map(); + private readonly approvalMessages = new Map(); + private readonly resourceVersions = new Map(); + private persistTail: Promise = Promise.resolve(); + + public constructor(options: KubernetesConversationStoreOptions) { + this.namespace = options.namespace; + this.configMapName = options.configMapName; + if (options.api) { + this.api = options.api; + } else { + const kubeConfig = options.kubeConfig ?? new KubeConfig(); + if (!options.kubeConfig) { + kubeConfig.loadFromCluster(); + } + this.api = kubeConfig.makeApiClient(CoreV1Api) as unknown as CoreV1ApiLike; + } + } + + public async initialize(): Promise { + const configMap = await this.readOrCreateConfigMap(); + this.loadFromData(configMap.data ?? {}); + } + + public async getByTeam( + teamName: string + ): Promise { + return this.byTeam.get(teamName.trim()); + } + + public async getByConversation( + conversationId: string + ): Promise { + return this.byConversation.get(conversationId.trim()); + } + + public async bind(binding: ConversationBinding): Promise { + const normalized = normalizeBinding(binding); + if (!normalized) { + throw new Error("invalid conversation binding"); + } + const previous = this.byConversation.get(normalized.conversationId); + if (previous?.teamName && previous.teamName !== normalized.teamName) { + this.byTeam.delete(previous.teamName); + } + this.byConversation.set(normalized.conversationId, normalized); + if (normalized.teamName) { + this.byTeam.set(normalized.teamName, normalized); + } + await this.persist(); + } + + public async listBindings(): Promise { + return [...this.byConversation.values()]; + } + + public async getApprovalMessage( + approvalNamespace: string, + approvalName: string + ): Promise { + return this.approvalMessages.get( + approvalMessageKey(approvalNamespace, approvalName) + ); + } + + public async recordApprovalMessage( + record: ApprovalMessageRecord + ): Promise { + this.approvalMessages.set( + approvalMessageKey(record.approvalNamespace, record.approvalName), + record + ); + await this.persist(); + } + + public async getLastResourceVersion( + stream: string + ): Promise { + return this.resourceVersions.get(stream.trim()); + } + + public async setLastResourceVersion( + stream: string, + value: string + ): Promise { + const normalizedStream = stream.trim(); + const normalizedValue = value.trim(); + if (!normalizedStream || !normalizedValue) { + return; + } + this.resourceVersions.set(normalizedStream, normalizedValue); + await this.persist(); + } + + private async readOrCreateConfigMap(): Promise { + const existing = await this.readConfigMap(); + if (existing) { + return existing; + } + return this.createConfigMap(); + } + + private async readConfigMap(): Promise { + try { + return unwrapResponse( + await this.api.readNamespacedConfigMap( + this.configMapName, + this.namespace + ) + ); + } catch (error) { + if (statusCodeOf(error) === 404) { + return undefined; + } + throw error; + } + } + + private async createConfigMap(): Promise { + const configMap: V1ConfigMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: this.configMapName, + namespace: this.namespace, + }, + data: this.serialize(), + }; + try { + return unwrapResponse( + await this.api.createNamespacedConfigMap(this.namespace, configMap) + ); + } catch (error) { + if (statusCodeOf(error) === 409) { + const existing = await this.readConfigMap(); + if (existing) { + return existing; + } + } + throw error; + } + } + + private async persist(): Promise { + const operation = this.persistTail.then(() => this.persistOnce()); + this.persistTail = operation.catch(() => undefined); + return operation; + } + + private async persistOnce(): Promise { + const current = await this.readOrCreateConfigMap(); + const resourceVersion = normalizeString(current.metadata?.resourceVersion); + const body: V1ConfigMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + ...current.metadata, + name: this.configMapName, + namespace: this.namespace, + ...(resourceVersion !== undefined ? { resourceVersion } : {}), + }, + data: this.serialize(), + }; + await this.api.replaceNamespacedConfigMap( + this.configMapName, + this.namespace, + body + ); + } + + private serialize(): Record { + const data: PersistedStore = { + bindings: [...this.byConversation.values()], + approvalMessages: [...this.approvalMessages.values()], + resourceVersions: Object.fromEntries(this.resourceVersions), + }; + return { + [BINDINGS_KEY]: JSON.stringify(data.bindings), + [APPROVAL_MESSAGES_KEY]: JSON.stringify(data.approvalMessages), + [RESOURCE_VERSIONS_KEY]: JSON.stringify(data.resourceVersions), + }; + } + + private loadFromData(data: Record): void { + this.byConversation.clear(); + this.byTeam.clear(); + this.approvalMessages.clear(); + this.resourceVersions.clear(); + + const rawBindings = data[BINDINGS_KEY]; + if (rawBindings) { + try { + const parsed = JSON.parse(rawBindings) as unknown[]; + for (const entry of parsed) { + const binding = normalizeBinding(entry); + if (!binding) { + continue; + } + this.byConversation.set(binding.conversationId, binding); + if (binding.teamName) { + this.byTeam.set(binding.teamName, binding); + } + } + } catch { + log("warn", "failed to parse persisted Teams conversation bindings"); + } + } + + const rawApprovalMessages = data[APPROVAL_MESSAGES_KEY]; + if (rawApprovalMessages) { + try { + const parsed = JSON.parse(rawApprovalMessages) as unknown[]; + for (const entry of parsed) { + const record = normalizeApprovalRecord(entry); + if (!record) { + continue; + } + this.approvalMessages.set( + approvalMessageKey( + record.approvalNamespace, + record.approvalName + ), + record + ); + } + } catch { + log("warn", "failed to parse persisted approval message records"); + } + } + + const rawResourceVersions = data[RESOURCE_VERSIONS_KEY]; + if (rawResourceVersions) { + try { + const parsed = JSON.parse(rawResourceVersions) as Record; + for (const [key, value] of Object.entries(parsed)) { + const normalizedValue = normalizeString(value); + if (normalizedValue) { + this.resourceVersions.set(key, normalizedValue); + } + } + } catch { + log("warn", "failed to parse persisted watch resource versions"); + } + } + } +} diff --git a/bridge/teams-gateway/src/hmac.ts b/bridge/teams-gateway/src/hmac.ts new file mode 100644 index 000000000..c5da7c68e --- /dev/null +++ b/bridge/teams-gateway/src/hmac.ts @@ -0,0 +1,26 @@ +// kars Bridge — Teams Gateway: HMAC-SHA256 authentication for internal endpoints. + +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const SIGNATURE_HEADER = "x-teams-internal-signature"; + +export function computeHmac(secret: string, body: string): string { + return createHmac("sha256", secret).update(body).digest("hex"); +} + +export function verifyHmac( + secret: string, + body: string, + signature: string | null +): boolean { + if (!signature || signature.length !== 64) return false; + const expected = computeHmac(secret, body); + try { + return timingSafeEqual( + Buffer.from(expected, "hex"), + Buffer.from(signature, "hex") + ); + } catch { + return false; + } +} diff --git a/bridge/teams-gateway/src/identity.ts b/bridge/teams-gateway/src/identity.ts new file mode 100644 index 000000000..7905507a8 --- /dev/null +++ b/bridge/teams-gateway/src/identity.ts @@ -0,0 +1,43 @@ +import type { TeamsGatewayConfig } from "./config.js"; +import { log } from "./log.js"; + +export interface TeamsIdentity { + readonly aadObjectId: string; + readonly displayName: string; +} + +export interface ResolvedPrincipal { + readonly entraSubject: string; + readonly sub: string; + readonly name: string; + readonly roles: readonly string[]; +} + +export function resolveIdentity( + config: TeamsGatewayConfig, + identity: TeamsIdentity | undefined +): ResolvedPrincipal | null { + const subject = identity?.aadObjectId.trim(); + if (!subject) { + log("warn", "rejecting Teams activity without Entra subject"); + return null; + } + + const mapping = config.entraRoleMappings.find( + (entry) => entry.entraSubject === subject + ); + if (!mapping) { + log("warn", "rejecting Teams activity from unmapped Entra subject", { + aadObjectId: subject, + displayName: identity?.displayName ?? "unknown", + }); + return null; + } + + return { + entraSubject: mapping.entraSubject, + sub: mapping.bridgeSubject, + name: mapping.displayName, + roles: [...mapping.bridgeRoles], + }; +} diff --git a/bridge/teams-gateway/src/log.ts b/bridge/teams-gateway/src/log.ts new file mode 100644 index 000000000..4486e8a08 --- /dev/null +++ b/bridge/teams-gateway/src/log.ts @@ -0,0 +1,45 @@ +// kars Bridge — Teams Gateway: structured logging with automatic secret redaction. + +export type LogLevel = "info" | "warn" | "error" | "debug"; + +const REDACT_PATTERNS = [ + /secret/i, + /token/i, + /authorization/i, + /password/i, + /bearer/i, + /credential/i, +]; + +function redactValue(key: string, value: unknown): unknown { + if (typeof value !== "string") return value; + if (REDACT_PATTERNS.some((p) => p.test(key))) return "[REDACTED]"; + return value; +} + +function sanitize(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = redactValue(key, value); + } + return result; +} + +export function log( + level: LogLevel, + message: string, + meta?: Record +): void { + const entry = { + ts: new Date().toISOString(), + level, + msg: message, + ...(meta ? sanitize(meta) : {}), + }; + const line = JSON.stringify(entry); + if (level === "error") { + process.stderr.write(line + "\n"); + } else { + process.stdout.write(line + "\n"); + } +} diff --git a/bridge/teams-gateway/src/main.ts b/bridge/teams-gateway/src/main.ts new file mode 100644 index 000000000..62dad34e8 --- /dev/null +++ b/bridge/teams-gateway/src/main.ts @@ -0,0 +1,585 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { pathToFileURL } from "node:url"; +import { App } from "@microsoft/teams.apps"; +import type { + AdaptiveCardActionCardResponse, + AdaptiveCardActionMessageResponse, +} from "@microsoft/teams.api"; +import { BffClient, type TeamCommandName } from "./bff-client.js"; +import { + buildApprovalCard, + buildDecidedCard, + buildProgressCard, + isCardActionPayload, + type ApprovalEvent, + type ProgressEvent, +} from "./cards.js"; +import { loadConfig, type TeamsGatewayConfig } from "./config.js"; +import { + InMemoryConversationStore, + KubernetesConversationStore, + type ConversationStore, +} from "./conversation-store.js"; +import { SIGNATURE_HEADER, verifyHmac } from "./hmac.js"; +import { resolveIdentity, type TeamsIdentity } from "./identity.js"; +import { log } from "./log.js"; +import { GatewayWatcher } from "./watcher.js"; + +interface TeamsMessageContext { + readonly activity: TeamsActivity; + send(activity: TeamsOutboundActivity): Promise; +} + +interface TeamsCardActionContext { + readonly activity: TeamsActivity; +} + +interface TeamsActivity { + readonly text?: string | undefined; + readonly from?: { + readonly aadObjectId?: string | undefined; + readonly name?: string | undefined; + } | undefined; + readonly conversation?: { + readonly id?: string | undefined; + readonly tenantId?: string | undefined; + } | undefined; + readonly serviceUrl?: string | undefined; + readonly value?: { + readonly action?: { + readonly data?: unknown; + } | undefined; + } | undefined; +} + +interface TeamsOutboundActivity { + readonly type: "message"; + readonly text?: string | undefined; + readonly attachments?: readonly { + readonly contentType: "application/vnd.microsoft.card.adaptive"; + readonly content: object; + }[]; +} + +interface TeamsAppLike { + on(route: string, handler: (context: unknown) => unknown): void; + start(port?: number | string): Promise; + send( + conversationId: string, + activity: TeamsOutboundActivity + ): Promise<{ id?: string | undefined } | null>; + readonly api?: { + readonly conversations?: { + updateActivity( + conversationId: string, + activityId: string, + activity: TeamsOutboundActivity + ): Promise; + } | undefined; + } | undefined; +} + +export interface GatewayHandlerDependencies { + readonly config: TeamsGatewayConfig; + readonly store: ConversationStore; + readonly bff: Pick; + readonly reconcileTeamApprovals?: ((teamName: string) => Promise) | undefined; +} + +interface GatewayCommand { + readonly name: "bind" | TeamCommandName; + readonly args: string; +} + +interface InternalNotifyRequest { + readonly teamName?: string | undefined; + readonly team?: string | undefined; + readonly approval?: ApprovalEvent | undefined; + readonly progress?: ProgressEvent | undefined; + readonly event?: ApprovalEvent | undefined; + readonly updateMessageId?: string | undefined; +} + +const HELP_TEXT = + "Commands: `/bind `, `/status`, `/list-tasks`, `/add-task `, `/run`, `/halt <run-name> [reason]`"; + +function adaptiveCardActivity(card: object): TeamsOutboundActivity { + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }, + ], + }; +} + +function messageResponse( + value: string +): AdaptiveCardActionMessageResponse { + return { + statusCode: 200, + type: "application/vnd.microsoft.activity.message", + value, + }; +} + +export function parseGatewayCommand(text: string): GatewayCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + const withoutSlash = trimmed.slice(1); + const [rawCommand, ...rest] = withoutSlash.split(/\s+/); + const args = rest.join(" ").trim(); + switch (rawCommand) { + case "bind": + case "status": + case "list-tasks": + case "add-task": + case "run": + case "halt": + return { name: rawCommand, args }; + default: + return null; + } +} + +export function extractIdentity( + activity: TeamsActivity +): TeamsIdentity | undefined { + const aadObjectId = activity.from?.aadObjectId?.trim(); + if (!aadObjectId) { + return undefined; + } + return { + aadObjectId, + displayName: activity.from?.name?.trim() || "Unknown", + }; +} + +export async function handleApprovalCardAction( + context: TeamsCardActionContext, + dependencies: GatewayHandlerDependencies +): Promise< + AdaptiveCardActionCardResponse | AdaptiveCardActionMessageResponse +> { + const principal = resolveIdentity( + dependencies.config, + extractIdentity(context.activity) + ); + if (!principal) { + return messageResponse("⛔ You are not authorized for this action."); + } + + const actionData = context.activity.value?.action?.data; + if (!isCardActionPayload(actionData)) { + return messageResponse("Invalid action payload."); + } + + const requestChangesReason = + typeof context.activity.value?.action?.data === "object" && + context.activity.value?.action?.data !== null && + typeof ( + context.activity.value.action.data as { + requestChangesReason?: unknown; + } + ).requestChangesReason === "string" + ? ( + context.activity.value.action.data as { + requestChangesReason: string; + } + ).requestChangesReason.trim() + : ""; + + if ( + actionData.verdict === "request-changes" && + requestChangesReason.length === 0 + ) { + return messageResponse( + "⚠️ Request Changes requires written feedback. Fill in the Feedback field and try again." + ); + } + + const decision = await dependencies.bff.submitDecision({ + approvalName: actionData.approvalName, + approvalNamespace: actionData.approvalNamespace, + verdict: actionData.verdict, + reason: + requestChangesReason.length > 0 ? requestChangesReason : undefined, + resourceVersion: actionData.resourceVersion, + ...(actionData.boundEnvelopeDigest !== undefined + ? { boundEnvelopeDigest: actionData.boundEnvelopeDigest } + : {}), + principal, + }); + + if (!decision.success) { + return messageResponse( + `⚠️ Decision failed: ${decision.error ?? "unknown error"}` + ); + } + + const decidedCard = buildDecidedCard( + { + name: actionData.approvalName, + namespace: actionData.approvalNamespace, + task: actionData.approvalName, + actionKind: "approval", + summary: `Approval ${actionData.approvalName}`, + resourceVersion: actionData.resourceVersion, + ...(actionData.boundEnvelopeDigest !== undefined + ? { boundEnvelopeDigest: actionData.boundEnvelopeDigest } + : {}), + }, + actionData.verdict, + principal.name, + requestChangesReason.length > 0 ? requestChangesReason : undefined + ); + + return { + statusCode: 200, + type: "application/vnd.microsoft.card.adaptive", + value: decidedCard, + }; +} + +export function registerAppHandlers( + app: TeamsAppLike, + dependencies: GatewayHandlerDependencies +): void { + app.on("message", async (context: unknown) => { + await handleMessage(context as TeamsMessageContext, dependencies); + }); + app.on("card.action.kars.approval.decision", async (context: unknown) => { + return handleApprovalCardAction( + context as TeamsCardActionContext, + dependencies + ); + }); + app.on("install.add", async (context: unknown) => { + await handleInstall(context as TeamsMessageContext, dependencies); + }); +} + +async function handleMessage( + context: TeamsMessageContext, + dependencies: GatewayHandlerDependencies +): Promise<void> { + const principal = resolveIdentity( + dependencies.config, + extractIdentity(context.activity) + ); + if (!principal) { + await context.send({ + type: "message", + text: "⛔ You are not authorized for this gateway.", + }); + return; + } + + const command = parseGatewayCommand(context.activity.text ?? ""); + if (!command) { + await context.send({ type: "message", text: HELP_TEXT }); + return; + } + + if (command.name === "bind") { + const teamName = command.args.trim(); + if (!teamName) { + await context.send({ + type: "message", + text: "Usage: `/bind <team-name>`", + }); + return; + } + const conversationId = context.activity.conversation?.id?.trim(); + if (!conversationId) { + await context.send({ + type: "message", + text: "Unable to bind this conversation.", + }); + return; + } + const ownership = await dependencies.bff.sendTeamCommand({ + teamName, + namespace: dependencies.config.watchNamespace, + command: "bind", + args: "", + principal, + }); + if (!ownership.success) { + await context.send({ + type: "message", + text: `Unable to bind team **${teamName}**: ${ownership.error ?? ownership.message}`, + }); + return; + } + await dependencies.store.bind({ + conversationId, + serviceUrl: context.activity.serviceUrl ?? "", + tenantId: + context.activity.conversation?.tenantId ?? + dependencies.config.tenantId, + teamName, + namespace: dependencies.config.watchNamespace, + boundAt: new Date().toISOString(), + }); + await dependencies.reconcileTeamApprovals?.(teamName); + await context.send({ + type: "message", + text: `Bound this conversation to team **${teamName}**.`, + }); + return; + } + + const conversationId = context.activity.conversation?.id?.trim() ?? ""; + const binding = conversationId + ? await dependencies.store.getByConversation(conversationId) + : undefined; + if (!binding?.teamName) { + await context.send({ + type: "message", + text: "This conversation is not bound to a Kars team yet. Use `/bind <team-name>` first.", + }); + return; + } + + const result = await dependencies.bff.sendTeamCommand({ + teamName: binding.teamName, + namespace: binding.namespace || dependencies.config.watchNamespace, + command: command.name, + args: command.args, + principal, + }); + await context.send({ + type: "message", + text: result.success + ? result.message + : `⚠️ ${result.error ?? "Command failed."}`, + }); +} + +async function handleInstall( + context: TeamsMessageContext, + dependencies: GatewayHandlerDependencies +): Promise<void> { + const conversationId = context.activity.conversation?.id?.trim(); + if (!conversationId) { + return; + } + await dependencies.store.bind({ + conversationId, + serviceUrl: context.activity.serviceUrl ?? "", + tenantId: + context.activity.conversation?.tenantId ?? dependencies.config.tenantId, + teamName: "", + namespace: dependencies.config.watchNamespace, + boundAt: new Date().toISOString(), + }); + await context.send({ + type: "message", + text: "Kars Teams Gateway installed. Use `/bind <team-name>` to connect this conversation.", + }); +} + +async function initConversationStore( + config: TeamsGatewayConfig +): Promise<ConversationStore> { + if (process.env["KUBERNETES_SERVICE_HOST"]) { + const store = new KubernetesConversationStore({ + namespace: config.conversationConfigMapNamespace, + configMapName: config.conversationConfigMapName, + }); + await store.initialize(); + return store; + } + log("info", "using in-memory Teams conversation store"); + return new InMemoryConversationStore(); +} + +async function readBody(request: IncomingMessage): Promise<string> { + return new Promise<string>((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + request.on("error", reject); + }); +} + +function getHeader( + request: IncomingMessage, + headerName: string +): string | null { + const value = request.headers[headerName.toLowerCase()]; + if (typeof value === "string") { + return value; + } + if (Array.isArray(value) && value.length > 0) { + return value[0] ?? null; + } + return null; +} + +export async function handleInternalNotify( + request: IncomingMessage, + response: ServerResponse, + dependencies: GatewayHandlerDependencies, + app: TeamsAppLike +): Promise<void> { + const rawBody = await readBody(request); + const signature = getHeader(request, SIGNATURE_HEADER); + if (!verifyHmac(dependencies.config.bffInternalSecret, rawBody, signature)) { + response.writeHead(401, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "unauthorized" })); + return; + } + + let payload: InternalNotifyRequest; + try { + payload = JSON.parse(rawBody) as InternalNotifyRequest; + } catch { + response.writeHead(400, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "invalid JSON" })); + return; + } + + const teamName = payload.teamName ?? payload.team; + if (!teamName) { + response.writeHead(400, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "missing team name" })); + return; + } + const binding = await dependencies.store.getByTeam(teamName); + if (!binding) { + response.writeHead(404, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "team is not bound" })); + return; + } + + const approval = payload.approval ?? payload.event; + const progress = payload.progress; + if (!approval && !progress) { + response.writeHead(400, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "missing approval or progress payload" })); + return; + } + + const card = approval + ? buildApprovalCard(approval) + : buildProgressCard(progress as ProgressEvent); + const updateMessageId = payload.updateMessageId?.trim(); + + if ( + updateMessageId && + app.api?.conversations?.updateActivity !== undefined + ) { + await app.api.conversations.updateActivity( + binding.conversationId, + updateMessageId, + adaptiveCardActivity(card) + ); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ sent: true, updated: true })); + return; + } + + const sent = await app.send( + binding.conversationId, + adaptiveCardActivity(card) + ); + if (approval && approval.boundEnvelopeDigest) { + await dependencies.store.recordApprovalMessage({ + approvalName: approval.name, + approvalNamespace: approval.namespace, + conversationId: binding.conversationId, + teamName: binding.teamName, + messageId: sent?.id ?? "", + resourceVersion: approval.resourceVersion, + boundEnvelopeDigest: approval.boundEnvelopeDigest, + sentAt: new Date().toISOString(), + }); + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ sent: true, id: sent?.id ?? null })); +} + +export async function main(): Promise<void> { + const config = loadConfig(); + const store = await initConversationStore(config); + const bff = new BffClient(config); + const app = new App({ + clientId: config.clientId, + clientSecret: config.clientSecret, + tenantId: config.tenantId, + }); + const watcher = process.env["KUBERNETES_SERVICE_HOST"] + ? new GatewayWatcher(config, store, app) + : undefined; + + registerAppHandlers(app as unknown as TeamsAppLike, { + config, + store, + bff, + reconcileTeamApprovals: watcher + ? (teamName) => watcher.reconcileTeamApprovals(teamName) + : undefined, + }); + + await app.start(config.port); + + const internalServer = createServer(async (request, response) => { + const url = request.url ?? ""; + if (url === "/healthz" || url === "/healthz/") { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ status: "ok" })); + return; + } + if ( + (url === "/api/notify" || url === "/api/notify/") && + request.method === "POST" + ) { + await handleInternalNotify( + request, + response, + { config, store, bff }, + app as unknown as TeamsAppLike + ); + return; + } + response.writeHead(404, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "not found" })); + }); + internalServer.listen(config.internalPort, "0.0.0.0", () => { + log("info", "Teams gateway internal server listening", { + port: String(config.internalPort), + }); + }); + + if (watcher) { + await watcher.start(); + } + + log("info", "Teams gateway started", { + port: String(config.port), + internalPort: String(config.internalPort), + }); +} + +const directRun = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (directRun) { + void main().catch((error) => { + log("error", "fatal Teams gateway startup failure", { + error: error instanceof Error ? error.message : String(error), + }); + process.exit(1); + }); +} diff --git a/bridge/teams-gateway/src/watcher.ts b/bridge/teams-gateway/src/watcher.ts new file mode 100644 index 000000000..4e9cb77cb --- /dev/null +++ b/bridge/teams-gateway/src/watcher.ts @@ -0,0 +1,849 @@ +import { CustomObjectsApi, KubeConfig, Watch } from "@kubernetes/client-node"; +import type { App } from "@microsoft/teams.apps"; +import { + buildApprovalCard, + buildDecidedCard, + buildProgressCard, + type ApprovalEvent, + type CardVerdict, + type ProgressEvent, +} from "./cards.js"; +import type { TeamsGatewayConfig } from "./config.js"; +import type { + ApprovalMessageRecord, + ConversationStore, +} from "./conversation-store.js"; +import { log } from "./log.js"; + +const GROUP = "kars.azure.com"; +const VERSION = "v1alpha1"; +const APPROVALS_PLURAL = "karsapprovals"; +const TASKS_PLURAL = "karstasks"; +const TEAMS_PLURAL = "karsteams"; +const APPROVALS_STREAM = "watch.karsapprovals"; +const TASKS_STREAM = "watch.karstasks"; +const TEAMS_STREAM = "watch.karsteams"; +const WATCH_TIMEOUT_SECONDS = 300; +const TEAM_METADATA_KEY = "kars.azure.com/team"; + +interface Metadata { + readonly name?: string | undefined; + readonly namespace?: string | undefined; + readonly resourceVersion?: string | undefined; + readonly labels?: Readonly<Record<string, string>> | undefined; + readonly annotations?: Readonly<Record<string, string>> | undefined; +} + +interface ApprovalDecisionResource { + readonly verdict?: string | undefined; + readonly decider?: string | undefined; + readonly reason?: string | undefined; +} + +interface ApprovalResource { + readonly metadata?: Metadata | undefined; + readonly spec?: { + readonly taskRef?: { readonly name?: string | undefined } | undefined; + readonly action?: { + readonly kind?: string | undefined; + readonly summary?: string | undefined; + readonly detail?: string | undefined; + readonly requestedTier?: number | undefined; + } | undefined; + readonly decision?: ApprovalDecisionResource | undefined; + } | undefined; + readonly status?: { + readonly phase?: string | undefined; + readonly boundEnvelopeDigest?: string | undefined; + readonly decider?: string | undefined; + readonly decidedAt?: string | undefined; + } | undefined; +} + +interface TaskResource { + readonly metadata?: Metadata | undefined; + readonly spec?: { + readonly objective?: string | undefined; + readonly displayName?: string | undefined; + } | undefined; + readonly status?: { + readonly phase?: string | undefined; + readonly executionPhase?: string | undefined; + readonly executionDetail?: string | undefined; + readonly assignmentSequence?: number | undefined; + readonly assignment?: { + readonly state?: string | undefined; + readonly stage?: string | undefined; + readonly error?: string | undefined; + } | undefined; + } | undefined; +} + +interface TeamResource { + readonly metadata?: Metadata | undefined; + readonly spec?: { + readonly displayName?: string | undefined; + readonly paused?: boolean | undefined; + readonly charter?: string | undefined; + } | undefined; + readonly status?: { + readonly phase?: string | undefined; + readonly health?: string | undefined; + readonly detail?: string | undefined; + readonly runtimeState?: string | undefined; + readonly generatedTaskCount?: number | undefined; + readonly lastGeneratedTask?: string | undefined; + readonly currentAssignmentTask?: string | undefined; + } | undefined; +} + +interface KubernetesList<T> { + readonly items?: readonly T[] | undefined; + readonly metadata?: { + readonly resourceVersion?: string | undefined; + } | undefined; +} + +interface CustomObjectsApiLike { + listNamespacedCustomObject( + group: string, + version: string, + namespace: string, + plural: string, + pretty?: string, + allowWatchBookmarks?: boolean, + _continue?: string, + fieldSelector?: string, + labelSelector?: string, + limit?: number, + resourceVersion?: string, + resourceVersionMatch?: string, + timeoutSeconds?: number, + watch?: boolean + ): Promise<unknown>; +} + +interface WatchLike { + watch( + path: string, + queryParams: Record<string, string | number | boolean | undefined>, + callback: (phase: string, apiObj: unknown, watchObj?: unknown) => void, + done: (err: unknown) => void + ): Promise<AbortController>; +} + +interface TeamsMessenger { + readonly api?: { + readonly conversations?: { + updateActivity( + conversationId: string, + activityId: string, + activity: TeamsActivity + ): Promise<unknown>; + } | undefined; + } | undefined; + send( + conversationId: string, + activity: TeamsActivity + ): Promise<{ id?: string | undefined } | null>; +} + +interface TeamsActivity { + readonly type: "message"; + readonly attachments: readonly { + readonly contentType: "application/vnd.microsoft.card.adaptive"; + readonly content: object; + }[]; +} + +export interface GatewayWatcherOptions { + readonly customObjectsApi?: CustomObjectsApiLike | undefined; + readonly watch?: WatchLike | undefined; + readonly reconnectDelayMs?: number | undefined; +} + +function normalizeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function unwrapResponse<T>(response: unknown): T { + if ( + typeof response === "object" && + response !== null && + "body" in response + ) { + return (response as { body: T }).body; + } + return response as T; +} + +function statusCodeOf(error: unknown): number | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + const statusCode = (error as { statusCode?: unknown }).statusCode; + if (typeof statusCode === "number") { + return statusCode; + } + const code = (error as { code?: unknown }).code; + return typeof code === "number" ? code : undefined; +} + +function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "AbortError" || error.message.includes("aborted")) + ); +} + +function metadataValue( + metadata: Metadata | undefined, + key: string +): string | undefined { + return metadata?.labels?.[key] ?? metadata?.annotations?.[key]; +} + +function isTerminalPhase(phase: string): boolean { + return phase !== "Pending"; +} + +function toActivity(card: object): TeamsActivity { + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }, + ], + }; +} + +function toApprovalEvent(resource: ApprovalResource): ApprovalEvent | undefined { + const metadata = resource.metadata; + const name = normalizeString(metadata?.name); + const namespace = normalizeString(metadata?.namespace); + const task = normalizeString(resource.spec?.taskRef?.name); + const actionKind = normalizeString(resource.spec?.action?.kind); + const summary = normalizeString(resource.spec?.action?.summary); + const resourceVersion = normalizeString(metadata?.resourceVersion); + if (!name || !namespace || !task || !actionKind || !summary || !resourceVersion) { + return undefined; + } + const detail = normalizeString(resource.spec?.action?.detail); + const team = normalizeString(metadataValue(metadata, TEAM_METADATA_KEY)); + const boundEnvelopeDigest = normalizeString( + resource.status?.boundEnvelopeDigest + ); + return { + name, + namespace, + task, + ...(team !== undefined ? { team } : {}), + actionKind, + summary, + ...(detail !== undefined ? { detail } : {}), + ...(resource.spec?.action?.requestedTier !== undefined + ? { requestedTier: resource.spec.action.requestedTier } + : {}), + resourceVersion, + ...(boundEnvelopeDigest !== undefined ? { boundEnvelopeDigest } : {}), + }; +} + +function taskSignature(resource: TaskResource): string { + return JSON.stringify({ + phase: resource.status?.phase ?? "", + executionPhase: resource.status?.executionPhase ?? "", + executionDetail: resource.status?.executionDetail ?? "", + assignmentSequence: resource.status?.assignmentSequence ?? 0, + assignmentState: resource.status?.assignment?.state ?? "", + assignmentStage: resource.status?.assignment?.stage ?? "", + assignmentError: resource.status?.assignment?.error ?? "", + }); +} + +function teamSignature(resource: TeamResource): string { + return JSON.stringify({ + phase: resource.status?.phase ?? "", + health: resource.status?.health ?? "", + detail: resource.status?.detail ?? "", + runtimeState: resource.status?.runtimeState ?? "", + generatedTaskCount: resource.status?.generatedTaskCount ?? 0, + lastGeneratedTask: resource.status?.lastGeneratedTask ?? "", + currentAssignmentTask: resource.status?.currentAssignmentTask ?? "", + paused: resource.spec?.paused ?? false, + }); +} + +function toTaskProgressEvent(resource: TaskResource): ProgressEvent | undefined { + const metadata = resource.metadata; + const taskName = normalizeString(metadata?.name); + const teamName = normalizeString(metadataValue(metadata, TEAM_METADATA_KEY)); + if (!taskName || !teamName) { + return undefined; + } + const title = + normalizeString(resource.spec?.displayName) ?? + normalizeString(resource.spec?.objective) ?? + taskName; + const phase = normalizeString(resource.status?.phase) ?? "Pending"; + const executionPhase = normalizeString(resource.status?.executionPhase) ?? "Idle"; + const stage = normalizeString(resource.status?.assignment?.stage); + const detail = normalizeString(resource.status?.executionDetail); + const summary = + detail ?? + normalizeString(resource.status?.assignment?.error) ?? + normalizeString(resource.status?.assignment?.state) ?? + `${title} changed state.`; + return { + kind: "task", + teamName, + resourceName: taskName, + title, + status: `${phase} / ${executionPhase}`, + summary, + ...(detail !== undefined ? { detail } : {}), + ...(stage !== undefined ? { stage } : {}), + }; +} + +function toTeamProgressEvent(resource: TeamResource): ProgressEvent | undefined { + const metadata = resource.metadata; + const teamName = normalizeString(metadata?.name); + if (!teamName) { + return undefined; + } + const title = normalizeString(resource.spec?.displayName) ?? teamName; + const phase = normalizeString(resource.status?.phase) ?? "Forming"; + const detail = normalizeString(resource.status?.detail); + const summary = + detail ?? + normalizeString(resource.status?.health) ?? + (resource.spec?.paused ? "Standing team paused." : "Standing team updated."); + const runtimeState = normalizeString(resource.status?.runtimeState); + return { + kind: "team", + teamName, + resourceName: teamName, + title, + status: phase, + summary, + ...(detail !== undefined ? { detail } : {}), + ...(runtimeState !== undefined ? { stage: runtimeState } : {}), + }; +} + +export class GatewayWatcher { + private readonly config: TeamsGatewayConfig; + private readonly store: ConversationStore; + private readonly app: TeamsMessenger; + private readonly customObjectsApi: CustomObjectsApiLike; + private readonly watch: WatchLike; + private readonly reconnectDelayMs: number; + private readonly abortControllers = new Set<AbortController>(); + private readonly taskState = new Map<string, string>(); + private readonly teamState = new Map<string, string>(); + private running = false; + + public constructor( + config: TeamsGatewayConfig, + store: ConversationStore, + app: App | TeamsMessenger, + options?: GatewayWatcherOptions | undefined + ) { + this.config = config; + this.store = store; + this.app = app as TeamsMessenger; + this.reconnectDelayMs = options?.reconnectDelayMs ?? 2000; + if (options?.customObjectsApi && options.watch) { + this.customObjectsApi = options.customObjectsApi; + this.watch = options.watch; + } else { + const kubeConfig = new KubeConfig(); + kubeConfig.loadFromCluster(); + this.customObjectsApi = + (options?.customObjectsApi ?? + (kubeConfig.makeApiClient( + CustomObjectsApi + ) as unknown as CustomObjectsApiLike)); + this.watch = options?.watch ?? new Watch(kubeConfig); + } + } + + public async start(): Promise<void> { + if (this.running) { + return; + } + + this.running = true; + void this.runApprovalLoop(); + void this.runTaskLoop(); + void this.runTeamLoop(); + log("info", "started Teams gateway Kubernetes watchers", { + namespace: this.config.watchNamespace, + }); + } + + public async reconcileTeamApprovals(teamName: string): Promise<void> { + const listed = await this.listResource<ApprovalResource>(APPROVALS_PLURAL); + for (const approval of listed.items) { + if ( + normalizeString(approval.metadata?.labels?.[TEAM_METADATA_KEY]) === + teamName + ) { + await this.reconcileApproval(approval); + } + } + } + + public stop(): void { + this.running = false; + for (const controller of this.abortControllers) { + controller.abort(); + } + this.abortControllers.clear(); + } + + private async runApprovalLoop(): Promise<void> { + let shouldRelist = true; + while (this.running) { + try { + let resourceVersion = await this.store.getLastResourceVersion( + APPROVALS_STREAM + ); + if (shouldRelist || !resourceVersion) { + const listed = await this.listResource<ApprovalResource>( + APPROVALS_PLURAL + ); + resourceVersion = listed.resourceVersion; + await this.store.setLastResourceVersion( + APPROVALS_STREAM, + resourceVersion + ); + for (const approval of listed.items) { + await this.reconcileApproval(approval); + } + shouldRelist = false; + } + await this.watchResource<ApprovalResource>( + APPROVALS_STREAM, + APPROVALS_PLURAL, + resourceVersion, + async (phase, approval) => { + if (phase === "ADDED" || phase === "MODIFIED") { + await this.reconcileApproval(approval); + } + } + ); + } catch (error) { + if (!this.running || isAbortError(error)) { + return; + } + if (statusCodeOf(error) === 410) { + shouldRelist = true; + continue; + } + shouldRelist = true; + log("warn", "approval watch disconnected; retrying", { + error: error instanceof Error ? error.message : String(error), + }); + await this.sleep(this.reconnectDelayMs); + } + } + } + + private async runTaskLoop(): Promise<void> { + let shouldRelist = true; + while (this.running) { + try { + let resourceVersion = await this.store.getLastResourceVersion(TASKS_STREAM); + if (shouldRelist || !resourceVersion) { + const listed = await this.listResource<TaskResource>(TASKS_PLURAL); + resourceVersion = listed.resourceVersion; + await this.store.setLastResourceVersion(TASKS_STREAM, resourceVersion); + const recovering = this.taskState.size > 0; + for (const task of listed.items) { + await this.handleTaskProgress(task, !recovering); + } + shouldRelist = false; + } + await this.watchResource<TaskResource>( + TASKS_STREAM, + TASKS_PLURAL, + resourceVersion, + async (phase, task) => { + if (phase !== "ADDED" && phase !== "MODIFIED") { + return; + } + await this.handleTaskProgress(task, phase === "ADDED"); + } + ); + } catch (error) { + if (!this.running || isAbortError(error)) { + return; + } + if (statusCodeOf(error) === 410) { + shouldRelist = true; + continue; + } + shouldRelist = true; + log("warn", "task watch disconnected; retrying", { + error: error instanceof Error ? error.message : String(error), + }); + await this.sleep(this.reconnectDelayMs); + } + } + } + + private async runTeamLoop(): Promise<void> { + let shouldRelist = true; + while (this.running) { + try { + let resourceVersion = await this.store.getLastResourceVersion(TEAMS_STREAM); + if (shouldRelist || !resourceVersion) { + const listed = await this.listResource<TeamResource>(TEAMS_PLURAL); + resourceVersion = listed.resourceVersion; + await this.store.setLastResourceVersion(TEAMS_STREAM, resourceVersion); + const recovering = this.teamState.size > 0; + for (const team of listed.items) { + await this.handleTeamProgress(team, !recovering); + } + shouldRelist = false; + } + await this.watchResource<TeamResource>( + TEAMS_STREAM, + TEAMS_PLURAL, + resourceVersion, + async (phase, team) => { + if (phase !== "ADDED" && phase !== "MODIFIED") { + return; + } + await this.handleTeamProgress(team, phase === "ADDED"); + } + ); + } catch (error) { + if (!this.running || isAbortError(error)) { + return; + } + if (statusCodeOf(error) === 410) { + shouldRelist = true; + continue; + } + shouldRelist = true; + log("warn", "team watch disconnected; retrying", { + error: error instanceof Error ? error.message : String(error), + }); + await this.sleep(this.reconnectDelayMs); + } + } + } + + private async listResource<T>(plural: string): Promise<{ + readonly items: readonly T[]; + readonly resourceVersion: string; + }> { + const response = unwrapResponse<KubernetesList<T>>( + await this.customObjectsApi.listNamespacedCustomObject( + GROUP, + VERSION, + this.config.watchNamespace, + plural + ) + ); + const resourceVersion = normalizeString( + response.metadata?.resourceVersion + ); + if (!resourceVersion) { + throw new Error(`list ${plural} did not return a resourceVersion`); + } + return { + items: response.items ?? [], + resourceVersion, + }; + } + + private async watchResource<T>( + stream: string, + plural: string, + resourceVersion: string, + onEvent: (phase: string, resource: T) => Promise<void> + ): Promise<void> { + const path = `/apis/${GROUP}/${VERSION}/namespaces/${this.config.watchNamespace}/${plural}`; + let pending = Promise.resolve(); + let controller: AbortController | undefined; + let processingError: unknown; + let resolveDone!: () => void; + let rejectDone!: (error: unknown) => void; + const done = new Promise<void>((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + controller = await this.watch.watch( + path, + { + allowWatchBookmarks: true, + resourceVersion, + timeoutSeconds: WATCH_TIMEOUT_SECONDS, + }, + (phase, resource) => { + pending = pending + .then(async () => { + const metadata = + typeof resource === "object" && resource !== null + ? (resource as { metadata?: Metadata }).metadata + : undefined; + const nextResourceVersion = normalizeString( + metadata?.resourceVersion + ); + if (phase === "BOOKMARK") { + if (nextResourceVersion) { + await this.store.setLastResourceVersion( + stream, + nextResourceVersion + ); + } + return; + } + await onEvent(phase, resource as T); + if (nextResourceVersion) { + await this.store.setLastResourceVersion( + stream, + nextResourceVersion + ); + } + }) + .catch((error) => { + processingError = error; + controller?.abort(); + }); + }, + (error) => { + void pending.finally(() => { + if (processingError) { + rejectDone(processingError); + } else if (error && !isAbortError(error)) { + rejectDone(error); + } else { + resolveDone(); + } + }); + } + ); + if (processingError) { + controller.abort(); + } + this.abortControllers.add(controller); + await done.finally(() => { + if (controller) { + this.abortControllers.delete(controller); + } + }); + } + + private async reconcileApproval(resource: ApprovalResource): Promise<void> { + const event = toApprovalEvent(resource); + if (!event) { + return; + } + const phase = normalizeString(resource.status?.phase) ?? "Pending"; + const decision = resource.spec?.decision; + const existing = await this.store.getApprovalMessage( + event.namespace, + event.name + ); + + if (phase === "Pending") { + if (!event.boundEnvelopeDigest || !event.team) { + return; + } + if (!existing) { + await this.sendApprovalCard(event); + return; + } + const binding = await this.store.getByTeam(event.team); + if (binding && existing.conversationId !== binding.conversationId) { + await this.sendApprovalCard(event); + return; + } + if ( + existing.boundEnvelopeDigest !== event.boundEnvelopeDigest || + existing.resourceVersion !== event.resourceVersion + ) { + await this.updateApprovalCard(existing, event); + } + return; + } + + if (!decision || !isTerminalPhase(phase) || !existing) { + return; + } + + const decisionKind = normalizeString( + resource.metadata?.annotations?.["kars.azure.com/review-decision-kind"] + ); + const verdict: CardVerdict = + decision.verdict === "approve" + ? "approve" + : decisionKind === "request-changes" + ? "request-changes" + : "deny"; + await this.updateApprovalDecision(existing, event, verdict, { + decider: + normalizeString(decision.decider) ?? + normalizeString(resource.status?.decider) ?? + "Unknown", + reason: normalizeString(decision.reason), + }); + } + + private async sendApprovalCard(event: ApprovalEvent): Promise<void> { + const binding = await this.store.getByTeam(event.team ?? ""); + if (!binding) { + return; + } + const sent = await this.app.send( + binding.conversationId, + toActivity(buildApprovalCard(event)) + ); + const record: ApprovalMessageRecord = { + approvalName: event.name, + approvalNamespace: event.namespace, + conversationId: binding.conversationId, + teamName: binding.teamName, + messageId: normalizeString(sent?.id) ?? "", + resourceVersion: event.resourceVersion, + boundEnvelopeDigest: event.boundEnvelopeDigest ?? "", + sentAt: new Date().toISOString(), + }; + await this.store.recordApprovalMessage(record); + } + + private async updateApprovalCard( + record: ApprovalMessageRecord, + event: ApprovalEvent + ): Promise<void> { + if (!record.messageId || !this.app.api?.conversations) { + await this.store.recordApprovalMessage({ + ...record, + resourceVersion: event.resourceVersion, + boundEnvelopeDigest: event.boundEnvelopeDigest ?? record.boundEnvelopeDigest, + sentAt: new Date().toISOString(), + }); + return; + } + await this.app.api.conversations.updateActivity( + record.conversationId, + record.messageId, + toActivity(buildApprovalCard(event)) + ); + await this.store.recordApprovalMessage({ + ...record, + resourceVersion: event.resourceVersion, + boundEnvelopeDigest: event.boundEnvelopeDigest ?? record.boundEnvelopeDigest, + sentAt: new Date().toISOString(), + }); + } + + private async updateApprovalDecision( + record: ApprovalMessageRecord, + event: ApprovalEvent, + verdict: CardVerdict, + decision: { + readonly decider: string; + readonly reason?: string | undefined; + } + ): Promise<void> { + if (!record.messageId || !this.app.api?.conversations) { + return; + } + await this.app.api.conversations.updateActivity( + record.conversationId, + record.messageId, + toActivity( + buildDecidedCard(event, verdict, decision.decider, decision.reason) + ) + ); + await this.store.recordApprovalMessage({ + ...record, + resourceVersion: event.resourceVersion, + boundEnvelopeDigest: + event.boundEnvelopeDigest ?? record.boundEnvelopeDigest, + sentAt: new Date().toISOString(), + }); + } + + private async handleTaskProgress( + resource: TaskResource, + isInitialAdd: boolean + ): Promise<void> { + const name = normalizeString(resource.metadata?.name); + const namespace = normalizeString(resource.metadata?.namespace); + if (!name || !namespace) { + return; + } + const key = `${namespace}/${name}`; + const next = taskSignature(resource); + const previous = this.taskState.get(key); + if (isInitialAdd) { + this.taskState.set(key, next); + return; + } + if (previous === next) { + return; + } + const progress = toTaskProgressEvent(resource); + if (progress && !(await this.sendProgressCard(progress))) { + return; + } + this.taskState.set(key, next); + } + + private async handleTeamProgress( + resource: TeamResource, + isInitialAdd: boolean + ): Promise<void> { + const name = normalizeString(resource.metadata?.name); + const namespace = normalizeString(resource.metadata?.namespace); + if (!name || !namespace) { + return; + } + const key = `${namespace}/${name}`; + const next = teamSignature(resource); + const previous = this.teamState.get(key); + if (isInitialAdd) { + this.teamState.set(key, next); + return; + } + if (previous === next) { + return; + } + const progress = toTeamProgressEvent(resource); + if (progress && !(await this.sendProgressCard(progress))) { + return; + } + this.teamState.set(key, next); + } + + private async sendProgressCard(event: ProgressEvent): Promise<boolean> { + const binding = await this.store.getByTeam(event.teamName); + if (!binding) { + return false; + } + await this.app.send( + binding.conversationId, + toActivity(buildProgressCard(event)) + ); + return true; + } + + private async sleep(ms: number): Promise<void> { + await new Promise<void>((resolve) => setTimeout(resolve, ms)); + } +} + +export { GatewayWatcher as ApprovalWatcher }; diff --git a/bridge/teams-gateway/tests/chart-lifecycle.test.ts b/bridge/teams-gateway/tests/chart-lifecycle.test.ts new file mode 100644 index 000000000..b3d0165ca --- /dev/null +++ b/bridge/teams-gateway/tests/chart-lifecycle.test.ts @@ -0,0 +1,176 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; + +const enabled = process.env.BRIDGE_TEST_KIND_LIFECYCLE === "1"; +const chart = fileURLToPath(new URL("../../deploy/helm/kars-bridge", import.meta.url)); +const legacyChart = fileURLToPath(new URL("./fixtures/legacy-namespace-chart", import.meta.url)); +const kubeconfig = process.env.BRIDGE_TEST_KUBECONFIG; + +function kubectl(args: string[], input?: unknown): string { + if (!kubeconfig) throw new Error("A disposable Kind kubeconfig is required"); + return execFileSync("kubectl", ["--kubeconfig", kubeconfig, ...args], { + encoding: "utf8", + input: input === undefined ? undefined : JSON.stringify(input), + stdio: ["pipe", "pipe", "pipe"], + timeout: 30_000, + }); +} + +function helm(args: string[]): string { + if (!kubeconfig) throw new Error("A disposable Kind kubeconfig is required"); + return execFileSync("helm", ["--kubeconfig", kubeconfig, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }); +} + +function uid(kind: string, name: string, namespace?: string): string { + return kubectl([ + ...(namespace ? ["--namespace", namespace] : []), + "get", kind, name, "-o", "jsonpath={.metadata.uid}", + ]); +} + +function seed(namespace: string): void { + kubectl(["create", "-f", "-"], { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", kind: "ConfigMap", + metadata: { name: "existing-kars-evidence", namespace }, + data: { evidence: "preserve-existing-core-data" }, + }, + { + apiVersion: "apps/v1", kind: "Deployment", + metadata: { name: "kars-controller", namespace }, + spec: { + replicas: 0, + selector: { matchLabels: { app: "existing-kars-controller" } }, + template: { + metadata: { labels: { app: "existing-kars-controller" } }, + spec: { containers: [{ name: "controller", image: "ghcr.io/azure/kars-controller:latest" }] }, + }, + }, + }, + { + apiVersion: "kars.azure.com/v1alpha1", kind: "BridgeLifecycleEvidence", + metadata: { name: "existing-user-resource", namespace }, + spec: { evidence: "preserve-existing-custom-resource" }, + }, + ], + }); +} + +function install(release: string, namespace: string, createNamespace: boolean): void { + const releaseNamespace = createNamespace ? "bridge-lifecycle-metadata" : namespace; + helm([ + "upgrade", "--install", release, chart, "--namespace", releaseNamespace, + "--set", `namespace=${namespace},createNamespace=${createNamespace}`, + "--set", "bff.replicas=0,web.replicas=0,teamsGateway.enabled=false,idp.enabled=false", + ]); +} + +describe.skipIf(!enabled)("real Helm add-on lifecycle in disposable Kind", () => { + beforeAll(() => { + // Never fall back to the caller's current context, especially a customer AKS cluster. + const config = JSON.parse(kubectl(["config", "view", "--minify", "-o", "json"])); + expect(config["current-context"]).toBe("kind-bridge-addon-lifecycle"); + expect(config.clusters[0].cluster.server).toMatch(/^https:\/\/127\.0\.0\.1:\d+$/); + kubectl(["create", "namespace", "bridge-lifecycle-metadata"]); + kubectl(["create", "-f", "-"], { + apiVersion: "apiextensions.k8s.io/v1", kind: "CustomResourceDefinition", + metadata: { name: "bridgelifecycleevidences.kars.azure.com" }, + spec: { + group: "kars.azure.com", + scope: "Namespaced", + names: { plural: "bridgelifecycleevidences", singular: "bridgelifecycleevidence", kind: "BridgeLifecycleEvidence" }, + versions: [{ + name: "v1alpha1", served: true, storage: true, + schema: { openAPIV3Schema: { + type: "object", + properties: { spec: { type: "object", properties: { evidence: { type: "string" } } } }, + } }, + }], + }, + }); + kubectl(["wait", "--for=condition=Established", "--timeout=30s", "crd/bridgelifecycleevidences.kars.azure.com"]); + }, 60_000); + + it.each([ + { namespace: "kars-system", managed: false, release: "bridge-shared" }, + { namespace: "bridge-lifecycle-dedicated", managed: true, release: "bridge-dedicated" }, + ])("preserves core and user resources in $namespace after removal", ({ namespace, managed, release }) => { + const crdUid = uid("crd", "bridgelifecycleevidences.kars.azure.com"); + if (!managed) kubectl(["create", "namespace", namespace]); + if (managed) install(release, namespace, true); + seed(namespace); + const namespaceUid = uid("namespace", namespace); + const controllerUid = uid("deployment", "kars-controller", namespace); + const evidenceUid = uid("configmap", "existing-kars-evidence", namespace); + const customUid = uid("bridgelifecycleevidences", "existing-user-resource", namespace); + + install(release, namespace, managed); + expect(uid("deployment", "kars-controller", namespace)).toBe(controllerUid); + expect(uid("configmap", "existing-kars-evidence", namespace)).toBe(evidenceUid); + expect(uid("bridgelifecycleevidences", "existing-user-resource", namespace)).toBe(customUid); + helm(["uninstall", release, "--namespace", managed ? "bridge-lifecycle-metadata" : namespace, "--wait", "--timeout", "45s"]); + + expect(uid("namespace", namespace)).toBe(namespaceUid); + expect(uid("crd", "bridgelifecycleevidences.kars.azure.com")).toBe(crdUid); + expect(uid("deployment", "kars-controller", namespace)).toBe(controllerUid); + expect(uid("configmap", "existing-kars-evidence", namespace)).toBe(evidenceUid); + expect(uid("bridgelifecycleevidences", "existing-user-resource", namespace)).toBe(customUid); + expect(kubectl(["--namespace", namespace, "get", "configmap", "existing-kars-evidence", "-o", "jsonpath={.data.evidence}"])) + .toBe("preserve-existing-core-data"); + const deployments = JSON.parse(kubectl([ + "--namespace", namespace, "get", "deployment", "-l", "app.kubernetes.io/name=kars-bridge", "-o", "json", + ])); + expect(deployments.items).toHaveLength(0); + }, 120_000); + + it("retains a legacy owned namespace even when an upgrade switches createNamespace off", () => { + const namespace = "bridge-lifecycle-legacy"; + const release = "bridge-legacy"; + kubectl(["create", "-f", "-"], { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: namespace, + labels: { + "app.kubernetes.io/managed-by": "Helm", + "app.kubernetes.io/name": "kars", + "customer.example/namespace-policy": "preserve", + }, + annotations: { + "meta.helm.sh/release-name": release, + "meta.helm.sh/release-namespace": namespace, + "customer.example/namespace-setting": "preserve", + }, + }, + }); + helm(["install", release, legacyChart, "--namespace", namespace]); + expect(helm(["get", "manifest", release, "--namespace", namespace])) + .not.toContain("helm.sh/resource-policy"); + seed(namespace); + const namespaceUid = uid("namespace", namespace); + const evidenceUid = uid("configmap", "existing-kars-evidence", namespace); + const controllerUid = uid("deployment", "kars-controller", namespace); + const customUid = uid("bridgelifecycleevidences", "existing-user-resource", namespace); + + install(release, namespace, false); + expect(helm(["get", "manifest", release, "--namespace", namespace])) + .toContain("helm.sh/resource-policy: keep"); + helm(["uninstall", release, "--namespace", namespace, "--wait", "--timeout", "45s"]); + expect(uid("namespace", namespace)).toBe(namespaceUid); + expect(uid("configmap", "existing-kars-evidence", namespace)).toBe(evidenceUid); + expect(uid("deployment", "kars-controller", namespace)).toBe(controllerUid); + expect(uid("bridgelifecycleevidences", "existing-user-resource", namespace)).toBe(customUid); + const retained = JSON.parse(kubectl(["get", "namespace", namespace, "-o", "json"])); + expect(retained.metadata.labels["app.kubernetes.io/name"]).toBe("kars"); + expect(retained.metadata.labels["customer.example/namespace-policy"]).toBe("preserve"); + expect(retained.metadata.annotations["customer.example/namespace-setting"]).toBe("preserve"); + }, 120_000); +}); diff --git a/bridge/teams-gateway/tests/chart-upgrade.test.ts b/bridge/teams-gateway/tests/chart-upgrade.test.ts new file mode 100644 index 000000000..9f409c6f1 --- /dev/null +++ b/bridge/teams-gateway/tests/chart-upgrade.test.ts @@ -0,0 +1,100 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { copyFileSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { loadYaml } from "@kubernetes/client-node"; +import { describe, expect, it } from "vitest"; + +const exec=promisify(execFile); +const root=fileURLToPath(new URL("../../",import.meta.url)); +const chart=join(root,"deploy/helm/kars-bridge"); +const oldValues=fileURLToPath(new URL("./fixtures/values-10505214.yaml",import.meta.url)); + +async function legacyRender(args:string[],lookup=false):Promise<{objects:any[];calls:string[]}> { + const directory=join(root,`.chart-upgrade-${randomUUID()}`); + mkdirSync(directory); + copyFileSync(join(chart,"Chart.yaml"),join(directory,"Chart.yaml")); + cpSync(join(chart,"templates"),join(directory,"templates"),{recursive:true}); + // Replace chart defaults, not merely --values overlay: this models Helm + // --reuse-values where NEW parent maps are actually absent. + copyFileSync(oldValues,join(directory,"values.yaml")); + const calls:string[]=[]; + const api=createServer((request,response)=>{ + const path=new URL(request.url!,"http://localhost").pathname; + calls.push(`${request.method} ${path}`); + const resources=(entries:Array<[string,string,boolean]>)=>entries.map(([name,kind,namespaced])=>({ + name,singularName:"",namespaced,kind,verbs:["get","list"]})); + const groups=["apps","rbac.authorization.k8s.io","networking.k8s.io"]; + const discovery:Record<string,Array<[string,string,boolean]>>={ + "/api/v1":[["namespaces","Namespace",false],["services","Service",true],["serviceaccounts","ServiceAccount",true], + ["configmaps","ConfigMap",true],["secrets","Secret",true]], + "/apis/apps/v1":[["deployments","Deployment",true]], + "/apis/rbac.authorization.k8s.io/v1":[["roles","Role",true],["rolebindings","RoleBinding",true], + ["clusterroles","ClusterRole",false],["clusterrolebindings","ClusterRoleBinding",false]], + "/apis/networking.k8s.io/v1":[["networkpolicies","NetworkPolicy",true],["ingresses","Ingress",true]], + }; + const value=path==="/version"?{major:"1",minor:"32",gitVersion:"v1.32.0"}: + path==="/api"?{apiVersion:"v1",kind:"APIVersions",versions:["v1"],serverAddressByClientCIDRs:[]}: + path==="/apis"?{apiVersion:"v1",kind:"APIGroupList",groups:groups.map(name=>({name, + versions:[{groupVersion:`${name}/v1`,version:"v1"}],preferredVersion:{groupVersion:`${name}/v1`,version:"v1"}}))}: + discovery[path]?{apiVersion:"v1",kind:"APIResourceList",groupVersion:path==="/api/v1"?"v1":path.slice("/apis/".length), + resources:resources(discovery[path]!)}: + path==="/api/v1/namespaces/kars-system"?{apiVersion:"v1",kind:"Namespace",metadata:{name:"kars-system",uid:"existing", + labels:{customer:"retained"},annotations:{"meta.helm.sh/release-name":"kars-bridge", + "meta.helm.sh/release-namespace":"kars-system",customer:"retained"}}}:null; + response.writeHead(value?200:404,{"content-type":"application/json"}); + response.end(JSON.stringify(value??{apiVersion:"v1",kind:"Status",code:404,reason:"NotFound"})); + }); + try { + const extra:string[]=[]; + if(lookup){ + await new Promise<void>((resolve)=>api.listen(0,"127.0.0.1",resolve)); + const address=api.address(); + if(!address||typeof address==="string")throw new Error("test API did not bind"); + const config=join(directory,"kubeconfig"); + writeFileSync(config,JSON.stringify({apiVersion:"v1",kind:"Config",clusters:[{name:"fixture",cluster:{server:`http://127.0.0.1:${address.port}`}}], + users:[{name:"fixture",user:{}}],contexts:[{name:"fixture",context:{cluster:"fixture",user:"fixture"}}],"current-context":"fixture"})); + extra.push("--dry-run=server","--disable-openapi-validation","--kubeconfig",config); + } + const {stdout}=await exec("helm",["template","kars-bridge",directory,"--namespace","kars-system","--is-upgrade",...extra,...args], + {timeout:15_000,maxBuffer:4*1024*1024,env:{...process.env,HOME:directory,HELM_CACHE_HOME:join(directory,"cache"), + HELM_CONFIG_HOME:join(directory,"config"),HELM_DATA_HOME:join(directory,"data")}}); + const objects=stdout.split(/^---\s*$/m) + .filter(doc=>doc.split("\n").some(line=>line.trim()&&!line.trimStart().startsWith("#"))) + .map(doc=>loadYaml(doc) as any).filter(Boolean); + return {objects,calls}; + } finally { + if(api.listening)await new Promise<void>((resolve,reject)=>api.close(error=>error?reject(error):resolve())); + rmSync(directory,{recursive:true,force:true}); + } +} + +describe("BASE105 private release-value compatibility",()=>{ + it("actually omits the new maps in its historical values fixture",()=>{ + const values=loadYaml(readFileSync(oldValues,"utf8")) as any; + expect(values.core).toBeUndefined(); + expect(values.networkPolicy.observations).toBeUndefined(); + }); + it.each([[],["--set","core.namespace="]].map(args=>({args})))("defaults BFF/web workspace and observations off with old values $args",async({args})=>{ + const {objects}=await legacyRender(args); + expect(objects.some(item=>item.metadata?.name==="kars-bridge-observation-egress")).toBe(false); + for(const name of ["kars-bridge-bff","kars-bridge-web"]){ + const env=objects.find(item=>item.kind==="Deployment"&&item.metadata.name===name).spec.template.spec.containers[0].env; + expect(env.find((item:any)=>item.name==="BRIDGE_DEFAULT_NAMESPACE").value).toBe("kars-system"); + if(name.endsWith("bff"))expect(env.find((item:any)=>item.name==="BRIDGE_CORE_NAMESPACE").value).toBe("kars-system"); + } + }); + it("executes Helm lookup and preserves a formerly-owned namespace with default flags",async()=>{ + const {objects,calls}=await legacyRender([],true); + expect(calls).toContain("GET /api/v1/namespaces/kars-system"); + expect(calls.every(call=>call.startsWith("GET "))).toBe(true); + const namespace=objects.find(item=>item.kind==="Namespace"&&item.metadata.name==="kars-system"); + expect(namespace.metadata.annotations["helm.sh/resource-policy"]).toBe("keep"); + expect(namespace.metadata.annotations.customer).toBe("retained"); + expect(namespace.metadata.labels.customer).toBe("retained"); + expect(objects.some(item=>item.metadata?.name==="kars-bridge-observation-egress")).toBe(false); + }); +}); diff --git a/bridge/teams-gateway/tests/chart.test.ts b/bridge/teams-gateway/tests/chart.test.ts new file mode 100644 index 000000000..5cd1f8111 --- /dev/null +++ b/bridge/teams-gateway/tests/chart.test.ts @@ -0,0 +1,255 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + loadYaml, + type KubernetesObject, + type V1ClusterRole, + type V1Deployment, + type V1Namespace, + type V1NetworkPolicy, + type V1Role, +} from "@kubernetes/client-node"; +import { describe, expect, it } from "vitest"; + +const chart = fileURLToPath(new URL("../../deploy/helm/kars-bridge", import.meta.url)); +const requiredApis = [ + "karssandboxes", "karstasks", "karsteams", "karsprofiles", "karsskills", + "karsapprovals", "egressapprovals", "karsreceipts", "mcpservers", + "inferencepolicies", "toolpolicies", "karsmemories", "karsevals", "karssreactions", "karscredentialgrants", +]; + +function render(...args: string[]): KubernetesObject[] { + const yaml = execFileSync( + "helm", + ["template", "kars-bridge", chart, "--include-crds", "--namespace", "kars-system", ...args], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }, + ); + // Helm may emit comment-only documents for disabled optional templates. + return yaml.split(/^---\s*$/m) + .filter((doc) => doc.split("\n").some((line) => line.trim() && !line.trimStart().startsWith("#"))) + .map((doc) => loadYaml(doc)); +} + +function resource<T extends KubernetesObject>( + resources: KubernetesObject[], + kind: string, + name: string, +): T { + const found = resources.find((item) => item.kind === kind && item.metadata?.name === name); + expect(found, `${kind}/${name}`).toBeDefined(); + return found as T; +} + +const scenarios = [ + { name: "defaults", args: [] }, + { name: "shared Kars namespace", args: ["--set", "namespace=kars-system,createNamespace=false"] }, + { name: "dedicated namespace", args: ["--set", "namespace=bridge-workspace,createNamespace=true"] }, + { name: "existing custom namespace", args: ["--set", "namespace=shared-workspace,createNamespace=false"] }, + { + name: "optional surfaces enabled", + args: ["--set", "idp.enabled=true,teamsGateway.enabled=true,ingress.enabled=true,ingress.host=bridge.example.test"], + }, +]; + +describe("Bridge optional add-on boundary (offline Helm manifests)", () => { + it("opens observation egress only for explicitly reviewed existing isolation and targets", () => { + expect(render().some((item) => item.metadata?.name === "kars-bridge-observation-egress")).toBe(false); + expect(() => render("--set", "networkPolicy.observations.enabled=true")).toThrow(/already egress-isolated/); + expect(() => render("--set", "networkPolicy.observations.enabled=true,networkPolicy.observations.existingIsolationConfirmed=true")) + .toThrow(/exact reviewed targetNamespaces/); + const policy = resource<V1NetworkPolicy>(render("--set", + "namespace=bridge-private,core.namespace=core-workspace,networkPolicy.observations.enabled=true,networkPolicy.observations.existingIsolationConfirmed=true,networkPolicy.observations.targetNamespaces[0]=kars-agent"), + "NetworkPolicy", "kars-bridge-observation-egress"); + expect(policy.metadata?.namespace).toBe("bridge-private"); + expect(policy.spec?.policyTypes).toEqual(["Egress"]); + expect(policy.spec?.egress?.[0]?.ports).toEqual([{port:9447,protocol:"TCP"}]); + expect(policy.spec?.egress?.[0]?.to?.[0]?.namespaceSelector?.matchExpressions?.[0]?.values).toEqual(["kars-agent"]); + expect(policy.spec?.podSelector.matchLabels?.["app.kubernetes.io/component"]).toBe("bff"); + }); + for (const scenario of scenarios) { + it(`owns only add-on resources with ${scenario.name}`, () => { + const resources = render(...scenario.args); + expect(resources.length).toBeGreaterThan(0); + const allowedKinds = new Set([ + "Namespace", "Deployment", "Service", "ServiceAccount", "ConfigMap", "Secret", + "ClusterRole", "ClusterRoleBinding", "Role", "RoleBinding", "NetworkPolicy", "Ingress", + ]); + for (const item of resources) { + expect(allowedKinds.has(item.kind!), item.kind).toBe(true); + expect(item.apiVersion).not.toMatch(/^kars\.azure\.com\//); + expect(item.metadata?.labels?.["app.kubernetes.io/name"]).toBe("kars-bridge"); + expect(item.metadata?.annotations?.["helm.sh/hook"]).toBeUndefined(); + expect(item.metadata?.ownerReferences ?? []).toEqual([]); + if (item.kind === "Namespace") { + expect(scenario.name).toBe("dedicated namespace"); + expect(item.metadata?.name).toBe("bridge-workspace"); + expect(item.metadata?.annotations?.["helm.sh/resource-policy"]).toBe("keep"); + } else { + expect(item.metadata?.name).toMatch(/^(kars-bridge(?:-|$)|kars-teams-conversations$|dex$)/); + } + } + expect(resources.some((item) => item.metadata?.name === "kars-controller")).toBe(false); + expect(resources.some((item) => item.metadata?.name === "kars-system")).toBe(false); + + for (const item of resources.filter((item) => item.kind === "NetworkPolicy")) { + const selector = (item as V1NetworkPolicy).spec?.podSelector; + expect(selector?.matchLabels?.["app.kubernetes.io/name"] ?? selector?.matchLabels?.app) + .toMatch(/^(kars-bridge|dex)$/); + } + + for (const item of resources.filter((item) => ["ClusterRole", "Role"].includes(item.kind!))) { + for (const rule of (item as V1Role).rules ?? []) { + expect(rule.apiGroups).not.toContain("*"); + expect(rule.resources).not.toContain("*"); + expect(rule.verbs).not.toContain("*"); + if (rule.resources?.includes("customresourcedefinitions")) { + expect(rule.verbs?.every((verb) => ["get", "list", "watch"].includes(verb))).toBe(true); + } + if (rule.resources?.some((name) => ["namespaces", "deployments"].includes(name))) { + expect(rule.verbs).not.toContain("delete"); + expect(rule.verbs).not.toContain("deletecollection"); + } + } + } + }); + } + + it("never claims the shared Kars namespace on a new install", () => { + for (const namespace of ["kars-system", ""]) { + expect(() => render("--set", `namespace=${namespace},createNamespace=true`)) + .toThrow(/Bridge must not own the shared kars-system namespace/); + } + }); + + it("keeps a legacy owned shared namespace in upgrades so retention can be applied safely", () => { + const resources = render("--is-upgrade", "--set", "namespace=kars-system,createNamespace=true"); + const namespace = resource<V1Namespace>(resources, "Namespace", "kars-system"); + expect(namespace.metadata?.annotations?.["helm.sh/resource-policy"]).toBe("keep"); + }); + + it("rejects trying to create the release storage namespace from its own chart", () => { + expect(() => render( + "--namespace", "bridge-workspace", + "--set", "namespace=bridge-workspace,createNamespace=true", + )).toThrow(/cannot bootstrap its own Helm release storage namespace/); + }); + + it("retains a dedicated namespace on install and upgrade to prevent cascading data deletion", () => { + for (const upgradeArgs of [[], ["--is-upgrade"]]) { + const resources = render( + "--set", "namespace=bridge-workspace,createNamespace=true", ...upgradeArgs, + ); + const namespaces = resources.filter((item) => item.kind === "Namespace"); + expect(namespaces).toHaveLength(1); + const namespace = namespaces[0] as V1Namespace; + expect(namespace.metadata?.name).toBe("bridge-workspace"); + expect(namespace.metadata?.annotations?.["helm.sh/resource-policy"]).toBe("keep"); + for (const item of resources.filter((item) => item.metadata?.namespace)) { + expect(item.metadata?.namespace).toBe("bridge-workspace"); + } + } + }); + + it("does not manage existing namespaces on install or upgrade", () => { + for (const namespace of ["kars-system", "shared-workspace"]) { + for (const upgradeArgs of [[], ["--is-upgrade"]]) { + const resources = render("--set", `namespace=${namespace},createNamespace=false`, ...upgradeArgs); + expect(resources.filter((item) => item.kind === "Namespace")).toEqual([]); + } + } + }); + + it("wires fail-closed readiness separately from process liveness", () => { + const resources = render(); + const bff = resource<V1Deployment>(resources, "Deployment", "kars-bridge-bff"); + const container = bff.spec!.template.spec!.containers[0]!; + expect(container.readinessProbe?.httpGet?.path).toBe("/readyz"); + expect(container.readinessProbe?.timeoutSeconds).toBeGreaterThan(5); + expect(container.livenessProbe?.httpGet?.path).toBe("/healthz"); + const role = resource<V1ClusterRole>(resources, "ClusterRole", "kars-bridge-kars-bridge"); + for (const api of requiredApis) { + expect(role.rules?.some((rule) => + rule.apiGroups?.includes("kars.azure.com") + && rule.resources?.includes(api) + && rule.verbs?.includes("list"), + ), `readiness list permission for ${api}`).toBe(true); + } + }); + + it("keeps the web image immutable while allowing only bounded cache and temporary writes", () => { + const resources = render(); + const web = resource<V1Deployment>(resources, "Deployment", "kars-bridge-web"); + const pod = web.spec!.template.spec!; + const container = pod.containers[0]!; + expect(container.securityContext?.readOnlyRootFilesystem).toBe(true); + expect(pod.securityContext?.fsGroup).toBe(10001); + expect(container.volumeMounts?.map((mount) => mount.mountPath).sort()) + .toEqual(["/app/.next/cache", "/tmp"]); + expect(pod.volumes?.every((volume) => volume.emptyDir?.sizeLimit)).toBe(true); + const custom = resource<V1Deployment>( + render("--set", "podSecurityContext.fsGroup=20001"), "Deployment", "kars-bridge-web", + ); + expect(custom.spec?.template.spec?.securityContext?.fsGroup).toBe(20001); + }); + + it("does not require tenant credentials or a running Teams gateway for web-only use", () => { + const resources = render("--set", "teamsGateway.replicas=3"); + const gateway = resource<V1Deployment>(resources, "Deployment", "kars-bridge-teams-gateway"); + expect(gateway.spec?.replicas).toBe(0); + expect(resources.some((item) => item.kind === "Secret")).toBe(false); + for (const name of ["kars-bridge-bff", "kars-bridge-web"]) { + const deployment = resource<V1Deployment>(resources, "Deployment", name); + expect(deployment.spec?.replicas).toBe(1); + for (const container of deployment.spec!.template.spec!.containers) { + for (const env of container.env ?? []) { + if (env.valueFrom?.secretKeyRef) { + expect(env.valueFrom.secretKeyRef.optional, env.name).toBe(true); + expect(env.valueFrom.secretKeyRef.name).toBe("kars-bridge-teams"); + } + } + } + } + const bff = resource<V1Deployment>(resources, "Deployment", "kars-bridge-bff"); + const env = bff.spec!.template.spec!.containers[0]!.env!; + for (const name of ["BRIDGE_TEAMS_INTERNAL_SECRET", "BRIDGE_TEAMS_ENTRA_ROLE_MAP"]) { + expect(env.find((item) => item.name === name)?.valueFrom?.secretKeyRef?.optional).toBe(true); + } + }); + + it("starts gateway replicas only after explicit enablement", () => { + const resources = render("--set", "teamsGateway.enabled=true,teamsGateway.replicas=2"); + expect(resource<V1Deployment>(resources, "Deployment", "kars-bridge-teams-gateway").spec?.replicas) + .toBe(2); + }); + + it("leaves all credential and Deployment mutation authority to core grants in both manifests", () => { + const standalone = readFileSync(new URL("../../deploy/rbac.yaml", import.meta.url), "utf8") + .split(/^---\s*$/m).map((doc) => loadYaml(doc) as KubernetesObject).filter(Boolean); + for (const manifests of [render(), standalone]) { + for (const object of manifests.filter((item) => ["Role", "ClusterRole"].includes(item.kind!))) { + for (const rule of (object as V1Role).rules ?? []) { + expect(rule.resources).not.toContain("secrets"); + if (rule.resources?.includes("deployments")) { + expect(rule.verbs?.every((verb) => ["get", "list", "watch"].includes(verb))).toBe(true); + } + if (rule.resources?.includes("karscredentialgrants")) { + expect(rule.verbs?.every((verb) => ["get", "list", "watch", "bridge-adapter"].includes(verb))).toBe(true); + } + } + } + } + }); + + it("separates the private add-on namespace from the configured core workspace", () => { + const resources=render("--set","namespace=bridge-private,core.namespace=core-workspace,createNamespace=false"); + const bff=resource<V1Deployment>(resources,"Deployment","kars-bridge-bff"); + const env=bff.spec!.template.spec!.containers[0]!.env!; + expect(bff.metadata?.namespace).toBe("bridge-private"); + expect(env.find((item)=>item.name==="BRIDGE_CORE_NAMESPACE")?.value).toBe("core-workspace"); + expect(env.find((item)=>item.name==="BRIDGE_INSTALL_NAMESPACE")?.value).toBe("bridge-private"); + const web=resource<V1Deployment>(resources,"Deployment","kars-bridge-web"); + expect(web.spec!.template.spec!.containers[0]!.env!.find((item)=>item.name==="BRIDGE_DEFAULT_NAMESPACE")?.value) + .toBe("core-workspace"); + }); +}); diff --git a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml new file mode 100644 index 000000000..bb03447b3 --- /dev/null +++ b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: legacy-bridge-namespace +version: 0.1.0 +description: Test fixture reproducing the old chart-owned namespace without retention. diff --git a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml new file mode 100644 index 000000000..67da82901 --- /dev/null +++ b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Release.Namespace }} diff --git a/bridge/teams-gateway/tests/fixtures/values-10505214.yaml b/bridge/teams-gateway/tests/fixtures/values-10505214.yaml new file mode 100644 index 000000000..09ea1c58f --- /dev/null +++ b/bridge/teams-gateway/tests/fixtures/values-10505214.yaml @@ -0,0 +1,221 @@ +# kars Bridge Helm values. +# +# The Bridge is ADDITIVE: it deploys on top of an existing kars install (kars +# CRDs + controller in `namespace`). It never creates or replaces kars components. +# Kubernetes-native templates. AKS and local kind are live-qualified; EKS and +# GKE require environment-specific identity, registry, ingress, CNI, inference, +# and compatibility validation. + +# The namespace the Bridge runs in. The kars chart creates `kars-system`; the +# Bridge joins it. Set createNamespace: true only when installing the Bridge into +# a fresh, dedicated namespace. New installs cannot claim kars-system. Legacy +# chart-owned namespaces remain in upgrades and are retained on uninstall; +# keep createNamespace unchanged until the safe upgrade has been applied. +# New release storage namespaces need Helm's --create-namespace with this +# value false; a chart-owned workload namespace needs separate release storage. +namespace: kars-system +createNamespace: false + +# imagePullSecrets: list of {name: <secret>} references to pre-created +# `kubernetes.io/dockerconfigjson` Secrets in `namespace`, for pulling the +# private BFF/web images. Empty ⇒ public images / node-identity pull. +global: + imagePullSecrets: [] + # e.g. + # - name: myregistry-pull + +# Shared signing secret for external OIDC deployments. The web signs the +# principal assertion with this key and the BFF verifies it independently. +# idp.enabled uses idp.secretName automatically. When idp.enabled=false and +# external OIDC is configured through web.extraEnv, set this Secret name. +auth: + principalSecretName: "" + principalSecretKey: "session-secret" + +# ── BFF (Rust API) ─────────────────────────────────────────────────────────── +bff: + image: + # Override per environment: AKS -> <acr>.azurecr.io, EKS -> <account>.dkr.ecr.<region>.amazonaws.com, + # GKE -> <region>-docker.pkg.dev/<project>/<repo>, kind -> a locally loaded image. + repository: ghcr.io/pallakatos/kars-bridge-bff + tag: "latest" + pullPolicy: IfNotPresent + replicas: 1 + port: 8081 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "500m", memory: "256Mi" } + # Extra env merged into the BFF container (e.g. BRIDGE_OPERATOR, BRIDGE_ROLES, + # BRIDGE_ENGINEERING_POLLER_SECONDS; default 60, minimum 15). + extraEnv: [] + +# ── Web (Next.js UI) ───────────────────────────────────────────────────────── +web: + image: + repository: ghcr.io/pallakatos/kars-bridge-web + tag: "latest" + pullPolicy: IfNotPresent + replicas: 1 + port: 3000 + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + # Extra env merged into the web container. Notably: + # BRIDGE_HEADLAMP_URL - deep-links the Console home + Cluster tools card to a + # Kubernetes dashboard (Headlamp or any other) for deep pod/node/event + # inspection. Point it at whatever URL your operators can actually reach + # it at (an Ingress host, a NodePort, or a `kubectl port-forward` target + # for local/kind demos) — the Bridge only deep-links, it doesn't proxy. + # Example: --set web.extraEnv[0].name=BRIDGE_HEADLAMP_URL \ + # --set web.extraEnv[0].value=http://localhost:3903 + # Left unset, the card shows an honest "not linked" state instead of a + # broken link. + # + # BRIDGE_OIDC_ISSUER / BRIDGE_OIDC_CLIENT_ID / BRIDGE_OIDC_CLIENT_SECRET / + # BRIDGE_SESSION_SECRET - connect a real OIDC identity provider (Entra + # ID, Okta, Auth0, Keycloak, Dex, ...) for genuine per-user SSO: a real + # Authorization Code + PKCE flow at /auth/login, ID-token signature/ + # issuer/audience/nonce verified against the provider's live JWKS, and a + # signed Bridge session issued on success. Put BRIDGE_OIDC_CLIENT_SECRET + # and BRIDGE_SESSION_SECRET in a K8s Secret (valueFrom.secretKeyRef), + # never inline in values.yaml. Optional: BRIDGE_OIDC_REDIRECT_URI + # (defaults to `{origin}/auth/callback`), BRIDGE_OIDC_SCOPES (default + # "profile email"), BRIDGE_OIDC_ROLE_CLAIM (the ID-token claim carrying + # group/role membership, default "roles"), and BRIDGE_OIDC_ROLE_MAP + # (JSON mapping an IdP group/role name to a Bridge role, e.g. + # '{"kars-admins":"admin","kars-operators":"operator"}'). Unmapped + # claims grant zero roles (fail-closed) — see /console/access. + # Left unset (the default — no IdP is registered anywhere in this + # project), /auth/login returns an honest "SSO not configured" response + # and the header's dev role-switch remains the way to preview roles. + extraEnv: [] + +# ── Ingress (optional) ─────────────────────────────────────────────────────── +# Off by default so a bare install just exposes ClusterIP Services (reach them via +# `kubectl port-forward`). Enable + set className/host per cloud: +# AKS: application-gateway | nginx EKS: alb | nginx GKE: gce | nginx kind: nginx +ingress: + enabled: false + className: "" + host: "" + annotations: {} + tls: [] + +# The ServiceAccount + least-privilege ClusterRole the BFF runs under. The REAL +# authorization boundary is this Role — keep it least-privilege. +rbac: + create: true + serviceAccountName: kars-bridge + +# NetworkPolicies so the Bridge works under a namespace default-deny (kars ships +# a kars-system-default-deny that drops pod-to-pod on the Bridge ports). These are +# additive allow-rules: web -> BFF on the BFF port, and ingress to web + BFF on +# their service ports. Disable only on clusters with no NetworkPolicy enforcement +# or no default-deny (they are harmless there, but off by request is supported). +networkPolicy: + create: true + +# ── In-cluster IdP (Dex) for multi-user SSO ────────────────────────────────── +# OFF by default: a bare install uses the dev role-switcher (no login). Turn ON +# for a multi-user colleague ring reachable over a single +# kubectl port-forward svc/kars-bridge-web 3000:3000 +# with no public ingress. The web pod proxies /dex/* to the in-cluster Dex +# Service, so the issuer is the browser origin + /dex — one port-forward, no +# /etc/hosts. See templates/idp.yaml + web/src/app/dex/[...path]/route.ts. +idp: + enabled: false + # Browser-facing issuer = {web origin}/dex. For the port-forward path this is + # localhost:3000/dex; behind a real ingress set it to https://<host>/dex. + issuer: "http://localhost:3000/dex" + clientId: "kars-bridge" + # Must match the browser origin the user actually loads the Bridge at. + redirectURIs: + - "http://localhost:3000/auth/callback" + scopes: "profile email groups" + # The ID-token claim Dex puts group membership in, mapped to Bridge roles. + roleClaim: "groups" + roleMap: + kars-employees: "user" + kars-operators: "operator" + kars-auditors: "auditor" + # In-cluster Service the web /dex proxy forwards to (NOT browser-facing). + # Empty derives `http://dex.<namespace>.svc.cluster.local:5556`. + dexUpstreamUrl: "" + # Secret holding client-secret + session-secret (64 chars each). Left empty, + # the chart generates stable random values and preserves them across upgrades. + secretName: "kars-bridge-oidc" + clientSecret: "" + sessionSecret: "" + dex: + image: "ghcr.io/dexidp/dex:v2.45.1" + enablePasswordDB: true + # Private-alpha seed users (bcrypt hash below = "password"). Replace with + # real per-colleague accounts, or drop these and wire `connectors` to a real + # upstream IdP. Passwords are only ever inside the cluster. + staticPasswords: + - email: "employee@kars.test" + username: "employee" + userID: "11111111-1111-4111-8111-111111111111" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + groups: ["kars-employees"] + - email: "operator@kars.test" + username: "operator" + userID: "22222222-2222-4222-8222-222222222222" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + groups: ["kars-operators"] + - email: "auditor@kars.test" + username: "auditor" + userID: "33333333-3333-4333-8333-333333333333" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + groups: ["kars-auditors"] + # Upstream OIDC/social connectors (Entra ID, GitHub, ...). When set, prefer + # these over static passwords for real deployments. + connectors: [] + +# Pod-level security context (rootless, read-only-friendly). Overridable per cloud +# if a platform requires different fsGroup/seccomp defaults. +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +nodeSelector: {} +tolerations: [] +affinity: {} + +# ── Teams Gateway (optional Microsoft Teams HITL integration) ───────────────── +# The gateway resources are installed at zero replicas by default so an admin can +# bootstrap credentials from Connections. `enabled` controls the initial +# replica count; the BFF scales it after secure configuration. +# 1. An Entra App Registration with Bot Channel enabled + admin consent +# 2. A Secret named `kars-bridge-teams` containing keys: +# client-id, tenant-id, client-secret, entra-role-map (JSON), bff-internal-secret +# 3. The BFF must also mount bff-internal-secret via BRIDGE_TEAMS_INTERNAL_SECRET +# +# The dedicated Secret is NEVER propagated to sandbox pods. +teamsGateway: + enabled: false + image: + repository: ghcr.io/pallakatos/kars-bridge-freeze-teams-gateway + tag: "latest" + pullPolicy: Always + replicas: 1 + port: 3978 + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "200m", memory: "256Mi" } + # Name of the dedicated K8s Secret holding Teams credentials. Keys: + # client-id, tenant-id, client-secret, entra-role-map, bff-internal-secret + # entra-role-map is JSON: + # [{"entra_subject":"<oid>","bridge_subject":"<oidc-sub>","roles":["operator"],"name":"Alice"}] + secretName: "kars-bridge-teams" + conversationConfigMapName: "kars-teams-conversations" + extraEnv: [] + # TLS Ingress for Teams webhook callbacks (routes to gateway, not web). + ingress: + enabled: false + className: "" + host: "" + path: "/api/messages" + tlsSecretName: "" + annotations: {} diff --git a/bridge/teams-gateway/tests/gateway.test.ts b/bridge/teams-gateway/tests/gateway.test.ts new file mode 100644 index 000000000..7d415fbb2 --- /dev/null +++ b/bridge/teams-gateway/tests/gateway.test.ts @@ -0,0 +1,673 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BffClient, + type DecisionRequest, + type TeamCommandRequest, +} from "../src/bff-client.js"; +import { + buildApprovalCard, + buildDecidedCard, + buildProgressCard, + isCardActionPayload, + type ApprovalEvent, +} from "../src/cards.js"; +import { loadConfig, parseRoleMappings, type TeamsGatewayConfig } from "../src/config.js"; +import { + approvalMessageKey, + InMemoryConversationStore, + KubernetesConversationStore, +} from "../src/conversation-store.js"; +import { computeHmac, SIGNATURE_HEADER, verifyHmac } from "../src/hmac.js"; +import { resolveIdentity, type TeamsIdentity } from "../src/identity.js"; +import { log } from "../src/log.js"; +import { registerAppHandlers } from "../src/main.js"; + +function mockConfig( + overrides?: Partial<TeamsGatewayConfig> | undefined +): TeamsGatewayConfig { + return { + clientId: "client-id", + clientSecret: "client-secret", + tenantId: "tenant-id", + entraRoleMappings: [ + { + entraSubject: "oid-operator", + bridgeSubject: "bridge-sub-operator", + bridgeRoles: ["operator", "user"], + displayName: "Alice Operator", + }, + { + entraSubject: "oid-user", + bridgeSubject: "bridge-sub-user", + bridgeRoles: ["user"], + displayName: "Bob User", + }, + ], + bffBaseUrl: "https://bridge.example.test", + bffInternalSecret: "bridge-internal-secret", + port: 3978, + internalPort: 3979, + conversationConfigMapNamespace: "kars-system", + conversationConfigMapName: "teams-gateway-store", + watchNamespace: "kars-system", + ...overrides, + }; +} + +function withEnv( + values: Record<string, string | undefined>, + callback: () => void +): void { + const saved = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]) + ); + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + callback(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +const approvalEvent: ApprovalEvent = { + name: "approval-1", + namespace: "kars-system", + task: "task-1", + team: "engineering", + actionKind: "checkpoint", + summary: "Review deliverable", + detail: "Please review the generated output.", + requestedTier: 2, + resourceVersion: "42", + boundEnvelopeDigest: "sha256:abc123", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("config validation", () => { + it("fails closed when required settings are missing", () => { + withEnv( + { + TEAMS_CLIENT_ID: undefined, + TEAMS_CLIENT_SECRET: undefined, + TEAMS_TENANT_ID: undefined, + TEAMS_BFF_BASE_URL: undefined, + TEAMS_BFF_INTERNAL_SECRET: undefined, + TEAMS_ENTRA_ROLE_MAP: undefined, + }, + () => { + expect(() => loadConfig()).toThrow("FATAL"); + } + ); + }); + + it("parses a valid role map", () => { + const result = parseRoleMappings( + '[{"entra_subject":"oid1","bridge_subject":"bridge-sub-1","roles":["operator"],"name":"Alice"}]' + ); + expect(result).toEqual([ + { + entraSubject: "oid1", + bridgeSubject: "bridge-sub-1", + bridgeRoles: ["operator"], + displayName: "Alice", + }, + ]); + }); +}); + +describe("log redaction", () => { + it("redacts secret-like fields", () => { + const lines: string[] = []; + const originalWrite = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + lines.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + log("info", "test", { + token: "abc", + authorization: "Bearer secret", + safeValue: "visible", + }); + } finally { + process.stdout.write = originalWrite; + } + const output = lines.join(""); + expect(output).toContain("[REDACTED]"); + expect(output).not.toContain("Bearer secret"); + expect(output).toContain("visible"); + }); +}); + +describe("identity resolution", () => { + const config = mockConfig(); + + it("resolves a mapped operator subject", () => { + const identity: TeamsIdentity = { + aadObjectId: "oid-operator", + displayName: "Alice", + }; + expect(resolveIdentity(config, identity)).toEqual({ + entraSubject: "oid-operator", + sub: "bridge-sub-operator", + name: "Alice Operator", + roles: ["operator", "user"], + }); + }); + + it("rejects an unmapped subject", () => { + expect( + resolveIdentity(config, { + aadObjectId: "oid-missing", + displayName: "Eve", + }) + ).toBeNull(); + }); +}); + +describe("HMAC auth", () => { + it("computes and verifies signatures", () => { + const secret = "shared-secret"; + const body = '{"hello":"world"}'; + const signature = computeHmac(secret, body); + expect(signature).toHaveLength(64); + expect(verifyHmac(secret, body, signature)).toBe(true); + expect(verifyHmac(secret, body, null)).toBe(false); + expect(verifyHmac(secret, body, "deadbeef")).toBe(false); + expect(SIGNATURE_HEADER).toBe("x-teams-internal-signature"); + }); +}); + +describe("cards", () => { + it("builds approval cards with merged execute payload routing", () => { + const card = buildApprovalCard(approvalEvent); + const body = card.body as Array<Record<string, unknown>>; + const input = body.find((item) => item.type === "Input.Text"); + expect(input).toBeDefined(); + expect(input?.id).toBe("requestChangesReason"); + + const actions = (card.actions ?? []) as Array<Record<string, unknown>>; + expect(actions).toHaveLength(3); + expect(actions.map((action) => action.verb)).toEqual([ + "kars.approve", + "kars.request-changes", + "kars.deny", + ]); + for (const action of actions) { + expect((action.data as { action?: string }).action).toBe( + "kars.approval.decision" + ); + } + }); + + it("validates card action payloads", () => { + expect( + isCardActionPayload({ + action: "kars.approval.decision", + approvalName: "approval-1", + approvalNamespace: "kars-system", + verdict: "request-changes", + resourceVersion: "7", + requestChangesReason: "Need more detail", + }) + ).toBe(true); + expect( + isCardActionPayload({ + action: "kars.approval.decision", + approvalName: "approval-1", + approvalNamespace: "kars-system", + verdict: "maybe", + resourceVersion: "7", + }) + ).toBe(false); + }); + + it("builds decided and progress cards", () => { + const decided = buildDecidedCard( + approvalEvent, + "request-changes", + "Alice Operator", + "Please tighten the write-up" + ); + const progress = buildProgressCard({ + kind: "task", + teamName: "engineering", + resourceName: "task-1", + title: "Run deliverable", + status: "Ready / Running", + summary: "The task delivered a result.", + detail: "A pull request is ready for review.", + stage: "delivery", + }); + expect((decided.body?.[0] as { text?: string } | undefined)?.text).toContain( + "Changes Requested" + ); + expect((progress.body?.[0] as { text?: string } | undefined)?.text).toContain( + "Run deliverable" + ); + }); +}); + +describe("conversation store", () => { + it("tracks bindings, dedupe records, and last resource versions in memory", async () => { + const store = new InMemoryConversationStore(); + await store.bind({ + conversationId: "conv-1", + serviceUrl: "https://service.example.test", + tenantId: "tenant-id", + teamName: "engineering", + namespace: "kars-system", + boundAt: "2026-01-01T00:00:00Z", + }); + await store.recordApprovalMessage({ + approvalName: "approval-1", + approvalNamespace: "kars-system", + conversationId: "conv-1", + teamName: "engineering", + messageId: "message-1", + resourceVersion: "42", + boundEnvelopeDigest: "sha256:abc123", + sentAt: "2026-01-01T00:01:00Z", + }); + await store.setLastResourceVersion("watch.karsapprovals", "99"); + + expect((await store.getByTeam("engineering"))?.conversationId).toBe( + "conv-1" + ); + expect( + await store.getApprovalMessage("kars-system", "approval-1") + ).toEqual( + expect.objectContaining({ + messageId: "message-1", + boundEnvelopeDigest: "sha256:abc123", + }) + ); + expect(await store.getLastResourceVersion("watch.karsapprovals")).toBe( + "99" + ); + }); + + it("persists bindings, message ids, and resource versions via ConfigMap API", async () => { + class FakeCoreV1Api { + public configMap: + | { + apiVersion: string; + kind: string; + metadata: { name: string; namespace: string; resourceVersion?: string | undefined }; + data: Record<string, string>; + } + | undefined; + + public async readNamespacedConfigMap(): Promise<unknown> { + if (!this.configMap) { + const error = Object.assign(new Error("Not Found"), { + code: 404, + statusCode: 404, + }); + throw error; + } + return this.configMap; + } + + public async createNamespacedConfigMap( + namespace: string, + body: { + apiVersion?: string; + kind?: string; + metadata?: { name?: string; namespace?: string }; + data?: Record<string, string>; + } + ): Promise<unknown> { + this.configMap = { + apiVersion: body.apiVersion ?? "v1", + kind: body.kind ?? "ConfigMap", + metadata: { + name: body.metadata?.name ?? "teams-store", + namespace, + resourceVersion: "1", + }, + data: body.data ?? {}, + }; + return this.configMap; + } + + public async replaceNamespacedConfigMap( + _name: string, + namespace: string, + body: { + apiVersion?: string; + kind?: string; + metadata?: { name?: string; namespace?: string; resourceVersion?: string | undefined }; + data?: Record<string, string>; + } + ): Promise<unknown> { + this.configMap = { + apiVersion: body.apiVersion ?? "v1", + kind: body.kind ?? "ConfigMap", + metadata: { + name: body.metadata?.name ?? "teams-store", + namespace, + resourceVersion: String( + Number(this.configMap?.metadata.resourceVersion ?? "0") + 1 + ), + }, + data: body.data ?? {}, + }; + return this.configMap; + } + } + + const api = new FakeCoreV1Api(); + const store = new KubernetesConversationStore({ + namespace: "kars-system", + configMapName: "teams-store", + api, + }); + await store.initialize(); + await store.bind({ + conversationId: "conv-2", + serviceUrl: "https://service.example.test", + tenantId: "tenant-id", + teamName: "engineering", + namespace: "kars-system", + boundAt: "2026-01-01T00:00:00Z", + }); + await store.recordApprovalMessage({ + approvalName: "approval-2", + approvalNamespace: "kars-system", + conversationId: "conv-2", + teamName: "engineering", + messageId: "message-2", + resourceVersion: "100", + boundEnvelopeDigest: "sha256:def456", + sentAt: "2026-01-01T00:02:00Z", + }); + await store.setLastResourceVersion("watch.karsapprovals", "101"); + + const reloaded = new KubernetesConversationStore({ + namespace: "kars-system", + configMapName: "teams-store", + api, + }); + await reloaded.initialize(); + + expect((await reloaded.getByConversation("conv-2"))?.teamName).toBe( + "engineering" + ); + expect( + approvalMessageKey("kars-system", "approval-2") + ).toBe("kars-system/approval-2"); + expect( + (await reloaded.getApprovalMessage("kars-system", "approval-2")) + ?.messageId + ).toBe("message-2"); + expect( + await reloaded.getLastResourceVersion("watch.karsapprovals") + ).toBe("101"); + }); +}); + +describe("BFF client payloads", () => { + it("sends decisions with entra subject/name and no principal object", async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init: init ?? {} }); + return new Response(JSON.stringify({ phase: "Denied" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = new BffClient(mockConfig()); + const result = await client.submitDecision({ + approvalName: "approval-1", + approvalNamespace: "kars-system", + verdict: "request-changes", + reason: "Need more detail", + resourceVersion: "42", + boundEnvelopeDigest: "sha256:abc123", + principal: { + entraSubject: "oid-operator", + sub: "bridge-sub-operator", + name: "Alice Operator", + roles: ["operator"], + }, + } satisfies DecisionRequest); + + expect(result.success).toBe(true); + const body = JSON.parse(String(requests[0]?.init.body)) as Record<string, unknown>; + expect(body).toMatchObject({ + approval_name: "approval-1", + approval_namespace: "kars-system", + verdict: "request-changes", + reason: "Need more detail", + resource_version: "42", + bound_envelope_digest: "sha256:abc123", + entra_subject: "oid-operator", + entra_name: "Alice Operator", + }); + expect(body.principal).toBeUndefined(); + }); + + it("sends commands with entra subject/name and no principal object", async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + globalThis.fetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init: init ?? {} }); + return new Response(JSON.stringify({ message: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const client = new BffClient(mockConfig()); + const result = await client.sendTeamCommand({ + teamName: "engineering", + namespace: "kars-system", + command: "status", + args: "", + principal: { + entraSubject: "oid-user", + sub: "bridge-sub-user", + name: "Bob User", + roles: ["user"], + }, + } satisfies TeamCommandRequest); + + expect(result).toEqual({ success: true, message: "ok" }); + const body = JSON.parse(String(requests[0]?.init.body)) as Record<string, unknown>; + expect(body).toMatchObject({ + team_name: "engineering", + namespace: "kars-system", + command: "status", + args: "", + entra_subject: "oid-user", + entra_name: "Bob User", + }); + expect(body.principal).toBeUndefined(); + }); +}); + +describe("main handler wiring", () => { + it("registers one routed card action handler", () => { + class FakeApp { + public readonly handlers = new Map<string, (context: unknown) => unknown>(); + public readonly api = { + conversations: { + updateActivity: vi.fn(async () => undefined), + }, + }; + public on(route: string, handler: (context: unknown) => unknown): void { + this.handlers.set(route, handler); + } + public async start(): Promise<void> { + return undefined; + } + public async send(): Promise<{ id: string }> { + return { id: "message-1" }; + } + } + + const app = new FakeApp(); + registerAppHandlers(app, { + config: mockConfig(), + store: new InMemoryConversationStore(), + bff: { + submitDecision: vi.fn(async () => ({ success: true, phase: "Denied" })), + sendTeamCommand: vi.fn(async () => ({ success: true, message: "ok" })), + }, + }); + + expect([...app.handlers.keys()].filter((key) => key.startsWith("card.action"))).toEqual([ + "card.action.kars.approval.decision", + ]); + }); + + it("binds an unbound conversation via /bind before requiring an existing binding", async () => { + class FakeApp { + public readonly handlers = new Map<string, (context: unknown) => unknown>(); + public readonly api = { + conversations: { + updateActivity: vi.fn(async () => undefined), + }, + }; + public on(route: string, handler: (context: unknown) => unknown): void { + this.handlers.set(route, handler); + } + public async start(): Promise<void> { + return undefined; + } + public async send(): Promise<{ id: string }> { + return { id: "message-1" }; + } + } + + const app = new FakeApp(); + const store = new InMemoryConversationStore(); + const sendTeamCommand = vi.fn(async () => ({ success: true, message: "ok" })); + const reconcileTeamApprovals = vi.fn(async () => undefined); + registerAppHandlers(app, { + config: mockConfig(), + store, + bff: { + submitDecision: vi.fn(async () => ({ success: true, phase: "Denied" })), + sendTeamCommand, + }, + reconcileTeamApprovals, + }); + const messageHandler = app.handlers.get("message"); + const sent: TeamsOutboundActivity[] = []; + await messageHandler?.({ + activity: { + text: "/bind engineering", + from: { aadObjectId: "oid-operator", name: "Alice" }, + conversation: { id: "conv-3", tenantId: "tenant-id" }, + serviceUrl: "https://service.example.test", + }, + send: async (activity: TeamsOutboundActivity) => { + sent.push(activity); + return undefined; + }, + }); + + expect((await store.getByConversation("conv-3"))?.teamName).toBe( + "engineering" + ); + expect(sendTeamCommand).toHaveBeenCalledWith( + expect.objectContaining({ + teamName: "engineering", + command: "bind", + }) + ); + expect(reconcileTeamApprovals).toHaveBeenCalledWith("engineering"); + expect(sent[0]?.text).toContain("Bound this conversation"); + }); + + it("reads requestChangesReason from action.data in the routed handler", async () => { + class FakeApp { + public readonly handlers = new Map<string, (context: unknown) => unknown>(); + public readonly api = { + conversations: { + updateActivity: vi.fn(async () => undefined), + }, + }; + public on(route: string, handler: (context: unknown) => unknown): void { + this.handlers.set(route, handler); + } + public async start(): Promise<void> { + return undefined; + } + public async send(): Promise<{ id: string }> { + return { id: "message-1" }; + } + } + + const app = new FakeApp(); + const submitDecision = vi.fn(async () => ({ + success: true, + phase: "Denied", + })); + registerAppHandlers(app, { + config: mockConfig(), + store: new InMemoryConversationStore(), + bff: { + submitDecision, + sendTeamCommand: vi.fn(async () => ({ success: true, message: "ok" })), + }, + }); + + const handler = app.handlers.get("card.action.kars.approval.decision"); + const result = await handler?.({ + activity: { + from: { aadObjectId: "oid-operator", name: "Alice" }, + value: { + action: { + data: { + action: "kars.approval.decision", + approvalName: "approval-1", + approvalNamespace: "kars-system", + verdict: "request-changes", + resourceVersion: "42", + boundEnvelopeDigest: "sha256:abc123", + requestChangesReason: "Please add more detail.", + }, + }, + }, + }, + }); + + expect(submitDecision).toHaveBeenCalledWith( + expect.objectContaining({ + verdict: "request-changes", + reason: "Please add more detail.", + }) + ); + expect(result).toMatchObject({ + statusCode: 200, + type: "application/vnd.microsoft.card.adaptive", + }); + }); +}); + +interface TeamsOutboundActivity { + readonly type: "message"; + readonly text?: string | undefined; + readonly attachments?: readonly { + readonly contentType: "application/vnd.microsoft.card.adaptive"; + readonly content: object; + }[]; +} diff --git a/bridge/teams-gateway/tests/monorepo.test.ts b/bridge/teams-gateway/tests/monorepo.test.ts new file mode 100644 index 000000000..3a48b12d8 --- /dev/null +++ b/bridge/teams-gateway/tests/monorepo.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { existsSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const repository = new URL("../../../", import.meta.url); +const read = (path: string) => readFileSync(new URL(path, repository), "utf8"); + +describe("optional Bridge monorepo boundary", () => { + it("includes the whole application without making it a core Cargo member", () => { + for (const path of [ + "bridge/bff/Cargo.toml", "bridge/bff/Cargo.lock", "bridge/web/package.json", + "bridge/teams-gateway/package.json", "bridge/deploy/helm/kars-bridge/Chart.yaml", + "bridge/docs/README.md", + ]) { + expect(existsSync(new URL(path, repository)), path).toBe(true); + } + const cargo = read("Cargo.toml"); + expect(cargo.match(/members\s*=\s*\[([\s\S]*?)\]/)?.[1]).not.toContain("bridge/"); + expect(cargo.match(/exclude\s*=\s*\[([\s\S]*?)\]/)?.[1]).toContain('"bridge/bff"'); + expect(read("bridge/bff/Cargo.toml")).not.toContain(".workspace = true"); + }); + + it("keeps application builds opt-in and source qualification on the same commit", () => { + const makefile = read("Makefile"); + expect(makefile).toContain(".DEFAULT_GOAL := help"); + expect(makefile.match(/^build:.*$/m)?.[0]).not.toContain("bridge"); + expect(makefile).toContain("$(MAKE) -C bridge check"); + const workflow = read(".github/workflows/bridge-native.yml"); + expect(workflow).toContain("working-directory: bridge"); + expect(workflow).toContain("path: bridge/.native/core"); + expect(workflow).toContain("CORE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }}"); + expect(workflow).not.toMatch(/pallakatos\/|pull_request_target|docker push|freeze-images/); + expect(read(".github/workflows/bridge-ci.yml")).toContain( + "node ci/npm-audit-bulk.mjs bridge/${{ matrix.project }}/package-lock.json"); + expect(read("bridge/Makefile")).toContain("\nimage-gateway:\n"); + expect(read("bridge/Makefile").match(/^images:.*$/m)?.[0]).not.toContain("gateway"); + }); + + it("includes application production code in the existing source gates", () => { + for (const gate of ["ci/no-custom-crypto.sh", "ci/no-stubs.sh"]) { + for (const path of ["bridge/bff/src/", "bridge/web/src/", "bridge/teams-gateway/src/"]) { + expect(read(gate), `${gate}: ${path}`).toContain(`'${path}'`); + } + } + expect(read("ci/security-audit-required.sh")).toContain( + "bridge/(bff/src/|web/src/|teams-gateway/src/|deploy/|"); + expect(read("ci/no-null-provider-prod.sh")).toContain("'bridge/deploy/'"); + expect(read(".github/codeql-config.yml")).toContain("paths-ignore: []"); + }); + + it("documents public integration without claiming a qualified image release", () => { + const compatibility = read("bridge/docs/compatibility.md"); + expect(compatibility).toContain("**Azure/kars**"); + expect(compatibility).toContain("**`kars-bridge`**"); + expect(compatibility).toContain("same immutable monorepo commit"); + expect(compatibility).toContain("not yet release-qualified"); + expect(compatibility).not.toContain("prepares a\n**private Bridge PR**"); + expect(read("bridge/docs/deployment.md")).toContain("repository root (`cd bridge`)"); + }); + + it("does not include operator state or a cluster-specific deployment overlay", () => { + expect(existsSync(new URL("bridge/.openclaw/", repository))).toBe(false); + expect(existsSync(new URL("bridge/deploy/helm/kars-bridge/values-aks-airunway.yaml", repository))) + .toBe(false); + expect(read("bridge/start-bff.sh")).not.toMatch(/lsof|kill|nohup/); + expect(read("bridge/start-bff.sh")).toContain("exec cargo run --locked"); + const tracked = execFileSync("git", ["ls-files", "--stage", "bridge/"], { + cwd: repository, encoding: "utf8", + }); + expect(tracked).not.toMatch(/^120000 /m); + expect(tracked).not.toMatch(/\/(?:node_modules|\.native|\.openclaw|target|dist)(?:\/|$)/m); + expect(tracked).not.toMatch(/\/\.env(?:\t|$)/m); + }); +}); diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts new file mode 100644 index 000000000..462f68e19 --- /dev/null +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -0,0 +1,354 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const read = (path: string) => + readFileSync(new URL(`../../${path}`, import.meta.url), "utf8"); +const workflow = readFileSync(new URL("../../../.github/workflows/bridge-native.yml", import.meta.url), "utf8"); +const gate = read("tests/native-credentials/api_gate.py"); + +describe("Monorepo native prerequisite", () => { + it("pins core and Bridge to the same source and existing actions without publishing images", () => { + expect(workflow).toContain("CORE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }}"); + expect(workflow).toContain("path: bridge/.native/core"); + expect(gate).toContain("from source_revision import CORE_REVISION"); + expect(read("tests/native-credentials/source_revision.py")).toContain("expected != revision"); + for (const action of workflow.matchAll(/uses: ([^\n#]+)/g)) { + expect(action[1].trim()).toMatch(/@[a-f0-9]{40}$/); + } + expect(workflow).not.toMatch(/pull_request_target|docker push|freeze-images|secrets\./); + expect(workflow).toContain("contents: read"); + expect(workflow).toContain("persist-credentials: false"); + expect(workflow).toContain("ref: ${{ github.event.pull_request.head.sha || github.sha }}"); + }); + + it("never converts API-only acceptance into runtime, CNI, or active-SRE qualification", () => { + expect(gate).toContain('"runtimeQualified": False'); + expect(gate).toContain('"networkPolicyEnforcementQualified": False'); + expect(gate).toContain('"activeSreCombinedQualified": False'); + expect(gate).toContain('"karssreregistrations", "--all-namespaces"'); + expect(gate).toContain('"validatingadmissionpolicies"'); + expect(gate).toContain('"expressionWarnings"'); + expect(gate).not.toMatch(/--validate=false|--disable-openapi-validation|failurePolicy.*Ignore/); + expect(gate).not.toContain("--create-namespace"); + expect(read("tests/native-credentials/api-values.yaml")).toContain("sre:\n enabled: false"); + }); + + it("parses the runner without executing local Kubernetes or producing cache files", () => { + for (const file of readdirSync(new URL("../../tests/native-credentials/", import.meta.url))) { + if (!file.endsWith(".py")) continue; + execFileSync("python3", ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], { + input: read(`tests/native-credentials/${file}`), + stdio: ["pipe", "pipe", "pipe"], + }); + } + }); + + it("verifies observer diagnostic redaction, source UID fences and network cleanup", () => { + expect(() => execFileSync("python3", [ + "-m", "unittest", "discover", "-s", "tests/native-credentials", + "-p", "test_*.py", + ], { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + stdio: ["ignore", "pipe", "pipe"], + // Six 20s offline format calls plus ten existing 5s status-shape controls. + timeout: 180_000, + })).not.toThrow(); + }, 190_000); + + it("uses real native clients, metadata-only audit, and separate actor TLS contexts", () => { + const api = read("tests/native-credentials/native_api.py"); + expect(api).toContain('ssl.create_default_context(cadata=self.ca), token'); + expect(api).toContain('"GITHUB_ACTIONS") == "true"'); + expect(api).not.toMatch(/insecure_skip|CERT_NONE|verify=False|kubectl.*proxy/); + const audit = read("tests/native-credentials/audit-policy.yaml"); + expect(audit).toContain("- level: Metadata"); + expect(audit).not.toMatch(/level: Request/); + const credentials = read("tests/native-credentials/credential_cases.py"); + expect(credentials).toContain('expected=(403,)'); + expect(credentials).toContain('event["verb"] in MUTATIONS'); + expect(credentials).toContain('event["requestReceivedTimestamp"] > created_at'); + expect(credentials).toContain('"kars.azure.com/credential-grant-uid": "foreign-grant-uid"'); + expect(credentials).toContain('"kars.azure.com/credential-binding-intent": "explicit-reference-v2"'); + expect(credentials).not.toContain("get_metadata"); + }); + + it("qualifies actual CNI traffic and never treats API existence as enforcement", () => { + expect(workflow).toContain("--version 1.18.5"); + expect(workflow).toContain("needs: [api-admission, native-runtime]"); + expect(workflow).toContain('test "$API_RESULT" = success'); + expect(workflow).toContain('test "$RUNTIME_RESULT" = success'); + expect(workflow).not.toContain("continue-on-error:"); + expect(read("tests/native-credentials/kind_config.py")).toContain('"disableDefaultCNI": True'); + const observations = read("tests/native-credentials/observation_cases.py"); + expect(observations).toContain('socket.create_connection(tuple(targets["control"]),3).close()'); + expect(observations).toContain('socket.create_connection((host,port),3)'); + expect(observations).toContain('self.public().get("available") is True'); + expect(observations).toContain('"name": "native-observation-task"'); + expect(observations).not.toContain("self.lifecycle.team_target"); + expect(read("tests/native-credentials/run.py")).toContain('case("team-rebind-uid-namespace-data-and-attestation-continuity", lifecycle.team_rebind)'); + expect(read("tests/native-credentials/run.py")).toContain('["metadataAtFailure"] = diagnostics(setup)'); + expect(observations).toContain('"/egress/learned/clear"'); + expect(read("tests/native-credentials/private_tls.py")).toContain('"x-kars-service-scope"'); + }); + + it("requires live CEL behavior, not merely missing type-check warnings", () => { + const cases = read("tests/native-credentials/admission_cases.py"); + expect(cases).toContain('["data", "stringData"]'); + expect(cases).toContain('"data": ["API_KEY"], "stringData": ["PASSWORD"]'); + expect(cases).toContain('["absent", "null", "non-null"]'); + expect(cases).toContain('"submittedDigest": variant, "storedDigest": wire_kind'); + expect(cases).toContain('"LoadBalancer", "NodePort"'); + expect(cases).toContain('"0.0.0.0/0"'); + expect(cases).toContain('"::/0"'); + expect(cases).toContain('policy in result.get("message", "")'); + expect(cases).toContain('"actor": "setup-admin", "bearerIssuanceQualified": False'); + expect(gate).toContain('evidence["nativeAdmissionCases"]["result"] != "passed"'); + }); + + it("preserves exec prohibition and distinguishes ephemeral files from retained namespace data", () => { + expect(read("tests/native-credentials/Dockerfile.runtime")).toMatch(/^USER 1000:1000$/m); + expect(read("tests/native-credentials/runtime_state.py")).toContain('"kars-sandbox-exec-ban" in result.stderr'); + const lifecycle = read("tests/native-credentials/lifecycle_cases.py"); + expect(lifecycle).toContain("assert_ephemeral_workspace(pod)"); + expect(lifecycle).toContain("assert_ephemeral_workspace(after_pod)"); + expect(lifecycle).toContain('after_state["dataMarker"] != before_state["dataMarker"]'); + expect(lifecycle).toContain('uid(after_datum) == uid(datum) and after_datum["data"] == datum["data"]'); + expect(lifecycle).toContain('uid(after_pod) != uid(pod)'); + expect(lifecycle).toContain('paused["spec"]["execution"]["launch"] is True'); + expect(read("tests/native-credentials/run.py")).toContain( + '"workspaceContract": "ephemeral-filesystem-with-namespace-resource-continuity"'); + expect(read("tests/native-credentials/credential_cases.py")).toContain('resource(CORE, "toolpolicies", "kars-default")'); + expect(read("tests/native-credentials/observation_cases.py")).toContain('"toEntities": ["kube-apiserver"]'); + expect(read("tests/native-credentials/observation_cases.py")).not.toContain("break-glass"); + const script = ` +import json, os, pathlib, sys, uuid +sys.path.insert(0, "tests/native-credentials") +from runtime_probe import state +from runtime_state import assert_ephemeral_workspace +from native_api import Failure +pod = {"spec":{"containers":[{"name":"openclaw","volumeMounts":[ + {"name":"workspace","mountPath":"/sandbox"}]}],"volumes":[{"name":"workspace","emptyDir":{}}]}} +assert_ephemeral_workspace(pod) +for volume in [ + {"name":"workspace","persistentVolumeClaim":{"claimName":"not-this-contract"}}, + {"name":"workspace","hostPath":{"path":"/tmp"}}, + {"name":"workspace","emptyDir":None}, +]: + invalid = json.loads(json.dumps(pod)) + invalid["spec"]["volumes"] = [volume] + try: + assert_ephemeral_workspace(invalid) + except Failure: + pass + else: + raise AssertionError("non-ephemeral workspace silently accepted") +directory = pathlib.Path(".native") / ("runtime-proof-" + str(uuid.uuid4())) +directory.mkdir(parents=True) +path = directory / "marker" +os.environ["SLACK_BOT_TOKEN"] = "native-secret-must-not-leave-the-process" +try: + first = state(path) + retained = state(path) + assert first["dataWritable"] and not first["dataExistedAtStart"] + assert retained["dataExistedAtStart"] and retained["dataMarker"] == first["dataMarker"] + assert first["slackPresent"] is True + assert "native-secret-must-not-leave-the-process" not in json.dumps(first) + path.unlink() + recreated = state(path) + assert not recreated["dataExistedAtStart"] and recreated["dataMarker"] != first["dataMarker"] +finally: + path.unlink(missing_ok=True) + directory.rmdir() +print("runtime-state-checks-passed") +`; + expect(execFileSync("python3", ["-c", script], { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim()).toBe("runtime-state-checks-passed"); + }); + + it("retains fixed scheduling failure categories without exporting scheduler messages", () => { + expect(read("tests/native-credentials/run.py")).toContain( + '"scheduling": scheduling_detail(item) if label == "pods" else None'); + const script = ` +import json, sys +sys.path.insert(0, "tests/native-credentials") +from native_api import scheduling_detail +def pod(message, status="False"): + return {"status":{"conditions":[{"type":"PodScheduled","status":status,"message":message}]}} +result = scheduling_detail(pod("private-node-name: Insufficient cpu; Insufficient memory; node(s) had untolerated taint {private-label}")) +assert result == {"status":"False","categories":["insufficient-cpu","insufficient-memory","untolerated-taint"]} +assert "private-" not in json.dumps(result) +assert scheduling_detail(pod("private-message")) == {"status":"False","categories":["unclassified"]} +assert scheduling_detail(pod("private-message", "True")) == {"status":"True","categories":[]} +assert scheduling_detail({}) == {"status":"Unknown","categories":["unavailable"]} +assert scheduling_detail(pod("node(s) didn't match Pod's node affinity/selector"))["categories"] == ["node-selection"] +assert scheduling_detail(pod("pod has unbound immediate PersistentVolumeClaims"))["categories"] == ["unbound-volume"] +print("scheduling-category-checks-passed") +`; + expect(execFileSync("python3", ["-c", script], { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim()).toBe("scheduling-category-checks-passed"); + }); + + it("releases only the owned completed Team execution through the normal unlaunch path", () => { + const runner = read("tests/native-credentials/run.py"); + expect(runner).toContain('case("completed-team-fixture-release", lifecycle.release_team_fixture,'); + expect(runner.indexOf('case("completed-team-fixture-release"')).toBeLessThan( + runner.indexOf('case("private-bff-observer-and-fresh-privacy-rpc"')); + expect(runner).toContain('case("private-bff-observer-and-fresh-privacy-rpc", observations.enable)'); + const script = ` +import copy, sys +from types import SimpleNamespace +sys.path.insert(0, "tests/native-credentials") +from lifecycle_cases import LifecycleCases +from native_api import Failure, resource +task_path = resource("workspace", "karstasks", "task") +sandbox_path = resource("workspace", "karssandboxes", "sandbox") +namespace_path = "/api/v1/namespaces/kars-sandbox" +class Api: + def __init__(self): + self.calls = [] + self.replace_at_write = False + self.objects = { + task_path: {"metadata":{"uid":"task-uid","resourceVersion":"7","ownerReferences":[ + {"kind":"KarsTeam","uid":"team-uid","controller":True}]},"spec":{"execution":{"launch":True}}}, + sandbox_path: {"metadata":{"uid":"sandbox-uid"}}, + namespace_path: {"metadata":{"uid":"namespace-uid"}}, + } + def get(self, path): + return copy.deepcopy(self.objects[path]) + def optional(self, path): + return copy.deepcopy(self.objects.get(path)) + def request(self, method, path, body, patch_type=None): + self.calls.append((path, body)) + assert method == "PATCH" and path == task_path + assert patch_type == "application/merge-patch+json" + assert body == {"metadata":{"uid":"task-uid","resourceVersion":"7"},"spec":{"execution":{"launch":False}}} + if self.replace_at_write: + self.objects[task_path]["metadata"]["uid"] = "replacement" + raise Failure("UID conflict") + self.objects[task_path]["spec"]["execution"]["launch"] = False + self.objects.pop(sandbox_path) + self.objects.pop(namespace_path) + return 200, copy.deepcopy(self.objects[task_path]) +for foreign in [None, task_path, sandbox_path, namespace_path, "at-write"]: + api = Api() + lifecycle = LifecycleCases(SimpleNamespace(admin=api), None, None) + lifecycle.team_target = {"workspace":"workspace","task":"task","sandbox":"sandbox", + "taskUid":"task-uid","teamUid":"team-uid", + "sandboxUid":"sandbox-uid","namespaceUid":"namespace-uid"} + if foreign == "at-write": + api.replace_at_write = True + elif foreign: + api.objects[foreign]["metadata"]["uid"] = "replacement" + if foreign: + try: + lifecycle.release_team_fixture() + except Failure: + pass + else: + raise AssertionError("foreign fixture cleanup was allowed") + assert len(api.calls) == (1 if foreign == "at-write" else 0) + assert api.objects[task_path]["spec"]["execution"]["launch"] is True + else: + lifecycle.release_team_fixture() + assert len(api.calls) == 1 and task_path in api.objects +print("team-fixture-release-checks-passed") +`; + expect(execFileSync("python3", ["-c", script], { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim()).toBe("team-fixture-release-checks-passed"); + }); + + it("rejects local native execution and validates signed test principals without disclosure", () => { + const script = ` +import base64, hashlib, hmac, json, os, secrets, sys +sys.path.insert(0, "tests/native-credentials") +import native_api +from boot import principal +os.environ["GITHUB_ACTIONS"] = "false" +try: + native_api.Setup() +except native_api.Failure: + pass +else: + raise AssertionError("local native execution was allowed") +key = secrets.token_hex(32) +token = principal(key) +header, payload, signature = token.split(".") +decode = lambda value: base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) +assert json.loads(decode(header))["alg"] == "HS256" +assert json.loads(decode(payload))["roles"] == ["operator", "user"] +assert hmac.compare_digest(decode(signature), hmac.new( + key.encode(), (header + "." + payload).encode(), hashlib.sha256).digest()) +assert native_api.core("workspace", "secrets", "source") == "/api/v1/namespaces/workspace/secrets/source" +detail = native_api.status_detail({ + "reason":"Invalid", + "message":"private-value-never-log: ValidatingAdmissionPolicy 'kars-boundary' failed: no such key: subResource", + "details":{"causes":[{"field":"spec.writers","reason":"FieldValueInvalid","message":"another-private-value"}]}}) +assert "kars-boundary" in detail and "subResource" in detail and "spec.writers" in detail +assert "private-value" not in detail +print("native-helper-checks-passed") +`; + const output = execFileSync("python3", ["-c", script], { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + expect(output.trim()).toBe("native-helper-checks-passed"); + }); + + it("uses loaded content digests rather than silently pulling runtime :latest from a registry", () => { + const script = ` +import json, sys +sys.path.insert(0, "tests/native-credentials") +import loaded_images +from native_api import Failure +for name in ["kars-native-runtime", "kars-native-router"]: + repository = "docker.io/library/" + name + digest = "a" * 64 + calls = [] + def execute(*args): + calls.append(args) + if "inspecti" in args: + return json.dumps({"status":{"id":"config-identity","repoDigests":[]}}) + if "list" in args: + ref = json.loads(args[-1].removeprefix("name==")) + return ("REF TYPE DIGEST SIZE PLATFORMS LABELS\\n" if "@" in ref else + f"{ref} application/vnd.oci.image.manifest.v1+json sha256:{digest} 1MiB linux/amd64 -\\n") + assert "tag" in args + return "" + loaded_images.command = execute + image = loaded_images.loaded_image(name) + assert image["repository"] + ":" + image["tag"] == repository + "@sha256:" + digest + assert image["pullPolicy"] == "IfNotPresent" + assert len([call for call in calls if "tag" in call]) == 2 + assert all("pull" not in call and "push" not in call for call in calls) +try: + loaded_images.manifest_digest("REF TYPE DIGEST SIZE PLATFORMS LABELS", "missing") +except Failure: + pass +else: + raise AssertionError("missing loaded digest silently fell back to a registry") +print("loaded-digest-checks-passed") +`; + expect(execFileSync("python3", ["-c", script], { + cwd: new URL("../../", import.meta.url), + env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim()).toBe("loaded-digest-checks-passed"); + }); +}); diff --git a/bridge/teams-gateway/tests/packaging.test.ts b/bridge/teams-gateway/tests/packaging.test.ts new file mode 100644 index 000000000..7e467f6b5 --- /dev/null +++ b/bridge/teams-gateway/tests/packaging.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dockerfile = readFileSync(new URL("../../bff/Dockerfile", import.meta.url), "utf8"); +const ignored = readFileSync(new URL("../../bff/.dockerignore", import.meta.url), "utf8"); + +describe("Private BFF image build contract", () => { + it("compiles real sources once with a locked, fail-closed build", () => { + const builder = (dockerfile.split(/^FROM .* AS runtime$/m)[0] ?? "") + .split("\n") + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n"); + expect(builder.match(/cargo\s+build\b/g)).toHaveLength(1); + expect(builder).toMatch(/RUN cargo build --release --locked && strip target\/release\/kars-bridge-bff/); + expect(builder).not.toMatch(/\|\|\s*true/); + expect(builder).not.toContain("fn main() {}"); + expect(builder.indexOf("COPY . .")).toBeGreaterThanOrEqual(0); + expect(builder.indexOf("COPY . .")).toBeLessThan(builder.indexOf("cargo build")); + }); + + it("uses the resulting non-root binary, not host build artifacts", () => { + expect(ignored.split(/\r?\n/)).toContain("target/"); + expect(dockerfile).toContain( + "COPY --from=build /src/target/release/kars-bridge-bff /usr/local/bin/kars-bridge-bff", + ); + expect(dockerfile).toMatch(/^USER 10001$/m); + expect(dockerfile).toContain('ENTRYPOINT ["/usr/local/bin/kars-bridge-bff"]'); + }); +}); diff --git a/bridge/teams-gateway/tsconfig.json b/bridge/teams-gateway/tsconfig.json new file mode 100644 index 000000000..f6e8a7a70 --- /dev/null +++ b/bridge/teams-gateway/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/bridge/teams-gateway/vitest.config.ts b/bridge/teams-gateway/vitest.config.ts new file mode 100644 index 000000000..19384e80f --- /dev/null +++ b/bridge/teams-gateway/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/bridge/tests/native-credentials/Dockerfile.bff b/bridge/tests/native-credentials/Dockerfile.bff new file mode 100644 index 000000000..e90ed015e --- /dev/null +++ b/bridge/tests/native-credentials/Dockerfile.bff @@ -0,0 +1,5 @@ +# Hosted build-once test packaging; private binaries never leave this runner. +FROM mcr.microsoft.com/azurelinux/distroless/base:3.0 +COPY kars-bridge-bff /usr/local/bin/kars-bridge-bff +USER 10001:10001 +ENTRYPOINT ["/usr/local/bin/kars-bridge-bff"] diff --git a/bridge/tests/native-credentials/Dockerfile.probe b/bridge/tests/native-credentials/Dockerfile.probe new file mode 100644 index 000000000..77e0e998c --- /dev/null +++ b/bridge/tests/native-credentials/Dockerfile.probe @@ -0,0 +1,4 @@ +# Test-only unauthenticated network peer; no cluster or observer credentials. +FROM python:3.12.11-alpine3.22 +USER 10001:10001 +CMD ["python3", "-c", "import time; time.sleep(86400)"] diff --git a/bridge/tests/native-credentials/Dockerfile.runtime b/bridge/tests/native-credentials/Dockerfile.runtime new file mode 100644 index 000000000..09cfdeb8c --- /dev/null +++ b/bridge/tests/native-credentials/Dockerfile.runtime @@ -0,0 +1,7 @@ +# Controlled test runtime, extending the exact core egress-guard fixture. +# No private image is published and no production runtime is replaced. +FROM kars-native-runtime-base:latest +RUN tdnf install -y python3 && tdnf clean all +COPY runtime_probe.py /opt/kars-native/runtime_probe.py +USER 1000:1000 +CMD ["python3", "/opt/kars-native/runtime_probe.py"] diff --git a/bridge/tests/native-credentials/admission_cases.py b/bridge/tests/native-credentials/admission_cases.py new file mode 100644 index 000000000..1005cc192 --- /dev/null +++ b/bridge/tests/native-credentials/admission_cases.py @@ -0,0 +1,215 @@ +"""Actual CEL evaluation with explicit setup-admin authority, not BFF issuance.""" + +import base64 +from datetime import datetime, timezone +import secrets + +from native_api import CORE, Failure, Setup, core, require, resource, uid + +NETWORK = "/apis/networking.k8s.io/v1" +PENDING = "kars.azure.com/credential-rebind-pending" + + +class Cases: + def __init__(self): + self.setup = Setup() + self.api = self.setup.admin + self.created = [] + self.results = {"actor": "setup-admin", "bearerIssuanceQualified": False} + + def create(self, path, body): + value = self.api.create(path, body) + self.created.append(f"{path}/{value['metadata']['name']}") + return value + + def namespace(self, name, isolated=False): + metadata = {"name": name} + if isolated: + metadata["labels"] = {"kars.azure.com/isolated": "strict"} + return self.create("/api/v1/namespaces", { + "apiVersion": "v1", "kind": "Namespace", "metadata": metadata, + }) + + def denied(self, method, path, body, policy, message, code): + status, result = self.api.request(method, path, body, expected=(code,), + patch_type="application/merge-patch+json" if method == "PATCH" else None) + require(policy in result.get("message", "") and message in result["message"], + "Native denial did not identify the intended admission predicate") + return status + + def stores(self): + namespace = "native-admission-stores" + workspace = self.namespace(namespace) + name = "kars-provider-native" + secret = self.create(core(namespace, "secrets"), { + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": name, "namespace": namespace}, + }) + grant = self.create(resource(namespace, "karscredentialgrants"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsCredentialGrant", + "metadata": {"name": "workspace", "namespace": namespace}, + "spec": {"workspaceUid": uid(workspace), "writers": [], "enabled": True, + "integrationStores": [{"purpose": "provider-default", + "secret": {"name": name, "uid": uid(secret)}}]}, + }) + path = core(namespace, "secrets", name) + self.api.patch(path, {"metadata": {"annotations": { + "kars.azure.com/credential-store-grant-uid": uid(grant), + }}}) + cases = [] + for field in ["data", "stringData"]: + value = secrets.token_hex(24) + encoded = base64.b64encode(value.encode()).decode() + updated = self.api.patch(path, {field: {"API_KEY": encoded if field == "data" else value}}) + require(uid(updated) == uid(secret) and updated["data"]["API_KEY"] == encoded, + "Valid enrolled Secret keys did not persist through native admission") + cases.append({"field": field, "keys": "allowed", "status": 200}) + for fields in [{"data": ["PASSWORD"]}, {"stringData": ["PASSWORD"]}, + {"data": ["API_KEY"], "stringData": ["PASSWORD"]}, + {"data": ["PASSWORD"], "stringData": ["API_KEY"]}]: + before = self.api.get(path) + body = {"metadata": {"uid": uid(before), + "resourceVersion": before["metadata"]["resourceVersion"]}} + for field, keys in fields.items(): + value = secrets.token_hex(24) + body[field] = {key: base64.b64encode(value.encode()).decode() if field == "data" else value + for key in keys} + code = self.denied("PATCH", path, body, "kars-credential-enrolled-store-shape", + "exact UID and purpose", 422) + after = self.api.get(path) + require(uid(after) == uid(before) and after.get("data") == before.get("data") + and after["metadata"]["resourceVersion"] == before["metadata"]["resourceVersion"], + "Forbidden enrolled keys mutated the native Secret") + cases.append({"fields": sorted(fields), "keys": "forbidden", "status": code}) + before = self.api.get(path) + self.api.patch(resource(namespace, "karscredentialgrants", "workspace"), {"spec": { + "integrationStores": [{"purpose": "provider-default", + "secret": {"name": name, "uid": "different-secret-uid"}}], + }}) + code = self.denied("PATCH", path, { + "metadata": {"uid": uid(before), "resourceVersion": before["metadata"]["resourceVersion"]}, + "stringData": {"API_KEY": secrets.token_hex(24)}, + }, "kars-credential-enrolled-store-shape", "exact UID and purpose", 422) + require(self.api.get(path)["data"] == before["data"], "Wrong enrolled UID changed secret values") + cases.append({"identity": "wrong-secret-uid", "status": code}) + self.results["enrolledStoreKeys"] = cases + + def rebind(self): + namespace = "native-admission-rebind" + self.namespace(namespace) + controller = self.setup.actor(CORE, "kars-controller") + cases = [] + for variant in ["absent", "null", "non-null"]: + name = f"resume-{variant}" + task = self.create(resource(namespace, "karstasks"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTask", + "metadata": {"name": name, "namespace": namespace, "annotations": {PENDING: "true"}}, + "spec": {"objective": "Native rebind admission evaluation", + "envelope": {"tier": 1, "authorityCeiling": 1, "delegationDepth": 0}, + "execution": {"launch": True}}, + }) + path = resource(namespace, "karstasks", name) + status = {"executionPhase": "CredentialsPaused", + "observedGeneration": task["metadata"]["generation"], + "conditions": [{"type": "Ready", "status": "False", + "lastTransitionTime": datetime.now(timezone.utc).isoformat(), + "reason": "NativeAdmissionProof", "message": "Synthetic paused fixture"}]} + if variant != "absent": + status["envelopeDigest"] = None if variant == "null" else "sha256:" + "a" * 64 + controller.request("PATCH", path + "/status", [ + {"op": "test", "path": "/metadata/uid", "value": uid(task)}, + {"op": "test", "path": "/metadata/resourceVersion", "value": task["metadata"]["resourceVersion"]}, + {"op": "add", "path": "/status", "value": status}, + ], patch_type="application/json-patch+json") + current = self.api.get(path) + observed = current["status"] + wire_kind = ("absent" if "envelopeDigest" not in observed else + "null" if observed["envelopeDigest"] is None else "non-null") + require((variant == "non-null") == (wire_kind == "non-null"), + "Native digest status fixture was not retained") + body = {"metadata": {"uid": uid(current), + "resourceVersion": current["metadata"]["resourceVersion"], + "annotations": {PENDING: None}}} + if variant == "non-null": + code = self.denied("PATCH", path, body, "kars-credential-rebind-authority", + "current paused authority", 422) + require(self.api.get(path)["metadata"]["annotations"].get(PENDING) == "true", + "Non-null digest denial removed the credential hold") + else: + code, resumed = self.api.request("PATCH", path, body, patch_type="application/merge-patch+json") + require(uid(resumed) == uid(task) and resumed["spec"]["execution"]["launch"] is True + and PENDING not in resumed["metadata"].get("annotations", {}), + "Current absent/null-digest pause could not resume nondestructively") + # Kubernetes may normalize an explicitly submitted null to absence + # under a non-nullable CRD field. Record the actual persisted wire + # shape rather than claiming a branch that never reached CEL. + cases.append({"submittedDigest": variant, "storedDigest": wire_kind, "status": code}) + self.results["pausedDigestResume"] = cases + self.results["pausedStatusWriter"] = f"system:serviceaccount:{CORE}:kars-controller" + + def exposure(self): + namespace = "native-admission-isolated" + self.namespace(namespace, isolated=True) + cases = [] + def service(name, kind=None): + spec = {"selector": {"app": "native-fixture"}, "ports": [{"port": 8443}]} + if kind: + spec["type"] = kind + return {"apiVersion": "v1", "kind": "Service", + "metadata": {"name": name, "namespace": namespace}, "spec": spec} + for kind in [None, "ClusterIP"]: + self.create(core(namespace, "services"), service("internal-default" if kind is None else "internal", kind)) + cases.append({"kind": "Service", "type": kind or "default-ClusterIP", "status": 201}) + for kind in ["LoadBalancer", "NodePort"]: + code = self.denied("POST", core(namespace, "services"), service(kind.lower(), kind), + "kars-no-public-router-exposure", "LoadBalancer/NodePort", 403) + cases.append({"kind": "Service", "type": kind, "status": code}) + ingress = {"apiVersion": "networking.k8s.io/v1", "kind": "Ingress", + "metadata": {"name": "public-ingress", "namespace": namespace}, + "spec": {"rules": [{"http": {"paths": [{"path": "/", "pathType": "Prefix", + "backend": {"service": {"name": "internal", "port": {"number": 8443}}}}]}}]}} + code = self.denied("POST", resource(namespace, "ingresses", group=NETWORK), ingress, + "kars-no-public-router-exposure", "forbid ingress objects", 403) + cases.append({"kind": "Ingress", "status": code}) + def network(name, peer): + return {"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": {"name": name, "namespace": namespace}, + "spec": {"podSelector": {"matchLabels": {"app": "native-fixture"}}, + "policyTypes": ["Ingress"], "ingress": [{"from": [peer]}]}} + self.create(resource(namespace, "networkpolicies", group=NETWORK), + network("private-peers", {"podSelector": {"matchLabels": {"app": "native-peer"}}})) + cases.append({"kind": "NetworkPolicy", "peer": "private-selector", "status": 201}) + for name, cidr in [("public-v4", "0.0.0.0/0"), ("public-v6", "::/0")]: + code = self.denied("POST", resource(namespace, "networkpolicies", group=NETWORK), + network(name, {"ipBlock": {"cidr": cidr}}), + "kars-no-public-router-exposure", "forbidden in sandbox namespaces", 403) + cases.append({"kind": "NetworkPolicy", "peer": cidr, "status": code}) + self.results["publicExposure"] = cases + + def cleanup(self): + for path in reversed(self.created): + if self.api.optional(path) is not None: + self.api.delete(path) + + +def run_cases(): + cases = Cases() + results = {} + try: + for name, operation in [("stores", cases.stores), ("rebind", cases.rebind), ("exposure", cases.exposure)]: + try: + operation() + results[name] = {"result": "passed"} + except Exception as error: + results[name] = {"result": "failed", + "failure": str(error) if isinstance(error, Failure) else type(error).__name__} + finally: + try: + cases.cleanup() + results["cleanup"] = {"result": "passed"} + except Exception as error: + results["cleanup"] = {"result": "failed", + "failure": str(error) if isinstance(error, Failure) else type(error).__name__} + cases.results["cases"] = results + cases.results["result"] = "passed" if all(value["result"] == "passed" for value in results.values()) else "failed" + return cases.results diff --git a/bridge/tests/native-credentials/api-values.yaml b/bridge/tests/native-credentials/api-values.yaml new file mode 100644 index 000000000..b4e7dfdb4 --- /dev/null +++ b/bridge/tests/native-credentials/api-values.yaml @@ -0,0 +1,16 @@ +controller: + replicas: 0 +inferenceRouter: + replicas: 0 +observationPrivacyRpc: + enabled: true +sre: + enabled: false +agentMesh: + enabled: false +meshPeer: + enabled: false +monitoring: + enabled: false + prometheus: + enabled: false diff --git a/bridge/tests/native-credentials/api_gate.py b/bridge/tests/native-credentials/api_gate.py new file mode 100644 index 000000000..1377513ed --- /dev/null +++ b/bridge/tests/native-credentials/api_gate.py @@ -0,0 +1,126 @@ +"""Real API prerequisite, deliberately independent of builds and SRE migration. + +There are no runtime credentials in this lane. Schema/admission failures are +not bypassed, and this lane makes no claim about NetworkPolicy enforcement. +""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +from source_revision import CORE_REVISION + +ROOT = Path(__file__).resolve().parents[2] +EVIDENCE = ROOT / ".native/evidence/api.json" + + +def run(*args, check=True): + result = subprocess.run( + args, cwd=ROOT, text=True, capture_output=True, timeout=180, check=False + ) + if check and result.returncode: + # Only called before credential/runtime setup. Never use this helper to + # print a general Kubernetes resource, pod log, or BFF response body. + raise RuntimeError(f"{args[0]} failed: {result.stderr[-16000:]}") + return result + + +def kubernetes(*args): + return json.loads(run("kubectl", *args, "-o", "json").stdout) + + +def main(): + evidence = { + "coreRevision": CORE_REVISION, + "bridgeRevision": run("git", "rev-parse", "HEAD").stdout.strip(), + "lane": "no-active-sre-api-prerequisite", + "runtimeQualified": False, + "networkPolicyEnforcementQualified": False, + "activeSreCombinedQualified": False, + "markers": [], + } + try: + actual = run("git", "-C", ".native/core", "rev-parse", "HEAD").stdout.strip() + if actual != CORE_REVISION or os.environ.get("CORE_REVISION") != CORE_REVISION: + raise RuntimeError("Exact public core checkout mismatch") + # The chart includes its namespace. Prepare its Helm ownership before + # install so release storage and the Namespace template do not race. + run("kubectl", "create", "namespace", "kars-system") + run("kubectl", "label", "namespace", "kars-system", "app.kubernetes.io/managed-by=Helm") + run("kubectl", "annotate", "namespace", "kars-system", + "meta.helm.sh/release-name=kars", "meta.helm.sh/release-namespace=kars-system") + run( + "helm", "install", "kars", ".native/core/deploy/helm/kars", + "--namespace", "kars-system", + "--values", "tests/native-credentials/api-values.yaml", + "--timeout", "120s", + ) + evidence["markers"].append("native-chart-install") + crds = kubernetes("get", "customresourcedefinitions")["items"] + required = { + "karscredentialgrants.kars.azure.com", + "karssandboxes.kars.azure.com", + "karstasks.kars.azure.com", + "karsteams.kars.azure.com", + } + present = {item["metadata"]["name"] for item in crds} + if not required <= present: + raise RuntimeError("Required credential/consumer CRDs are absent") + run("kubectl", "wait", "--for=condition=Established", "--timeout=60s", + *[f"crd/{name}" for name in sorted(required)]) + evidence["markers"].append("native-credential-crds-established") + deadline = time.monotonic() + 60 + while True: + policies = [ + item for item in kubernetes("get", "validatingadmissionpolicies")["items"] + if item["metadata"]["name"].startswith("kars-") + ] + if not policies: + raise RuntimeError("Core admission policies are absent") + pending = [ + item["metadata"]["name"] for item in policies + if item.get("status", {}).get("observedGeneration") + != item["metadata"]["generation"] + ] + if not pending: + break + if time.monotonic() >= deadline: + raise RuntimeError("Admission type-checking did not acknowledge current generations") + time.sleep(1) + warnings = [ + {"policy": item["metadata"]["name"], "field": warning.get("fieldRef"), + "warning": warning.get("warning")} + for item in policies + for warning in item.get("status", {}).get("typeChecking", {}).get("expressionWarnings", []) + ] + evidence["admissionWarnings"] = warnings + if warnings: + raise RuntimeError("Native admission expression warnings; see secret-free evidence") + evidence["markers"].append("native-admission-typechecking-clean") + registrations = kubernetes("get", "karssreregistrations", "--all-namespaces")["items"] + if registrations: + raise RuntimeError("No-active-SRE lane unexpectedly contains registration") + evidence["markers"].append("no-active-sre-registration") + from admission_cases import run_cases + evidence["nativeAdmissionCases"] = run_cases() + if evidence["nativeAdmissionCases"]["result"] != "passed": + raise RuntimeError("Native admission positive/negative cases failed; see bounded evidence") + evidence["markers"].append("native-admission-positive-and-intended-denial-cases") + evidence["result"] = "passed" + except Exception as error: + evidence["result"] = "failed" + from native_api import Failure + evidence["failure"] = str(error) if isinstance(error, (RuntimeError, Failure)) else type(error).__name__ + print(evidence["failure"], file=sys.stderr) + finally: + EVIDENCE.parent.mkdir(parents=True, exist_ok=True) + EVIDENCE.write_text(json.dumps(evidence, indent=2) + "\n") + print(json.dumps({key: evidence[key] for key in ("coreRevision", "lane", "markers", "result")})) + return 0 if evidence["result"] == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bridge/tests/native-credentials/api_outcome_diagnostics.py b/bridge/tests/native-credentials/api_outcome_diagnostics.py new file mode 100644 index 000000000..36a0ca58e --- /dev/null +++ b/bridge/tests/native-credentials/api_outcome_diagnostics.py @@ -0,0 +1,152 @@ +"""Case-bounded metadata audit outcomes for two UID-proven native actors.""" + +from collections import Counter +from datetime import datetime, timezone +import json +import subprocess + +from native_api import CORE, ROOT, require, resource +from observation_diagnostics import READ_ERRORS, UNAVAILABLE, identity, recheck_actor, resolve_actor + +MAX_BYTES = 4 * 1024 * 1024 +MAX_LINE_BYTES = 65536 +ACTORS = {"bff_writer": "bff", "observer_router": "router"} +POD_UID = "authentication.kubernetes.io/pod-uid" +POD_NAME = "authentication.kubernetes.io/pod-name" + + +def read_audit_tail(): + result = subprocess.run( + ["docker", "exec", "bridge-native-control-plane", "tail", "-c", str(MAX_BYTES), + "/var/log/kars-native-audit/audit.log"], + cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=15, check=False, + ) + require(result.returncode == 0 and len(result.stdout) <= MAX_BYTES, "Metadata audit unavailable") + return result.stdout + + +def timestamp(value): + if not isinstance(value, str) or len(value) > 64: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed if parsed.tzinfo is not None else None + except ValueError: + return None + + +def status_category(code): + if 200 <= code < 300: + return "successful-response" + return { + 401: "unauthenticated-response", 403: "denied-response", 404: "not-found-response", + 409: "conflict-response", 422: "invalid-response", 429: "rate-limited-response", + 408: "timeout-response", 504: "timeout-response", + }.get(code, "server-error-response" if code >= 500 else "other-response") + + +def rules_for(setup, kind, target, actor): + require(isinstance(target, dict) and isinstance(target.get("uid"), str) and target["uid"], UNAVAILABLE) + namespace, name = target["workspace"], target["sandbox"] + require((kind == "observer_router" and namespace == CORE) + or (kind == "bff_writer" and namespace == "native-delivery" + and name in ("native-delivery-a", "native-delivery-b")), UNAVAILABLE) + path = resource(namespace, "karssandboxes", name) + value = setup.admin.get(path) + require(identity(value)[0] == target["uid"] + and value["metadata"].get("name") == name + and value["metadata"].get("namespace") == namespace, UNAVAILABLE) + actor["anchors"][path] = value + actor["targetUid"] = identity(value)[0] + rules = {("get", "kars.azure.com", "v1alpha1", "karssandboxes", namespace, name)} + if kind == "bff_writer": + namespace_path = f"/api/v1/namespaces/{namespace}" + workspace = setup.admin.get(namespace_path) + identity(workspace) + require(workspace["metadata"].get("name") == namespace, UNAVAILABLE) + actor["anchors"][namespace_path] = workspace + source = f"kars-credential-input-sandbox-{name}" + rules.update({ + ("get", "kars.azure.com", "v1alpha1", "karscredentialgrants", namespace, "workspace"), + ("patch", "kars.azure.com", "v1alpha1", "karssandboxes", namespace, name), + ("get", "", "v1", "namespaces", None, namespace), + ("get", "", "v1", "namespaces", None, f"kars-{name}"), + ("create", "", "v1", "secrets", namespace, source), + ("get", "", "v1", "secrets", namespace, source), + ("patch", "", "v1", "secrets", namespace, source), + # Authorization may reject CREATE before an object name is decoded. + ("create", "", "v1", "secrets", namespace, None), + }) + return rules + + +def project(raw, actor, rules, since, until): + require(isinstance(raw, bytes) and len(raw) <= MAX_BYTES, "Metadata audit unavailable") + counts = Counter() + pods = {pod["uid"]: pod["name"] for pod in actor["pods"]} + for line in raw.splitlines(): + if len(line) > MAX_LINE_BYTES: + continue + try: + event = json.loads(line) + if (not isinstance(event, dict) or event.get("level") != "Metadata" + or event.get("stage") != "ResponseComplete" or event.get("impersonatedUser") is not None): + continue + user = event.get("user") or {} + extra = user.get("extra") or {} + pod_uid = extra.get(POD_UID) + if (user.get("username") != actor["username"] or user.get("uid") != actor["serviceAccountUid"] + or not isinstance(pod_uid, list) or len(pod_uid) != 1 + or not isinstance(pod_uid[0], str) or pod_uid[0] not in pods + or (POD_NAME in extra and extra[POD_NAME] != [pods[pod_uid[0]]])): + continue + started, completed = timestamp(event.get("requestReceivedTimestamp")), timestamp(event.get("stageTimestamp")) + if started is None or completed is None or not since <= started <= completed <= until: + continue + ref = event.get("objectRef") or {} + if ref.get("subresource"): + continue + key = (event.get("verb"), ref.get("apiGroup", ""), ref.get("apiVersion"), + ref.get("resource"), ref.get("namespace") or None, ref.get("name") or None) + if key not in rules: + continue + if ref.get("resource") == "karssandboxes" and ref.get("uid") not in (None, "", actor["targetUid"]): + continue + code = (event.get("responseStatus") or {}).get("code") + if type(code) is not int or not 100 <= code <= 599: + continue + counts[(*key[:4], code, status_category(code))] += 1 + except (ValueError, TypeError, AttributeError): + continue + return [ + {"verb": key[0], "apiGroup": key[1], "apiVersion": key[2], "resource": key[3], + "http_status": key[4], "category": key[5], "count": count} + for key, count in list(counts.items())[-24:] + ] + + +def collect(setup, kind, target, since): + result = {"actor": kind if kind in ACTORS else "unknown", "available": False, + "category": "source-unavailable", "coverage": "bounded-metadata-tail", "outcomes": []} + try: + until = datetime.now(timezone.utc) + require(isinstance(since, datetime) and since.tzinfo is not None + and 0 <= (until - since).total_seconds() <= 600, UNAVAILABLE) + actor = resolve_actor(setup, ACTORS[kind], target) + rules = rules_for(setup, kind, target, actor) + recheck_actor(setup, actor) + except READ_ERRORS: + return result + try: + raw = read_audit_tail() + outcomes = project(raw, actor, rules, since, until) + except READ_ERRORS: + result["category"] = "audit-unavailable" + return result + try: + recheck_actor(setup, actor) + except READ_ERRORS: + return result + result.update(available=bool(outcomes), outcomes=outcomes, + category="outcomes-retained" if outcomes else "no-matching-evidence") + return result diff --git a/bridge/tests/native-credentials/audit-policy.yaml b/bridge/tests/native-credentials/audit-policy.yaml new file mode 100644 index 000000000..fde2573bd --- /dev/null +++ b/bridge/tests/native-credentials/audit-policy.yaml @@ -0,0 +1,9 @@ +apiVersion: audit.k8s.io/v1 +kind: Policy +omitStages: + - RequestReceived + - ResponseStarted +rules: + # No request/response bodies: Secrets, TokenRequests and principal assertions + # must never enter the disposable audit log or uploaded evidence. + - level: Metadata diff --git a/bridge/tests/native-credentials/boot.py b/bridge/tests/native-credentials/boot.py new file mode 100644 index 000000000..e4aff5d83 --- /dev/null +++ b/bridge/tests/native-credentials/boot.py @@ -0,0 +1,160 @@ +"""Administrative fixture setup, kept separate from the native BFF actor.""" + +import base64 +from contextlib import contextmanager +import hashlib +import hmac +import http.client +import json +import secrets +import subprocess +import time + +from native_api import BRIDGE, CORE, ROOT, STATE, command, core, private_file, require, until +from loaded_images import loaded_image + + +def install_core(setup): + namespace = setup.namespace(CORE) + setup.admin.patch(f"/api/v1/namespaces/{CORE}", { + "metadata": {"labels": {"app.kubernetes.io/managed-by": "Helm"}, + "annotations": {"meta.helm.sh/release-name": "kars", + "meta.helm.sh/release-namespace": CORE}}, + }) + values = { + "controller": { + "replicas": 1, + "image": {"repository": "docker.io/library/kars-native-controller", + "tag": "latest", "pullPolicy": "IfNotPresent"}, + "resources": {"requests": {"cpu": "100m", "memory": "256Mi"}, + "limits": {"cpu": "2", "memory": "2Gi"}}, + "extraEnv": [{"name": "RUST_LOG", "value": "warn"}, + {"name": "LEADER_ELECTION_ENABLED", "value": "true"}], + }, + "sandbox": { + "image": loaded_image("kars-native-runtime"), + "nodeSelector": {"kubernetes.io/hostname": "bridge-native-worker"}, + }, + "inferenceRouter": {"image": loaded_image("kars-native-router")}, + "observationPrivacyRpc": {"enabled": True}, + "foundry": {"endpoint": "https://native.invalid"}, + "sre": {"enabled": False}, "agentMesh": {"enabled": False}, + "meshPeer": {"enabled": False}, + "monitoring": {"enabled": False, "prometheus": {"enabled": False}}, + } + private_file("core-values.json", json.dumps(values)) + command("helm", "install", "kars", ".native/core/deploy/helm/kars", + "--namespace", CORE, "--values", ".native/core-values.json", "--timeout", "180s") + command("kubectl", "rollout", "status", "deployment/kars-controller", + "-n", CORE, "--timeout=180s", timeout=195) + require(not setup.admin.get("/apis/kars.azure.com/v1alpha1/karssreregistrations")["items"], + "Initial lane must not have active or retired SRE registration") + return namespace + + +def install_bridge(setup): + setup.namespace(BRIDGE) + signing_key = secrets.token_hex(32) + setup.admin.create(core(BRIDGE, "secrets"), { + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": "native-principal", "namespace": BRIDGE}, + "data": {"session-secret": base64.b64encode(signing_key.encode()).decode()}, + }) + values = { + "namespace": BRIDGE, "createNamespace": False, "core": {"namespace": CORE}, + "auth": {"principalSecretName": "native-principal"}, + "bff": { + "image": {"repository": "docker.io/library/kars-native-bff", + "tag": "latest", "pullPolicy": "IfNotPresent"}, + "extraEnv": [ + {"name": "BRIDGE_ORCHESTRATOR_ENDPOINT", "value": "http://127.0.0.1:9"}, + {"name": "BRIDGE_ENGINEERING_POLLER_SECONDS", "value": "86400"}, + {"name": "RUST_LOG", "value": "warn"}, + ], + }, + } + private_file("bridge-values.json", json.dumps(values)) + manifest = command( + "helm", "template", "bridge-native", "deploy/helm/kars-bridge", + "--namespace", BRIDGE, "--values", ".native/bridge-values.json", + "--show-only", "templates/rbac.yaml", "--show-only", "templates/bff.yaml", + ) + command("kubectl", "apply", "-f", "-", stdin=manifest) + command("kubectl", "rollout", "status", "deployment/kars-bridge-bff", + "-n", BRIDGE, "--timeout=180s", timeout=195) + return signing_key + + +def principal(key): + def encode(value): + return base64.urlsafe_b64encode(json.dumps(value, separators=(",", ":")).encode()).rstrip(b"=") + payload = b".".join([ + encode({"alg": "HS256", "typ": "JWT"}), + encode({"sub": "native-operator", "name": "Native qualification", + "roles": ["operator", "user"], "exp": int(time.time()) + 3600}), + ]) + signature = base64.urlsafe_b64encode( + hmac.new(key.encode(), payload, hashlib.sha256).digest(), + ).rstrip(b"=") + return (payload + b"." + signature).decode() + + +class Bff: + def __init__(self, key): + self.token = principal(key) + + def call(self, method, path, body=None, expected=200, authenticated=True, include_status=False): + connection = http.client.HTTPConnection("127.0.0.1", 18081, timeout=60) + headers = {"Content-Type": "application/json"} + if authenticated: + headers["x-kars-principal-token"] = self.token + try: + connection.request(method, path, None if body is None else json.dumps(body), headers) + response = connection.getresponse() + raw = response.read(1024 * 1024) + allowed = expected if isinstance(expected, tuple) else (expected,) + require(response.status in allowed, f"BFF {method} returned {response.status}, expected {expected}") + require(len(raw) < 1024 * 1024, "BFF response exceeded bound") + value = json.loads(raw) if raw else {} + return (response.status, value) if include_status else value + finally: + connection.close() + + def channel(self, namespace, value, channel="slack", expected=200): + return self.call("POST", f"/api/namespaces/{namespace}/channels", + {"channel": channel, "token": value}, expected) + + def ready(self): + try: + result = self.call("GET", "/readyz", expected=(200, 503), authenticated=False) + return result.get("status") == "ok" and result.get("cluster_configured") is True + except (ConnectionError, OSError): + return False + + +@contextmanager +def bridge_connection(key): + with (STATE / "port-forward.log").open("w") as output: + process = subprocess.Popen( + ["kubectl", "port-forward", "-n", BRIDGE, "service/kars-bridge-bff", + "18081:8081", "--address=127.0.0.1"], + cwd=ROOT, stdin=subprocess.DEVNULL, stdout=output, stderr=output, + ) + try: + client = Bff(key) + def ready(): + require(process.poll() is None, "BFF port-forward terminated") + try: + client.call("GET", "/readyz", authenticated=False) + return True + except (ConnectionError, OSError): + return False + until("real BFF readiness", ready, 30) + yield client + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/bridge/tests/native-credentials/credential_cases.py b/bridge/tests/native-credentials/credential_cases.py new file mode 100644 index 000000000..4db1787c3 --- /dev/null +++ b/bridge/tests/native-credentials/credential_cases.py @@ -0,0 +1,272 @@ +"""Public BFF entrypoints under actual native writer RBAC and API audit.""" + +import base64 +import copy +import secrets + +from native_api import BRIDGE, CORE, WRITER, core, require, resource, uid, until + +SOURCE = "kars-credential-input-workspace" +KEY = "SLACK_BOT_TOKEN" +MUTATIONS = {"create", "patch", "update", "delete", "deletecollection"} + + +def selection(grant, source, scope="workspace", owner=None, keys=None): + value = {"scope": scope, "source": {"name": source["metadata"]["name"], "uid": uid(source)}, + "keys": keys or [KEY]} + if owner: + value["owner"] = owner + return {"grant": {"name": "workspace", "uid": uid(grant)}, "sources": [value]} + + +def inference(setup, namespace, name): + policy_path = resource(namespace, "toolpolicies", "kars-default") + if setup.admin.optional(policy_path) is None: + policy = setup.admin.get(resource(CORE, "toolpolicies", "kars-default")) + setup.admin.create(resource(namespace, "toolpolicies"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "ToolPolicy", + "metadata": {"name": "kars-default", "namespace": namespace}, + "spec": policy["spec"], + }) + return setup.admin.create(resource(namespace, "inferencepolicies"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "InferencePolicy", + "metadata": {"name": name, "namespace": namespace}, + "spec": {"appliesTo": {"sandboxName": name}, + "modelPreference": {"primary": {"provider": "azure-openai", "deployment": "native"}}}, + }) + + +def sandbox(setup, namespace, name, bindings=None, legacy=None, suspended=True): + inference(setup, namespace, name) + spec = {"runtime": {"kind": "OpenClaw", "openclaw": {}}, + "sandbox": {"isolation": "standard"}, "inferenceRef": {"name": name}, + "suspended": suspended} + if bindings: + spec["credentialBindings"] = bindings + if legacy: + spec["credentialsRef"] = legacy + return setup.admin.create(resource(namespace, "karssandboxes"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsSandbox", + "metadata": {"name": name, "namespace": namespace}, "spec": spec, + }) + + +def paused_team(setup, namespace, name): + return setup.admin.create(resource(namespace, "karsteams"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTeam", + "metadata": {"name": name, "namespace": namespace}, + "spec": {"charter": "Native credential qualification", "paused": True, "blueprint": {}, + "envelope": {"tier": 1, "authorityCeiling": 1, "delegationDepth": 0}}, + }) + + +def changes_since(setup, actor, namespace, before): + ids = {event["auditID"] for event in before} + return [event for event in setup.audit_barrier(actor, namespace) if event["auditID"] not in ids] + + +class CredentialCases: + def __init__(self, setup, bff): + self.setup = setup + self.bff = bff + self.writer = setup.admin.get(core(BRIDGE, "serviceaccounts", WRITER)) + self.actor = setup.actor(BRIDGE, WRITER) + + def workspace(self, namespace): + self.setup.namespace(namespace) + return self.setup.grant(namespace, self.writer) + + def bootstrap(self): + namespace = "native-bootstrap" + grant = self.workspace(namespace) + team = paused_team(self.setup, namespace, "bootstrap") + v1_source = self.setup.admin.create(core(namespace, "secrets"), { + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": "kars-credential-source-native-v1", "namespace": namespace, + "annotations": {"kars.azure.com/credential-purpose": "agent-source-v1", + "kars.azure.com/credential-target": "native-v1", + "kars.azure.com/credential-workspace": namespace, + "kars.azure.com/credential-binding-intent": "explicit-reference-v1"}}, + "data": {KEY: base64.b64encode(secrets.token_hex(24).encode()).decode()}, + }) + legacy = sandbox(self.setup, namespace, "native-v1", + legacy={"name": v1_source["metadata"]["name"], "uid": uid(v1_source)}) + self.actor.request("GET", core(namespace, "secrets", SOURCE), expected=(403,)) + self.actor.request("GET", core(namespace, "secrets"), expected=(403,)) + before = self.setup.audit_barrier(self.actor, namespace) + value = secrets.token_hex(24) + result = self.bff.channel(namespace, value) + require("slack" in result.get("enabled", []), "Workspace channel was not reported stored") + source = self.setup.admin.get(core(namespace, "secrets", SOURCE)) + require(base64.b64decode(source["data"][KEY]).decode() == value, + "Native source did not retain the requested value") + observed = self.setup.ready_grant(namespace) + require(any(item["name"] == SOURCE and item["uid"] == uid(source) + for item in observed["status"]["sources"]), "Core did not acknowledge CREATE UID") + self.actor.get(core(namespace, "secrets", SOURCE)) + current = self.setup.admin.get(resource(namespace, "karsteams", "bootstrap")) + binding = current["spec"]["blueprint"]["credentialBindings"] + require(binding["grant"]["uid"] == uid(grant) + and binding["sources"][0]["source"]["uid"] == uid(source), + "Team did not bind the actual created source UID") + require(uid(current) == uid(team), "Workspace authoring recreated a Team") + current_legacy = self.setup.admin.get(resource(namespace, "karssandboxes", "native-v1")) + require(uid(current_legacy) == uid(legacy) + and current_legacy["spec"]["credentialsRef"] == legacy["spec"]["credentialsRef"] + and "credentialBindings" not in current_legacy["spec"], + "Workspace authoring converted a v1 consumer without explicit migration") + events = changes_since(self.setup, self.actor, namespace, before) + posts = [event for event in events if event["verb"] == "create" + and event.get("objectRef", {}).get("resource") == "secrets" + and event.get("responseStatus", {}).get("code") == 201] + require(len(posts) == 1, "Bootstrap did not use one exclusive native source CREATE") + created_at = posts[0]["requestReceivedTimestamp"] + gets = [event for event in events if event["verb"] == "get" + and event.get("objectRef", {}).get("resource") == "secrets" + and event.get("objectRef", {}).get("name") == SOURCE] + require(gets and all(event["requestReceivedTimestamp"] > created_at for event in gets), + "BFF attempted native source GET before exclusive CREATE") + first_get = min(event["requestReceivedTimestamp"] for event in gets) + require(any(event["verb"] == "get" + and event.get("objectRef", {}).get("resource") == "karscredentialgrants" + and created_at < event["requestReceivedTimestamp"] < first_get + for event in events), "No metadata acknowledgement precedes native source read") + return namespace + + def collision(self): + namespace = "native-collision" + self.workspace(namespace) + paused_team(self.setup, namespace, "collision-survivor") + existing = self.setup.admin.create(core(namespace, "secrets"), { + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": SOURCE, "namespace": namespace, "annotations": { + "kars.azure.com/credential-purpose": "agent-input-v2", + "kars.azure.com/credential-workspace": namespace, + "kars.azure.com/credential-binding-intent": "explicit-reference-v2", + "kars.azure.com/credential-target-kind": "Workspace", + "kars.azure.com/credential-target": namespace, + "kars.azure.com/credential-grant-uid": "foreign-grant-uid", + }}, + "data": {KEY: base64.b64encode(secrets.token_hex(24).encode()).decode()}, + }) + before_team = self.setup.admin.get(resource(namespace, "karsteams", "collision-survivor")) + self.actor.request("GET", core(namespace, "secrets", SOURCE), expected=(403,)) + before = self.setup.audit_barrier(self.actor, namespace) + self.bff.channel(namespace, secrets.token_hex(24), expected=502) + after = self.setup.admin.get(core(namespace, "secrets", SOURCE)) + after_team = self.setup.admin.get(resource(namespace, "karsteams", "collision-survivor")) + require(uid(after) == uid(existing) and after["data"] == existing["data"], + "Unobserved source collision changed values or adopted a replacement") + require(uid(after_team) == uid(before_team) and after_team["spec"] == before_team["spec"], + "Unobserved source collision changed a consumer") + events = changes_since(self.setup, self.actor, namespace, before) + mutations = [event for event in events if event["verb"] in MUTATIONS] + require(len(mutations) == 1 and mutations[0]["verb"] == "create" + and mutations[0].get("responseStatus", {}).get("code") == 409, + "Collision was not one exclusive CREATE409 with no fallback mutations") + require(not any(event["verb"] == "get" + and event.get("objectRef", {}).get("resource") == "secrets" + for event in events), "Collision performed an unauthorized adoption GET") + + def late_conflict(self, existing): + namespace = "native-conflict-existing" if existing else "native-conflict-new" + grant = self.workspace(namespace) + if existing: + self.bff.channel(namespace, secrets.token_hex(24)) + self.setup.ready_grant(namespace) + team = paused_team(self.setup, namespace, "first-consumer") + foreign = {"grant": {"name": "workspace", "uid": "foreign-grant-uid"}, "sources": [{ + "scope": "workspace", "source": {"name": SOURCE, "uid": "foreign-source-uid"}, "keys": [KEY], + }]} + late = sandbox(self.setup, namespace, "native-late-" + ("old" if existing else "new"), foreign) + before_source = self.setup.admin.optional(core(namespace, "secrets", SOURCE)) + snapshots = [ + (resource(namespace, "karsteams", team["metadata"]["name"]), uid(team), copy.deepcopy(team["spec"])), + (resource(namespace, "karssandboxes", late["metadata"]["name"]), uid(late), copy.deepcopy(late["spec"])), + ] + self.setup.ready_grant(namespace) + before = self.setup.audit_barrier(self.actor, namespace) + self.bff.channel(namespace, secrets.token_hex(24), expected=502) + after_source = self.setup.admin.optional(core(namespace, "secrets", SOURCE)) + require((before_source is None and after_source is None) or ( + before_source is not None and after_source is not None + and uid(before_source) == uid(after_source) + and before_source.get("data") == after_source.get("data") + ), "Late consumer conflict mutated source values or UID") + for path, identity, spec in snapshots: + current = self.setup.admin.get(path) + require(uid(current) == identity and current["spec"] == spec, + "Late consumer conflict partially converted earlier consumers") + events = changes_since(self.setup, self.actor, namespace, before) + require(not any(event["verb"] in MUTATIONS for event in events), + "Public BFF entrypoint mutated native API before complete consumer preflight") + require(uid(self.setup.ready_grant(namespace)) == uid(grant), + "Late conflict replaced the workspace grant") + + def native_denials(self, namespace): + self.setup.account(BRIDGE, "unregistered") + self.setup.namespace("bridge-native-alias") + self.setup.account("bridge-native-alias", WRITER) + for actor in [self.setup.actor(BRIDGE, "unregistered"), + self.setup.actor("bridge-native-alias", WRITER)]: + actor.request("GET", core(namespace, "secrets", SOURCE), expected=(403,)) + actor.request("GET", core(namespace, "secrets"), expected=(403,)) + actor.request("POST", core(namespace, "secrets"), { + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": "kars-credential-input-workspace", "namespace": namespace}, + }, expected=(403,)) + self.actor.request("GET", core(BRIDGE, "secrets", "native-principal"), expected=(403,)) + self.actor.request("GET", core(namespace, "secrets") + "?watch=true&timeoutSeconds=1", + expected=(403,)) + self.actor.request("POST", resource(namespace, "roles", group="/apis/rbac.authorization.k8s.io/v1"), { + "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", + "metadata": {"name": "reader-alias", "namespace": namespace}, + "rules": [{"apiGroups": [""], "resources": ["secrets"], "verbs": ["get"]}], + }, expected=(403,)) + self.actor.request("POST", resource(namespace, "rolebindings", group="/apis/rbac.authorization.k8s.io/v1"), { + "apiVersion": "rbac.authorization.k8s.io/v1", "kind": "RoleBinding", + "metadata": {"name": "reader-alias", "namespace": namespace}, + "subjects": [{"kind": "ServiceAccount", "name": WRITER, "namespace": BRIDGE}], + "roleRef": {"apiGroup": "rbac.authorization.k8s.io", "kind": "Role", + "name": "kars-credential-writer-alias"}, + }, expected=(403,)) + + def removal_before_import(self): + namespace = "native-removal" + self.setup.namespace(namespace) + legacy = self.setup.admin.create(core(namespace, "secrets"), { + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": "kars-workspace-channels", "namespace": namespace}, + "data": {KEY: base64.b64encode(secrets.token_hex(24).encode()).decode()}, + }) + self.setup.grant(namespace, self.writer) + grant = until("native metadata-only legacy inventory", + lambda: (value if (value := self.setup.ready_grant(namespace)) + .get("status", {}).get("legacySources") else None)) + imports = grant["status"]["legacySources"] + require(any(item["secret"]["uid"] == uid(legacy) for item in imports), + "Legacy inventory did not pin actual native Secret UID") + # Explicit operator review of this synthetic legacy source, before + # creating its new source. A channel save is never migration approval. + self.setup.admin.patch(resource(namespace, "karscredentialgrants", "workspace"), + {"spec": {"legacyImports": imports}}) + self.setup.ready_grant(namespace) + self.bff.call("DELETE", f"/api/namespaces/{namespace}/channels/slack") + source = self.setup.admin.get(core(namespace, "secrets", SOURCE)) + removed = json_removed(source) + require(KEY not in source.get("data", {}) and KEY in removed, + "First source CREATE lost persistent pre-import removal intent") + self.bff.channel(namespace, secrets.token_hex(24), channel="telegram") + source_after = self.setup.admin.get(core(namespace, "secrets", SOURCE)) + old_after = self.setup.admin.get(core(namespace, "secrets", "kars-workspace-channels")) + require(uid(source_after) == uid(source) and KEY not in source_after.get("data", {}) + and KEY in json_removed(source_after), + "Later native import or source update resurrected a removed credential") + require(uid(old_after) == uid(legacy) and old_after["data"] == legacy["data"], + "Explicit legacy review mutated the preserved original source") + + +def json_removed(source): + import json + return json.loads(source["metadata"].get("annotations", {}).get( + "kars.azure.com/credential-removed-keys", "[]")) diff --git a/bridge/tests/native-credentials/credential_review.py b/bridge/tests/native-credentials/credential_review.py new file mode 100644 index 000000000..5dff42e5e --- /dev/null +++ b/bridge/tests/native-credentials/credential_review.py @@ -0,0 +1,128 @@ +"""Explicit operator metadata review/resubmission, not transport or status retries.""" + +import copy +import json + +from native_api import require + +REVIEW = "/api/operator/credentials/review" +WRITE = "/api/operator/credentials" + + +def _review(value, request): + require(isinstance(value, dict) and isinstance(value.get("token"), str), + "Credential review token missing") + metadata = value.get("metadata", {}) + require(isinstance(metadata, dict), "Credential review metadata missing") + target, grant, source = (metadata.get(name, {}) for name in ("target", "grant", "source")) + require(all(isinstance(item, dict) for item in (target, grant, source)), + "Credential review identities malformed") + require(target.get("kind") == request["kind"] and target.get("namespace") == request["namespace"] + and target.get("name") == request["target"] and target.get("uid") == request["targetUid"] + and metadata.get("key") == request["key"], "Credential review target/key differs") + require(type(target.get("generation")) is int and target["generation"] > 0 + and isinstance(target.get("version"), str) and isinstance(target.get("intent"), str) + and isinstance(grant.get("uid"), str) and type(grant.get("generation")) is int + and isinstance(grant.get("intent"), str) and isinstance(grant.get("workspaceUid"), str) + and isinstance(grant.get("legacyInventory"), str), "Credential review authority missing") + require(isinstance(source.get("name"), str) and isinstance(source.get("keys"), list) + and type(value.get("expiresAt")) is int and type(value.get("submission")) is int + and value["submission"] in (1, 2, 3) + and isinstance(value.get("continuation"), bool) and isinstance(value.get("bindingOnly"), bool), + "Credential review metadata malformed") + return copy.deepcopy(value) + + +def _same_review(previous, current, receipt): + old, new = previous["metadata"], current["metadata"] + for kind in ("target", "grant"): + left, right = copy.deepcopy(old[kind]), copy.deepcopy(new[kind]) + left.pop("version", None) + right.pop("version", None) + require(left == right, "Credential target or grant authority changed during re-review") + require(current["expiresAt"] == previous["expiresAt"] + and current["submission"] == previous["submission"] + 1 + and current["continuation"] is True, "Credential continuation bound changed") + require(old["key"] == new["key"], "Credential key changed during review") + source = receipt.get("source") + if receipt.get("outcome") == "no-write-attempted": + require(source is None and not current["bindingOnly"] and old["source"] == new["source"], + "Unwritten source changed; no adoption is permitted") + else: + require(receipt.get("outcome") == "source-stored" and isinstance(source, dict) + and current["bindingOnly"], "No acknowledged partial write authorizes continuation") + require(all(new["source"].get(key) == source.get(key) + for key in ("name", "uid", "version", "metadataDigest")) + and isinstance(source.get("uid"), str) and isinstance(source.get("version"), str), + "Acknowledged partial source identity/version changed") + require(sorted(new["source"]["keys"]) == sorted(set(old["source"]["keys"] + [old["key"]])), + "Acknowledged source key scope changed") + + +def reviewed_credential_write(bff, request, expected_grant, expected_generation, report=None): + """At most three separately reviewed submissions; no credential readback.""" + if report is None: + report = lambda fact: print("CREDENTIAL-REVIEW " + json.dumps(fact, sort_keys=True)) + metadata_input = {key: request[key] for key in ("namespace", "kind", "target", "targetUid", "key")} + reviewed = _review(bff.call("POST", REVIEW, metadata_input), metadata_input) + require(reviewed["submission"] == 1 and not reviewed["continuation"] and not reviewed["bindingOnly"] + and reviewed["metadata"]["target"]["generation"] == expected_generation + and reviewed["metadata"]["grant"]["uid"] == expected_grant["metadata"]["uid"] + and reviewed["metadata"]["grant"]["generation"] == expected_grant["metadata"]["generation"], + "Initial operator review differs from the created target or enrolled grant") + for attempt in range(1, 4): + report({"stage": "operator-metadata-reviewed", "submission": attempt, + "sameAuthority": True, "bindingOnly": reviewed["bindingOnly"]}) + code, result = bff.call("POST", WRITE, {**request, "review": reviewed["token"]}, + expected=(200, 409), include_status=True) + require(isinstance(result, dict), "Credential result malformed") + report({"stage": "reviewed-credential-submission", "submission": attempt, "httpStatus": code}) + if code == 200: + require(result.get("stored") is True + and result.get("source", {}).get("name") == reviewed["metadata"]["source"]["name"], + "Reviewed credential write was not confirmed") + if reviewed["metadata"]["source"].get("uid") is not None: + require(result["source"].get("uid") == reviewed["metadata"]["source"]["uid"], + "Completion changed the reviewed source UID") + return result + require(code == 409 and result.get("error", {}).get("code") == "conflict", + "Only a typed conflict may request explicit re-review") + receipt = result["error"].get("credentialContinuation") + require(attempt < 3 and isinstance(receipt, dict) and isinstance(receipt.get("token"), str), + "No server-owned continuation proof or submission budget remains") + refresh_code, refreshed_result = bff.call("POST", REVIEW, { + **metadata_input, "continuation": receipt["token"], + }, expected=(200, 409), include_status=True) + if refresh_code == 409: + # A fresh metadata-only view diagnoses the refusal; its ticket is + # never submitted and cannot rebase this already-reviewed write. + current_code, current = bff.call("POST", REVIEW, metadata_input, + expected=(200, 409), include_status=True) + facts = {"stage": "operator-re-review-rejected", "httpStatus": refresh_code, + "currentMetadataAvailable": current_code == 200, "writeResubmitted": False} + if current_code == 200: + current = _review(current, metadata_input)["metadata"] + previous = copy.deepcopy(reviewed["metadata"]) + if receipt.get("outcome") == "source-stored" and isinstance(receipt.get("source"), dict): + previous["source"] = { + **receipt["source"], + "keys": sorted(set(previous["source"]["keys"] + [previous["key"]])), + } + facts["unchanged"] = { + area: {field: previous[area].get(field) == current[area].get(field) + for field in fields} + for area, fields in ( + ("target", ("uid", "generation", "version", "intent")), + ("grant", ("uid", "generation", "version", "intent", "workspaceUid", "legacyInventory")), + ("source", ("uid", "version", "metadataDigest", "keys")), + ) + } + report(facts) + require(False, "Credential re-review was rejected; no authority or source was rebased") + require(refresh_code == 200, "Credential re-review response was not accepted") + refreshed = _review(refreshed_result, metadata_input) + _same_review(reviewed, refreshed, receipt) + report({"stage": "operator-metadata-refreshed", "submission": refreshed["submission"], + "sameAuthority": True, "acknowledgedSource": receipt.get("source") is not None}) + reviewed = refreshed + raise AssertionError("Credential submission bound exhausted") diff --git a/bridge/tests/native-credentials/kind_config.py b/bridge/tests/native-credentials/kind_config.py new file mode 100644 index 000000000..88cdfe314 --- /dev/null +++ b/bridge/tests/native-credentials/kind_config.py @@ -0,0 +1,40 @@ +"""Generate only a disposable Kind topology with enforced CNI and metadata audit.""" + +import json +from pathlib import Path + +root = Path(__file__).resolve().parents[2] +state = root / ".native" +state.mkdir(mode=0o700, exist_ok=True) +(state / "audit").mkdir(mode=0o700, exist_ok=True) +patch = """kind: ClusterConfiguration +apiServer: + extraArgs: + audit-policy-file: /etc/kars-native/audit-policy.yaml + audit-log-path: /var/log/kars-native-audit/audit.log + audit-log-maxsize: "25" + audit-log-maxbackup: "1" + extraVolumes: + - name: native-audit-policy + hostPath: /etc/kars-native/audit-policy.yaml + mountPath: /etc/kars-native/audit-policy.yaml + readOnly: true + pathType: File + - name: native-audit-log + hostPath: /var/log/kars-native-audit + mountPath: /var/log/kars-native-audit + pathType: DirectoryOrCreate +""" +config = { + "kind": "Cluster", "apiVersion": "kind.x-k8s.io/v1alpha4", + "networking": {"disableDefaultCNI": True, "podSubnet": "10.244.0.0/16"}, + "nodes": [ + {"role": "control-plane", "kubeadmConfigPatches": [patch], "extraMounts": [ + {"hostPath": str(root / "tests/native-credentials/audit-policy.yaml"), + "containerPath": "/etc/kars-native/audit-policy.yaml", "readOnly": True}, + {"hostPath": str(state / "audit"), "containerPath": "/var/log/kars-native-audit"}, + ]}, + {"role": "worker", "labels": {"kars.azure.com/pool": "sandbox"}}, + ], +} +(state / "kind.json").write_text(json.dumps(config, indent=2) + "\n") diff --git a/bridge/tests/native-credentials/lifecycle_cases.py b/bridge/tests/native-credentials/lifecycle_cases.py new file mode 100644 index 000000000..12f0f04ed --- /dev/null +++ b/bridge/tests/native-credentials/lifecycle_cases.py @@ -0,0 +1,369 @@ +"""Real consumer lifecycle, namespace-resource continuity and ephemeral workspaces.""" + +import base64 +import copy +import json +import secrets +import time + +from credential_cases import KEY, SOURCE, sandbox, selection +from credential_review import reviewed_credential_write +from native_api import BRIDGE, CORE, WRITER, command, core, require, resource, uid, until +from runtime_state import assert_agent_exec_denied, assert_ephemeral_workspace, runtime_state + +APPS = "/apis/apps/v1" +RBAC = "/apis/rbac.authorization.k8s.io/v1" + + +def owner_is(value, kind, identity): + return any(owner["kind"] == kind and owner["uid"] == identity and owner.get("controller") + for owner in value["metadata"].get("ownerReferences", [])) + + +def deployment_path(name): + return resource(f"kars-{name}", "deployments", name, APPS) + + +def reviewable_target(setup, created): + workspace, name = created["metadata"]["namespace"], created["metadata"]["name"] + runtime_name = f"kars-{name}" + expected_spec = copy.deepcopy(created["spec"]) + expected_uid = uid(created) + generation = created["metadata"]["generation"] + + def check(): + current = setup.admin.get(resource(workspace, "karssandboxes", name)) + require(uid(current) == expected_uid and current["spec"] == expected_spec + and current["metadata"].get("generation") == generation + and not current["metadata"].get("deletionTimestamp"), + "Credential target changed before initial operator review") + namespace = setup.admin.optional(f"/api/v1/namespaces/{runtime_name}") + if namespace is None: + return None + require(namespace.get("kind") == "Namespace" + and namespace["metadata"].get("name") == runtime_name + and not namespace["metadata"].get("deletionTimestamp"), + "Credential runtime namespace is not current") + owner = namespace["metadata"].get("annotations", {}).get("kars.azure.com/sandbox-uid") + binding = current["metadata"].get("annotations", {}).get("kars.azure.com/namespace-uid") + require(owner == expected_uid, "Credential runtime namespace has a different owner") + if binding is None: + return None + require(binding == uid(namespace), "Credential target namespace binding changed") + if "kars.azure.com/namespace-cleanup" not in current["metadata"].get("finalizers", []): + return None + return current + + # Namespace ownership is initialized through metadata writes after CREATE. + # It must be part of the first review, not silently rebased after submission. + return until("core-owned target before initial credential review", check, 60) + + +def running(setup, workspace, name): + def check(): + value = setup.admin.get(resource(workspace, "karssandboxes", name)) + deployment = setup.admin.optional(deployment_path(name)) + if not deployment or deployment.get("status", {}).get("availableReplicas", 0) != 1: + return None + namespace = setup.admin.get(f"/api/v1/namespaces/kars-{name}") + annotations = deployment["metadata"].get("annotations", {}) + require(annotations.get("kars.azure.com/credential-sandbox-uid") == uid(value) + and annotations.get("kars.azure.com/credential-namespace-uid") == uid(namespace) + and namespace["metadata"].get("annotations", {}).get("kars.azure.com/sandbox-uid") == uid(value), + "Running deployment does not bind its current Sandbox and runtime namespace UIDs") + pods = setup.admin.get(core(f"kars-{name}", "pods"))["items"] + replicasets = setup.admin.get(resource(f"kars-{name}", "replicasets", group=APPS))["items"] + owners = {uid(item) for item in replicasets if owner_is(item, "Deployment", uid(deployment))} + candidates = [ + pod for pod in pods + if any(owner_is(pod, "ReplicaSet", identity) for identity in owners) + and not pod["metadata"].get("deletionTimestamp") + and len(pod.get("status", {}).get("containerStatuses", [])) >= 2 + and all(item.get("ready") for item in pod["status"]["containerStatuses"]) + and all(item.get("ready") for item in pod.get("status", {}).get("initContainerStatuses", [])) + ] + return (value, deployment, candidates[0]) if len(candidates) == 1 else None + return until(f"real owned router and runtime for {name}", check, 240) + + +def halted(setup, name): + def check(): + deployment = setup.admin.optional(deployment_path(name)) + if deployment and deployment["spec"].get("replicas", 1) != 0: + return False + pods = setup.admin.get(core(f"kars-{name}", "pods"))["items"] + return not pods + until(f"all consumers, including terminating Pods, stopped for {name}", check) + + +class LifecycleCases: + def __init__(self, setup, bff, credentials): + self.setup, self.bff, self.credentials = setup, bff, credentials + self.targets = {} + self.team_target = None + self.credential_diagnostic_target = None + + def create_delivery(self): + workspace = "native-delivery" + grant = self.credentials.workspace(workspace) + for name in ["native-delivery-a", "native-delivery-b"]: + value = sandbox(self.setup, workspace, name) + self.credential_diagnostic_target = {"workspace": workspace, "sandbox": name, "uid": uid(value)} + value = reviewable_target(self.setup, value) + token = secrets.token_hex(24) + reviewed_credential_write(self.bff, { + "namespace": workspace, "kind": "KarsSandbox", "target": name, + "targetUid": uid(value), "key": KEY, "value": token, + }, grant, value["metadata"]["generation"]) + path = resource(workspace, "karssandboxes", name) + current = self.setup.admin.get(path) + selected = current["spec"]["credentialBindings"]["sources"][-1] + require(selected["owner"]["uid"] == uid(value), "Target selection did not bind actual CREATE UID") + self.setup.admin.patch(path, {"spec": {"suspended": False}}) + value, deployment, pod = running(self.setup, workspace, name) + projection = self.setup.admin.get(core(f"kars-{name}", "secrets", f"{name}-credential-projection")) + require(base64.b64decode(projection["data"][KEY]).decode() == token, + "Real core projection did not deliver selected source value") + runtime = self.setup.admin.get(f"/api/v1/namespaces/kars-{name}") + require(owner_is(projection, "Namespace", uid(runtime)) + and projection["metadata"]["annotations"].get("kars.azure.com/credential-sandbox-uid") == uid(value) + and projection["metadata"]["annotations"].get("kars.azure.com/credential-projection-uid") == uid(projection), + "Core projection is not sealed to current namespace/Sandbox/projection UIDs") + proof = runtime_state(f"kars-{name}", pod["metadata"]["name"]) + require(proof["slackPresent"], "Projected credential did not reach the real agent environment") + self.targets[name] = {"workspace": workspace, "grant": grant, "sandbox": value, + "deployment": deployment, "pod": pod, "selection": selected, + "source": self.setup.admin.get(core(workspace, "secrets", selected["source"]["name"]))} + + def source_uid_fence(self): + selected = self.targets["native-delivery-a"] + workspace = selected["workspace"] + old = selected["source"] + path = core(workspace, "secrets", old["metadata"]["name"]) + self.setup.admin.delete(path) + halted(self.setup, "native-delivery-a") + survivor = running(self.setup, workspace, "native-delivery-b") + require(uid(survivor[0]) == uid(self.targets["native-delivery-b"]["sandbox"]), + "Deleting one selected source recreated another consumer") + replacement = copy.deepcopy(old) + replacement["metadata"] = { + key: value for key, value in old["metadata"].items() + if key in ["name", "namespace", "annotations", "labels", "ownerReferences"] + } + replacement["data"][KEY] = base64.b64encode(secrets.token_hex(24).encode()).decode() + new = self.setup.admin.create(core(workspace, "secrets"), replacement) + require(uid(new) != uid(old), "Recreated source unexpectedly retained UID") + self.setup.ready_grant(workspace) + time.sleep(5) + halted(self.setup, "native-delivery-a") + current = self.setup.admin.get(resource(workspace, "karssandboxes", "native-delivery-a")) + require(current["spec"]["credentialBindings"]["sources"][-1]["source"]["uid"] == uid(old), + "Controller silently adopted a source replacement") + running(self.setup, workspace, "native-delivery-b") + + def team_rebind(self): + self.setup.grant(CORE, self.credentials.writer) + self.bff.channel(CORE, secrets.token_hex(24)) + grant = self.setup.ready_grant(CORE) + source = self.setup.admin.get(core(CORE, "secrets", SOURCE)) + team = self.setup.admin.create(resource(CORE, "karsteams"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTeam", + "metadata": {"name": "native-team", "namespace": CORE, + "annotations": {"kars.azure.com/owner-sub": "native-operator"}}, + "spec": {"charter": "Native credential continuity", "paused": False, + "envelope": {"tier": 2, "authorityCeiling": 2, "delegationDepth": 2}, + "blueprint": {"runtime": "OpenClaw", "isolation": "standard", + "model": {"provider": "azure-openai", "deployment": "native"}, + "credentialBindings": selection(grant, source)}}, + }) + def principal(): + tasks = self.setup.admin.get(resource(CORE, "karstasks"))["items"] + return next((task for task in tasks if owner_is(task, "KarsTeam", uid(team)) + and task["metadata"].get("annotations", {}).get("kars.azure.com/team-role") == "principal"), None) + task = until("real Team-owned principal task", principal) + task_name = task["metadata"]["name"] + task_path = resource(CORE, "karstasks", task_name) + self.setup.admin.patch(task_path, { + "metadata": {"annotations": {"kars.azure.com/owner-sub": "native-operator"}}, + "spec": {"execution": {"launch": True}}, + }) + def bound(): + current = self.setup.admin.get(task_path) + return current if current.get("status", {}).get("sandboxRef", {}).get("name") else None + task = until("Task actual execution Sandbox", bound) + name = task["status"]["sandboxRef"]["name"] + value, deployment, pod = running(self.setup, CORE, name) + namespace = self.setup.admin.get(f"/api/v1/namespaces/kars-{name}") + self.team_target = {"task": task_name, "sandbox": name, "workspace": CORE, + "taskUid": uid(task), "teamUid": uid(team), + "sandboxUid": uid(value), "namespaceUid": uid(namespace)} + datum = self.setup.admin.create(core(f"kars-{name}", "configmaps"), { + "apiVersion": "v1", "kind": "ConfigMap", + "metadata": {"name": "native-continuity", "namespace": f"kars-{name}"}, + "data": {"sentinel": "retained-user-data"}, + }) + assert_agent_exec_denied(f"kars-{name}", pod["metadata"]["name"]) + assert_ephemeral_workspace(pod) + before_state = runtime_state(f"kars-{name}", pod["metadata"]["name"]) + require(before_state["dataWritable"] and before_state["dataMarker"], + "Controlled agent cannot write its ephemeral sandbox workspace") + pod_path = core(f"kars-{name}", "pods", pod["metadata"]["name"]) + old_finalizers = pod["metadata"].get("finalizers", []) + self.setup.admin.patch(pod_path, { + "metadata": {"finalizers": old_finalizers + ["native.kars.test/continuity"]}, + }) + before_digest = self.setup.admin.get(task_path)["status"].get("envelopeDigest") + self.bff.channel(CORE, secrets.token_hex(24), channel="telegram") + def pausing(): + current = self.setup.admin.get(task_path) + deployment_now = self.setup.admin.get(deployment_path(name)) + return current if ( + current.get("status", {}).get("executionPhase") == "PausingCredentials" + and not current["status"].get("envelopeDigest") + and any(condition["type"] == "Ready" and condition["status"] == "False" + for condition in current["status"].get("conditions", [])) + and deployment_now["spec"].get("replicas") == 0 + ) else None + paused = until("real rebind pause while old terminating Pod remains", pausing) + require(paused["spec"]["execution"]["launch"] is True, + "Credential rebind used destructive unlaunch") + require(self.setup.admin.optional(resource(CORE, "karsreceipts", task_name)) is None, + "Old current receipt survived revoked rebind authority") + old_pod = self.setup.admin.get(pod_path) + require(old_pod["metadata"].get("deletionTimestamp"), "Rebind did not retire the old consumer") + require("TELEGRAM_BOT_TOKEN" not in paused["spec"]["blueprint"]["credentialBindings"]["sources"][0]["keys"], + "New authority was installed before old terminating consumers disappeared") + self.setup.admin.patch(pod_path, {"metadata": {"finalizers": old_finalizers}}) + def resumed(): + current = self.setup.admin.get(task_path) + status = current.get("status", {}) + return current if ( + status.get("phase") == "Ready" + and status.get("observedGeneration") == current["metadata"]["generation"] + and status.get("envelopeDigest") and status["envelopeDigest"] != before_digest + and status.get("executionPhase") == "Running" + ) else None + current = until("current reattested Team credentials and resumed execution", resumed, 240) + after, after_deployment, after_pod = running(self.setup, CORE, name) + require(uid(current) == uid(task) and uid(after) == uid(value) + and uid(after_deployment) == uid(deployment) + and uid(self.setup.admin.get(f"/api/v1/namespaces/kars-{name}")) == uid(namespace), + "Rebind recreated durable Task/Sandbox/Deployment/namespace identity") + after_datum = self.setup.admin.get(core(f"kars-{name}", "configmaps", "native-continuity")) + require(uid(after_datum) == uid(datum) and after_datum["data"] == datum["data"], + "Rebind deleted namespace-owned stored data") + receipt = self.setup.admin.get(resource(CORE, "karsreceipts", task_name)) + require(owner_is(receipt, "KarsTask", uid(task)) + and current["status"]["envelopeDigest"] in json.dumps(receipt["spec"]), + "Resumed receipt does not bind current Task authority") + require(uid(after_pod) != uid(pod), "Old consumer was reused after authority change") + assert_ephemeral_workspace(after_pod) + after_state = runtime_state(f"kars-{name}", after_pod["metadata"]["name"]) + require(after_state["telegramPresent"], "Resumed agent did not receive its new credential authority") + require(after_state["dataWritable"] and after_state["dataMarker"] + and after_state["dataMarker"] != before_state["dataMarker"], + "Replacement Pod did not receive its own writable ephemeral workspace") + + def release_team_fixture(self): + require(self.team_target is not None, "No owned Team execution fixture to release") + target = self.team_target + task_path = resource(target["workspace"], "karstasks", target["task"]) + sandbox_path = resource(target["workspace"], "karssandboxes", target["sandbox"]) + namespace_path = f"/api/v1/namespaces/kars-{target['sandbox']}" + + def current(): + task = self.setup.admin.get(task_path) + require(uid(task) == target["taskUid"] and owner_is(task, "KarsTeam", target["teamUid"]), + "Team execution fixture ownership changed; no cleanup performed") + sandbox_now = self.setup.admin.optional(sandbox_path) + namespace_now = self.setup.admin.optional(namespace_path) + require(sandbox_now is None or uid(sandbox_now) == target["sandboxUid"], + "Team Sandbox fixture was replaced; no cleanup performed") + require(namespace_now is None or uid(namespace_now) == target["namespaceUid"], + "Team namespace fixture was replaced; no cleanup performed") + return task, sandbox_now, namespace_now + + task, _, _ = current() + self.setup.admin.request("PATCH", task_path, { + "metadata": {"uid": uid(task), "resourceVersion": task["metadata"]["resourceVersion"]}, + "spec": {"execution": {"launch": False}}, + }, patch_type="application/merge-patch+json") + + def released(): + task, sandbox_now, namespace_now = current() + require(task["spec"]["execution"]["launch"] is False, + "Completed Team fixture was relaunched during cleanup") + return sandbox_now is None and namespace_now is None + + until("normal owned Team execution cleanup before independent observer", released) + + def writer_uninstall(self): + retained = self.targets["native-delivery-b"] + name, workspace = "native-delivery-b", retained["workspace"] + before = running(self.setup, workspace, name) + source_path = core(workspace, "secrets", retained["source"]["metadata"]["name"]) + source = self.setup.admin.get(source_path) + controller = self.setup.admin.get(core(CORE, "serviceaccounts", "kars-controller")) + namespace = self.setup.admin.get(f"/api/v1/namespaces/{CORE}") + writer_path = core(BRIDGE, "serviceaccounts", WRITER) + writer = self.setup.admin.get(writer_path) + require(any(item.startswith("kars.azure.com/credential-reader-") + for item in writer["metadata"].get("finalizers", [])), + "Native writer has no name-continuity hold") + self.setup.admin.patch(resource(BRIDGE, "deployments", "kars-bridge-bff", APPS), + {"spec": {"replicas": 0}}) + # Freeze reconciliation to make the otherwise fast native name-hold + # transition observable; never change the controller's election setting. + controller_path = resource(CORE, "deployments", "kars-controller", APPS) + self.setup.admin.patch(controller_path, {"spec": {"replicas": 0}}) + until("controller stopped before held-name race", lambda: + not self.setup.admin.get(core(CORE, "pods") + + "?labelSelector=app.kubernetes.io%2Fcomponent%3Dcontroller")["items"]) + self.setup.admin.delete(writer_path) + held = self.setup.admin.get(writer_path) + require(uid(held) == uid(writer) and held["metadata"].get("deletionTimestamp"), + "Writer deletion bypassed its native name hold") + self.setup.admin.request("POST", core(BRIDGE, "serviceaccounts"), { + "apiVersion": "v1", "kind": "ServiceAccount", "metadata": {"name": WRITER, "namespace": BRIDGE}, + }, expected=(409,)) + self.setup.admin.patch(controller_path, {"spec": {"replicas": 1}}) + until("all old writer reads revoked before name release", + lambda: self.setup.admin.optional(writer_path) is None, 240) + review = self.setup.admin.create("/apis/authorization.k8s.io/v1/subjectaccessreviews", { + "apiVersion": "authorization.k8s.io/v1", "kind": "SubjectAccessReview", + "spec": {"user": f"system:serviceaccount:{BRIDGE}:{WRITER}", + "groups": ["system:serviceaccounts", f"system:serviceaccounts:{BRIDGE}", + "system:authenticated"], + "resourceAttributes": {"namespace": workspace, "verb": "get", "group": "", + "resource": "secrets", "name": source["metadata"]["name"]}}, + }) + require(review["status"].get("allowed") is False + and not review["status"].get("evaluationError"), + "Native name was released before its Secret read authority was revoked") + recreated = self.setup.account(BRIDGE, WRITER) + require(uid(recreated) != uid(writer), "Name reuse retained a deleted ServiceAccount UID") + replacement_actor = self.setup.actor(BRIDGE, WRITER) + replacement_actor.request("GET", source_path, expected=(403,)) + replacement_actor.request("GET", core(workspace, "secrets"), expected=(403,)) + after = running(self.setup, workspace, name) + latest_source = self.setup.admin.get(source_path) + require(uid(after[0]) == uid(before[0]) and uid(after[1]) == uid(before[1]) + and uid(latest_source) == uid(source) and latest_source["data"] == source["data"] + and uid(self.setup.admin.get(f"/api/v1/namespaces/{CORE}")) == uid(namespace) + and uid(self.setup.admin.get(core(CORE, "serviceaccounts", "kars-controller"))) == uid(controller), + "Writer uninstall destroyed valid source/consumer/core identity") + grant = self.setup.admin.get(resource(workspace, "karscredentialgrants", "workspace")) + require(grant["status"]["phase"] == "Ready" + and any(item["type"] == "WriterReady" and item["status"] == "False" + for item in grant["status"].get("conditions", [])), + "Writer revocation incorrectly revoked independent delivery authority") + + def grant_revocation(self): + retained = self.targets["native-delivery-b"] + workspace, name = retained["workspace"], "native-delivery-b" + self.setup.admin.patch(resource(workspace, "karscredentialgrants", "workspace"), + {"spec": {"enabled": False}}) + halted(self.setup, name) + source = self.setup.admin.get(core(workspace, "secrets", retained["source"]["metadata"]["name"])) + require(uid(source) == uid(retained["source"]) and source["data"] == retained["source"]["data"], + "Revocation deleted durable native source values") diff --git a/bridge/tests/native-credentials/loaded_images.py b/bridge/tests/native-credentials/loaded_images.py new file mode 100644 index 000000000..0791f99fb --- /dev/null +++ b/bridge/tests/native-credentials/loaded_images.py @@ -0,0 +1,48 @@ +"""Pin Kind-imported Docker images using containerd's actual manifest identity.""" + +import json + +from native_api import command, require + + +def manifest_digest(table, reference): + rows = [line.split() for line in table.splitlines()] + matches = [row for row in rows if row and row[0] == reference] + require(len(matches) == 1 and len(matches[0]) >= 3, + "Loaded containerd image identity missing or ambiguous") + digest = matches[0][2] + require(digest.startswith("sha256:") and len(digest) == 71 + and all(character in "0123456789abcdef" for character in digest[7:]), + "Loaded containerd manifest digest is malformed") + return digest + + +def loaded_image(name): + repository = f"docker.io/library/{name}" + tagged = f"{repository}:latest" + expected = None + for node in ["bridge-native-control-plane", "bridge-native-worker"]: + table = command("docker", "exec", node, "ctr", "--namespace", "k8s.io", + "images", "list", f"name=={json.dumps(tagged)}") + digest = manifest_digest(table, tagged) + require(expected is None or expected == digest, "Kind nodes loaded different image content") + expected = digest + reference = f"{repository}@{digest}" + before = json.loads(command("docker", "exec", node, "crictl", "inspecti", tagged)) + # Docker archives imported by Kind can have empty CRI repoDigests. + # Add the exact digest alias to local containerd metadata, never pull, + # republish, or substitute the image's config hash for its manifest. + aliases = command("docker", "exec", node, "ctr", "--namespace", "k8s.io", + "images", "list", f"name=={json.dumps(reference)}") + if any(line.split() and line.split()[0] == reference for line in aliases.splitlines()): + require(manifest_digest(aliases, reference) == digest, "Existing image alias differs") + else: + command("docker", "exec", node, "ctr", "--namespace", "k8s.io", + "images", "tag", tagged, reference) + after = json.loads(command("docker", "exec", node, "crictl", "inspecti", reference)) + require(after["status"]["id"] == before["status"]["id"], + "Digest alias did not resolve to the original loaded image") + algorithm, digest = expected.split(":", 1) + # The chart concatenates repository:tag. This renders a digest-only image; + # it avoids :latest => Always defaulting on the router's native Pod schema. + return {"repository": f"{repository}@{algorithm}", "tag": digest, "pullPolicy": "IfNotPresent"} diff --git a/bridge/tests/native-credentials/native_api.py b/bridge/tests/native-credentials/native_api.py new file mode 100644 index 000000000..3b6e8a721 --- /dev/null +++ b/bridge/tests/native-credentials/native_api.py @@ -0,0 +1,261 @@ +"""Bounded real Kubernetes clients; actor contexts never inherit admin keys.""" + +import base64 +import http.client +import json +import os +from pathlib import Path +import re +import ssl +import subprocess +import time +import urllib.parse + +ROOT = Path(__file__).resolve().parents[2] +STATE = ROOT / ".native" +CORE = "kars-system" +BRIDGE = "bridge-native" +WRITER = "kars-bridge" +GROUP = "/apis/kars.azure.com/v1alpha1" + + +class Failure(Exception): + """Only static, secret-free diagnostics may enter this exception.""" + + +def require(condition, message): + if not condition: + raise Failure(message) + + +def command(*args, stdin=None, timeout=180): + result = subprocess.run( + args, input=stdin, cwd=ROOT, capture_output=True, text=True, + timeout=timeout, check=False, + ) + require(result.returncode == 0, f"Setup command {args[0]} failed ({result.returncode})") + return result.stdout + + +def private_file(name, value): + path = STATE / name + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor = os.open(path, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600) + with os.fdopen(descriptor, "w") as output: + output.write(value) + return path + + +def until(description, operation, timeout=180): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = operation() + if value: + return value + time.sleep(1) + raise Failure(f"Deadline: {description}") + + +def resource(namespace, plural, name=None, group=GROUP): + path = f"{group}/namespaces/{namespace}/{plural}" + return path + (f"/{name}" if name else "") + + +def core(namespace, plural, name=None): + return resource(namespace, plural, name, "/api/v1") + + +def uid(value): + return value["metadata"]["uid"] + + +def status_detail(value): + if not isinstance(value, dict): + return "invalid-status" + details = [] + reason = value.get("reason", "") + if isinstance(reason, str) and re.fullmatch(r"[A-Za-z]{1,64}", reason): + details.append(reason) + message = value.get("message", "") + if isinstance(message, str): + policy = re.search(r"ValidatingAdmissionPolicy ['\"]([a-z0-9-]{1,253})['\"]", message) + if policy: + details.append(f"policy={policy[1]}") + missing = re.search(r"no such key: ([A-Za-z_][A-Za-z0-9_]{0,127})", message) + if missing: + details.append(f"missing-field={missing[1]}") + causes = value.get("details") + causes = causes.get("causes") if isinstance(causes, dict) else [] + for cause in (causes if isinstance(causes, list) else [])[:8]: + if not isinstance(cause, dict): + continue + field = cause.get("field", "") + reason = cause.get("reason", "") + if isinstance(field, str) and re.fullmatch(r"[A-Za-z0-9_.\[\]-]{1,256}", field): + details.append(f"field={field}") + if isinstance(reason, str) and re.fullmatch(r"[A-Za-z]{1,64}", reason): + details.append(f"cause={reason}") + return ", ".join(details) or "status-without-safe-detail" + + +def scheduling_detail(pod): + condition = next((item for item in pod.get("status", {}).get("conditions", []) + if item.get("type") == "PodScheduled"), None) + if condition is None: + return {"status": "Unknown", "categories": ["unavailable"]} + status = condition.get("status") + if status != "False": + return {"status": status if status in ["True", "Unknown"] else "Unknown", "categories": []} + message = condition.get("message", "") + message = message.lower() if isinstance(message, str) else "" + patterns = [ + ("insufficient cpu", "insufficient-cpu"), + ("insufficient memory", "insufficient-memory"), + ("insufficient ephemeral-storage", "insufficient-ephemeral-storage"), + ("too many pods", "pod-capacity"), + ("node affinity/selector", "node-selection"), + ("didn't match node selector", "node-selection"), + ("untolerated taint", "untolerated-taint"), + ("volume node affinity conflict", "volume-affinity"), + ("unbound immediate persistentvolumeclaims", "unbound-volume"), + ("pod anti-affinity", "pod-affinity"), + ] + categories = sorted({category for pattern, category in patterns if pattern in message}) + return {"status": "False", "categories": categories or ["unclassified"]} + + +class Api: + def __init__(self, server, context, token=None): + endpoint = urllib.parse.urlsplit(server) + require(endpoint.scheme == "https" and not endpoint.username and not endpoint.password, + "Kubernetes endpoint must be authenticated HTTPS") + self.host = endpoint.hostname + self.port = endpoint.port or 443 + self.context = context + self.token = token + self.server = server + + def request(self, method, path, body=None, expected=(200,), patch_type=None): + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + if body is not None: + headers["Content-Type"] = patch_type or "application/json" + connection = http.client.HTTPSConnection( + self.host, self.port, context=self.context, timeout=15, + ) + try: + connection.request(method, path, body=None if body is None else json.dumps(body), + headers=headers) + response = connection.getresponse() + raw = response.read(2 * 1024 * 1024) + require(len(raw) < 2 * 1024 * 1024, "Kubernetes response exceeded the bound") + value = json.loads(raw) if raw else {} + if response.status not in expected: + raise Failure( + f"Kubernetes {method} {path.split('?')[0]} returned {response.status} ({status_detail(value)})") + return response.status, value + finally: + connection.close() + + def get(self, path): + return self.request("GET", path)[1] + + def optional(self, path): + status, body = self.request("GET", path, expected=(200, 404)) + return body if status == 200 else None + + def create(self, path, value, expected=(201,)): + return self.request("POST", path, value, expected)[1] + + def patch(self, path, value): + current = self.get(path) + metadata = value.setdefault("metadata", {}) + metadata.update(uid=uid(current), resourceVersion=current["metadata"]["resourceVersion"]) + return self.request("PATCH", path, value, patch_type="application/merge-patch+json")[1] + + def delete(self, path): + current = self.get(path) + return self.request("DELETE", path, { + "apiVersion": "v1", "kind": "DeleteOptions", + "preconditions": {"uid": uid(current), + "resourceVersion": current["metadata"]["resourceVersion"]}, + }, expected=(200, 202))[1] + + +class Setup: + def __init__(self): + require(os.environ.get("GITHUB_ACTIONS") == "true", + "Native qualification runs only in its disposable hosted job") + config = json.loads(command("kubectl", "config", "view", "--minify", "--raw", "-o", "json")) + cluster = config["clusters"][0]["cluster"] + user = config["users"][0]["user"] + self.ca = base64.b64decode(cluster["certificate-authority-data"]).decode() + certificate = private_file("admin.crt", base64.b64decode(user["client-certificate-data"]).decode()) + key = private_file("admin.key", base64.b64decode(user["client-key-data"]).decode()) + context = ssl.create_default_context(cadata=self.ca) + context.load_cert_chain(certificate, key) + self.admin = Api(cluster["server"], context) + self.cluster = cluster + + def actor(self, namespace, name): + token = self.admin.create(core(namespace, "serviceaccounts", name) + "/token", { + "apiVersion": "authentication.k8s.io/v1", "kind": "TokenRequest", + "spec": {"expirationSeconds": 3600}, + })["status"]["token"] + # A fresh TLS context is essential: an admin client certificate would + # take precedence over this bearer and invalidate every RBAC assertion. + return Api(self.cluster["server"], ssl.create_default_context(cadata=self.ca), token) + + def namespace(self, name): + return self.admin.create("/api/v1/namespaces", { + "apiVersion": "v1", "kind": "Namespace", "metadata": {"name": name}, + }) + + def account(self, namespace, name): + return self.admin.create(core(namespace, "serviceaccounts"), { + "apiVersion": "v1", "kind": "ServiceAccount", + "metadata": {"name": name, "namespace": namespace}, + }) + + def grant(self, namespace, writer, keys=None): + workspace = self.admin.get(f"/api/v1/namespaces/{namespace}") + result = self.admin.create(resource(namespace, "karscredentialgrants"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsCredentialGrant", + "metadata": {"name": "workspace", "namespace": namespace}, + "spec": {"workspaceUid": uid(workspace), "enabled": True, + "writers": [{"namespace": BRIDGE, "name": WRITER, "uid": uid(writer)}], + "agentKeys": keys or ["SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN"], + "integrationStores": [], "legacyImports": [], + "observationTargets": [], "githubConnections": []}, + }) + self.ready_grant(namespace) + return result + + def ready_grant(self, namespace): + def ready(): + grant = self.admin.get(resource(namespace, "karscredentialgrants", "workspace")) + status = grant.get("status", {}) + return grant if ( + status.get("phase") == "Ready" + and status.get("observedGeneration") == grant["metadata"]["generation"] + and any(condition["type"] == "WriterReady" and condition["status"] == "True" + for condition in status.get("conditions", [])) + ) else None + return until(f"current native grant and writer in {namespace}", ready) + + def audit(self, namespace=None): + raw = command("docker", "exec", "bridge-native-control-plane", + "cat", "/var/log/kars-native-audit/audit.log") + return [ + event for line in raw.splitlines() if line + for event in [json.loads(line)] + if event.get("stage") == "ResponseComplete" + and event.get("user", {}).get("username") == f"system:serviceaccount:{BRIDGE}:{WRITER}" + and (namespace is None or event.get("objectRef", {}).get("namespace") == namespace) + ] + + def audit_barrier(self, actor, namespace): + actor.get(resource(namespace, "karscredentialgrants", "workspace")) + time.sleep(1) + return self.audit(namespace) diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py new file mode 100644 index 000000000..a2e0a454e --- /dev/null +++ b/bridge/tests/native-credentials/observation_cases.py @@ -0,0 +1,303 @@ +"""Fresh no-registration TLS proofs and real CNI paths, with no legacy fallback.""" + +import base64 +import copy +import http.client +import json +import secrets +import ssl +import time + +from lifecycle_cases import running +from credential_cases import SOURCE, selection +from native_api import BRIDGE, CORE, WRITER, command, core, private_file, require, resource, uid, until +from private_tls import call, forward +from runtime_state import runtime_state + +CAPABILITY = "kars.azure.com/observation-privacy/v1" +PATH = "/internal/observations/verify-privacy" +OBSERVER = "router-services-observer" +NETWORK = "/apis/networking.k8s.io/v1" + + +class ObservationCases: + def __init__(self, setup, bff, lifecycle): + self.setup, self.bff, self.lifecycle = setup, bff, lifecycle + self.observer_target = None + + def target(self): + require(self.observer_target is not None, "Independent native observation Task is not ready") + return self.observer_target + + def prepare_target(self): + grant = self.setup.ready_grant(CORE) + source = self.setup.admin.get(core(CORE, "secrets", SOURCE)) + task = self.setup.admin.create(resource(CORE, "karstasks"), { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTask", + "metadata": {"name": "native-observation-task", "namespace": CORE, + "annotations": {"kars.azure.com/owner-sub": "native-operator"}}, + "spec": {"objective": "Independent native observation qualification", + "envelope": {"tier": 1, "authorityCeiling": 1, "delegationDepth": 0}, + "execution": {"launch": True}, + "blueprint": {"runtime": "OpenClaw", "isolation": "standard", + "model": {"provider": "azure-openai", "deployment": "native"}, + "credentialBindings": selection(grant, source)}}, + }) + def ready(): + current = self.setup.admin.get(resource(CORE, "karstasks", task["metadata"]["name"])) + require(uid(current) == uid(task), "Observation Task was recreated") + status = current.get("status", {}) + return current if status.get("phase") == "Ready" and status.get("sandboxRef", {}).get("name") else None + current = until("independent real observation Task authority", ready, 180) + self.observer_target = {"task": task["metadata"]["name"], + "sandbox": current["status"]["sandboxRef"]["name"], "workspace": CORE} + value, _, _ = running(self.setup, CORE, self.observer_target["sandbox"]) + self.observer_target["uid"] = uid(value) + + def public(self): + target = self.target() + return self.bff.call("GET", f"/api/namespaces/{CORE}/tasks/{target['task']}/egress/learned") + + def enable(self): + self.prepare_target() + target = self.target() + value, _, _ = running(self.setup, CORE, target["sandbox"]) + unavailable = self.public() + require(unavailable.get("available") is False + and "unavailable" in unavailable.get("reason", "").lower(), + "Unenrolled observation did not clearly report capability unavailable") + # A pre-existing, test-owned isolation baseline precedes the private + # chart's additive path. This never changes production network policy. + # Cilium represents the host-network API server by its reserved entity; + # CIDR-only Kubernetes rules do not reliably match that identity. + self.setup.admin.create(resource(BRIDGE, "ciliumnetworkpolicies", group="/apis/cilium.io/v2"), { + "apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy", + "metadata": {"name": "native-bff-api-baseline", "namespace": BRIDGE}, + "spec": {"endpointSelector": {"matchLabels": { + "app.kubernetes.io/name": "kars-bridge", "app.kubernetes.io/component": "bff"}}, + "egress": [ + {"toEntities": ["kube-apiserver"], "toPorts": [{"ports": [ + {"port": "443", "protocol": "TCP"}, {"port": "6443", "protocol": "TCP"}]}]}, + {"toEndpoints": [{"matchLabels": { + "k8s:io.kubernetes.pod.namespace": "kube-system", "k8s:k8s-app": "kube-dns"}}], + "toPorts": [{"ports": [{"port": "53", "protocol": "UDP"}, + {"port": "53", "protocol": "TCP"}]}]}, + ]}, + }) + self.setup.admin.create(resource(BRIDGE, "networkpolicies", group=NETWORK), { + "apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": {"name": "native-bff-baseline", "namespace": BRIDGE}, + "spec": {"podSelector": {"matchLabels": { + "app.kubernetes.io/name": "kars-bridge", "app.kubernetes.io/component": "bff"}}, + "policyTypes": ["Egress"], "egress": [ + {"to": [{"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "kube-system"}}, + "podSelector": {"matchLabels": {"k8s-app": "kube-dns"}}}], + "ports": [{"protocol": "UDP", "port": 53}, {"protocol": "TCP", "port": 53}]}, + ]}, + }) + values = {"namespace": BRIDGE, "networkPolicy": {"observations": { + "enabled": True, "existingIsolationConfirmed": True, + "targetNamespaces": [f"kars-{target['sandbox']}"], + }}} + private_file("observation-values.json", json.dumps(values)) + manifest = command( + "helm", "template", "bridge-native", "deploy/helm/kars-bridge", "--namespace", BRIDGE, + "--values", ".native/observation-values.json", "--show-only", "templates/observation-egress.yaml", + ) + command("kubectl", "apply", "-f", "-", stdin=manifest) + until("BFF retains API connectivity under existing Cilium isolation", self.bff.ready, 30) + self.setup.admin.patch(resource(CORE, "karscredentialgrants", "workspace"), {"spec": { + "observationTargets": [{"kind": "KarsSandbox", "namespace": CORE, + "name": target["sandbox"], "uid": uid(value)}], + }}) + self.ready() + until("real BFF-to-observer9447 and router-to-verifier9448", lambda: + self.public().get("available") is True, 240) + + def ready(self): + target = self.target() + def ready(): + value = self.setup.admin.get(resource(CORE, "karssandboxes", target["sandbox"])) + return value if value.get("status", {}).get("serviceObservation", {}).get("phase") == "Ready" else None + value = until("core-issued current observer capability", ready, 240) + self.setup.ready_grant(CORE) + return value + + def material(self): + target = self.target() + value = self.ready() + actor = self.setup.actor(BRIDGE, WRITER) + secret = actor.get(core(f"kars-{target['sandbox']}", "secrets", OBSERVER)) + require(uid(secret) == value["status"]["serviceObservation"]["secret"]["uid"], + "Native recipient did not receive the current canonical observer Secret") + return value, secret, json.loads(base64.b64decode(secret["data"]["config.json"])), ( + base64.b64decode(secret["data"]["observation-token"]).decode() + ) + + def tls_api(self): + target = self.target() + value, secret, binding, token = self.material() + _, _, pod = running(self.setup, CORE, target["sandbox"]) + with forward(f"kars-{target['sandbox']}", f"pod/{pod['metadata']['name']}", 19447, 9447), ( + forward(CORE, "service/kars-observation-privacy", 19448, 9448) + ): + status, scope = call(19447, binding, token, "GET", "/internal/observations/scope") + require(status == 200 and scope["privacy_verifier"] == CAPABILITY, + "Scope did not perform the approved fresh core verifier protocol") + request = { + "capability": CAPABILITY, "purpose": "read-only-observation-privacy", + "target": {"workspace": CORE, "workspaceUid": binding["workspaceUid"], + "name": target["sandbox"], "uid": uid(value), + "namespaceUid": value["status"]["serviceObservation"]["namespaceUid"]}, + "grantUid": binding["grant"]["uid"], "grantGeneration": binding["grant"]["generation"], + "recipients": binding["recipients"], "credentialVersion": f"{uid(secret)}:{secret['metadata']['resourceVersion']}", + "identity": binding["identity"], "scopeId": scope["scope_id"], "operation": "learned", + "epoch": binding.get("privacyEpoch"), "nonce": secrets.token_hex(32), "verifier": binding["verifier"], + } + require(request["epoch"] is None, "Initial TLS lane unexpectedly acquired SRE registration") + status, proof = call(19448, binding["verifier"], token, "POST", PATH, request) + require(status == 200 and proof.get("allowed") is True + and proof["nonce"] == request["nonce"] and proof["epoch"] is None, + "Full real no-registration privacy proof failed") + require(set(proof) == {"capability", "purpose", "allowed", "requestDigest", "nonce", "epoch"}, + "Privacy response exposed more than bounded proof metadata") + mutations = [ + ("target", "uid", "different-sandbox-uid"), + ("target", "namespaceUid", "different-namespace-uid"), + ("target", "workspaceUid", "different-workspace-uid"), + (None, "grantUid", "different-grant-uid"), + (None, "grantGeneration", binding["grant"]["generation"] + 1), + (None, "credentialVersion", "different-source-version"), + (None, "purpose", "operator"), + (None, "nonce", "malformed"), + (None, "epoch", "unregistered-fabricated-epoch"), + ("verifier", "expiresAt", int(time.time()) - 1), + ("verifier", "controllerUid", "different-controller-uid"), + ] + requests = [] + for parent, key, replacement in mutations: + invalid = copy.deepcopy(request) + (invalid[parent] if parent else invalid)[key] = replacement + requests.append(invalid) + invalid = copy.deepcopy(request) + invalid["recipients"][0]["uid"] = "different-recipient-uid" + requests.append(invalid) + invalid = copy.deepcopy(request) + invalid["sourceSecretName"] = "arbitrary-private-secret" + requests.append(invalid) + for invalid in requests: + status, rejected = call(19448, binding["verifier"], token, "POST", PATH, invalid) + require(status == 403 and rejected.get("allowed") is False, + "Wrong target/grant/recipient/purpose/nonce/version/epoch was authorized") + require(set(rejected) == {"capability", "allowed"}, "Denial exposed an authority oracle") + for method, path in [ + ("GET", "/api/v1/namespaces/kars-system/secrets"), + ("POST", "/internal/egress/reset"), ("POST", "/token"), + ("POST", PATH + "?source=arbitrary"), ("DELETE", "/internal/observations/scope"), + ]: + status, _ = call(19448, binding["verifier"], token, method, path, request) + require(status in (403, 405), "Purpose credential reached an RPC mutation or proxy route") + status, _ = call(19447, binding, token, "GET", + "/internal/observations/egress/learned", scope="wrong-current-scope") + require(status == 409, "Observer accepted a different local request scope") + for path in ["/internal/egress/reset", "/internal/services/control", "/api/github/token"]: + status, _ = call(19447, binding, token, "POST", path) + require(status in (403, 404, 405, 410), "Observation purpose authorized a mutation") + with forward(f"kars-{target['sandbox']}", f"pod/{pod['metadata']['name']}", 18443, 8443): + for method, path in [("GET", "/egress/learned"), ("POST", "/egress/learned/clear")]: + connection = http.client.HTTPConnection("127.0.0.1", 18443, timeout=15) + try: + connection.request(method, path, headers={"Authorization": f"Bearer {token}"}) + response = connection.getresponse() + response.read(65536) + require(response.status == 403, + "Purpose-only token authorized a real legacy route even over loopback") + finally: + connection.close() + wrong_tls = dict(binding["verifier"], serverName=binding["serverName"]) + try: + call(19448, wrong_tls, token, "POST", PATH, request) + except ssl.SSLCertVerificationError: + pass + else: + raise AssertionError("UID hostname pinning was not enforced") + # Remove a required admission binding. A positive proof issued + # moments ago cannot authorize another request after this change. + admission_path = "/apis/admissionregistration.k8s.io/v1/validatingadmissionpolicybindings/kars-observation-privacy-material" + admission = self.setup.admin.get(admission_path) + self.setup.admin.delete(admission_path) + try: + status, denied = call(19448, binding["verifier"], token, "POST", PATH, request) + require(status == 403 and denied.get("allowed") is False, + "A cached positive proof survived loss of actual admission") + finally: + admission["metadata"] = {"name": admission["metadata"]["name"]} + self.setup.admin.create("/apis/admissionregistration.k8s.io/v1/validatingadmissionpolicybindings", admission) + + def cni_denials(self): + target = self.target() + until("current public observer after admission restoration", + lambda: self.public().get("available") is True, 240) + _, _, pod = running(self.setup, CORE, target["sandbox"]) + verifier_service = self.setup.admin.get(core(CORE, "services", "kars-observation-privacy")) + runtime = f"kars-{target['sandbox']}" + agent = self.setup.actor(runtime, pod["spec"]["serviceAccountName"]) + agent.request("GET", core(runtime, "secrets", OBSERVER), expected=(403,)) + agent.request("GET", core(CORE, "secrets", "kars-observation-privacy-tls"), expected=(403,)) + require(runtime_state(runtime, pod["metadata"]["name"])["observationFilesUnreadable"], + "The protected agent could read private observation material") + self.setup.namespace("native-untrusted") + self.setup.admin.create(core("native-untrusted", "pods"), { + "apiVersion": "v1", "kind": "Pod", + "metadata": {"name": "network-peer", "namespace": "native-untrusted"}, + "spec": {"automountServiceAccountToken": False, + "securityContext": {"runAsUser": 10001, "runAsNonRoot": True, + "seccompProfile": {"type": "RuntimeDefault"}}, + "containers": [{"name": "peer", "image": "docker.io/library/kars-native-probe:latest", + "imagePullPolicy": "IfNotPresent", + "securityContext": {"allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}}}]}, + }) + command("kubectl", "wait", "-n", "native-untrusted", "--for=condition=Ready", + "pod/network-peer", "--timeout=120s", timeout=135) + targets = { + "control": [self.setup.admin.get(core("default", "services", "kubernetes"))["spec"]["clusterIP"], 443], + "denied": [[pod["status"]["podIP"], 9447], [verifier_service["spec"]["clusterIP"], 9448]], + } + script = """import json,socket,sys +targets=json.load(sys.stdin) +socket.create_connection(tuple(targets["control"]),3).close() +for host,port in targets["denied"]: + try: + connection=socket.create_connection((host,port),3) + except (TimeoutError,OSError): + continue + connection.close() + sys.exit(1) +print("unauthorized-peer-tcp-denied") +""" + result = command("kubectl", "exec", "-i", "-n", "native-untrusted", "network-peer", "--", + "python3", "-c", script, stdin=json.dumps(targets), timeout=30) + require(result.strip() == "unauthorized-peer-tcp-denied", + "Real CNI did not deny unauthorized peer TCP9447/9448") + require(self.public().get("available") is True, + "Negative CNI test only passed because the authorized service was unavailable") + + def rotation(self): + value, old, binding, token = self.material() + target = self.target() + self.setup.admin.patch(resource(CORE, "karscredentialgrants", "workspace"), + {"spec": {"agentKeys": ["SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", "BRAVE_API_KEY"]}}) + until("rotated source revision acknowledged", lambda: + (self.ready()["status"]["serviceObservation"]["version"] + != f"{uid(old)}:{old['metadata']['resourceVersion']}"), 240) + _, fresh, new_binding, new_token = self.material() + require(new_token != token and new_binding["grant"]["generation"] > binding["grant"]["generation"], + "Grant generation did not rotate actual observer credential authority") + _, _, pod = running(self.setup, CORE, target["sandbox"]) + with forward(f"kars-{target['sandbox']}", f"pod/{pod['metadata']['name']}", 19447, 9447): + status, _ = call(19447, new_binding, token, "GET", "/internal/observations/scope") + require(status == 403, "Rotated observer consumer retained the old bearer") + status, scope = call(19447, new_binding, new_token, "GET", "/internal/observations/scope") + require(status == 200 and scope["privacy_verifier"] == CAPABILITY, + "New observer bearer did not perform a fresh current proof") diff --git a/bridge/tests/native-credentials/observation_diagnostics.py b/bridge/tests/native-credentials/observation_diagnostics.py new file mode 100644 index 000000000..2c3383158 --- /dev/null +++ b/bridge/tests/native-credentials/observation_diagnostics.py @@ -0,0 +1,195 @@ +"""Failure-only projection of fixed core stages; raw logs never enter evidence.""" + +import json +import subprocess + +from native_api import BRIDGE, CORE, WRITER, Failure, command, core, require, resource + +STAGES = frozenset(""" +consumer_absent consumer_address consumer_credential consumer_lineage consumer_namespace +consumer_pods consumer_rollout consumer_rollout_pending +observer_bearer observer_binding observer_body observer_configuration observer_expiry +observer_grant_current observer_grant_read observer_http observer_metadata_client +observer_namespace_current observer_namespace_read observer_origin observer_privacy_revision +observer_recipient_account observer_recipient_current observer_recipient_namespace +observer_registration_current observer_registration_read observer_route_authorization +observer_route_identity observer_route_scope observer_scope_binding observer_scope_current +observer_secret_denial_result observer_secret_denial_review observer_target_current +observer_target_read observer_tls_client observer_transport observer_verifier +observer_workspace_current observer_workspace_read +rpc_admission rpc_audience_denial rpc_authority rpc_authorization rpc_bearer rpc_binding +rpc_body rpc_capacity rpc_controller_privacy rpc_credential_current rpc_credential_read +rpc_endpoint_available rpc_final_endpoint rpc_final_snapshot rpc_grant_current rpc_grant_read +rpc_headers rpc_initial_endpoint rpc_initial_snapshot rpc_namespaces rpc_proof_current +rpc_recipients rpc_registration rpc_request rpc_request_json rpc_runtime_privacy +rpc_service_identity rpc_target_current rpc_target_read rpc_writer_authority +verifier_account_read verifier_address verifier_audience_denial verifier_audience_review +verifier_binding verifier_body verifier_descriptor_current verifier_descriptor_read +verifier_endpoint verifier_exchange verifier_http verifier_identity_current verifier_namespace_read +verifier_proof_binding verifier_proof_json verifier_request verifier_service_current +verifier_service_read verifier_tls_client verifier_transport +""".split()) + +TARGETS = { + "controller": "kars_controller::observation_privacy", + "router": "kars_inference_router::observation_privacy", +} + + +def project(raw, component): + if component not in TARGETS: + return [] + records = [] + for line in raw.splitlines()[-512:]: + try: + value = json.loads(line) + except (ValueError, TypeError): + continue + if not isinstance(value, dict) or value.get("target") != TARGETS.get(component): + continue + fields = value.get("fields") + if not isinstance(fields, dict) or fields.get("message") != "Private observation readiness pending": + continue + stage, status = fields.get("stage"), fields.get("http_status") + if (not isinstance(stage, str) or stage not in STAGES or type(status) is not int + or not (status == 0 or 100 <= status <= 599) + or type(fields.get("timeout")) is not bool or type(fields.get("connect")) is not bool): + continue + record = {key: fields[key] for key in ("stage", "http_status", "timeout", "connect")} + if not records or records[-1] != record: + records.append(record) + return records[-24:] + + +UNAVAILABLE = "Observation diagnostic provenance unavailable" +VERSION = "kars.azure.com/services-observer-version" +READ_ERRORS = (Failure, AssertionError, KeyError, TypeError, ValueError, RuntimeError, + OSError, subprocess.SubprocessError) + + +def identity(value): + metadata = value["metadata"] + require(not metadata.get("deletionTimestamp") + and all(isinstance(metadata.get(key), str) and metadata[key] + for key in ("uid", "resourceVersion")), UNAVAILABLE) + return metadata["uid"], metadata["resourceVersion"] + + +def owner(value, kind): + owners = [item for item in value["metadata"].get("ownerReferences", []) + if item.get("controller") is True] + if len(owners) != 1: + return None + value = owners[0] + return value if (value.get("apiVersion") == "apps/v1" and value.get("kind") == kind + and value.get("name") and value.get("uid")) else None + + +def resolve_actor(setup, component, target): + anchors = {} + + def read(path): + value = setup.admin.get(path) + identity(value) + anchors[path] = value + return value + + namespace_uid = deployment_uid = version = None + if component == "controller": + namespace, name, account, container = CORE, "kars-controller", "kars-controller", "controller" + labels = {"app.kubernetes.io/name": "kars", "app.kubernetes.io/component": "controller"} + elif component == "bff": + namespace, name, account, container = BRIDGE, "kars-bridge-bff", WRITER, "bff" + labels = {"app.kubernetes.io/name": "kars-bridge", "app.kubernetes.io/component": "bff"} + else: + require(component == "router" and isinstance(target, dict) + and target.get("workspace") == CORE, UNAVAILABLE) + name = target["sandbox"] + sandbox = read(resource(CORE, "karssandboxes", name)) + require(sandbox["metadata"].get("namespace") == CORE + and sandbox["metadata"].get("name") == name + and (target.get("uid") is None or identity(sandbox)[0] == target["uid"]), UNAVAILABLE) + observed = sandbox.get("status", {}).get("serviceObservation") or {} + namespace_uid, deployment_uid = observed.get("namespaceUid"), observed.get("deploymentUid") + version = observed.get("version") + require(observed.get("phase") in ("Prepared", "Ready") + and isinstance(namespace_uid, str) and namespace_uid + and isinstance(deployment_uid, str) and deployment_uid + and isinstance(version, str) and version, UNAVAILABLE) + namespace, account, container = f"kars-{name}", "sandbox", "inference-router" + labels = {"kars.azure.com/sandbox": name} + ns = read(f"/api/v1/namespaces/{namespace}") + require(ns["metadata"].get("name") == namespace + and (namespace_uid is None or identity(ns)[0] == namespace_uid), UNAVAILABLE) + deployment = read(resource(namespace, "deployments", name, "/apis/apps/v1")) + require(deployment["metadata"].get("namespace") == namespace + and deployment["metadata"].get("name") == name + and (deployment_uid is None or identity(deployment)[0] == deployment_uid), UNAVAILABLE) + template = deployment.get("spec", {}).get("template", {}) + require(template.get("spec", {}).get("serviceAccountName") == account + and all(template.get("metadata", {}).get("labels", {}).get(key) == value + for key, value in labels.items()) + and (version is None or template.get("metadata", {}).get("annotations", {}).get(VERSION) + == version), UNAVAILABLE) + service_account = read(core(namespace, "serviceaccounts", account)) + require(service_account["metadata"].get("namespace") == namespace + and service_account["metadata"].get("name") == account, UNAVAILABLE) + pods = setup.admin.get(core(namespace, "pods"))["items"] + candidates = [pod for pod in pods + if pod["metadata"].get("namespace") == namespace + and pod.get("spec", {}).get("serviceAccountName") == account + and (version is None or pod["metadata"].get("annotations", {}).get(VERSION) == version) + and all(pod["metadata"].get("labels", {}).get(key) == value + for key, value in labels.items())] + require(len(candidates) <= 8, UNAVAILABLE) + sources = [] + for pod in candidates: + reference = owner(pod, "ReplicaSet") + if reference is None: + continue + set_path = resource(namespace, "replicasets", reference["name"], "/apis/apps/v1") + replica_set = setup.admin.get(set_path) + lineage = owner(replica_set, "Deployment") + if (identity(replica_set)[0] != reference["uid"] + or replica_set["metadata"].get("namespace") != namespace + or replica_set["metadata"].get("name") != reference["name"] + or lineage is None or lineage["name"] != name + or lineage["uid"] != identity(deployment)[0]): + continue + anchors[set_path] = replica_set + pod_path = core(namespace, "pods", pod["metadata"]["name"]) + current = read(pod_path) + require(identity(current) == identity(pod) + and current["metadata"].get("namespace") == namespace + and current["metadata"].get("name") == pod["metadata"]["name"], UNAVAILABLE) + sources.append({"name": pod["metadata"]["name"], "uid": identity(pod)[0]}) + require(bool(sources), UNAVAILABLE) + return {"anchors": anchors, "pods": sources, "namespace": namespace, "container": container, + "username": f"system:serviceaccount:{namespace}:{account}", + "serviceAccountUid": identity(service_account)[0]} + + +def recheck_actor(setup, actor): + for path, before in actor["anchors"].items(): + require(identity(setup.admin.get(path)) == identity(before), UNAVAILABLE) + + +def sample(setup, component, target): + result = {"component": component, "available": False, "records": []} + try: + actor = resolve_actor(setup, component, target) + records = [] + for pod in actor["pods"]: + raw = command("kubectl", "logs", "-n", actor["namespace"], pod["name"], "-c", actor["container"], + "--tail=512", "--limit-bytes=131072", "--request-timeout=10s", timeout=15) + records.extend(project(raw, component)) + recheck_actor(setup, actor) + result.update(available=bool(records), records=records[-24:]) + except READ_ERRORS: + return result + return result + + +def collect(setup, target): + samples = [sample(setup, component, target) for component in ("controller", "router")] + return {"available": all(item["available"] for item in samples), "samples": samples} diff --git a/bridge/tests/native-credentials/observer_cilium_diagnostics.py b/bridge/tests/native-credentials/observer_cilium_diagnostics.py new file mode 100644 index 000000000..e7de69095 --- /dev/null +++ b/bridge/tests/native-credentials/observer_cilium_diagnostics.py @@ -0,0 +1,494 @@ +"""Read-only, fixed-field Cilium 1.18.5 witnesses for the observer experiment.""" + +import copy +import json +import re +import subprocess + +from native_api import ROOT, Failure, core, require, resource +from observation_diagnostics import READ_ERRORS, identity, owner +import observer_network_diagnostics as network + +CILIUM = "/apis/cilium.io/v2" +CONFIG = core("kube-system", "configmaps", "cilium-config") +DAEMONSET = resource("kube-system", "daemonsets", "cilium", "/apis/apps/v1") +CONFIG_OUTPUT = ( + r'jsonpath={.PolicyCIDRMatchMode}{"\t"}{.EnableCiliumNetworkPolicy}{"\t"}' + r'{.EnableK8sNetworkPolicy}{"\n"}' +) +# v1.18.5 status.go prints a StatusResponse object; this is not an option.Config field. +# https://github.com/cilium/cilium/blob/v1.18.5/api/v1/models/kube_proxy_replacement.go#L43-L45 +KUBE_PROXY_OUTPUT = r'jsonpath={.kube-proxy-replacement.mode}{"\n"}' +ENDPOINT_OUTPUT = ( + r'jsonpath={[0].id}{"\t"}{[0].status.identity.id}{"\t"}' + r'{[0].status.policy.spec.policy-revision}{"\t"}' + r'{[0].status.policy.realized.policy-revision}{"\t"}' + r'{[0].status.policy.realized.policy-enabled}{"\t"}' + r'{[0].status.external-identifiers.k8s-namespace}{"\t"}' + r'{[0].status.external-identifiers.k8s-pod-name}{"\n"}' +) +UNAVAILABLE = "Cilium diagnostic provenance unavailable" +STAGES = frozenset(""" +network_snapshot network_validated origin_recheck namespace_read namespace_identity +configmap_read configmap_identity daemonset_read daemonset_identity account_read account_identity +resource_names agent_list_read agent_select agent_read agent_identity agent_layout agent_constraints +agent_selector configmap_fields config_exec config_framing config_fields config_mode config_defaults +kube_proxy_exec kube_proxy_framing kube_proxy_fields +endpoint_read endpoint_identity endpoint_owner endpoint_fields endpoint_addresses endpoint_pins +endpoint_exec endpoint_framing endpoint_projection_fields endpoint_projection_pins endpoint_revision_bounds +endpoint_recheck endpoint_snapshot_match cnp_list_read cnp_inventory cnp_identity cnp_digest +anchor_read anchor_identity cnp_recheck network_recheck complete +""".split()) +FACT_KEYS = frozenset(""" +httpStatus exitStatus timedOut byteCount lineCount fieldCount objectShape metadataShape +uidPresent resourceVersionPresent namePresent namespacePresent deleting metadataSyntaxValid +uidMatches resourceVersionMatches +namespaceMatches nameMatches configmapMatches daemonsetMatches accountMatches index count +agentSpecShape containersShape statusesShape templateShape ownerCount ownerMatches listIdentityMatches +containerCount statusCount templateContainerCount ready containerIdPresent restartCountValid +serviceAccountMatches imageMatchesTemplate imageExpectedVersion imageHasDigest selectorShape selectorMatches +dataShape modeSyntax cnpFlagSyntax kubernetesFlagSyntax kubeProxySyntax booleanFieldsValid +requiredTokensPresent +statusShape identityShape networkingShape addressingCount podAddressCount nodeAddressPresent +endpointIdValid securityIdValid podUidMatches addressesMatch nodeMatchesPod nodeMatchesAgent +numericFieldsValid endpointIdMatches securityIdMatches policyModeRecognized namespaceFieldMatches +podFieldMatches realizedAheadOfDesired desiredRevisionValid realizedRevisionValid bindingMatches +specShape specsShape managedFieldsShape authorityMatches anchorKind configurationMatches +""".split()) +FACT_VALUES = frozenset(""" +object array null string number boolean other missing empty true false json-null +json-empty-array json-nodes-array json-other invalid-json go-nil go-no-value +namespace configmap daemonset account agent +status-true status-false +""".split()) + + +def checkpoint(witness, stage, **facts): + if witness is None: + return + require(stage in STAGES and set(facts) <= FACT_KEYS, UNAVAILABLE) + require(all(value is None or type(value) is bool + or (type(value) is int and -(2**31) <= value < 2**63) + or (isinstance(value, str) and value in FACT_VALUES) for value in facts.values()), UNAVAILABLE) + witness["lastStage"] = stage + witness.setdefault("checks", {})[stage] = facts + + +def shape(value): + if value is None: + return "null" + return {dict: "object", list: "array", str: "string", bool: "boolean", + int: "number", float: "number"}.get(type(value), "other") + + +def rendering(value): + known = {"": "empty", "null": "json-null", "[]": "json-empty-array", + "<nil>": "go-nil", "<no value>": "go-no-value", "true": "true", "false": "false", + "True": "status-true", "False": "status-false"} + if not isinstance(value, str): + return "other" + if value in known: + return known[value] + try: + decoded = json.loads(value) + return "json-nodes-array" if decoded == ["nodes"] else "json-other" + except ValueError: + return "invalid-json" + + +def identity_facts(value): + metadata = value.get("metadata") if isinstance(value, dict) else None + fields = metadata if isinstance(metadata, dict) else {} + return {"objectShape": shape(value), "metadataShape": shape(metadata), + "uidPresent": isinstance(fields.get("uid"), str) and bool(fields["uid"]), + "resourceVersionPresent": isinstance(fields.get("resourceVersion"), str) and bool(fields["resourceVersion"]), + "namePresent": isinstance(fields.get("name"), str) and bool(fields["name"]), + "namespacePresent": isinstance(fields.get("namespace"), str) and bool(fields["namespace"]), + "deleting": bool(fields.get("deletionTimestamp")), + "metadataSyntaxValid": all(isinstance(fields.get(key), str) + and re.fullmatch(r"[A-Za-z0-9_.:-]{1,253}", fields[key]) is not None + for key in ("name", "uid", "resourceVersion"))} + + +def api_read(setup, path, witness, stage): + checkpoint(witness, stage, httpStatus=None) + code, value = setup.admin.request("GET", path, expected=tuple(range(100, 600))) + checkpoint(witness, stage, httpStatus=code, objectShape=shape(value)) + require(code == 200, UNAVAILABLE) + return value + + +def read_projection(agent, endpoint_id=None, witness=None): + if endpoint_id is None: + arguments = ["config", "--read-only", "--output", CONFIG_OUTPUT] + else: + require(type(endpoint_id) is int and 0 < endpoint_id <= 65535, UNAVAILABLE) + arguments = ["endpoint", "get", str(endpoint_id), "--output", ENDPOINT_OUTPUT] + prefix = "config" if endpoint_id is None else "endpoint" + return _read_cli_projection(agent, arguments, prefix, witness) + + +def read_kube_proxy_projection(agent, witness=None): + # Keep status' default require-k8s-connectivity=true; never mask daemon/API failure. + arguments = ["status", "--timeout=10s", "--output", KUBE_PROXY_OUTPUT] + return _read_cli_projection(agent, arguments, "kube_proxy", witness) + + +def _read_cli_projection(agent, arguments, prefix, witness): + checkpoint(witness, f"{prefix}_exec", exitStatus=None, timedOut=False) + try: + result = subprocess.run( + ["kubectl", "--context", "kind-bridge-native", "--request-timeout=10s", + "exec", "-n", "kube-system", agent["metadata"]["name"], "-c", "cilium-agent", + "--", "cilium-dbg", *arguments], + cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=12, check=False, + ) + except subprocess.TimeoutExpired: + checkpoint(witness, f"{prefix}_exec", exitStatus=None, timedOut=True) + raise + checkpoint(witness, f"{prefix}_exec", exitStatus=result.returncode, timedOut=False, + byteCount=len(result.stdout)) + require(result.returncode == 0 and len(result.stdout) <= 4096, UNAVAILABLE) + checkpoint(witness, f"{prefix}_framing", byteCount=len(result.stdout)) + lines = result.stdout.decode("utf8").strip("\r\n").splitlines() + checkpoint(witness, f"{prefix}_framing", byteCount=len(result.stdout), lineCount=len(lines)) + require(len(lines) == 1, UNAVAILABLE) + return lines[0].split("\t") + + +def effective_configuration(fields, witness=None): + checkpoint(witness, "config_fields", fieldCount=len(fields), + requiredTokensPresent=len(fields) == 3 and all(isinstance(value, str) and bool(value) for value in fields), + modeSyntax=rendering(fields[0]) if fields else "missing", + cnpFlagSyntax=rendering(fields[1]) if len(fields) > 1 else "missing", + kubernetesFlagSyntax=rendering(fields[2]) if len(fields) > 2 else "missing", + booleanFieldsValid=all(value in ("true", "false") for value in fields[1:])) + require(len(fields) == 3 and all(value in ("true", "false") for value in fields[1:]), UNAVAILABLE) + checkpoint(witness, "config_mode", modeSyntax=rendering(fields[0])) + modes = json.loads(fields[0]) + require(modes is None or (isinstance(modes, list) and modes in ([], ["nodes"])), UNAVAILABLE) + return {"policyCIDRMatchMode": modes or [], "ciliumNetworkPolicyEnabled": fields[1] == "true", + "kubernetesNetworkPolicyEnabled": fields[2] == "true"} + + +def kube_proxy_mode(fields, witness=None): + checkpoint(witness, "kube_proxy_fields", fieldCount=len(fields), + requiredTokensPresent=len(fields) == 1 and isinstance(fields[0], str) and bool(fields[0]), + kubeProxySyntax=rendering(fields[0]) if fields else "missing", + policyModeRecognized=len(fields) == 1 and fields[0] in ("True", "False")) + require(len(fields) == 1 and fields[0] in ("True", "False"), UNAVAILABLE) + return fields[0] == "True" + + +def configmap_facts(config): + data = config.get("data") + require(isinstance(data, dict), UNAVAILABLE) + mode = data.get("policy-cidr-match-mode", "") + enforcement = data.get("enable-policy") + replacement = data.get("kube-proxy-replacement") + return {"identity": network.metadata(config), + "policyCIDRMatchMode": [] if mode == "" else ["nodes"] if mode == "nodes" else ["unrecognized"], + "policyEnforcementMode": enforcement if enforcement in ("default", "always", "never") else "unrecognized", + "kubeProxyReplacement": replacement if replacement in ("true", "false") else "unrecognized"} + + +def policy_authority(value): + metadata = dict(value["metadata"]) + metadata.pop("resourceVersion", None) + fields = network.bounded_items(metadata.pop("managedFields", []), 64) + require(all(isinstance(field, dict) for field in fields), UNAVAILABLE) + metadata["managedFields"] = [field for field in fields if field.get("subresource") != "status"] + return network.spec_digest({"metadata": metadata, "spec": value.get("spec"), "specs": value.get("specs")}) + + +def policies(setup, namespace, temporary_name, witness=None): + value = api_read(setup, resource(namespace, "ciliumnetworkpolicies", group=CILIUM), + witness, "cnp_list_read") + items = value.get("items") if isinstance(value, dict) else None + checkpoint(witness, "cnp_inventory", objectShape=shape(items), + count=len(items) if isinstance(items, list) else None) + values = network.bounded_items( + items, 32) + result = [] + for index, value in enumerate(values): + checkpoint(witness, "cnp_identity", index=index, **identity_facts(value)) + require(value["metadata"].get("namespace") == namespace, UNAVAILABLE) + if value["metadata"]["name"] == temporary_name: + continue + network.metadata(value) + result.append(value) + return result + + +def endpoint_binding(endpoint, pod, agent, witness=None): + checkpoint(witness, "endpoint_identity", **identity_facts(endpoint)) + metadata = network.metadata(endpoint) + references = endpoint["metadata"].get("ownerReferences", []) + checkpoint(witness, "endpoint_owner", namespaceMatches=metadata.get("namespace") == pod["metadata"]["namespace"], + nameMatches=metadata["name"] == pod["metadata"]["name"], + objectShape=shape(references), ownerCount=len(references) if isinstance(references, list) else None) + require(metadata["namespace"] == pod["metadata"]["namespace"] + and metadata["name"] == pod["metadata"]["name"], UNAVAILABLE) + require(isinstance(references, list), UNAVAILABLE) + checkpoint(witness, "endpoint_owner", ownerCount=len(references), + ownerMatches=len(references) == 1 and isinstance(references[0], dict) + and references[0].get("apiVersion") == "v1" and references[0].get("kind") == "Pod" + and references[0].get("name") == pod["metadata"]["name"], + podUidMatches=len(references) == 1 and isinstance(references[0], dict) + and references[0].get("uid") == identity(pod)[0]) + require(len(references) == 1 and references[0].get("apiVersion") == "v1" + and references[0].get("kind") == "Pod" and references[0].get("name") == pod["metadata"]["name"] + and references[0].get("uid") == identity(pod)[0], UNAVAILABLE) + status = endpoint["status"] + checkpoint(witness, "endpoint_fields", statusShape=shape(status), + identityShape=shape(status.get("identity")) if isinstance(status, dict) else "missing") + endpoint_id, security_id = status.get("id"), status.get("identity", {}).get("id") + checkpoint(witness, "endpoint_fields", + endpointIdValid=type(endpoint_id) is int and 0 < endpoint_id <= 65535, + securityIdValid=type(security_id) is int and 0 < security_id < 2**32) + require(type(endpoint_id) is int and 0 < endpoint_id <= 65535 + and type(security_id) is int and 0 < security_id < 2**32, UNAVAILABLE) + networking = status.get("networking") + checkpoint(witness, "endpoint_addresses", networkingShape=shape(networking), + addressingCount=len(networking.get("addressing", [])) if isinstance(networking, dict) + and isinstance(networking.get("addressing", []), list) else None, + nodeAddressPresent=isinstance(networking, dict) and isinstance(networking.get("node"), str)) + addressing = network.bounded_items(networking["addressing"], 2) + addresses = sorted(network.private_ip(item[key]) for item in addressing + for key in ("ipv4", "ipv6") if item.get(key)) + pod_addresses = sorted(network.private_ip(item["ip"]) + for item in pod["status"].get("podIPs", [{"ip": pod["status"]["podIP"]}])) + node_ip = network.private_ip(status["networking"]["node"]) + pod_node = network.private_ip(pod["status"]["hostIP"]) + agent_node = network.private_ip(agent["status"]["hostIP"]) + checkpoint(witness, "endpoint_pins", addressesMatch=bool(addresses) and addresses == pod_addresses, + nodeMatchesPod=node_ip == pod_node, nodeMatchesAgent=node_ip == agent_node) + if witness is not None: + witness["observedEndpointAddresses"] = { + "endpointNodeAddress": node_ip, "podNodeAddress": pod_node, "agentNodeAddress": agent_node, + "endpointAddresses": addresses, "podAddresses": pod_addresses} + require(addresses and addresses == pod_addresses + and node_ip == network.private_ip(pod["status"]["hostIP"]) + == network.private_ip(agent["status"]["hostIP"]), UNAVAILABLE) + return {"uid": metadata["uid"], "podUid": identity(pod)[0], "endpointId": endpoint_id, + "securityIdentity": security_id, "nodeAddress": node_ip, "addresses": addresses} + + +def endpoint_revision(fields, binding, pod, witness=None): + checkpoint(witness, "endpoint_projection_fields", fieldCount=len(fields), + numericFieldsValid=len(fields) >= 4 and all( + isinstance(value, str) and re.fullmatch(r"[0-9]{1,19}", value) is not None + for value in fields[:4])) + require(len(fields) == 7 and all(re.fullmatch(r"[0-9]{1,19}", value) for value in fields[:4]), + UNAVAILABLE) + endpoint_id, security_id, desired, realized = map(int, fields[:4]) + checkpoint(witness, "endpoint_projection_pins", endpointIdMatches=endpoint_id == binding["endpointId"], + securityIdMatches=security_id == binding["securityIdentity"], + policyModeRecognized=fields[4] in ("none", "ingress", "egress", "both"), + namespaceFieldMatches=fields[5] == pod["metadata"]["namespace"], + podFieldMatches=fields[6] == pod["metadata"]["name"]) + require(endpoint_id == binding["endpointId"] and security_id == binding["securityIdentity"] + and fields[4] in ("none", "ingress", "egress", "both") + and fields[5] == pod["metadata"]["namespace"] and fields[6] == pod["metadata"]["name"], UNAVAILABLE) + checkpoint(witness, "endpoint_revision_bounds", desiredRevisionValid=0 <= desired < 2**63, + realizedRevisionValid=0 <= realized < 2**63, realizedAheadOfDesired=realized > desired) + # Cilium v1.18.5 UpdatePolicy advances policyRevision for a no-op without + # updating nextPolicyRevision; its API exposes both independent counters. + # https://github.com/cilium/cilium/blob/v1.18.5/pkg/endpoint/policy.go#L672-L718 + # https://github.com/cilium/cilium/blob/v1.18.5/pkg/endpoint/api.go#L452-L486 + require(0 <= realized < 2**63 and 0 <= desired < 2**63, UNAVAILABLE) + return {"endpointId": endpoint_id, "securityIdentity": security_id, + "desiredPolicyRevision": desired, "realizedPolicyRevision": realized, + "policyEnabled": fields[4]} + + +def snapshot(setup, target, temporary_name=None, witness=None): + if witness is not None: + witness.update(complete=False, networkValidated=False) + try: + return _snapshot(setup, target, temporary_name, witness) + except READ_ERRORS + (AttributeError, IndexError) as error: + if witness is not None: + witness["failureKind"] = ( + "command-timeout" if isinstance(error, subprocess.TimeoutExpired) else + "transport-timeout" if isinstance(error, TimeoutError) else + "constraint" if isinstance(error, (Failure, AssertionError)) else + "shape" if isinstance(error, (KeyError, TypeError, AttributeError, IndexError)) else + "format" if isinstance(error, ValueError) else + "transport" if isinstance(error, (OSError, subprocess.SubprocessError)) else "operation") + if isinstance(error, (AttributeError, IndexError)): + raise Failure(UNAVAILABLE) from None + raise + + +def _snapshot(setup, target, temporary_name, witness): + # Never omit a KNP with the same name as the temporary Cilium policy. + checkpoint(witness, "network_snapshot") + base = network.snapshot(setup, target) + checkpoint(witness, "network_validated") + if witness is not None: + witness["networkValidated"] = True + witness["validatedNetworkFacts"] = copy.deepcopy(base["facts"]) + checkpoint(witness, "origin_recheck") + network.selected_origin(setup) + anchors = {} + anchor_kinds = {} + + def read(path, kind): + value = api_read(setup, path, witness, f"{kind}_read") + checkpoint(witness, f"{kind}_identity", **identity_facts(value)) + network.metadata(value) + anchors[path] = value + anchor_kinds[path] = kind + return value + + namespace = read("/api/v1/namespaces/kube-system", "namespace") + config = read(CONFIG, "configmap") + daemonset = read(DAEMONSET, "daemonset") + account = read(core("kube-system", "serviceaccounts", "cilium"), "account") + checkpoint(witness, "resource_names", namespaceMatches=namespace["metadata"]["name"] == "kube-system", + configmapMatches=config["metadata"]["name"] == "cilium-config" + and config["metadata"].get("namespace") == "kube-system", + daemonsetMatches=daemonset["metadata"]["name"] == "cilium" + and daemonset["metadata"].get("namespace") == "kube-system", + accountMatches=account["metadata"]["name"] == "cilium" + and account["metadata"].get("namespace") == "kube-system") + require(namespace["metadata"]["name"] == "kube-system" + and config["metadata"]["name"] == "cilium-config" + and config["metadata"].get("namespace") == "kube-system" + and daemonset["metadata"]["name"] == "cilium" + and daemonset["metadata"].get("namespace") == "kube-system" + and account["metadata"].get("namespace") == "kube-system" + and account["metadata"].get("name") == "cilium", UNAVAILABLE) + listed = api_read(setup, core("kube-system", "pods"), witness, "agent_list_read") + checkpoint(witness, "agent_select", objectShape=shape(listed.get("items")), + count=len(listed["items"]) if isinstance(listed.get("items"), list) else None) + candidates = [pod for pod in network.bounded_items(listed["items"], 64) + if pod.get("spec", {}).get("nodeName") == "bridge-native-worker" + and pod["metadata"].get("labels", {}).get("k8s-app") == "cilium"] + checkpoint(witness, "agent_select", count=len(candidates)) + require(len(candidates) == 1, UNAVAILABLE) + listed_agent = candidates[0] + agent_path = core("kube-system", "pods", listed_agent["metadata"]["name"]) + agent = read(agent_path, "agent") + reference = owner(agent, "DaemonSet") + checkpoint(witness, "agent_identity", listIdentityMatches=identity(agent) == identity(listed_agent), + uidMatches=identity(agent)[0] == identity(listed_agent)[0], + resourceVersionMatches=identity(agent)[1] == identity(listed_agent)[1], + namespaceMatches=agent["metadata"].get("namespace") == "kube-system", + ownerMatches=reference is not None and reference["name"] == "cilium" + and reference["uid"] == identity(daemonset)[0]) + require(identity(agent) == identity(listed_agent) and agent["metadata"].get("namespace") == "kube-system" + and reference is not None and reference["name"] == "cilium" + and reference["uid"] == identity(daemonset)[0], UNAVAILABLE) + checkpoint(witness, "agent_layout", agentSpecShape=shape(agent.get("spec")), + statusShape=shape(agent.get("status")), templateShape=shape(daemonset.get("spec", {}).get("template"))) + checkpoint(witness, "agent_layout", containersShape=shape(agent["spec"].get("containers")), + statusesShape=shape(agent["status"].get("containerStatuses")), + templateShape=shape(daemonset["spec"]["template"].get("spec"))) + containers = [item for item in agent["spec"]["containers"] if item["name"] == "cilium-agent"] + statuses = [item for item in agent["status"]["containerStatuses"] if item["name"] == "cilium-agent"] + template = daemonset["spec"]["template"] + expected_containers = [item for item in template["spec"]["containers"] if item["name"] == "cilium-agent"] + checkpoint(witness, "agent_constraints", containerCount=len(containers), statusCount=len(statuses), + templateContainerCount=len(expected_containers)) + require(len(containers) == len(statuses) == len(expected_containers) == 1, UNAVAILABLE) + checkpoint(witness, "agent_constraints", ready=statuses[0].get("ready") is True, + containerIdPresent=bool(statuses[0].get("containerID")), + restartCountValid=type(statuses[0].get("restartCount")) is int, + serviceAccountMatches=agent["spec"].get("serviceAccountName") + == template["spec"].get("serviceAccountName") == "cilium", + imageMatchesTemplate=expected_containers[0].get("image") == containers[0].get("image"), + imageHasDigest=isinstance(containers[0].get("image"), str) and "@sha256:" in containers[0]["image"], + imageExpectedVersion=isinstance(containers[0].get("image"), str) and re.fullmatch( + r"quay\.io/cilium/cilium:v1\.18\.5(?:@sha256:[a-f0-9]{64})?", containers[0]["image"]) is not None) + require(len(containers) == len(statuses) == 1 and statuses[0].get("ready") is True + and statuses[0].get("containerID") and type(statuses[0].get("restartCount")) is int + and agent["spec"].get("serviceAccountName") == template["spec"].get("serviceAccountName") == "cilium" + and len(expected_containers) == 1 and expected_containers[0]["image"] == containers[0]["image"] + and re.fullmatch(r"quay\.io/cilium/cilium:v1\.18\.5(?:@sha256:[a-f0-9]{64})?", containers[0]["image"]), + UNAVAILABLE) + checkpoint(witness, "agent_selector", selectorShape=shape(daemonset["spec"].get("selector"))) + selector_matches = network.matches(daemonset["spec"]["selector"], agent["metadata"].get("labels", {})) + checkpoint(witness, "agent_selector", selectorMatches=selector_matches) + require(selector_matches, UNAVAILABLE) + checkpoint(witness, "configmap_fields", dataShape=shape(config.get("data"))) + desired_config = configmap_facts(config) + if witness is not None: + witness["observedConfigMap"] = desired_config + checkpoint(witness, "config_exec", exitStatus=None, timedOut=False) + fields = read_projection(agent, witness=witness) + effective_config = effective_configuration(fields, witness) + if witness is not None: + witness["observedEffectiveConfig"] = dict(effective_config) + checkpoint(witness, "kube_proxy_exec", exitStatus=None, timedOut=False) + status_fields = read_kube_proxy_projection(agent, witness=witness) + effective_config["kubeProxyReplacement"] = kube_proxy_mode(status_fields, witness) + if witness is not None: + witness["observedEffectiveConfig"] = dict(effective_config) + expected = (desired_config["policyCIDRMatchMode"] == [] + and desired_config["policyEnforcementMode"] == "default" + and desired_config["kubeProxyReplacement"] == "false" + and effective_config == {"policyCIDRMatchMode": [], "ciliumNetworkPolicyEnabled": True, + "kubernetesNetworkPolicyEnabled": True, "kubeProxyReplacement": False}) + checkpoint(witness, "config_defaults", configurationMatches=expected) + bindings, endpoints = {}, [] + for item in base["actor"]["pods"]: + pod = base["actor"]["anchors"][core(base["actor"]["namespace"], "pods", item["name"])] + path = resource(base["actor"]["namespace"], "ciliumendpoints", item["name"], CILIUM) + endpoint = api_read(setup, path, witness, "endpoint_read") + binding = endpoint_binding(endpoint, pod, agent, witness) + checkpoint(witness, "endpoint_exec", exitStatus=None, timedOut=False) + fields = read_projection(agent, binding["endpointId"], witness=witness) + revision = endpoint_revision(fields, binding, pod, witness) + expected &= revision["policyEnabled"] in ("egress", "both") + current = api_read(setup, path, witness, "endpoint_recheck") + current_binding = endpoint_binding(current, pod, agent, witness) + checkpoint(witness, "endpoint_snapshot_match", bindingMatches=current_binding == binding) + require(current_binding == binding, UNAVAILABLE) + bindings[path] = binding + endpoints.append({"identity": network.metadata(current), "podUid": item["uid"], **revision}) + baseline = policies(setup, base["actor"]["namespace"], temporary_name, witness) + policy_identities = {} + policy_facts = [] + for index, policy in enumerate(baseline): + checkpoint(witness, "cnp_digest", index=index, specShape=shape(policy.get("spec")), + specsShape=shape(policy.get("specs")), managedFieldsShape=shape(policy["metadata"].get("managedFields", []))) + path = resource(base["actor"]["namespace"], "ciliumnetworkpolicies", policy["metadata"]["name"], CILIUM) + policy_identities[path] = policy_authority(policy) + policy_facts.append({"identity": network.metadata(policy), + "specDigest": network.spec_digest({"spec": policy.get("spec"), "specs": policy.get("specs")})}) + for path, previous in anchors.items(): + current = api_read(setup, path, witness, "anchor_read") + checkpoint(witness, "anchor_identity", anchorKind=anchor_kinds[path], **identity_facts(current)) + matched = identity(current) == identity(previous) + checkpoint(witness, "anchor_identity", anchorKind=anchor_kinds[path], listIdentityMatches=matched, + uidMatches=identity(current)[0] == identity(previous)[0], + resourceVersionMatches=identity(current)[1] == identity(previous)[1]) + require(matched, UNAVAILABLE) + current_policies = policies(setup, base["actor"]["namespace"], temporary_name, witness) + checkpoint(witness, "cnp_recheck", count=len(current_policies)) + matched = {resource(base["actor"]["namespace"], "ciliumnetworkpolicies", value["metadata"]["name"], CILIUM): + policy_authority(value) for value in current_policies} == policy_identities + checkpoint(witness, "cnp_recheck", count=len(current_policies), authorityMatches=matched) + require(matched, UNAVAILABLE) + base["facts"]["cilium"] = { + "configMap": desired_config, "effectiveAgentConfig": effective_config, + "configurationMatchesExpected": expected, "agent": network.metadata(agent), + "daemonSet": network.metadata(daemonset), "endpoints": endpoints, + "baselineCiliumNetworkPolicies": policy_facts, "policyRevisionIsNotRuleSpecificProof": True} + base["stable"]["cilium"] = { + "anchors": {path: identity(value) for path, value in anchors.items()}, + "configDigest": network.spec_digest(config.get("data")), "effectiveConfig": effective_config, + "agentProcess": (statuses[0]["containerID"], statuses[0]["restartCount"]), + "endpoints": bindings, "policies": policy_identities} + checkpoint(witness, "network_recheck") + current_base = network.snapshot(setup, target) + require(current_base["stable"] == { + key: value for key, value in base["stable"].items() if key != "cilium"}, UNAVAILABLE) + base["ready"] = current_base["ready"] + base["actor"] = current_base["actor"] + if witness is not None: + witness["complete"] = True + checkpoint(witness, "complete") + return base diff --git a/bridge/tests/native-credentials/observer_network_diagnostics.py b/bridge/tests/native-credentials/observer_network_diagnostics.py new file mode 100644 index 000000000..bbb88360c --- /dev/null +++ b/bridge/tests/native-credentials/observer_network_diagnostics.py @@ -0,0 +1,471 @@ +"""Failure-only exact API egress experiment; never changes acceptance results.""" + +import copy +from datetime import datetime, timezone +import ipaddress +import hashlib +import json +import os +import re +import secrets +import time +import urllib.parse + +from api_gate import CORE_REVISION +from api_outcome_diagnostics import collect as api_outcomes +from native_api import CORE, Failure, command, core, require, resource +from observation_diagnostics import ( + READ_ERRORS, identity, recheck_actor, resolve_actor, +) + +NETWORK = "/apis/networking.k8s.io/v1" +CILIUM = "/apis/cilium.io/v2" +FAILURE = "Deadline: core-issued current observer capability" +UNAVAILABLE = "Observer network diagnostic provenance unavailable" +API_SERVICE = core("default", "services", "kubernetes") +API_ENDPOINTS = core("default", "endpoints", "kubernetes") +SELECTOR_KEYS = ("kars.azure.com/sandbox", "pod-template-hash") + + +def loopback_origin(server): + require(isinstance(server, str) and len(server) <= 256 + and not any(character.isspace() or ord(character) < 32 for character in server), UNAVAILABLE) + endpoint = urllib.parse.urlsplit(server) + require(endpoint.scheme == "https" and endpoint.hostname and "%" not in endpoint.hostname + and endpoint.username is None and endpoint.password is None + and endpoint.path in ("", "/") and "?" not in server and "#" not in server, UNAVAILABLE) + address = ipaddress.ip_address(endpoint.hostname) + port = endpoint.port if endpoint.port is not None else 443 + require(address.is_loopback and 0 < port <= 65535, UNAVAILABLE) + return str(address), port + + +def client_origin(setup): + origin = loopback_origin(setup.admin.server) + require(origin == loopback_origin(setup.cluster["server"]) + and type(setup.admin.port) is int + and origin == (str(ipaddress.ip_address(setup.admin.host)), setup.admin.port), UNAVAILABLE) + return origin + + +def selected_origin(setup): + config = json.loads(command("kubectl", "config", "view", "--minify", "-o", "json", timeout=10)) + require(isinstance(config, dict), UNAVAILABLE) + expected = "kind-bridge-native" + contexts = bounded_items(config["contexts"], 1) + clusters = bounded_items(config["clusters"], 1) + users = bounded_items(config["users"], 1) + require(len(contexts) == len(clusters) == len(users) == 1 + and all(isinstance(value, dict) for value in contexts + clusters + users), UNAVAILABLE) + binding, cluster = contexts[0].get("context"), clusters[0].get("cluster") + require(isinstance(binding, dict) and isinstance(cluster, dict), UNAVAILABLE) + require(config.get("current-context") == expected + and contexts[0]["name"] == clusters[0]["name"] == users[0]["name"] == expected + and binding.get("cluster") == expected and binding.get("user") == expected, UNAVAILABLE) + require(not cluster.get("proxy-url") and not cluster.get("insecure-skip-tls-verify") + and not cluster.get("tls-server-name"), UNAVAILABLE) + origin = loopback_origin(cluster["server"]) + require(origin == client_origin(setup), UNAVAILABLE) + return origin + + +def sandbox_authority(value): + """Keep JSON types distinct as well as every non-status authority field.""" + identity(value) + authority = copy.deepcopy({key: item for key, item in value.items() if key != "status"}) + authority["metadata"].pop("resourceVersion", None) + fields = bounded_items(authority["metadata"].get("managedFields", []), 64) + require(all(isinstance(field, dict) for field in fields), UNAVAILABLE) + # Status-subresource field ownership is bookkeeping, not a new authority. + authority["metadata"]["managedFields"] = [ + field for field in fields if field.get("subresource") != "status"] + observed = value["status"]["serviceObservation"] + require(observed.get("phase") in ("Prepared", "Ready"), UNAVAILABLE) + authority["serviceObservation"] = { + key: item for key, item in observed.items() if key not in ("phase", "reason")} + return json.dumps(authority, sort_keys=True, separators=(",", ":")) + + +def recheck_snapshot_actor(setup, actor, source_path): + before = actor["anchors"][source_path] + recheck_actor(setup, {**actor, "anchors": { + path: value for path, value in actor["anchors"].items() if path != source_path}}) + current = setup.admin.get(source_path) + require(sandbox_authority(current) == sandbox_authority(before), UNAVAILABLE) + actor["anchors"][source_path] = current + return current + + +def bounded_items(value, maximum): + require(isinstance(value, list) and len(value) <= maximum, UNAVAILABLE) + return value + + +def metadata(value): + identity(value) + result = {key: value["metadata"][key] for key in ("name", "uid", "resourceVersion")} + if value["metadata"].get("namespace"): + result["namespace"] = value["metadata"]["namespace"] + require(all(isinstance(item, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,253}", item) + for item in result.values()), UNAVAILABLE) + return result + + +def spec_digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def matches(selector, labels): + require(isinstance(selector, dict) and set(selector) <= {"matchLabels", "matchExpressions"} + and isinstance(labels, dict), UNAVAILABLE) + exact = selector.get("matchLabels", {}) + require(isinstance(exact, dict) and len(exact) <= 32 + and all(isinstance(key, str) and isinstance(value, str) for key, value in exact.items()), + UNAVAILABLE) + result = all(labels.get(key) == value for key, value in exact.items()) + for expression in bounded_items(selector.get("matchExpressions", []), 16): + require(isinstance(expression, dict) and set(expression) <= {"key", "operator", "values"} + and isinstance(expression.get("key"), str), UNAVAILABLE) + key, operator = expression["key"], expression.get("operator") + values = bounded_items(expression.get("values", []), 16) + require(all(isinstance(value, str) for value in values), UNAVAILABLE) + if operator in ("In", "NotIn"): + require(bool(values), UNAVAILABLE) + result &= labels.get(key) in values if operator == "In" else labels.get(key) not in values + elif operator in ("Exists", "DoesNotExist"): + require(not values, UNAVAILABLE) + result &= key in labels if operator == "Exists" else key not in labels + else: + raise Failure(UNAVAILABLE) + return result + + +def private_ip(value): + require(isinstance(value, str) and len(value) <= 45, UNAVAILABLE) + address = ipaddress.ip_address(value) + private_ranges = ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7") + require(any(address in ipaddress.ip_network(cidr) for cidr in private_ranges) and not any(( + address.is_loopback, address.is_link_local, address.is_multicast, + address.is_unspecified, address.is_reserved, getattr(address, "scope_id", None), + )), UNAVAILABLE) + return str(address) + + +def api_targets(service, endpoints): + for value in (service, endpoints): + require(value["metadata"].get("namespace") == "default" + and value["metadata"].get("name") == "kubernetes", UNAVAILABLE) + metadata(value) + spec = service["spec"] + require(spec.get("type", "ClusterIP") == "ClusterIP" and not spec.get("externalIPs") + and not spec.get("externalName"), UNAVAILABLE) + ports = [port for port in bounded_items(spec["ports"], 8) + if port.get("name") == "https" and port.get("protocol", "TCP") == "TCP"] + require(len(ports) == 1 and type(ports[0].get("port")) is int and ports[0]["port"] == 443, + UNAVAILABLE) + require(ports[0].get("targetPort") in (6443, "https"), UNAVAILABLE) + addresses = bounded_items(spec.get("clusterIPs", [spec["clusterIP"]]), 2) + require(addresses and spec["clusterIP"] in addresses, UNAVAILABLE) + targets = {(private_ip(address), 443) for address in addresses} + endpoint_targets = set() + for subset in bounded_items(endpoints.get("subsets", []), 8): + ready = bounded_items(subset.get("addresses", []), 8) + ports = [port for port in bounded_items(subset.get("ports", []), 8) + if port.get("name") == "https" and port.get("protocol", "TCP") == "TCP"] + require(ready and len(ports) == 1 and type(ports[0].get("port")) is int + and ports[0]["port"] == 6443, UNAVAILABLE) + endpoint_targets.update((private_ip(address["ip"]), ports[0]["port"]) for address in ready) + require(endpoint_targets and len(targets | endpoint_targets) <= 16, UNAVAILABLE) + return [{"address": address, "port": port} + for address, port in sorted(targets | endpoint_targets)] + + +def rule_match(rule, target): + """Declared Kubernetes rule matching only; CNI entity/DNAT behavior is unknown.""" + require(isinstance(rule, dict) and set(rule) <= {"to", "ports"}, UNAVAILABLE) + ports = bounded_items(rule.get("ports", []), 16) + port_match = not ports + unknown_port = False + for port in ports: + require(isinstance(port, dict) and set(port) <= {"port", "endPort", "protocol"}, UNAVAILABLE) + if port.get("protocol", "TCP") != "TCP": + continue + start, end = port.get("port"), port.get("endPort", port.get("port")) + if start is None: + port_match = True + elif isinstance(start, str): + unknown_port = True + else: + require(type(start) is int and type(end) is int and 0 < start <= end <= 65535, UNAVAILABLE) + port_match |= start <= target["port"] <= end + peers = bounded_items(rule.get("to", []), 16) + address_match, unknown_peer = not peers, False + address = ipaddress.ip_address(target["address"]) + for peer in peers: + require(isinstance(peer, dict) + and set(peer) <= {"ipBlock", "namespaceSelector", "podSelector"}, UNAVAILABLE) + if not peer: + address_match = True + elif "ipBlock" in peer: + require(set(peer) == {"ipBlock"}, UNAVAILABLE) + block = peer["ipBlock"] + network = ipaddress.ip_network(block["cidr"]) + exceptions = [ipaddress.ip_network(value) + for value in bounded_items(block.get("except", []), 16)] + require(all(value.subnet_of(network) for value in exceptions), UNAVAILABLE) + address_match |= address in network and not any(address in value for value in exceptions) + else: + unknown_peer = True + if address_match and port_match: + return True + if (address_match or unknown_peer) and (port_match or unknown_port): + return None + return False + + +def policy_facts(policy, pods, targets): + spec = policy["spec"] + types = bounded_items(spec.get("policyTypes", []), 2) + require(types and set(types) <= {"Ingress", "Egress"}, UNAVAILABLE) + selected = [identity(pod)[0] for pod in pods + if matches(spec["podSelector"], pod["metadata"].get("labels", {}))] + rules = bounded_items(spec.get("egress", []), 32) + return {"identity": metadata(policy), "specDigest": spec_digest(spec), + "policyTypes": types, "selectedObserverPodUids": selected, + "egressRuleCount": len(rules), "apiDeclaredMatches": [ + {**target, "ruleMatches": [rule_match(rule, target) for rule in rules]} + for target in targets + ] if "Egress" in types and selected else []} + + +def snapshot(setup, target, temporary_name=None): + require(isinstance(target, dict) and target.get("workspace") == CORE + and target.get("task") == "native-observation-task" and target.get("uid"), UNAVAILABLE) + actor = resolve_actor(setup, "router", target) + namespace = actor["namespace"] + source_path = resource(CORE, "karssandboxes", target["sandbox"]) + source = actor["anchors"][source_path] + ns = actor["anchors"][f"/api/v1/namespaces/{namespace}"] + deployment = actor["anchors"][resource(namespace, "deployments", target["sandbox"], "/apis/apps/v1")] + require(ns["metadata"].get("annotations", {}).get("kars.azure.com/sandbox-uid") == target["uid"] + and deployment["metadata"].get("annotations", {}).get("kars.azure.com/credential-sandbox-uid") + == target["uid"] + and deployment["metadata"].get("annotations", {}).get("kars.azure.com/credential-namespace-uid") + == identity(ns)[0], UNAVAILABLE) + pods = [actor["anchors"][core(namespace, "pods", item["name"])] for item in actor["pods"]] + selector = {"matchLabels": {key: pods[0]["metadata"].get("labels", {}).get(key) + for key in SELECTOR_KEYS}} + require(all(isinstance(value, str) and re.fullmatch(r"[a-z0-9][-a-z0-9]{0,62}", value) + for value in selector["matchLabels"].values()), UNAVAILABLE) + pod_processes = [] + for pod in pods: + spec = pod["spec"] + routers = [container for container in spec["containers"] if container.get("name") == "inference-router"] + statuses = [item for item in pod.get("status", {}).get("containerStatuses", []) + if item.get("name") == "inference-router"] + require(matches(selector, pod["metadata"].get("labels", {})) + and not spec.get("hostNetwork") and not spec.get("hostPID") + and not spec.get("shareProcessNamespace") and not spec.get("ephemeralContainers") + and spec.get("nodeName") == "bridge-native-worker" + and len(routers) == len(statuses) == 1, UNAVAILABLE) + security = routers[0].get("securityContext", {}) + run_as = security.get("runAsUser", spec.get("securityContext", {}).get("runAsUser")) + require(type(run_as) is int and run_as == 1001 + and not security.get("privileged") and security.get("allowPrivilegeEscalation") is False + and statuses[0].get("ready") is True and statuses[0].get("containerID") + and type(statuses[0].get("restartCount")) is int, UNAVAILABLE) + pod_processes.append((identity(pod)[0], statuses[0]["containerID"], statuses[0]["restartCount"])) + all_pods = bounded_items(setup.admin.get(core(namespace, "pods"))["items"], 64) + consumers = [pod for pod in all_pods if matches(selector, pod["metadata"].get("labels", {}))] + require(sorted(identity(pod)[0] for pod in consumers) == sorted(item["uid"] for item in actor["pods"]), + "Diagnostic selector has unknown or foreign consumers") + require(all(pod["metadata"].get("namespace") == namespace + and identity(pod) == identity(actor["anchors"][core(namespace, "pods", pod["metadata"]["name"])]) + for pod in consumers), UNAVAILABLE) + for path in ("/api/v1/namespaces/default", API_SERVICE, API_ENDPOINTS): + actor["anchors"][path] = setup.admin.get(path) + metadata(actor["anchors"][path]) + require(actor["anchors"]["/api/v1/namespaces/default"]["metadata"].get("name") == "default", UNAVAILABLE) + targets = api_targets(actor["anchors"][API_SERVICE], actor["anchors"][API_ENDPOINTS]) + policies = bounded_items(setup.admin.get(resource(namespace, "networkpolicies", group=NETWORK))["items"], 32) + policies = [value for value in policies if value["metadata"].get("name") != temporary_name] + require(all(value["metadata"].get("namespace") == namespace for value in policies), UNAVAILABLE) + for policy in policies: + path = resource(namespace, "networkpolicies", policy["metadata"]["name"], NETWORK) + actor["anchors"][path] = policy + facts = {"namespace": metadata(ns), "deployment": metadata(deployment), + "pods": [{"identity": metadata(pod), "selector": selector, + "container": "inference-router", "configuredRunAsUser": 1001} for pod in pods], + "apiService": metadata(actor["anchors"][API_SERVICE]), + "apiEndpoints": metadata(actor["anchors"][API_ENDPOINTS]), "destinations": targets, + "networkPolicies": [policy_facts(policy, pods, targets) for policy in policies], + "cniBehaviorProven": False} + source = recheck_snapshot_actor(setup, actor, source_path) + stable = { + "sandboxAuthority": sandbox_authority(source), + "uids": {path: identity(value)[0] for path, value in actor["anchors"].items()}, + "generation": source["metadata"].get("generation"), + "deploymentGeneration": deployment["metadata"].get("generation"), + "observerVersion": source["status"]["serviceObservation"]["version"], + "selector": selector, "processes": sorted(pod_processes), "destinations": targets, + "api": {path: identity(actor["anchors"][path]) for path in (API_SERVICE, API_ENDPOINTS)}, + "policies": {identity(value): value["spec"] for value in policies}, + } + return {"actor": actor, "stable": stable, "facts": facts, "selector": selector, + "namespace": ns, "deployment": deployment, + "ready": source["status"]["serviceObservation"]["phase"] == "Ready"} + + +def policy_plan(before, name): + ports = sorted({target["port"] for target in before["facts"]["destinations"]}) + require(ports == [443, 6443] and all(type(target["port"]) is int for target in before["facts"]["destinations"]), + UNAVAILABLE) + return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy", + "metadata": {"name": name, "namespace": before["actor"]["namespace"], + "ownerReferences": [{"apiVersion": "apps/v1", "kind": "Deployment", + "name": before["deployment"]["metadata"]["name"], + "uid": identity(before["deployment"])[0], + "controller": False, "blockOwnerDeletion": False}]}, + "spec": {"endpointSelector": copy.deepcopy(before["selector"]), + "egress": [{"toEntities": ["kube-apiserver"], + "toPorts": [{"ports": [{"protocol": "TCP", "port": str(port)} + for port in ports]}]}]}} + + +def remove_policy(setup, before, path, created): + require(client_origin(setup) == before["apiOrigin"], "Diagnostic API origin changed before cleanup") + ns = setup.admin.get(f'/api/v1/namespaces/{before["actor"]["namespace"]}') + require(identity(ns)[0] == identity(before["namespace"])[0], "Diagnostic namespace changed before cleanup") + current = setup.admin.optional(path) + deleted = current is not None + if current is not None: + require(identity(current)[0] == identity(created)[0] + and current["metadata"].get("name") == created["metadata"]["name"] + and current["metadata"].get("namespace") == before["actor"]["namespace"], + "Diagnostic policy was replaced; cleanup refused") + setup.admin.request("DELETE", path, { + "apiVersion": "v1", "kind": "DeleteOptions", + "preconditions": {"uid": identity(created)[0], "resourceVersion": identity(current)[1]}, + }, expected=(200, 202)) + for _ in range(5): + current = setup.admin.optional(path) + if current is None: + require(identity(setup.admin.get(f'/api/v1/namespaces/{before["actor"]["namespace"]}'))[0] + == identity(before["namespace"])[0], "Diagnostic namespace changed during cleanup") + return "uid-rv-deletion-verified" if deleted else "already-absent-verified" + require(current["metadata"].get("uid") == identity(created)[0], "Diagnostic policy name was reused") + time.sleep(1) + raise Failure("Diagnostic policy cleanup was not verified") + + +def collect(setup, target, failed_case): + from observer_cilium_diagnostics import snapshot as cilium_snapshot + + result = {"diagnosticOnly": True, "originalResult": "failed", "available": False, + "category": "not-eligible", "policyCreated": False, "cleanup": "not-required", + "cniAcceptanceQualified": False, "experiment": "cilium-kube-apiserver-entity"} + if (not isinstance(failed_case, dict) or failed_case.get("result") != "failed" + or failed_case.get("failure") != FAILURE): + return result + before = created = path = None + try: + result["stage"] = "disposable-host" + require(os.environ.get("GITHUB_ACTIONS") == "true" + and os.environ.get("GITHUB_REPOSITORY") == "Azure/kars" + and os.environ.get("CORE_REVISION") == CORE_REVISION, UNAVAILABLE) + origin = selected_origin(setup) + require(command("docker", "inspect", "bridge-native-worker", "--format", + '{{index .Config.Labels "io.x-k8s.kind.cluster"}}', timeout=10).strip() + == "bridge-native", UNAVAILABLE) + result["stage"] = "baseline-snapshot" + result["baselineSnapshot"] = {} + before = cilium_snapshot(setup, target, witness=result["baselineSnapshot"]) + before["apiOrigin"] = origin + result.update(available=True, before=before["facts"], category="baseline-retained") + if not before["facts"]["cilium"]["configurationMatchesExpected"]: + result["category"] = "unexpected-cilium-configuration-no-intervention" + return result + if before["ready"]: + result.update(category="already-ready-without-intervention", samePodObserverReady=True) + return result + isolated = {pod_uid for policy in before["facts"]["networkPolicies"] + if "Egress" in policy["policyTypes"] for pod_uid in policy["selectedObserverPodUids"]} + if not {pod["uid"] for pod in before["actor"]["pods"]} <= isolated: + result["category"] = "no-observed-egress-isolation-no-intervention" + return result + name = f"native-observer-api-{secrets.token_hex(8)}" + result["stage"] = "pre-create-recheck" + path = resource(before["actor"]["namespace"], "ciliumnetworkpolicies", name, CILIUM) + require(setup.admin.optional(path) is None, "Diagnostic policy name is already occupied") + + def recheck(phase, temporary_name=None): + result["observationStage"] = f"{phase}-snapshot" + result["observationSnapshot"] = {} + result.pop("stableChecks", None) + result.pop("ciliumStableChecks", None) + observed = cilium_snapshot(setup, target, temporary_name, + witness=result["observationSnapshot"]) + result["observationStage"] = f"{phase}-stability" + result["stableChecks"] = { + key: observed["stable"].get(key) == before["stable"].get(key) + for key in ("sandboxAuthority", "uids", "generation", "deploymentGeneration", + "observerVersion", "selector", "processes", "destinations", "api", "policies", "cilium") + } + result["ciliumStableChecks"] = { + key: observed["stable"].get("cilium", {}).get(key) + == before["stable"].get("cilium", {}).get(key) + for key in ("anchors", "configDigest", "effectiveConfig", "agentProcess", "endpoints", "policies") + } + require(observed["stable"] == before["stable"], UNAVAILABLE) + return observed + + current = recheck("pre-create") + if current["ready"]: + result.update(category="already-ready-without-intervention", samePodObserverReady=True) + return result + require(selected_origin(setup) == origin, "Diagnostic API origin changed before policy creation") + desired = policy_plan(before, name) + result["stage"] = "temporary-policy-create" + result["cleanup"] = "creation-unconfirmed" + candidate = setup.admin.create(resource(before["actor"]["namespace"], "ciliumnetworkpolicies", group=CILIUM), desired) + require(candidate["metadata"].get("name") == name + and candidate["metadata"].get("namespace") == before["actor"]["namespace"], UNAVAILABLE) + metadata(candidate) + created = candidate + result.update(policyCreated=True, policy=metadata(created), cleanup="pending") + require(created["spec"] == desired["spec"], "Diagnostic policy was mutated") + started = datetime.now(timezone.utc) + deadline = time.monotonic() + 60 + result["category"] = "no-progress-observed" + result["stage"] = "same-pod-observation" + while time.monotonic() < deadline: + current = recheck("before-api", name) + result["observationStage"] = "temporary-policy-identity" + policy = setup.admin.get(path) + require(identity(policy)[0] == identity(created)[0] and policy["spec"] == desired["spec"], UNAVAILABLE) + result["observationStage"] = "api-outcomes" + outcomes = api_outcomes(setup, "observer_router", target, started) + current = recheck("after-api", name) + result["duringApiOutcomes"] = outcomes + result["duringCiliumEndpoints"] = current["facts"]["cilium"]["endpoints"] + result["apiResponseObserved"] = outcomes["available"] + result["samePodObserverReady"] = current["ready"] + if outcomes["available"] or current["ready"]: + result["category"] = "same-pod-progress-with-temporary-policy" + break + time.sleep(5) + recheck("final", name) + except READ_ERRORS: + result["category"] = "provenance-or-operation-unavailable" + if result.get("stage") == "baseline-snapshot" and result.get("baselineSnapshot"): + result["baselineStoppingStage"] = result["baselineSnapshot"].get("lastStage") + if result.get("observationStage"): + result["observationStoppingStage"] = result["observationStage"] + finally: + if created is not None: + try: + result["cleanup"] = remove_policy(setup, before, path, created) + except READ_ERRORS: + result["cleanup"] = "unverified-or-refused" + result["category"] = "cleanup-not-verified" + return result diff --git a/bridge/tests/native-credentials/private_tls.py b/bridge/tests/native-credentials/private_tls.py new file mode 100644 index 000000000..74f9d6157 --- /dev/null +++ b/bridge/tests/native-credentials/private_tls.py @@ -0,0 +1,68 @@ +"""Pinned local TLS transport for API tests, not NetworkPolicy evidence.""" + +from contextlib import contextmanager +import http.client +import json +import socket +import ssl +import subprocess + +from native_api import ROOT, STATE, require, until + + +class PinnedConnection(http.client.HTTPConnection): + def __init__(self, port, name, ca): + super().__init__("127.0.0.1", port, timeout=20) + self.name = name + self.context = ssl.create_default_context(cadata=ca) + + def connect(self): + raw = socket.create_connection((self.host, self.port), self.timeout) + try: + self.sock = self.context.wrap_socket(raw, server_hostname=self.name) + except BaseException: + raw.close() + raise + + +def call(port, endpoint, token, method, path, body=None, scope=None): + connection = PinnedConnection(port, endpoint["serverName"], endpoint["caPem"]) + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + if scope is not None: + headers["x-kars-service-scope"] = scope + try: + connection.request(method, path, None if body is None else json.dumps(body), headers) + response = connection.getresponse() + raw = response.read(65536) + require(len(raw) < 65536, "Private TLS API response exceeded bound") + require(response.status not in (301, 302, 303, 307, 308), "Private API attempted redirect") + value = json.loads(raw) if raw and response.getheader("Content-Type", "").startswith("application/json") else None + return response.status, value + finally: + connection.close() + + +@contextmanager +def forward(namespace, target, local, remote): + with (STATE / f"forward-{local}.log").open("w") as output: + process = subprocess.Popen([ + "kubectl", "port-forward", "-n", namespace, target, + f"{local}:{remote}", "--address=127.0.0.1", + ], cwd=ROOT, stdin=subprocess.DEVNULL, stdout=output, stderr=output) + try: + def listening(): + require(process.poll() is None, "Private API port-forward terminated") + try: + with socket.create_connection(("127.0.0.1", local), 1): + return True + except OSError: + return False + until("private TLS forwarding listener", listening, 30) + yield + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/bridge/tests/native-credentials/run.py b/bridge/tests/native-credentials/run.py new file mode 100644 index 000000000..19b8b04de --- /dev/null +++ b/bridge/tests/native-credentials/run.py @@ -0,0 +1,197 @@ +"""Hosted-only native acceptance. Each emitted marker records an actual outcome.""" + +import json +import os +import sys +import time +from datetime import datetime, timezone + +from api_gate import CORE_REVISION +from api_outcome_diagnostics import collect as api_outcome_diagnostics +from boot import bridge_connection, install_bridge, install_core +from credential_cases import CredentialCases +from lifecycle_cases import LifecycleCases +from native_api import CORE, STATE, Failure, Setup, command, core, require, scheduling_detail, status_detail +from observation_cases import ObservationCases +from observation_diagnostics import collect as observation_diagnostics +from observer_network_diagnostics import collect as observer_network_diagnostics + + +def diagnostics(setup): + result = {} + for label, path in [ + ("pods", "/api/v1/pods"), + ("grants", "/apis/kars.azure.com/v1alpha1/karscredentialgrants"), + ("tasks", "/apis/kars.azure.com/v1alpha1/karstasks"), + ("sandboxes", "/apis/kars.azure.com/v1alpha1/karssandboxes"), + ("deployments", "/apis/apps/v1/deployments"), + ]: + try: + objects = setup.admin.get(path)["items"] + result[label] = [{ + "namespace": item["metadata"].get("namespace"), + "name": item["metadata"]["name"], "uid": item["metadata"]["uid"], + "phase": item.get("status", {}).get("phase"), + "generation": item["metadata"].get("generation"), + "observedGeneration": item.get("status", {}).get("observedGeneration"), + "executionPhase": item.get("status", {}).get("executionPhase"), + "executionDetail": item.get("status", {}).get("executionDetail"), + "envelopeDigest": item.get("status", {}).get("envelopeDigest"), + "credentialMarkers": {key: value for key, value in item["metadata"].get("annotations", {}).items() + if key in ["kars.azure.com/credential-rebind-pending", + "kars.azure.com/credential-rebind-task-uid", + "kars.azure.com/credential-bundle-uid", + "kars.azure.com/namespace-uid"]}, + "credentialBindings": item.get("spec", {}).get("credentialBindings", + (item.get("spec", {}).get("blueprint") or {}).get("credentialBindings")), + "credentialsRef": item.get("spec", {}).get("credentialsRef"), + "execution": item.get("spec", {}).get("execution"), + "replicas": item.get("spec", {}).get("replicas"), + "reason": item.get("status", {}).get("reason"), + "integrationError": item.get("status", {}).get("integrationError"), + "serviceObservation": item.get("status", {}).get("serviceObservation"), + "conditions": [{key: condition.get(key) for key in ("type", "status", "reason")} + for condition in item.get("status", {}).get("conditions", [])], + "scheduling": scheduling_detail(item) if label == "pods" else None, + "containers": [ + {"name": container["name"], "ready": container.get("ready"), + "waitingReason": container.get("state", {}).get("waiting", {}).get("reason")} + for container in item.get("status", {}).get("containerStatuses", []) + ], + } for item in objects if not item["metadata"].get("namespace", "").startswith("kube-")] + except Exception as error: + result[label] = {"unavailable": type(error).__name__} + try: + raw = command("docker", "exec", "bridge-native-control-plane", + "cat", "/var/log/kars-native-audit/audit.log") + errors = [] + for line in raw.splitlines(): + event = json.loads(line) + status = event.get("responseStatus", {}) + if (event.get("stage") == "ResponseComplete" + and event.get("user", {}).get("username") == f"system:serviceaccount:{CORE}:kars-controller" + and status.get("code", 0) >= 400 and status["code"] != 404): + ref = event.get("objectRef", {}) + errors.append({"verb": event["verb"], "code": status["code"], + "resource": ref.get("resource"), "namespace": ref.get("namespace"), + "name": ref.get("name"), "subresource": ref.get("subresource"), + "detail": status_detail(status)}) + result["recentControllerRejections"] = errors[-24:] + except Exception as error: + result["recentControllerRejections"] = {"unavailable": type(error).__name__} + return result + + +def main(): + report = {"coreRevision": CORE_REVISION, "bridgeRevision": command("git", "rev-parse", "HEAD").strip(), + "lane": "no-active-sre-native", "runtimeFixture": "controlled-no-LLM-agent", + "workspaceContract": "ephemeral-filesystem-with-namespace-resource-continuity", + "cases": {}, "runtimeQualified": False, + "networkPolicyEnforcementQualified": False, "activeSreCombinedQualified": False} + path = STATE / "evidence/native.json" + path.parent.mkdir(parents=True, exist_ok=True) + setup = None + + def save(): + path.write_text(json.dumps(report, indent=2) + "\n") + + def case(name, operation, allowed=True): + if not allowed: + report["cases"][name] = {"result": "blocked", "reason": "native prerequisite did not complete"} + save() + return None + start = time.monotonic() + started_at = datetime.now(timezone.utc) + try: + value = operation() + report["cases"][name] = {"result": "passed"} + return value + except Exception as error: + report["cases"][name] = { + "result": "failed", + "failure": str(error) if isinstance(error, Failure) else type(error).__name__, + } + if setup: + if name == "private-bff-observer-and-fresh-privacy-rpc": + report["cases"][name]["observationReadiness"] = observation_diagnostics( + setup, observations.observer_target) + report["cases"][name]["actorApiOutcomes"] = api_outcome_diagnostics( + setup, "observer_router", observations.observer_target, started_at) + elif name == "real-source-projection-and-runtime": + report["cases"][name]["actorApiOutcomes"] = api_outcome_diagnostics( + setup, "bff_writer", lifecycle.credential_diagnostic_target, started_at) + report["cases"][name]["metadataAtFailure"] = diagnostics(setup) + if name == "private-bff-observer-and-fresh-privacy-rpc": + # Persist the original failure before adding any diagnostic policy. + save() + report["cases"][name]["observerApiReachability"] = observer_network_diagnostics( + setup, observations.observer_target, report["cases"][name]) + return None + finally: + report["cases"][name]["seconds"] = round(time.monotonic() - start, 2) + save() + print(json.dumps({"nativeCase": name, **{key: value for key, value in report["cases"][name].items() + if key not in ("metadataAtFailure", "observationReadiness", + "actorApiOutcomes", "observerApiReachability")}}), flush=True) + + def passed(name): + return report["cases"].get(name, {}).get("result") == "passed" + + try: + require(command("git", "-C", ".native/core", "rev-parse", "HEAD").strip() == CORE_REVISION + and os.environ.get("CORE_REVISION") == CORE_REVISION, + "Unreviewed core source cannot enter native acceptance") + setup = Setup() + install_core(setup) + key = install_bridge(setup) + with bridge_connection(key) as bff: + bff.call("GET", f"/api/namespaces/{CORE}/channels", authenticated=False, expected=401) + credentials = CredentialCases(setup, bff) + case("live-controller-native-writer-readiness", + lambda: credentials.workspace("native-grant-preflight")) + require(passed("live-controller-native-writer-readiness"), + "Live core did not issue native writer authority; see grant diagnostics") + lifecycle = LifecycleCases(setup, bff, credentials) + observations = ObservationCases(setup, bff, lifecycle) + workspace = case("create-only-bootstrap-and-v1-preservation", credentials.bootstrap) + case("unobserved-collision-no-adoption", credentials.collision) + case("new-source-late-conflict-zero-mutations", lambda: credentials.late_conflict(False)) + case("existing-source-late-conflict-zero-mutations", lambda: credentials.late_conflict(True)) + case("persistent-removal-before-reviewed-legacy-import", credentials.removal_before_import) + case("native-403-unregistered-namespace-role-alias", + lambda: credentials.native_denials(workspace), workspace is not None) + case("real-source-projection-and-runtime", lifecycle.create_delivery) + case("selected-source-revocation-and-uid-fence", lifecycle.source_uid_fence, + passed("real-source-projection-and-runtime")) + case("team-rebind-uid-namespace-data-and-attestation-continuity", lifecycle.team_rebind) + case("completed-team-fixture-release", lifecycle.release_team_fixture, + lifecycle.team_target is not None) + case("private-bff-observer-and-fresh-privacy-rpc", observations.enable) + case("purpose-only-pinned-tls-api-negative-matrix", observations.tls_api, + passed("private-bff-observer-and-fresh-privacy-rpc")) + case("cni-9447-9448-positive-and-unauthorized-peer-denial", observations.cni_denials, + passed("private-bff-observer-and-fresh-privacy-rpc")) + case("observer-rotation-current-bearer-and-revocation", observations.rotation, + passed("private-bff-observer-and-fresh-privacy-rpc")) + case("writer-uninstall-held-name-and-delivery-continuity", lifecycle.writer_uninstall, + passed("real-source-projection-and-runtime")) + case("grant-revocation-halts-consumer-retains-source", lifecycle.grant_revocation, + passed("real-source-projection-and-runtime")) + report["runtimeQualified"] = all(item["result"] == "passed" for item in report["cases"].values()) + report["networkPolicyEnforcementQualified"] = passed( + "cni-9447-9448-positive-and-unauthorized-peer-denial") + except Exception as error: + report["setupFailure"] = str(error) if isinstance(error, Failure) else type(error).__name__ + finally: + if setup: + report["diagnostics"] = diagnostics(setup) + report["result"] = "passed" if report["runtimeQualified"] else "failed" + save() + print(json.dumps({key: report[key] for key in + ("coreRevision", "bridgeRevision", "workspaceContract", "result", "runtimeQualified", + "networkPolicyEnforcementQualified", "activeSreCombinedQualified")}), flush=True) + return 0 if report["runtimeQualified"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bridge/tests/native-credentials/runtime_probe.py b/bridge/tests/native-credentials/runtime_probe.py new file mode 100644 index 000000000..89e62ce16 --- /dev/null +++ b/bridge/tests/native-credentials/runtime_probe.py @@ -0,0 +1,55 @@ +"""Test-runtime self-observation; no exec, Secret disclosure, or arbitrary IO.""" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import secrets + + +def state(path=None): + path = path or Path("/sandbox/native-continuity") + before = path.is_file() + writable = True + marker = None + try: + if not before: + path.write_text(secrets.token_hex(16)) + marker = path.read_text() + except OSError: + writable = False + return { + "uid": os.getuid(), "dataExistedAtStart": before, + "dataWritable": writable, "dataMarker": marker, + "slackPresent": bool(os.environ.get("SLACK_BOT_TOKEN")), + "telegramPresent": bool(os.environ.get("TELEGRAM_BOT_TOKEN")), + "observationFilesUnreadable": all(not os.access(name, os.R_OK) for name in [ + "/etc/kars/observations/observation-token", + "/etc/kars/observation-identity/config.json", + ]), + } + + +def main(): + proof = state() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path != "/native-proof": + self.send_error(404) + return + body = json.dumps(proof).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + HTTPServer(("127.0.0.1", 18789), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/bridge/tests/native-credentials/runtime_state.py b/bridge/tests/native-credentials/runtime_state.py new file mode 100644 index 000000000..cabe19376 --- /dev/null +++ b/bridge/tests/native-credentials/runtime_state.py @@ -0,0 +1,47 @@ +"""Observe the controlled runtime over the existing permitted gateway port.""" + +import http.client +import json +import subprocess + +from native_api import ROOT, require +from private_tls import forward + + +def assert_ephemeral_workspace(pod): + spec = pod.get("spec", {}) + agents = [container for container in spec.get("containers", []) if container["name"] == "openclaw"] + require(len(agents) == 1, "Native workspace fixture has no unique agent container") + mounts = [mount for mount in agents[0].get("volumeMounts", []) if mount["mountPath"] == "/sandbox"] + require(len(mounts) == 1 and not mounts[0].get("readOnly") + and not mounts[0].get("subPath") and not mounts[0].get("subPathExpr"), + "Native workspace fixture must mount the writable sandbox volume directly") + volumes = [volume for volume in spec.get("volumes", []) if volume["name"] == mounts[0]["name"]] + require(len(volumes) == 1 and isinstance(volumes[0].get("emptyDir"), dict) + and set(volumes[0]) <= {"name", "emptyDir"}, + "Native workspace fixture must retain the existing emptyDir contract") + + +def runtime_state(namespace, pod): + with forward(namespace, f"pod/{pod}", 18790, 18789): + connection = http.client.HTTPConnection("127.0.0.1", 18790, timeout=10) + try: + connection.request("GET", "/native-proof") + response = connection.getresponse() + body = response.read(4096) + require(response.status == 200 and len(body) < 4096, + "Controlled runtime proof unavailable") + proof = json.loads(body) + require(proof.get("uid") == 1000, "Runtime proof did not execute as the protected agent UID") + return proof + finally: + connection.close() + + +def assert_agent_exec_denied(namespace, pod): + result = subprocess.run([ + "kubectl", "exec", "-n", namespace, pod, "-c", "openclaw", "--", "true", + ], cwd=ROOT, capture_output=True, text=True, timeout=30, check=False) + require(result.returncode != 0 and "kars-sandbox-exec-ban" in result.stderr + and "Forbidden" in result.stderr, + "Native exec rejection did not come from the intended agent boundary") diff --git a/bridge/tests/native-credentials/source_revision.py b/bridge/tests/native-credentials/source_revision.py new file mode 100644 index 000000000..bf0e70af1 --- /dev/null +++ b/bridge/tests/native-credentials/source_revision.py @@ -0,0 +1,24 @@ +"""Bind Bridge/core qualification to the same checked-out monorepo commit.""" + +import os +from pathlib import Path +import re +import subprocess + + +def checked_revision(): + repository = Path(__file__).resolve().parents[3] + result = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=10, check=False, + ) + revision = result.stdout.strip() + if result.returncode or re.fullmatch(r"[a-f0-9]{40}", revision) is None: + raise RuntimeError("Monorepo source revision is unavailable") + expected = os.environ.get("CORE_REVISION") + if expected is not None and expected != revision: + raise RuntimeError("Qualification source differs from the workflow's exact commit") + return revision + + +CORE_REVISION = checked_revision() diff --git a/bridge/tests/native-credentials/test_cilium_baseline_witness.py b/bridge/tests/native-credentials/test_cilium_baseline_witness.py new file mode 100644 index 000000000..5162679dc --- /dev/null +++ b/bridge/tests/native-credentials/test_cilium_baseline_witness.py @@ -0,0 +1,214 @@ +"""Controlled formats and failure checkpoints, not live Cilium qualification.""" + +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from native_api import Failure +import observer_cilium_diagnostics as cilium +import observer_network_diagnostics as network +import test_observer_network_diagnostics as fixtures + + +class CiliumBaselineWitnessTests(unittest.TestCase): + def setUp(self): + self.api = fixtures.NetworkFixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + + def collect(self): + return fixtures.ObserverNetworkTests.collect(self) + + def assert_stopped(self, result, stage): + self.assertFalse(result["available"]) + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["baselineStoppingStage"], stage) + witness = result["baselineSnapshot"] + self.assertFalse(witness["complete"]) + self.assertTrue(witness["networkValidated"]) + self.assertEqual(witness["lastStage"], stage) + self.assertEqual(witness["validatedNetworkFacts"]["pods"][0]["identity"]["uid"], "agent-pod-uid") + self.assertNotIn("cilium", witness["validatedNetworkFacts"]) + self.assertNotIn("canary", json.dumps(result)) + self.assertEqual(self.api.created, []) + self.assertEqual(self.api.deleted, []) + return witness + + def test_nil_empty_and_unknown_modes_expose_rendering_without_becoming_defaults(self): + original = cilium.read_projection + for value, syntax in (("", "empty"), ("<nil>", "go-nil"), ("<no value>", "go-no-value"), + ("private-mode-canary", "invalid-json")): + with self.subTest(syntax=syntax): + self.setUp() + self.api.read_projection = original + raw = f"{value}\ttrue\ttrue\n\n".encode() + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace(returncode=0, stdout=raw)): + result = self.collect() + witness = self.assert_stopped(result, "config_mode") + self.assertEqual(witness["failureKind"], "format") + self.assertEqual(witness["checks"]["config_mode"]["modeSyntax"], syntax) + self.assertEqual(witness["checks"]["config_fields"]["fieldCount"], 3) + self.assertEqual(witness["checks"]["config_exec"]["exitStatus"], 0) + self.assertEqual(witness["checks"]["config_exec"]["byteCount"], len(raw)) + self.assertEqual(witness["checks"]["config_framing"]["lineCount"], 1) + self.assertIn("observedConfigMap", witness) + self.assertNotIn("observedEffectiveConfig", witness) + + def test_framing_exit_and_timeout_failures_have_distinct_fixed_facts(self): + original = cilium.read_projection + for code, raw, stage in ((0, b"", "config_framing"), (0, b"one\ntwo", "config_framing"), + (1, b"", "config_exec")): + self.setUp() + self.api.read_projection = original + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace(returncode=code, stdout=raw)): + result = self.collect() + witness = self.assert_stopped(result, stage) + self.assertEqual(witness["checks"]["config_exec"]["exitStatus"], code) + self.assertFalse(witness["checks"]["config_exec"]["timedOut"]) + self.setUp() + self.api.read_projection = original + with patch.object(cilium.subprocess, "run", side_effect=subprocess.TimeoutExpired("private-canary", 12)): + result = self.collect() + witness = self.assert_stopped(result, "config_exec") + self.assertTrue(witness["checks"]["config_exec"]["timedOut"]) + self.assertEqual(witness["failureKind"], "command-timeout") + + def test_actual_api_status_is_retained_without_error_objects_or_headers(self): + for path, stage in ((cilium.CONFIG, "configmap_read"), (cilium.DAEMONSET, "daemonset_read"), + (fixtures.CEP, "endpoint_read")): + for code in (403, 404, 503): + self.setUp() + original = self.api.request + def request(method, selected, body=None, expected=(200,)): + if method == "GET" and selected == path: + return code, {"message": "private-body-canary", "headers": {"token": "private-header-canary"}} + return original(method, selected, body, expected) + with patch.object(self.api, "request", side_effect=request): + result = self.collect() + witness = self.assert_stopped(result, stage) + self.assertEqual(witness["checks"][stage]["httpStatus"], code) + + def test_identity_image_and_owner_assumptions_report_the_actual_false_check(self): + cases = [ + (lambda: self.api.objects[cilium.CONFIG]["metadata"].pop("resourceVersion"), + "configmap_identity", "resourceVersionPresent"), + (lambda: self.api.objects[fixtures.CILIUM_POD]["spec"].update(serviceAccountName="foreign"), + "agent_constraints", "serviceAccountMatches"), + (lambda: self.api.objects[fixtures.CILIUM_POD]["spec"]["containers"][0].update(image="private-image-canary"), + "agent_constraints", "imageExpectedVersion"), + (lambda: self.api.objects[fixtures.CEP]["metadata"]["ownerReferences"][0].update(uid="foreign"), + "endpoint_owner", "podUidMatches"), + (lambda: self.api.objects[fixtures.CEP]["status"]["identity"].update(id=0), + "endpoint_fields", "securityIdValid"), + (lambda: self.api.objects[fixtures.CEP]["status"]["networking"].update(node="172.18.0.99"), + "endpoint_pins", "nodeMatchesPod"), + ] + for mutate, stage, check in cases: + with self.subTest(stage=stage, check=check): + self.setUp() + mutate() + witness = self.assert_stopped(self.collect(), stage) + self.assertFalse(witness["checks"][stage][check]) + + def test_missing_shapes_and_endpoint_projection_mismatches_are_not_generic(self): + self.api.objects[fixtures.CEP]["status"]["identity"] = None + witness = self.assert_stopped(self.collect(), "endpoint_fields") + self.assertEqual(witness["failureKind"], "shape") + self.assertEqual(witness["checks"]["endpoint_fields"]["identityShape"], "null") + self.setUp() + original = self.api.read_projection + def read(agent, endpoint_id=None, witness=None): + fields = original(agent, endpoint_id) + if endpoint_id is not None: + fields[6] = "private-pod-name-canary" + return fields + self.api.read_projection = read + witness = self.assert_stopped(self.collect(), "endpoint_projection_pins") + self.assertFalse(witness["checks"]["endpoint_projection_pins"]["podFieldMatches"]) + self.assertIn("observedEffectiveConfig", witness) + + def test_late_failure_keeps_only_previously_validated_network_facts(self): + original = network.snapshot + reads = 0 + def snapshot(*args, **kwargs): + nonlocal reads + reads += 1 + if reads == 2: + raise Failure("private-late-canary") + return original(*args, **kwargs) + with patch.object(network, "snapshot", side_effect=snapshot): + witness = self.assert_stopped(self.collect(), "network_recheck") + self.assertNotIn("cilium", witness["validatedNetworkFacts"]) + + def test_checkpoint_surface_rejects_arbitrary_keys_values_and_stages(self): + for stage, facts in (("private-stage-canary", {}), ("config_fields", {"private-key-canary": True}), + ("config_fields", {"modeSyntax": "private-value-canary"}), + ("config_fields", {"objectShape": {"body": "private-canary"}})): + witness = {} + with self.assertRaises(Failure): + cilium.checkpoint(witness, stage, **facts) + self.assertEqual(witness, {}) + + def test_every_required_config_token_must_survive_projection(self): + for index in range(3): + self.setUp() + self.api.effective_config[index] = "" + result = self.collect() + stage = "config_mode" if index == 0 else "config_fields" + witness = self.assert_stopped(result, stage) + self.assertFalse(witness["checks"]["config_fields"]["requiredTokensPresent"]) + for token in ("null", "[]"): + witness = {} + self.assertEqual(cilium.effective_configuration([token, "true", "true"], witness) + ["policyCIDRMatchMode"], []) + self.assertTrue(witness["checks"]["config_fields"]["requiredTokensPresent"]) + + @unittest.skipUnless(shutil.which("kubectl"), "Existing kubectl JSONPath engine is unavailable") + def test_existing_jsonpath_engine_with_credential_free_offline_format_control(self): + projection = cilium.CONFIG_OUTPUT.replace("{.", "{.extensions[0].extension.") + fields_by_name = ("PolicyCIDRMatchMode", "EnableCiliumNetworkPolicy", "EnableK8sNetworkPolicy") + cases = [(mode, None) for mode in (None, [], ["nodes"])] + [([], name) for name in fields_by_name] + with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent) as directory: + path = Path(directory) / "offline-context.json" + for mode, omitted in cases: + value = {"apiVersion": "v1", "kind": "Config", + "clusters": [{"name": "offline", "cluster": {"server": "https://127.0.0.1:9"}}], + "users": [{"name": "offline", "user": {}}], + "contexts": [{"name": "offline", "context": {"cluster": "offline", "user": "offline"}}], + "current-context": "offline", "extensions": [{"name": "format-control", "extension": { + "PolicyCIDRMatchMode": mode, "EnableCiliumNetworkPolicy": True, + "EnableK8sNetworkPolicy": True}}]} + if omitted: + del value["extensions"][0]["extension"][omitted] + path.write_text(json.dumps(value)) + output = subprocess.run( + ["kubectl", "--kubeconfig", str(path), "config", "view", "--minify", + "--allow-missing-template-keys=true", "-o", projection], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20, check=False) + self.assertEqual(output.returncode, 0, "Offline JSONPath control failed") + fields = output.stdout.decode().strip("\r\n").split("\t") + self.assertEqual(len(fields), 3) + witness = {} + if omitted: + self.assertEqual(fields[fields_by_name.index(omitted)], "") + with self.assertRaises((Failure, ValueError)): + cilium.effective_configuration(fields, witness) + self.assertFalse(witness["checks"]["config_fields"]["requiredTokensPresent"]) + elif mode is None and fields[0] not in ("null", "[]"): + self.assertEqual(fields[1:], ["true", "true"]) + self.assertIn(cilium.rendering(fields[0]), ("empty", "go-nil", "go-no-value")) + with self.assertRaises((Failure, ValueError)): + cilium.effective_configuration(fields, witness) + self.assertEqual(witness["lastStage"], "config_mode") + else: + self.assertEqual(fields[1:], ["true", "true"]) + parsed = cilium.effective_configuration(fields, witness) + self.assertEqual(parsed["policyCIDRMatchMode"], mode or []) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_cilium_status_schema.py b/bridge/tests/native-credentials/test_cilium_status_schema.py new file mode 100644 index 000000000..2582c9bf7 --- /dev/null +++ b/bridge/tests/native-credentials/test_cilium_status_schema.py @@ -0,0 +1,158 @@ +"""Tagged status-field controls; no live Cilium or Kubernetes qualification.""" + +import copy +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from native_api import Failure +import observer_cilium_diagnostics as cilium +import test_observer_network_diagnostics as fixtures + +# v1.18.5 StatusResponse.kube-proxy-replacement is an object, and Mode is a +# case-sensitive string enum, not a DaemonConfigurationMap boolean. +STATUS_FALSE = {"kube-proxy-replacement": {"mode": "False", "deviceList": [], "devices": []}, + "cilium": {"state": "Ok", "msg": "private-status-canary"}} + + +class CiliumStatusSchemaTests(unittest.TestCase): + def setUp(self): + self.api = fixtures.NetworkFixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + + def collect(self): + return fixtures.ObserverNetworkTests.collect(self) + + def test_typed_false_completes_existing_fences_and_true_is_a_config_contradiction(self): + result = self.collect() + self.assertTrue(result["baselineSnapshot"]["complete"]) + self.assertFalse(result["baselineSnapshot"]["observedEffectiveConfig"]["kubeProxyReplacement"]) + self.assertEqual(result["baselineSnapshot"]["checks"]["kube_proxy_fields"]["kubeProxySyntax"], "status-false") + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.setUp() + self.api.kube_proxy_status = ["True"] + result = self.collect() + self.assertEqual(result["category"], "unexpected-cilium-configuration-no-intervention") + self.assertTrue(result["baselineSnapshot"]["observedEffectiveConfig"]["kubeProxyReplacement"]) + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_missing_unknown_lowercase_and_wrong_shape_status_never_default_false(self): + for fields in ([], [""], ["false"], ["true"], ["FALSE"], ["Disabled"], ["<nil>"], ["null"], + ["0"], ['"False"'], ["False", "extra"], [False], [None], ["private-mode-canary"]): + with self.subTest(fields=type(fields[0]).__name__ if fields else "missing"): + self.setUp() + self.api.kube_proxy_status = fields + result = self.collect() + self.assertFalse(result["available"]) + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["baselineStoppingStage"], "kube_proxy_fields") + witness = result["baselineSnapshot"] + self.assertFalse(witness["complete"]) + self.assertTrue(witness["networkValidated"]) + self.assertNotIn("kubeProxyReplacement", witness["observedEffectiveConfig"]) + self.assertNotIn("canary", json.dumps(result)) + self.assertEqual(self.api.created, []) + + def test_status_cli_uses_the_typed_object_path_and_preserves_failure_bounds(self): + agent = self.api.objects[fixtures.CILIUM_POD] + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace( + returncode=0, stdout=b"False\n\n")) as run: + fields = cilium.read_kube_proxy_projection(agent) + self.assertEqual(fields, ["False"]) + self.assertFalse(cilium.kube_proxy_mode(fields)) + args = run.call_args.args[0] + self.assertEqual(args[11:], ["cilium-dbg", "status", "--timeout=10s", "--output", + 'jsonpath={.kube-proxy-replacement.mode}{"\\n"}']) + self.assertNotIn("--require-k8s-connectivity=false", args) + self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL) + self.assertEqual(run.call_args.kwargs["timeout"], 12) + self.assertNotIn("KubeProxyReplacement", cilium.CONFIG_OUTPUT) + + def test_status_cli_errors_timeouts_and_empty_output_do_not_authorize_intervention(self): + original = cilium.read_kube_proxy_projection + for code, raw, stage in ((1, b"False\n", "kube_proxy_exec"), + (0, b"", "kube_proxy_framing"), + (0, b"False\nprivate-extra-canary\n", "kube_proxy_framing"), + (0, b"private-mode-canary\n", "kube_proxy_fields")): + self.setUp() + self.api.read_kube_proxy_projection = original + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace(returncode=code, stdout=raw)): + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["baselineStoppingStage"], stage) + self.assertNotIn("kubeProxyReplacement", result["baselineSnapshot"]["observedEffectiveConfig"]) + self.assertNotIn("canary", json.dumps(result)) + self.assertEqual(self.api.created, []) + self.setUp() + self.api.read_kube_proxy_projection = original + with patch.object(cilium.subprocess, "run", side_effect=subprocess.TimeoutExpired("private-canary", 12)): + result = self.collect() + self.assertEqual(result["baselineStoppingStage"], "kube_proxy_exec") + self.assertTrue(result["baselineSnapshot"]["checks"]["kube_proxy_exec"]["timedOut"]) + self.assertFalse(result["policyCreated"]) + + def test_agent_identity_change_during_status_read_remains_fatal(self): + def status(_agent, witness=None): + self.api.objects[fixtures.CILIUM_POD]["metadata"]["uid"] = "replacement" + return ["False"] + self.api.read_kube_proxy_projection = status + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["baselineStoppingStage"], "anchor_identity") + self.assertFalse(result["baselineSnapshot"]["checks"]["anchor_identity"]["uidMatches"]) + + @unittest.skipUnless(shutil.which("kubectl"), "Existing offline JSONPath engine is unavailable") + def test_old_missing_field_and_correct_tagged_status_shape_with_offline_jsonpath(self): + old_projection = (r'jsonpath={.PolicyCIDRMatchMode}{"\t"}{.EnableCiliumNetworkPolicy}{"\t"}' + r'{.EnableK8sNetworkPolicy}{"\t"}{.KubeProxyReplacement}{"\n"}') + config = {"PolicyCIDRMatchMode": [], "EnableCiliumNetworkPolicy": True, "EnableK8sNetworkPolicy": True} + with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent) as directory: + path = Path(directory) / "offline-context.json" + context = {"apiVersion": "v1", "kind": "Config", + "clusters": [{"name": "offline", "cluster": {"server": "https://127.0.0.1:9"}}], + "users": [{"name": "offline", "user": {}}], + "contexts": [{"name": "offline", "context": {"cluster": "offline", "user": "offline"}}], + "current-context": "offline", + "extensions": [{"name": "tagged-shapes", "extension": {"config": config, "status": STATUS_FALSE}}]} + def project(expression, member): + path.write_text(json.dumps(context)) + expression = expression.replace("{.", f"{{.extensions[0].extension.{member}.") + output = subprocess.run( + ["kubectl", "--kubeconfig", str(path), "config", "view", "--minify", + "--allow-missing-template-keys=true", "-o", expression], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5, check=False) + self.assertEqual(output.returncode, 0, "Offline tagged-shape projection failed") + return output.stdout.decode().strip("\r\n").split("\t") + legacy = project(old_projection, "config") + self.assertEqual(legacy, ["[]", "true", "true", ""]) + with self.assertRaises(Failure): + cilium.effective_configuration(legacy) + current = project(cilium.CONFIG_OUTPUT, "config") + self.assertEqual(current, ["[]", "true", "true"]) + self.assertNotIn("kubeProxyReplacement", cilium.effective_configuration(current)) + for mode, expected in (("False", False), ("True", True)): + value = copy.deepcopy(STATUS_FALSE) + value["kube-proxy-replacement"]["mode"] = mode + context["extensions"][0]["extension"]["status"] = value + fields = project(cilium.KUBE_PROXY_OUTPUT, "status") + self.assertEqual(fields, [mode]) + self.assertIs(cilium.kube_proxy_mode(fields), expected) + self.assertNotIn("canary", "\t".join(fields)) + for value in ({}, {"kube-proxy-replacement": None}, {"kube-proxy-replacement": {}}, + {"kube-proxy-replacement": {"mode": None}}, + {"kube-proxy-replacement": {"mode": False}}, + {"kube-proxy-replacement": {"mode": "false"}}): + context["extensions"][0]["extension"]["status"] = value + with self.assertRaises(Failure): + cilium.kube_proxy_mode(project(cilium.KUBE_PROXY_OUTPUT, "status")) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_credential_review.py b/bridge/tests/native-credentials/test_credential_review.py new file mode 100644 index 000000000..557063ca9 --- /dev/null +++ b/bridge/tests/native-credentials/test_credential_review.py @@ -0,0 +1,174 @@ +"""Operator protocol orchestration tests, not native delivery evidence.""" + +import copy +import unittest + +from credential_review import REVIEW, WRITE, reviewed_credential_write +from native_api import Failure, require + +PRIVATE = "PRIVATE_VALUE_NEVER_REPORTED" +REQUEST = {"namespace": "work", "kind": "KarsSandbox", "target": "target", + "targetUid": "target-uid", "key": "SLACK_BOT_TOKEN", "value": PRIVATE} +GRANT = {"metadata": {"uid": "grant-uid", "generation": 1}} + + +def review(): + return {"token": "initial-review", "expiresAt": 5000, "submission": 1, + "continuation": False, "bindingOnly": False, "metadata": { + "target": {"namespace": "work", "kind": "KarsSandbox", "name": "target", + "uid": "target-uid", "generation": 1, "version": "1", "intent": "target-intent"}, + "grant": {"uid": "grant-uid", "generation": 1, "version": "1", + "intent": "grant-intent", "workspaceUid": "workspace-uid", "legacyInventory": "legacy"}, + "source": {"name": "kars-credential-input-sandbox-target", "uid": None, + "version": None, "metadataDigest": None, "keys": []}, + "key": "SLACK_BOT_TOKEN"}} + + +def receipt(stored=True): + return {"token": "owned-continuation", "outcome": "source-stored" if stored else "no-write-attempted", + "source": {"name": "kars-credential-input-sandbox-target", "uid": "source-uid", + "version": "1", "metadataDigest": "source-intent"} if stored else None} + + +class BffFixture: + def __init__(self, stored=True): + self.calls = [] + self.stored = stored + self.failures = 1 + self.failure_status = 409 + self.proof = True + self.alter = lambda _: None + self.current = review() + self.writes = 0 + self.transport = False + self.reject_refresh = False + + def call(self, method, path, body, expected=200, include_status=False): + self.calls.append((method, path, copy.deepcopy(body))) + if path == REVIEW: + require("value" not in body, "Review must never receive the credential value") + if body.get("continuation"): + require(body["continuation"] == "owned-continuation", "Wrong owned proof") + if self.reject_refresh: + self.alter(self.current) + return 409, {"error": {"code": "conflict"}} + self.current["continuation"] = True + self.current["bindingOnly"] = self.stored + self.current["submission"] += 1 + self.current["metadata"]["target"]["version"] = str(self.current["submission"]) + self.current["metadata"]["grant"]["version"] = str(self.current["submission"]) + if self.stored: + self.current["metadata"]["source"] = { + **receipt()["source"], "keys": ["SLACK_BOT_TOKEN"]} + self.alter(self.current) + current = copy.deepcopy(self.current) + return (200, current) if include_status else current + require(path == WRITE and include_status, "Unexpected protocol path") + self.writes += 1 + if self.transport: + raise ConnectionError("fixed transport failure") + if self.failures: + self.failures -= 1 + require(self.failure_status in expected, "Unexpected BFF failure") + error = {"code": "conflict"} + if self.proof: + error["credentialContinuation"] = receipt(self.stored) + return self.failure_status, {"error": error} + return 200, {"stored": True, "source": {"name": self.current["metadata"]["source"]["name"], + "uid": "source-uid"}} + + +class CredentialReviewTests(unittest.TestCase): + def run_protocol(self, bff): + facts = [] + result = reviewed_credential_write(bff, REQUEST, GRANT, 1, facts.append) + self.assertNotIn(PRIVATE, str(facts)) + return result, facts + + def test_acknowledged_partial_write_and_no_write_need_separate_review_then_resubmit(self): + for stored in (False, True): + with self.subTest(stored=stored): + bff = BffFixture(stored) + result, facts = self.run_protocol(bff) + self.assertTrue(result["stored"]) + self.assertEqual([path for _, path, _ in bff.calls], [REVIEW, WRITE, REVIEW, WRITE]) + self.assertEqual([f["httpStatus"] for f in facts if "httpStatus" in f], [409, 200]) + self.assertEqual(bff.calls[1][2]["value"], bff.calls[3][2]["value"]) + + def test_first_success_does_not_add_a_second_write_or_review(self): + bff = BffFixture() + bff.failures = 0 + self.run_protocol(bff) + self.assertEqual([path for _, path, _ in bff.calls], [REVIEW, WRITE]) + + def test_generic_failure_collision_and_lost_ack_are_not_retry_authority(self): + for mode in ("502", "403", "collision", "transport"): + with self.subTest(mode=mode): + bff = BffFixture() + if mode in ("502", "403"): + bff.failure_status = int(mode) + if mode == "collision": + bff.proof = False + if mode == "transport": + bff.transport = True + with self.assertRaises((Failure, ConnectionError)): + self.run_protocol(bff) + self.assertEqual([path for _, path, _ in bff.calls], [REVIEW, WRITE]) + + def test_every_review_fence_is_checked_before_the_next_write(self): + for area, field, value in [ + ("target", "uid", "replacement"), ("target", "generation", 2), + ("target", "intent", "changed"), ("grant", "uid", "replacement"), + ("grant", "generation", 2), ("grant", "intent", "changed"), + ("grant", "workspaceUid", "replacement"), ("grant", "legacyInventory", "changed"), + ("source", "uid", "replacement"), ("source", "version", "2"), + ("source", "metadataDigest", "changed"), ("source", "keys", ["OTHER_KEY"]), + ]: + with self.subTest(area=area, field=field): + bff = BffFixture() + bff.alter = lambda current: current["metadata"][area].__setitem__(field, value) + with self.assertRaises(Failure): + self.run_protocol(bff) + self.assertEqual(bff.writes, 1) + + def test_unwritten_receipt_cannot_adopt_a_newly_appearing_source(self): + bff = BffFixture(False) + bff.alter = lambda current: current["metadata"]["source"].update( + uid="foreign", version="1", metadataDigest="foreign") + with self.assertRaises(Failure): + self.run_protocol(bff) + self.assertEqual(bff.writes, 1) + + def test_three_submissions_is_the_bound_even_with_new_owned_receipts(self): + bff = BffFixture() + bff.failures = 10 + with self.assertRaises(Failure): + self.run_protocol(bff) + self.assertEqual(bff.writes, 3) + self.assertEqual(len(bff.calls), 6) + + def test_expiry_cannot_be_extended_by_re_review(self): + bff = BffFixture() + bff.alter = lambda current: current.__setitem__("expiresAt", 6000) + with self.assertRaises(Failure): + self.run_protocol(bff) + self.assertEqual(bff.writes, 1) + + def test_rejected_refresh_records_only_change_flags_and_never_submits_fresh_ticket(self): + bff = BffFixture(False) + bff.reject_refresh = True + bff.alter = lambda current: current["metadata"]["target"].update(intent="new-private-intent") + facts = [] + with self.assertRaises(Failure): + reviewed_credential_write(bff, REQUEST, GRANT, 1, facts.append) + self.assertEqual(bff.writes, 1) + self.assertEqual([path for _, path, _ in bff.calls], [REVIEW, WRITE, REVIEW, REVIEW]) + self.assertFalse(facts[-1]["unchanged"]["target"]["intent"]) + self.assertTrue(facts[-1]["unchanged"]["target"]["uid"]) + self.assertFalse(facts[-1]["writeResubmitted"]) + for secret in (PRIVATE, "new-private-intent", "initial-review", "owned-continuation"): + self.assertNotIn(secret, str(facts)) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_credential_target_startup.py b/bridge/tests/native-credentials/test_credential_target_startup.py new file mode 100644 index 000000000..7f0f2ac9f --- /dev/null +++ b/bridge/tests/native-credentials/test_credential_target_startup.py @@ -0,0 +1,80 @@ +"""Initial ownership must settle before reviewing a credential write.""" + +import copy +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from lifecycle_cases import reviewable_target +from native_api import Failure + + +class TargetStartupTests(unittest.TestCase): + def setUp(self): + self.created = { + "kind": "KarsSandbox", + "metadata": {"namespace": "workspace", "name": "agent", "uid": "target-uid", + "generation": 1, "resourceVersion": "1"}, + "spec": {"suspended": True}, + } + self.target = copy.deepcopy(self.created) + self.namespace = { + "kind": "Namespace", + "metadata": {"name": "kars-agent", "uid": "namespace-uid", + "annotations": {"kars.azure.com/sandbox-uid": "target-uid"}}, + } + self.admin = SimpleNamespace( + get=lambda _path: copy.deepcopy(self.target), + optional=lambda _path: copy.deepcopy(self.namespace), + ) + self.setup = SimpleNamespace(admin=self.admin) + + def initialize(self): + self.target["metadata"].update({ + "resourceVersion": "3", + "finalizers": ["kars.azure.com/namespace-cleanup"], + "annotations": {"kars.azure.com/namespace-uid": "namespace-uid"}, + }) + + def test_waits_for_actual_backlink_and_finalizer_before_initial_review(self): + def wait(_label, check, seconds): + self.assertEqual(seconds, 60) + self.assertIsNone(check()) + self.target["metadata"]["annotations"] = { + "kars.azure.com/namespace-uid": "namespace-uid"} + self.assertIsNone(check()) + self.initialize() + return check() + with patch("lifecycle_cases.until", side_effect=wait): + current = reviewable_target(self.setup, self.created) + self.assertEqual(current, self.target) + self.assertEqual(current["spec"], self.created["spec"]) + self.assertNotIn("annotations", self.created["metadata"]) + + def test_absent_namespace_is_not_adopted_or_created_by_the_fixture(self): + self.admin.optional = lambda _path: None + with patch("lifecycle_cases.until", side_effect=lambda _label, check, _seconds: check()): + self.assertIsNone(reviewable_target(self.setup, self.created)) + + def test_identity_intent_generation_and_namespace_changes_fail_instead_of_rebasing(self): + for mutation in ("uid", "generation", "spec", "owner", "binding", "terminating"): + with self.subTest(mutation=mutation): + self.setUp() + self.initialize() + if mutation in ("uid", "generation"): + self.target["metadata"][mutation] = "changed" + elif mutation == "spec": + self.target["spec"]["suspended"] = False + elif mutation == "owner": + self.namespace["metadata"]["annotations"]["kars.azure.com/sandbox-uid"] = "foreign" + elif mutation == "binding": + self.target["metadata"]["annotations"]["kars.azure.com/namespace-uid"] = "foreign" + else: + self.namespace["metadata"]["deletionTimestamp"] = "2026-09-11T00:00:00Z" + with patch("lifecycle_cases.until", side_effect=lambda _label, check, _seconds: check()): + with self.assertRaises(Failure): + reviewable_target(self.setup, self.created) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_observation_diagnostics.py b/bridge/tests/native-credentials/test_observation_diagnostics.py new file mode 100644 index 000000000..68177029b --- /dev/null +++ b/bridge/tests/native-credentials/test_observation_diagnostics.py @@ -0,0 +1,417 @@ +import copy +from datetime import datetime, timedelta, timezone +import json +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from native_api import BRIDGE, CORE, WRITER, core, resource +import api_outcome_diagnostics as api_outcomes +from observation_diagnostics import TARGETS, VERSION, collect, project + + +def event(component="router", **updates): + value = { + "target": TARGETS[component], + "fields": {"message": "Private observation readiness pending", + "stage": "observer_target_read", "http_status": 403, + "timeout": False, "connect": False}, + "span": {"private": "private-span-canary"}, + } + value["fields"].update(updates) + return json.dumps(value) + + +TARGET = {"workspace": CORE, "sandbox": "agent"} +SANDBOX = resource(CORE, "karssandboxes", "agent") +RUNTIME = "kars-agent" +POD = core(RUNTIME, "pods", "agent-pod") +DEPLOYMENT = resource(RUNTIME, "deployments", "agent", "/apis/apps/v1") +REPLICA_SET = resource(RUNTIME, "replicasets", "agent-rs", "/apis/apps/v1") + + +class Fixture: + def __init__(self): + self.objects = {} + for namespace, name, account, labels in [ + (CORE, "kars-controller", "kars-controller", + {"app.kubernetes.io/name": "kars", "app.kubernetes.io/component": "controller"}), + (RUNTIME, "agent", "sandbox", {"kars.azure.com/sandbox": "agent"}), + (BRIDGE, "kars-bridge-bff", WRITER, + {"app.kubernetes.io/name": "kars-bridge", "app.kubernetes.io/component": "bff"}), + ]: + self.objects[f"/api/v1/namespaces/{namespace}"] = { + "metadata": {"name": namespace, "uid": namespace + "-uid", "resourceVersion": "1"}} + self.objects[core(namespace, "serviceaccounts", account)] = { + "metadata": {"name": account, "namespace": namespace, + "uid": account + "-sa-uid", "resourceVersion": "1"}} + metadata = {"name": name, "namespace": namespace, "uid": name + "-deployment", + "resourceVersion": "1"} + template = {"metadata": {"labels": labels, "annotations": {VERSION: "observer:1"}}, + "spec": {"serviceAccountName": account}} + self.objects[resource(namespace, "deployments", name, "/apis/apps/v1")] = { + "metadata": metadata, "spec": {"template": template}} + self.objects[resource(namespace, "replicasets", name + "-rs", "/apis/apps/v1")] = { + "metadata": {"name": name + "-rs", "namespace": namespace, + "uid": name + "-rs-uid", "resourceVersion": "1", + "ownerReferences": [{"apiVersion": "apps/v1", "kind": "Deployment", + "name": name, "uid": metadata["uid"], "controller": True}]}} + self.objects[core(namespace, "pods", name + "-pod")] = { + "kind": "Pod", "metadata": { + "name": name + "-pod", "namespace": namespace, "uid": name + "-pod-uid", + "resourceVersion": "1", "labels": labels, "annotations": {VERSION: "observer:1"}, + "ownerReferences": [{"apiVersion": "apps/v1", "kind": "ReplicaSet", + "name": name + "-rs", "uid": name + "-rs-uid", "controller": True}]}, + "spec": {"serviceAccountName": account}} + self.objects[SANDBOX] = { + "metadata": {"name": "agent", "namespace": CORE, "uid": "sandbox-uid", "resourceVersion": "1"}, + "status": {"serviceObservation": {"phase": "Prepared", "namespaceUid": RUNTIME + "-uid", + "deploymentUid": "agent-deployment", "version": "observer:1"}}} + + def get(self, path): + if path.endswith("/pods"): + namespace = path.split("/")[-2] + return {"items": copy.deepcopy([ + item for item in self.objects.values() + if item.get("kind") == "Pod" and item["metadata"]["namespace"] == namespace])} + return copy.deepcopy(self.objects[path]) + + +def logs(*args, **kwargs): + return event(component="controller" if args[3] == CORE else "router") + + +class ObservationDiagnosticsTests(unittest.TestCase): + def test_only_fixed_fields_survive(self): + result = project(event(error="private-body-canary", token="private-token-canary"), "router") + self.assertEqual(result, [{"stage": "observer_target_read", "http_status": 403, + "timeout": False, "connect": False}]) + self.assertNotIn("canary", json.dumps(result)) + self.assertEqual(project(event(), "controller"), []) + + def test_unknown_stages_types_and_non_events_are_dropped(self): + raw = "\n".join([ + "raw-private-canary", "null", "[]", "42", + event(stage="private-stage-canary"), event(stage=[]), event(http_status=True), + event(http_status=700), event(timeout="true"), event(connect=1), + event(message="private-message-canary"), + ]) + self.assertEqual(project(raw, "router"), []) + + def test_bounds_and_consecutive_duplicates(self): + self.assertEqual(len(project("\n".join([event()] * 600), "router")), 1) + result = project("\n".join(event(http_status=400 + index % 100) for index in range(600)), "router") + self.assertEqual(len(result), 24) + self.assertEqual(result[-1]["http_status"], 499) + + def test_zero_status_is_not_misreported_as_http_success(self): + result = project(event(http_status=0, timeout=True, connect=True), "router") + self.assertEqual(result[0]["http_status"], 0) + self.assertTrue(result[0]["timeout"]) + self.assertTrue(result[0]["connect"]) + + def test_collection_errors_never_emit_exception_or_raw_output(self): + with patch("observation_diagnostics.command", side_effect=RuntimeError("private-error-canary")): + result = collect(SimpleNamespace(admin=Fixture()), TARGET) + self.assertFalse(result["available"]) + self.assertTrue(all(not item["available"] and not item["records"] for item in result["samples"])) + self.assertNotIn("canary", json.dumps(result)) + + def test_only_current_uid_linked_sources_supply_records(self): + with patch("observation_diagnostics.command", side_effect=logs) as command: + result = collect(SimpleNamespace(admin=Fixture()), TARGET) + self.assertTrue(result["available"]) + self.assertEqual(command.call_count, 2) + for call in command.call_args_list: + self.assertIn("--limit-bytes=131072", call.args) + self.assertIn("--tail=512", call.args) + self.assertNotIn("uid", json.dumps(result)) + + def test_foreign_labeled_same_account_pods_and_lineage_mismatches_are_not_logged(self): + for path, field, replacement in [ + (POD, "kind", "Deployment"), + (POD, "uid", "foreign-rs-uid"), + (POD, "apiVersion", "foreign/v1"), + (POD, "controller", False), + (REPLICA_SET, "uid", "foreign-deployment-uid"), + (REPLICA_SET, "name", "foreign-deployment"), + (REPLICA_SET, "kind", "Pod"), + ]: + with self.subTest(path=path, field=field): + fixture = Fixture() + fixture.objects[path]["metadata"]["ownerReferences"][0][field] = replacement + with patch("observation_diagnostics.command", side_effect=logs) as command: + result = collect(SimpleNamespace(admin=fixture), TARGET) + self.assertFalse(result["available"]) + self.assertEqual(result["samples"][1], {"component": "router", "available": False, "records": []}) + self.assertEqual([call.args[3] for call in command.call_args_list], [CORE]) + + def test_foreign_controller_pod_with_correct_labels_and_account_is_not_logged(self): + fixture = Fixture() + controller_pod = core(CORE, "pods", "kars-controller-pod") + fixture.objects[controller_pod]["metadata"]["ownerReferences"] = [] + with patch("observation_diagnostics.command", side_effect=logs) as command: + result = collect(SimpleNamespace(admin=fixture), TARGET) + self.assertFalse(result["available"]) + self.assertEqual(result["samples"][0]["records"], []) + self.assertEqual([call.args[3] for call in command.call_args_list], [RUNTIME]) + + def test_recreated_or_incomplete_roots_and_stale_versions_are_unavailable(self): + for path, mutate in [ + (DEPLOYMENT, lambda value: value["metadata"].update(uid="replaced")), + (f"/api/v1/namespaces/{RUNTIME}", lambda value: value["metadata"].update(uid="replaced")), + (SANDBOX, lambda value: value["status"]["serviceObservation"].pop("deploymentUid")), + (SANDBOX, lambda value: value["metadata"].pop("resourceVersion")), + (POD, lambda value: value["metadata"]["annotations"].update({VERSION: "old:1"})), + (POD, lambda value: value["metadata"].update(deletionTimestamp="terminating")), + ]: + with self.subTest(path=path): + fixture = Fixture() + mutate(fixture.objects[path]) + with patch("observation_diagnostics.command", side_effect=logs) as command: + result = collect(SimpleNamespace(admin=fixture), TARGET) + self.assertFalse(result["available"]) + self.assertEqual(result["samples"][1]["records"], []) + self.assertEqual([call.args[3] for call in command.call_args_list], [CORE]) + + def test_uid_or_revision_change_during_logs_discards_projected_records(self): + for path in [SANDBOX, f"/api/v1/namespaces/{RUNTIME}", DEPLOYMENT, REPLICA_SET, POD]: + for field in ["uid", "resourceVersion"]: + with self.subTest(path=path, field=field): + fixture = Fixture() + + def racing_logs(*args, **kwargs): + if args[3] == RUNTIME: + fixture.objects[path]["metadata"][field] = "changed" + return logs(*args, **kwargs) + + with patch("observation_diagnostics.command", side_effect=racing_logs): + result = collect(SimpleNamespace(admin=fixture), TARGET) + self.assertFalse(result["available"]) + self.assertEqual(result["samples"][1]["records"], []) + + + def test_missing_target_and_empty_or_unrecognized_records_are_unavailable(self): + for raw in ["", "raw-private-canary", event(stage="unknown-private-canary")]: + with self.subTest(raw=bool(raw)): + with patch("observation_diagnostics.command", return_value=raw): + result = collect(SimpleNamespace(admin=Fixture()), TARGET) + self.assertFalse(result["available"]) + self.assertTrue(all(not item["available"] and not item["records"] for item in result["samples"])) + self.assertNotIn("canary", json.dumps(result)) + with patch("observation_diagnostics.command", side_effect=logs): + result = collect(SimpleNamespace(admin=Fixture()), None) + self.assertFalse(result["available"]) + self.assertEqual(result["samples"][1]["records"], []) + + +class ApiOutcomeDiagnosticsTests(unittest.TestCase): + def setUp(self): + self.fixture = Fixture() + self.setup = SimpleNamespace(admin=self.fixture) + self.since = datetime.now(timezone.utc) - timedelta(seconds=30) + self.observer = dict(TARGET, uid="sandbox-uid") + self.delivery = {"workspace": "native-delivery", "sandbox": "native-delivery-a", "uid": "delivery-uid"} + self.fixture.objects["/api/v1/namespaces/native-delivery"] = { + "metadata": {"name": "native-delivery", "uid": "delivery-namespace", "resourceVersion": "1"}} + self.delivery_path = resource("native-delivery", "karssandboxes", "native-delivery-a") + self.fixture.objects[self.delivery_path] = { + "metadata": {"name": "native-delivery-a", "namespace": "native-delivery", + "uid": "delivery-uid", "resourceVersion": "1"}} + + def audit(self, actor="observer_router", code=200): + router = actor == "observer_router" + ns, account, name = (RUNTIME, "sandbox", "agent") if router else (BRIDGE, WRITER, "kars-bridge-bff") + target = self.observer if router else self.delivery + return { + "level": "Metadata", "stage": "ResponseComplete", "verb": "get", + "requestReceivedTimestamp": self.since.isoformat(), + "stageTimestamp": (self.since + timedelta(seconds=1)).isoformat(), + "user": {"username": f"system:serviceaccount:{ns}:{account}", "uid": account + "-sa-uid", + "extra": {api_outcomes.POD_UID: [name + "-pod-uid"], + api_outcomes.POD_NAME: [name + "-pod"]}}, + "objectRef": {"apiGroup": "kars.azure.com", "apiVersion": "v1alpha1", + "resource": "karssandboxes", "namespace": target["workspace"], "name": target["sandbox"]}, + "responseStatus": {"code": code}, + } + + def collect(self, events, actor="observer_router", effect=None): + raw = b"\n".join(json.dumps(event).encode() for event in events) + + def read(): + if effect: + effect() + return raw + + with patch("api_outcome_diagnostics.read_audit_tail", side_effect=read): + return api_outcomes.collect(self.setup, actor, + self.observer if actor == "observer_router" else self.delivery, self.since) + + def test_real_response_statuses_are_distinct_from_absence(self): + for code, category in [ + (200, "successful-response"), (201, "successful-response"), + (401, "unauthenticated-response"), (403, "denied-response"), + (409, "conflict-response"), (422, "invalid-response"), + (429, "rate-limited-response"), (500, "server-error-response"), (504, "timeout-response"), + ]: + with self.subTest(code=code): + result = self.collect([self.audit(code=code)]) + self.assertTrue(result["available"]) + self.assertEqual(result["category"], "outcomes-retained") + self.assertEqual(result["outcomes"][0]["http_status"], code) + self.assertEqual(result["outcomes"][0]["category"], category) + for code in [0, None, True, "403", 700]: + with self.subTest(code=code): + result = self.collect([self.audit(code=code)]) + self.assertFalse(result["available"]) + self.assertEqual(result["category"], "no-matching-evidence") + self.assertEqual(result["outcomes"], []) + + def test_only_allowlisted_primitive_outcomes_survive(self): + event = self.audit(code=403) + event.update(requestURI="/private-url-canary?token=private-token-canary", + requestObject={"secret": "private-request-canary"}, + responseObject={"secret": "private-response-canary"}, + headers={"authorization": "private-header-canary"}) + event["responseStatus"]["message"] = "private-error-canary" + event["user"]["extra"]["credential"] = ["private-credential-canary"] + result = self.collect([event, event]) + self.assertEqual(result["outcomes"], [{ + "verb": "get", "apiGroup": "kars.azure.com", "apiVersion": "v1alpha1", + "resource": "karssandboxes", "http_status": 403, "category": "denied-response", "count": 2, + }]) + self.assertNotIn("canary", json.dumps(result)) + self.assertNotIn("uid", json.dumps(result).lower()) + + def test_other_actor_or_pod_cannot_supply_an_outcome(self): + mutations = [ + lambda e: e["user"].update(uid="foreign-account"), + lambda e: e["user"].update(username="system:serviceaccount:foreign:sandbox"), + lambda e: e["user"]["extra"].update({api_outcomes.POD_UID: ["foreign-pod"]}), + lambda e: e["user"]["extra"].update({api_outcomes.POD_UID: ["agent-pod-uid", "foreign-pod"]}), + lambda e: e["user"]["extra"].pop(api_outcomes.POD_UID), + lambda e: e["user"]["extra"].update({api_outcomes.POD_NAME: ["foreign-pod"]}), + lambda e: e.update(impersonatedUser={"username": "other"}), + ] + for mutate in mutations: + event = self.audit(code=403) + mutate(event) + result = self.collect([event]) + self.assertFalse(result["available"]) + self.assertEqual(result["category"], "no-matching-evidence") + + def test_bff_write_scope_is_limited_to_captured_target_workspace_and_source(self): + for verb, group, version, resource_name, namespace, name in [ + ("get", "kars.azure.com", "v1alpha1", "karscredentialgrants", "native-delivery", "workspace"), + ("patch", "kars.azure.com", "v1alpha1", "karssandboxes", "native-delivery", "native-delivery-a"), + ("create", "", "v1", "secrets", "native-delivery", "kars-credential-input-sandbox-native-delivery-a"), + ("create", "", "v1", "secrets", "native-delivery", None), + ("get", "", "v1", "secrets", "native-delivery", "kars-credential-input-sandbox-native-delivery-a"), + ("get", "", "v1", "namespaces", None, "native-delivery"), + ]: + event = self.audit("bff_writer", 403) + event["verb"] = verb + event["objectRef"] = {"apiGroup": group, "apiVersion": version, "resource": resource_name, + "namespace": namespace, "name": name} + self.assertTrue(self.collect([event], "bff_writer")["available"]) + for replacement in [ + {"namespace": "foreign"}, {"name": "foreign"}, + {"resource": "pods"}, {"subresource": "exec"}, {"apiVersion": "v2"}, + {"uid": "foreign-target"}, + ]: + event = self.audit("bff_writer", 403) + event["objectRef"].update(replacement) + self.assertFalse(self.collect([event], "bff_writer")["available"]) + event = self.audit("bff_writer") + event["verb"] = "list" + event["objectRef"].update(apiGroup="", apiVersion="v1", resource="secrets", name=None) + self.assertFalse(self.collect([event], "bff_writer")["available"]) + + def test_only_completed_responses_in_the_current_case_window_match(self): + for mutate in [ + lambda e: e.update(stage="RequestReceived"), + lambda e: e.update(stage="ResponseStarted"), + lambda e: e.update(level="RequestResponse"), + lambda e: e.pop("requestReceivedTimestamp"), + lambda e: e.update(requestReceivedTimestamp=(self.since - timedelta(seconds=1)).isoformat()), + lambda e: e.update(stageTimestamp=(datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat()), + lambda e: e.update(stageTimestamp="unparseable"), + ]: + event = self.audit() + mutate(event) + result = self.collect([event]) + self.assertFalse(result["available"]) + self.assertEqual(result["outcomes"], []) + + def test_source_replacement_during_audit_capture_discards_outcomes(self): + for path in [SANDBOX, f"/api/v1/namespaces/{RUNTIME}", DEPLOYMENT, REPLICA_SET, POD, + core(RUNTIME, "serviceaccounts", "sandbox")]: + for field in ("uid", "resourceVersion"): + with self.subTest(path=path, field=field): + original = self.fixture.objects[path]["metadata"][field] + result = self.collect([self.audit()], effect=lambda: self.fixture.objects[path]["metadata"].update( + {field: "changed"})) + self.assertFalse(result["available"]) + self.assertEqual(result["category"], "source-unavailable") + self.assertEqual(result["outcomes"], []) + self.fixture.objects[path]["metadata"][field] = original + + def test_bff_foreign_workload_or_changed_target_never_reads_audit(self): + pod_path = core(BRIDGE, "pods", "kars-bridge-bff-pod") + self.fixture.objects[pod_path]["metadata"]["ownerReferences"] = [] + with patch("api_outcome_diagnostics.read_audit_tail") as read: + result = api_outcomes.collect(self.setup, "bff_writer", self.delivery, self.since) + self.assertEqual(result["category"], "source-unavailable") + read.assert_not_called() + self.fixture = Fixture() + self.setup.admin = self.fixture + self.fixture.objects[self.delivery_path] = { + "metadata": {"name": "native-delivery-a", "namespace": "native-delivery", + "uid": "replacement", "resourceVersion": "1"}} + with patch("api_outcome_diagnostics.read_audit_tail") as read: + result = api_outcomes.collect(self.setup, "bff_writer", self.delivery, self.since) + self.assertFalse(result["available"]) + read.assert_not_called() + + def test_audit_unavailable_and_no_matching_evidence_are_not_success_or_denial(self): + with patch("api_outcome_diagnostics.read_audit_tail", side_effect=RuntimeError("private-error-canary")): + result = api_outcomes.collect(self.setup, "observer_router", self.observer, self.since) + self.assertEqual(result["category"], "audit-unavailable") + self.assertFalse(result["available"]) + self.assertNotIn("canary", json.dumps(result)) + for raw in [b"", b"invalid-private-canary\nnull\n[]", json.dumps(self.audit("bff_writer")).encode()]: + with patch("api_outcome_diagnostics.read_audit_tail", return_value=raw): + result = api_outcomes.collect(self.setup, "observer_router", self.observer, self.since) + self.assertEqual(result["category"], "no-matching-evidence") + self.assertFalse(result["available"]) + self.assertEqual(result["outcomes"], []) + + def test_reader_uses_only_the_existing_bounded_metadata_audit_tail(self): + completed = SimpleNamespace(returncode=0, stdout=b"{}") + with patch("api_outcome_diagnostics.subprocess.run", return_value=completed) as run: + self.assertEqual(api_outcomes.read_audit_tail(), b"{}") + self.assertEqual(run.call_args.args[0][-4:], [ + "tail", "-c", str(api_outcomes.MAX_BYTES), "/var/log/kars-native-audit/audit.log"]) + self.assertEqual(run.call_args.kwargs["timeout"], 15) + self.assertEqual(run.call_args.kwargs["stderr"], api_outcomes.subprocess.DEVNULL) + completed.stdout = b"x" * (api_outcomes.MAX_BYTES + 1) + with patch("api_outcome_diagnostics.subprocess.run", return_value=completed): + with self.assertRaises(Exception): + api_outcomes.read_audit_tail() + + +class UnexpectedDiagnosticErrorsTests(unittest.TestCase): + def test_stage_collector_does_not_hide_programming_errors(self): + with patch("observation_diagnostics.resolve_actor", side_effect=NameError("fixture bug")): + with self.assertRaises(NameError): + collect(SimpleNamespace(), TARGET) + + def test_api_collector_does_not_hide_programming_errors(self): + with patch("api_outcome_diagnostics.resolve_actor", side_effect=NameError("fixture bug")): + with self.assertRaises(NameError): + api_outcomes.collect(SimpleNamespace(), "observer_router", TARGET, + datetime.now(timezone.utc)) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py b/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py new file mode 100644 index 000000000..bcc8561f0 --- /dev/null +++ b/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py @@ -0,0 +1,337 @@ +import copy +import json +import subprocess +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from native_api import Failure, core, resource +import observer_cilium_diagnostics as cilium +import observer_network_diagnostics as network +import test_observer_network_diagnostics as network_tests +from test_observer_network_diagnostics import ( + NetworkFixture, TARGET, RUNTIME, POD, POLICY, CEP, CILIUM_POD, redacted_context, +) + +BASELINE_CNP = resource(RUNTIME, "ciliumnetworkpolicies", "existing-private-policy", cilium.CILIUM) + + +class CiliumObserverTests(unittest.TestCase): + def setUp(self): + self.api = NetworkFixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + + def collect(self, **kwargs): + return network_tests.ObserverNetworkTests.collect(self, **kwargs) + + def snapshot(self): + with patch.object(network, "command", return_value=json.dumps(redacted_context())), \ + patch.object(cilium, "read_projection", side_effect=self.api.read_projection), \ + patch.object(cilium, "read_kube_proxy_projection", side_effect=self.api.read_kube_proxy_projection): + return cilium.snapshot(self.setup, TARGET) + + def baseline_policy(self): + self.api.objects[BASELINE_CNP] = { + "kind": "CiliumNetworkPolicy", + "metadata": {"name": "existing-private-policy", "namespace": RUNTIME, + "uid": "baseline-cnp-uid", "resourceVersion": "1", + "annotations": {"private": "private-annotation-canary"}}, + "spec": {"endpointSelector": {"matchLabels": {"kars.azure.com/sandbox": "agent"}}, + "description": "private-description-canary", "ingress": []}, + "status": {"nodes": {"private-node-canary": {"error": "private-error-canary"}}}, + } + + def test_actual_configuration_identity_revision_and_policy_digests_are_allowlisted(self): + self.baseline_policy() + facts = self.snapshot()["facts"] + self.assertTrue(facts["cilium"]["configurationMatchesExpected"]) + self.assertEqual(facts["cilium"]["effectiveAgentConfig"], { + "policyCIDRMatchMode": [], "ciliumNetworkPolicyEnabled": True, + "kubernetesNetworkPolicyEnabled": True, "kubeProxyReplacement": False}) + endpoint = facts["cilium"]["endpoints"][0] + self.assertEqual(endpoint["podUid"], "agent-pod-uid") + self.assertEqual(endpoint["endpointId"], 123) + self.assertEqual(endpoint["securityIdentity"], 12345) + self.assertEqual(endpoint["desiredPolicyRevision"], 7) + self.assertEqual(endpoint["realizedPolicyRevision"], 7) + self.assertEqual(endpoint["policyEnabled"], "both") + baseline = facts["cilium"]["baselineCiliumNetworkPolicies"][0] + self.assertEqual(baseline["identity"]["uid"], "baseline-cnp-uid") + self.assertRegex(baseline["specDigest"], r"^[0-9a-f]{64}$") + self.assertRegex(facts["networkPolicies"][0]["specDigest"], r"^[0-9a-f]{64}$") + self.assertNotIn("canary", json.dumps(facts)) + self.assertTrue(facts["cilium"]["policyRevisionIsNotRuleSpecificProof"]) + + def test_installed_config_contradictions_stop_without_any_policy_write(self): + for key, value in (("policy-cidr-match-mode", "nodes"), ("enable-policy", "never"), + ("kube-proxy-replacement", "true"), ("policy-cidr-match-mode", "private-canary")): + with self.subTest(key=key, value=value): + self.setUp() + self.api.objects[cilium.CONFIG]["data"][key] = value + result = self.collect() + self.assertTrue(result["available"]) + self.assertEqual(result["category"], "unexpected-cilium-configuration-no-intervention") + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + self.assertNotIn("canary", json.dumps(result)) + + def test_effective_agent_contradictions_also_stop_when_configmap_looks_expected(self): + for fields in (['["nodes"]', "true", "true"], ["[]", "false", "true"], + ["[]", "true", "false"]): + with self.subTest(fields=fields): + self.api.effective_config = fields + result = self.collect() + self.assertEqual(result["category"], "unexpected-cilium-configuration-no-intervention") + self.assertEqual(self.api.created, []) + self.assertEqual(cilium.effective_configuration(["null", "true", "true"])["policyCIDRMatchMode"], []) + for invalid in (["", "true", "true"], ["[]", "private-canary", "true"], + ['["unknown-private-canary"]', "true", "true"]): + with self.assertRaises((Failure, ValueError)): + cilium.effective_configuration(invalid) + + def test_non_enforcing_endpoint_and_unreviewed_ports_do_not_gain_a_policy(self): + original = self.api.read_projection + def read(agent, endpoint_id=None, witness=None): + fields = original(agent, endpoint_id) + if endpoint_id is not None: + fields[4] = "none" + return fields + self.api.read_projection = read + result = self.collect() + self.assertEqual(result["category"], "unexpected-cilium-configuration-no-intervention") + self.assertEqual(self.api.created, []) + before = network.snapshot(self.setup, TARGET) + before["facts"]["destinations"].append({"address": "172.18.0.2", "port": 22}) + with self.assertRaises(Failure): + network.policy_plan(before, "invalid-ports") + + def test_foreign_cilium_agent_lineage_account_image_or_selector_is_rejected(self): + mutations = [ + lambda: self.api.objects[CILIUM_POD]["metadata"]["ownerReferences"][0].update(uid="foreign"), + lambda: self.api.objects[CILIUM_POD]["metadata"].update(ownerReferences=[]), + lambda: self.api.objects[CILIUM_POD]["spec"].update(serviceAccountName="foreign"), + lambda: self.api.objects[CILIUM_POD]["spec"]["containers"][0].update(image="quay.io/cilium/cilium:v1.19.0"), + lambda: self.api.objects[cilium.DAEMONSET]["spec"].update(selector={"matchLabels": {"foreign": "true"}}), + lambda: self.api.objects[cilium.CONFIG]["metadata"].update(namespace="foreign"), + ] + for index, mutate in enumerate(mutations): + with self.subTest(index=index): + self.setUp() + mutate() + self.assertFalse(self.collect()["policyCreated"]) + self.assertEqual(self.api.created, []) + self.setUp() + pod = copy.deepcopy(self.api.objects[CILIUM_POD]) + pod["metadata"].update(name="foreign-cilium", uid="foreign-agent", ownerReferences=[]) + self.api.objects[core("kube-system", "pods", "foreign-cilium")] = pod + self.assertFalse(self.collect()["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_cilium_endpoint_requires_exact_pod_uid_address_and_node_binding(self): + mutations = [ + lambda value: value["metadata"]["ownerReferences"][0].update(uid="foreign-pod"), + lambda value: value["metadata"].update(ownerReferences=[]), + lambda value: value["metadata"].update(name="other"), + lambda value: value["status"]["networking"].update(node="172.18.0.3"), + lambda value: value["status"]["networking"].update(addressing=[{"ipv4": "10.244.1.99"}]), + lambda value: value["status"]["identity"].update(id=0), + lambda value: value["status"].update(id=True), + ] + for index, mutate in enumerate(mutations): + with self.subTest(index=index): + self.setUp() + mutate(self.api.objects[CEP]) + self.assertFalse(self.collect()["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_cli_projection_endpoint_or_workload_mismatch_is_not_accepted(self): + binding = {"endpointId": 123, "securityIdentity": 12345} + valid = ["123", "12345", "7", "7", "both", RUNTIME, "agent-pod"] + for index, replacement in ((0, "124"), (1, "99999"), (2, "-1"), + (3, str(2**63)), (4, "unknown"), (5, "foreign"), (6, "foreign")): + fields = valid.copy() + fields[index] = replacement + with self.assertRaises(Failure): + cilium.endpoint_revision(fields, binding, self.api.objects[POD]) + self.assertEqual(cilium.endpoint_revision(valid, binding, self.api.objects[POD])["realizedPolicyRevision"], 7) + + def test_realized_revision_ahead_of_desired_is_a_bounded_observation(self): + witness = {} + fields = ["123", "12345", "7", "8", "both", RUNTIME, "agent-pod"] + result = cilium.endpoint_revision(fields, {"endpointId": 123, "securityIdentity": 12345}, + self.api.objects[POD], witness) + self.assertEqual(result["desiredPolicyRevision"], 7) + self.assertEqual(result["realizedPolicyRevision"], 8) + self.assertTrue(witness["checks"]["endpoint_revision_bounds"]["realizedAheadOfDesired"]) + + def test_noop_revision_advance_alone_never_counts_as_api_or_readiness_progress(self): + self.api.after_create = lambda: setattr(self.api, "policy_revisions", [7, 8]) + with patch.object(network.time, "monotonic", side_effect=[0, 1, 61]): + result = self.collect(outcomes={"available": False, "category": "no-matching-evidence"}) + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["category"], "no-progress-observed") + self.assertFalse(result["apiResponseObserved"]) + self.assertFalse(result["samePodObserverReady"]) + self.assertFalse(result["cniAcceptanceQualified"]) + self.assertEqual(result["originalResult"], "failed") + self.assertEqual(result["duringCiliumEndpoints"][0]["desiredPolicyRevision"], 7) + self.assertEqual(result["duringCiliumEndpoints"][0]["realizedPolicyRevision"], 8) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + + def test_fixed_cli_only_prints_requested_fields_and_never_mutates_configuration(self): + agent = self.api.objects[CILIUM_POD] + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace( + returncode=0, stdout=b"[]\ttrue\ttrue\n\n")) as run: + self.assertEqual(cilium.read_projection(agent), ["[]", "true", "true"]) + args = run.call_args.args[0] + self.assertEqual(args[:9], ["kubectl", "--context", "kind-bridge-native", "--request-timeout=10s", + "exec", "-n", "kube-system", "cilium-worker", "-c"]) + self.assertEqual(args[9:], ["cilium-agent", "--", "cilium-dbg", "config", "--read-only", "--output", + cilium.CONFIG_OUTPUT]) + self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL) + self.assertEqual(run.call_args.kwargs["timeout"], 12) + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace( + returncode=0, stdout=b"123\t12345\t7\t7\tboth\tkars-agent\tagent-pod\n")) as run: + cilium.read_projection(agent, 123) + self.assertEqual(run.call_args.args[0][12:], ["endpoint", "get", "123", "--output", cilium.ENDPOINT_OUTPUT]) + for projection in (cilium.CONFIG_OUTPUT, cilium.KUBE_PROXY_OUTPUT, cilium.ENDPOINT_OUTPUT): + self.assertTrue(projection.startswith("jsonpath=")) + self.assertNotIn(".log", projection) + self.assertNotIn(".labels", projection) + self.assertNotIn("token", projection) + + def test_projection_bounds_and_unknown_outputs_fail_closed(self): + agent = self.api.objects[CILIUM_POD] + for status, raw in ((1, b"private-canary"), (0, b"x" * 4097), (0, b"one\ntwo"), (0, b"")): + with patch.object(cilium.subprocess, "run", return_value=SimpleNamespace(returncode=status, stdout=raw)): + with self.assertRaises(Failure): + cilium.read_projection(agent) + for endpoint in ("123; private-command", 0, True, 65536): + with patch.object(cilium.subprocess, "run") as run: + with self.assertRaises(Failure): + cilium.read_projection(agent, endpoint) + run.assert_not_called() + + def test_policy_revision_and_cep_status_updates_are_observations_not_identity_changes(self): + def update(): + self.api.policy_revisions = [8, 8] + self.api.objects[CEP]["metadata"]["resourceVersion"] = "2" + self.api.objects[CEP]["status"]["log"] = ["private-after-log-canary"] + self.api.after_create = update + result = self.collect() + self.assertEqual(result["category"], "same-pod-progress-with-temporary-policy") + self.assertTrue(result["apiResponseObserved"]) + self.assertFalse(result["samePodObserverReady"]) + self.assertEqual(result["duringCiliumEndpoints"][0]["realizedPolicyRevision"], 8) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertNotIn("canary", json.dumps(result)) + + def test_ready_transition_during_cilium_reads_is_not_lost_or_attributed_to_a_policy(self): + original = self.api.read_projection + def read(agent, endpoint_id=None, witness=None): + self.api.source["metadata"]["resourceVersion"] = "2" + self.api.source["status"]["serviceObservation"].update(phase="Ready", reason="Verified") + return original(agent, endpoint_id) + self.api.read_projection = read + result = self.collect() + self.assertTrue(result["samePodObserverReady"]) + self.assertEqual(result["category"], "already-ready-without-intervention") + self.assertEqual(self.api.created, []) + + def test_cilium_identity_or_config_changes_during_probe_abort_and_clean_only_owned_cnp(self): + mutations = [ + lambda: self.api.objects[CEP]["metadata"].update(uid="replacement-cep"), + lambda: self.api.objects[CEP]["status"]["identity"].update(id=999), + lambda: self.api.objects[cilium.CONFIG]["metadata"].update(resourceVersion="2"), + lambda: self.api.objects[CILIUM_POD]["metadata"].update(uid="replacement-agent"), + lambda: self.api.objects[CILIUM_POD]["status"]["containerStatuses"][0].update(restartCount=1), + lambda: self.api.objects[cilium.DAEMONSET]["metadata"].update(uid="replacement-daemonset"), + ] + for index, mutate in enumerate(mutations): + with self.subTest(index=index): + self.setUp() + self.api.after_create = mutate + result = self.collect() + self.assertEqual(result["category"], "provenance-or-operation-unavailable") + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(len(self.api.deleted), 1) + self.assertIn("/ciliumnetworkpolicies/", self.api.deleted[0][1]) + + def test_post_create_stability_failure_retains_only_fixed_comparison_facts(self): + self.api.after_create = lambda: self.api.objects[cilium.CONFIG]["metadata"].update(resourceVersion="2") + result = self.collect() + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["originalResult"], "failed") + self.assertEqual(result["category"], "provenance-or-operation-unavailable") + self.assertEqual(result["observationStoppingStage"], "before-api-stability") + self.assertTrue(result["observationSnapshot"]["complete"]) + self.assertFalse(result["stableChecks"]["cilium"]) + self.assertFalse(result["ciliumStableChecks"]["anchors"]) + self.assertNotIn("duringApiOutcomes", result) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertFalse(result["cniAcceptanceQualified"]) + + def test_post_create_projection_failure_keeps_current_witness_not_stale_stability(self): + original = self.api.read_projection + + def read(agent, endpoint_id=None, witness=None): + if self.api.created and endpoint_id is not None: + cilium.checkpoint(witness, "endpoint_projection_fields", numericFieldsValid=False) + raise Failure("private-post-create-error-canary") + return original(agent, endpoint_id, witness) + + self.api.read_projection = read + result = self.collect() + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["observationStoppingStage"], "before-api-snapshot") + self.assertFalse(result["observationSnapshot"]["complete"]) + self.assertEqual(result["observationSnapshot"]["lastStage"], "endpoint_projection_fields") + self.assertEqual(result["observationSnapshot"]["failureKind"], "constraint") + self.assertNotIn("stableChecks", result) + self.assertNotIn("ciliumStableChecks", result) + self.assertNotIn("duringApiOutcomes", result) + self.assertNotIn("canary", json.dumps(result)) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(result["originalResult"], "failed") + self.assertFalse(result["cniAcceptanceQualified"]) + + def test_existing_cnp_spec_or_identity_changes_are_fatal_but_status_updates_are_not(self): + for change in ("spec", "uid", "status"): + self.setUp() + self.baseline_policy() + def update(): + value = self.api.objects[BASELINE_CNP] + value["metadata"]["resourceVersion"] = "2" + if change == "spec": + value["spec"]["ingress"] = [{}] + elif change == "uid": + value["metadata"]["uid"] = "foreign" + else: + value["status"] = {"private-node": {"private": "private-status-canary"}} + self.api.after_create = update + result = self.collect() + expected = "same-pod-progress-with-temporary-policy" if change == "status" else "provenance-or-operation-unavailable" + self.assertEqual(result["category"], expected) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertIn(BASELINE_CNP, self.api.objects) + self.assertNotIn(BASELINE_CNP, [entry[1] for entry in self.api.deleted]) + + def test_knp_with_same_name_is_never_omitted_or_deleted_and_no_ip_policy_is_created(self): + name = "native-observer-api-0123456789abcdef" + value = copy.deepcopy(self.api.objects[POLICY]) + value["metadata"].update(name=name, uid="existing-same-name-knp") + path = resource(RUNTIME, "networkpolicies", name, network.NETWORK) + self.api.objects[path] = value + with patch.object(network.secrets, "token_hex", return_value="0123456789abcdef"): + result = self.collect() + self.assertTrue(result["policyCreated"]) + self.assertEqual(len(result["before"]["networkPolicies"]), 2) + self.assertEqual(self.api.objects[path], value) + self.assertEqual(self.api.created[0][1]["kind"], "CiliumNetworkPolicy") + self.assertNotIn("ipBlock", json.dumps(self.api.created[0][1])) + self.assertNotIn("policyCIDRMatchMode", json.dumps(self.api.created[0][1])) + self.assertNotIn("toServices", json.dumps(self.api.created[0][1])) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_observer_network_diagnostics.py b/bridge/tests/native-credentials/test_observer_network_diagnostics.py new file mode 100644 index 000000000..9d999531c --- /dev/null +++ b/bridge/tests/native-credentials/test_observer_network_diagnostics.py @@ -0,0 +1,631 @@ +import contextlib +import copy +import io +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +from native_api import CORE, Failure, core, resource +from observation_diagnostics import VERSION +import observer_network_diagnostics as network +import observer_cilium_diagnostics as cilium +from test_observation_diagnostics import Fixture, SANDBOX, RUNTIME, POD, DEPLOYMENT, REPLICA_SET + +TARGET = {"workspace": CORE, "sandbox": "agent", "uid": "sandbox-uid", "task": "native-observation-task"} +FAILED = {"result": "failed", "failure": network.FAILURE} +POLICY = resource(RUNTIME, "networkpolicies", "sandbox-policy", network.NETWORK) +CEP = resource(RUNTIME, "ciliumendpoints", "agent-pod", cilium.CILIUM) +CILIUM_POD = core("kube-system", "pods", "cilium-worker") +ENV = {"GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": "Azure/kars", + "CORE_REVISION": network.CORE_REVISION} +OUTCOMES = {"available": True, "category": "outcomes-retained", "coverage": "bounded-metadata-tail", + "outcomes": [{"verb": "get", "apiGroup": "kars.azure.com", "apiVersion": "v1alpha1", + "resource": "karssandboxes", "http_status": 403, + "category": "denied-response", "count": 1}]} + + +def redacted_context(server="https://127.0.0.1:36443"): + name = "kind-bridge-native" + return {"current-context": name, + "contexts": [{"name": name, "context": {"cluster": name, "user": name}}], + "clusters": [{"name": name, "cluster": { + "server": server, "certificate-authority-data": "DATA+OMITTED"}}], + "users": [{"name": name, "user": { + "client-certificate-data": "DATA+OMITTED", "client-key-data": "DATA+OMITTED"}}], + "extensions": [{"name": "private-context-canary", "extension": "private-context-canary"}]} + + +class NetworkFixture(Fixture): + def __init__(self): + super().__init__() + self.server, self.host, self.port = "https://127.0.0.1:36443", "127.0.0.1", 36443 + self.created, self.deleted = [], [] + self.after_create = None + self.delete_conflict = False + self.source = self.objects[SANDBOX] + self.source["metadata"]["generation"] = 1 + self.source["spec"] = {"runtime": {"kind": "OpenClaw"}, "governance": {"enabled": True}} + namespace = self.objects[f"/api/v1/namespaces/{RUNTIME}"] + namespace["metadata"]["annotations"] = {"kars.azure.com/sandbox-uid": "sandbox-uid"} + deployment = self.objects[DEPLOYMENT] + deployment["metadata"].update(generation=3, annotations={ + "kars.azure.com/credential-sandbox-uid": "sandbox-uid", + "kars.azure.com/credential-namespace-uid": RUNTIME + "-uid"}) + pod = self.objects[POD] + pod["metadata"]["labels"]["pod-template-hash"] = "abc123" + pod["spec"].update(nodeName="bridge-native-worker", containers=[ + {"name": "inference-router", "securityContext": { + "runAsUser": 1001, "allowPrivilegeEscalation": False}}, + {"name": "agent", "env": [{"name": "PRIVATE", "value": "private-env-canary"}]}, + ]) + pod["status"] = {"phase": "Running", "hostIP": "172.18.0.2", "podIP": "10.244.1.10", "containerStatuses": [ + {"name": "inference-router", "containerID": "containerd://private-id-canary", + "ready": True, "restartCount": 0}]} + self.objects["/api/v1/namespaces/default"] = { + "metadata": {"name": "default", "uid": "default-uid", "resourceVersion": "1"}} + self.objects[network.API_SERVICE] = { + "metadata": {"name": "kubernetes", "namespace": "default", "uid": "service-uid", + "resourceVersion": "1", "annotations": {"private": "private-service-canary"}}, + "spec": {"type": "ClusterIP", "clusterIP": "10.96.0.1", "clusterIPs": ["10.96.0.1"], + "ports": [{"name": "https", "protocol": "TCP", "port": 443, "targetPort": 6443}]}} + self.objects[network.API_ENDPOINTS] = { + "metadata": {"name": "kubernetes", "namespace": "default", "uid": "endpoints-uid", + "resourceVersion": "1"}, + "subsets": [{"addresses": [{"ip": "172.18.0.2"}], + "ports": [{"name": "https", "protocol": "TCP", "port": 6443}]}]} + self.objects[POLICY] = { + "kind": "NetworkPolicy", + "metadata": {"name": "sandbox-policy", "namespace": RUNTIME, "uid": "baseline-policy", + "resourceVersion": "1", "annotations": {"private": "private-policy-canary"}}, + "spec": {"podSelector": {"matchLabels": {"kars.azure.com/sandbox": "agent"}}, + "policyTypes": ["Ingress", "Egress"], "egress": [ + {"to": [{"ipBlock": {"cidr": "0.0.0.0/0", + "except": ["10.0.0.0/8", "172.16.0.0/12"]}}], + "ports": [{"protocol": "TCP", "port": 443}]}]}} + self.objects["/api/v1/namespaces/kube-system"] = { + "metadata": {"name": "kube-system", "uid": "kube-system-uid", "resourceVersion": "1"}} + self.objects[cilium.CONFIG] = { + "metadata": {"name": "cilium-config", "namespace": "kube-system", "uid": "config-uid", "resourceVersion": "1"}, + "data": {"policy-cidr-match-mode": "", "enable-policy": "default", "kube-proxy-replacement": "false", + "private-unrelated-key": "private-config-canary"}} + container = {"name": "cilium-agent", "image": "quay.io/cilium/cilium:v1.18.5"} + self.objects[cilium.DAEMONSET] = { + "metadata": {"name": "cilium", "namespace": "kube-system", "uid": "daemonset-uid", "resourceVersion": "1"}, + "spec": {"selector": {"matchLabels": {"k8s-app": "cilium"}}, + "template": {"spec": {"serviceAccountName": "cilium", "containers": [container]}}}} + self.objects[core("kube-system", "serviceaccounts", "cilium")] = { + "metadata": {"name": "cilium", "namespace": "kube-system", "uid": "cilium-account-uid", "resourceVersion": "1"}} + self.objects[CILIUM_POD] = { + "kind": "Pod", "metadata": {"name": "cilium-worker", "namespace": "kube-system", + "uid": "cilium-pod-uid", "resourceVersion": "1", "labels": {"k8s-app": "cilium"}, + "ownerReferences": [{"apiVersion": "apps/v1", "kind": "DaemonSet", "name": "cilium", + "uid": "daemonset-uid", "controller": True}]}, + "spec": {"nodeName": "bridge-native-worker", "serviceAccountName": "cilium", "containers": [container]}, + "status": {"hostIP": "172.18.0.2", "containerStatuses": [ + {"name": "cilium-agent", "ready": True, "containerID": "private-cilium-container-canary", + "restartCount": 0}]}} + self.objects[CEP] = { + "metadata": {"name": "agent-pod", "namespace": RUNTIME, "uid": "cep-uid", "resourceVersion": "1", + "ownerReferences": [{"apiVersion": "v1", "kind": "Pod", "name": "agent-pod", + "uid": "agent-pod-uid"}]}, + "status": {"id": 123, "identity": {"id": 12345, "labels": ["private-label-canary"]}, + "networking": {"node": "172.18.0.2", "addressing": [{"ipv4": "10.244.1.10"}]}, + "log": ["private-endpoint-log-canary"]}} + self.effective_config = ["[]", "true", "true"] + self.kube_proxy_status = ["False"] + self.policy_revisions = [7, 7] + + def get(self, path): + if path.endswith("/networkpolicies"): + return {"items": copy.deepcopy([item for item in self.objects.values() + if item.get("kind") == "NetworkPolicy"])} + if path.endswith("/ciliumnetworkpolicies"): + return {"items": copy.deepcopy([item for item in self.objects.values() + if item.get("kind") == "CiliumNetworkPolicy"])} + return super().get(path) + + def read_projection(self, _agent, endpoint_id=None, witness=None): + if endpoint_id is None: + return self.effective_config + return [str(endpoint_id), "12345", *(str(value) for value in self.policy_revisions), + "both", RUNTIME, "agent-pod"] + + def read_kube_proxy_projection(self, _agent, witness=None): + return self.kube_proxy_status + + def optional(self, path): + return self.get(path) if path in self.objects else None + + def create(self, path, value): + value = copy.deepcopy(value) + value["metadata"].update(uid="diagnostic-policy-uid", resourceVersion="7") + self.created.append((path, copy.deepcopy(value))) + self.objects[path + "/" + value["metadata"]["name"]] = copy.deepcopy(value) + if self.after_create: + self.after_create() + return value + + def request(self, method, path, body=None, expected=(200,)): + if method == "GET": + try: + return 200, self.get(path) + except KeyError: + return 404, {"message": "private-api-response-canary"} + if self.delete_conflict: + raise Failure("DELETE returned 409") + current = self.objects[path] + assert method == "DELETE" + assert expected == (200, 202) + assert body["preconditions"] == { + "uid": current["metadata"]["uid"], "resourceVersion": current["metadata"]["resourceVersion"]} + self.deleted.append((method, path, copy.deepcopy(body))) + del self.objects[path] + return 200, {} + + +class ObserverNetworkTests(unittest.TestCase): + def setUp(self): + self.api = NetworkFixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + + def collect(self, outcomes=OUTCOMES, failed=FAILED, config=None, commands=None): + config = redacted_context() if config is None else config + if commands is None: + commands = lambda *args, **_kwargs: json.dumps(config) if args[0] == "kubectl" else "bridge-native" + with patch.dict(os.environ, ENV), \ + patch.object(network, "command", side_effect=commands), \ + patch.object(cilium, "read_projection", side_effect=self.api.read_projection), \ + patch.object(cilium, "read_kube_proxy_projection", side_effect=self.api.read_kube_proxy_projection), \ + patch.object(network, "api_outcomes", return_value=outcomes), \ + patch.object(network.time, "sleep"): + return network.collect(self.setup, TARGET, failed) + + def test_snapshot_projects_exact_api_targets_without_private_fields(self): + before = network.snapshot(self.setup, TARGET) + self.assertEqual(before["facts"]["destinations"], [ + {"address": "10.96.0.1", "port": 443}, {"address": "172.18.0.2", "port": 6443}]) + self.assertEqual(before["facts"]["networkPolicies"][0]["apiDeclaredMatches"], [ + {"address": "10.96.0.1", "port": 443, "ruleMatches": [False]}, + {"address": "172.18.0.2", "port": 6443, "ruleMatches": [False]}]) + self.assertNotIn("canary", json.dumps(before["facts"])) + self.assertFalse(before["facts"]["cniBehaviorProven"]) + + def test_status_only_sandbox_ready_update_during_snapshot_reads_is_retained(self): + self.api.source["metadata"]["managedFields"] = [ + {"manager": "native", "operation": "Update", "apiVersion": "kars.azure.com/v1alpha1", + "fieldsType": "FieldsV1", "fieldsV1": {"f:spec": {}}, "time": "2026-09-10T20:00:00Z"}, + {"manager": "controller", "operation": "Update", "subresource": "status", + "fieldsType": "FieldsV1", "fieldsV1": {"f:status": {}}, "time": "2026-09-10T20:00:00Z"}] + before = network.snapshot(self.setup, TARGET) + original_get = self.api.get + def advancing_get(path): + if path == network.API_SERVICE: + self.api.source["metadata"]["resourceVersion"] = "2" + self.api.source["status"]["serviceObservation"].update(phase="Ready", reason="Verified") + self.api.source["status"]["conditions"] = [{"type": "Ready", "status": "True"}] + self.api.source["metadata"]["managedFields"][1]["time"] = "2026-09-10T20:00:01Z" + return original_get(path) + with patch.object(self.api, "get", side_effect=advancing_get): + after = network.snapshot(self.setup, TARGET) + self.assertTrue(after["ready"]) + self.assertEqual(after["actor"]["anchors"][SANDBOX]["metadata"]["resourceVersion"], "2") + self.assertEqual(before["stable"], after["stable"]) + + def test_status_update_with_any_authority_mutation_during_snapshot_is_rejected(self): + mutations = [ + lambda value: value["spec"]["governance"].update(enabled=False), + lambda value: value["spec"]["governance"].update(enabled=1), + lambda value: value["metadata"].update(generation=2), + lambda value: value["metadata"].update(uid="replacement"), + lambda value: value["metadata"].update(namespace="foreign"), + lambda value: value["metadata"].update(name="foreign"), + lambda value: value["metadata"].update(labels={"authority": "changed"}), + lambda value: value["metadata"].update(annotations={"authority": "changed"}), + lambda value: value["metadata"].update(ownerReferences=[{"uid": "foreign"}]), + lambda value: value["metadata"].update(finalizers=["foreign"]), + lambda value: value["metadata"].update(deletionTimestamp="terminating"), + lambda value: value["metadata"].update(managedFields=[{"manager": "new-spec-manager"}]), + lambda value: value["status"]["serviceObservation"].update(version="rotated"), + lambda value: value["status"]["serviceObservation"].update(namespaceUid="foreign"), + lambda value: value["status"]["serviceObservation"].update(deploymentUid="foreign"), + lambda value: value["status"]["serviceObservation"].update(grant={"uid": "foreign"}), + lambda value: value["status"]["serviceObservation"].update(secret={"uid": "foreign"}), + lambda value: value["status"]["serviceObservation"].update(capability="different"), + ] + for index, mutation in enumerate(mutations): + with self.subTest(index=index): + self.setUp() + original_get = self.api.get + changed = False + def racing_get(path): + nonlocal changed + if path == network.API_SERVICE and not changed: + changed = True + self.api.source["metadata"]["resourceVersion"] = "2" + self.api.source["status"]["serviceObservation"]["phase"] = "Ready" + mutation(self.api.source) + return original_get(path) + with patch.object(self.api, "get", side_effect=racing_get): + with self.assertRaises(Failure): + network.snapshot(self.setup, TARGET) + + def test_authority_mutations_between_snapshots_remain_unstable_even_without_generation_change(self): + before = network.snapshot(self.setup, TARGET) + self.api.source["spec"]["governance"]["enabled"] = False + after = network.snapshot(self.setup, TARGET) + self.assertNotEqual(before["stable"], after["stable"]) + + def test_inflight_status_progress_after_policy_creation_is_retained_and_cleaned_up(self): + armed = False + def created(): + nonlocal armed + armed = True + self.api.after_create = created + original_get = self.api.get + def advancing_get(path): + nonlocal armed + if armed and path == network.API_SERVICE: + armed = False + self.api.source["metadata"]["resourceVersion"] = "2" + self.api.source["status"]["serviceObservation"].update(phase="Ready", reason="Verified") + return original_get(path) + absent = {"available": False, "category": "no-matching-evidence", + "coverage": "bounded-metadata-tail", "outcomes": []} + failed = copy.deepcopy(FAILED) + with patch.object(self.api, "get", side_effect=advancing_get): + result = self.collect(outcomes=absent, failed=failed) + self.assertEqual(result["category"], "same-pod-progress-with-temporary-policy") + self.assertTrue(result["samePodObserverReady"]) + self.assertEqual(result["duringApiOutcomes"], absent) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(failed, FAILED) + + def test_other_anchor_resource_versions_stay_strict_within_snapshot(self): + for changed_path in (DEPLOYMENT, POD, POLICY, network.API_SERVICE, network.API_ENDPOINTS): + with self.subTest(path=changed_path): + self.setUp() + original_get = self.api.get + reads = {} + def racing_get(path): + reads[path] = reads.get(path, 0) + 1 + if path == changed_path and reads[path] == (1 if path == POLICY else 2): + self.api.objects[path]["metadata"]["resourceVersion"] = "changed" + return original_get(path) + with patch.object(self.api, "get", side_effect=racing_get): + with self.assertRaises(Failure): + network.snapshot(self.setup, TARGET) + + def test_nonloopback_setup_origin_never_creates_a_policy(self): + self.api.server = self.setup.cluster["server"] = "https://203.0.113.1:36443" + self.api.host = "203.0.113.1" + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_only_exact_host_ports_are_added_and_owned_cleanup_is_fenced(self): + original = copy.deepcopy(self.api.objects) + failed = copy.deepcopy(FAILED) + result = self.collect(failed=failed) + self.assertEqual(failed, FAILED) + self.assertEqual(result["category"], "same-pod-progress-with-temporary-policy") + self.assertTrue(result["diagnosticOnly"]) + self.assertEqual(result["originalResult"], "failed") + self.assertFalse(result["cniAcceptanceQualified"]) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(len(self.api.created), 1) + self.assertEqual(len(self.api.deleted), 1) + policy = self.api.created[0][1] + self.assertEqual(policy["kind"], "CiliumNetworkPolicy") + self.assertEqual(policy["apiVersion"], "cilium.io/v2") + self.assertTrue(self.api.created[0][0].endswith("/ciliumnetworkpolicies")) + self.assertEqual(policy["spec"], { + "endpointSelector": {"matchLabels": {"kars.azure.com/sandbox": "agent", "pod-template-hash": "abc123"}}, + "egress": [{"toEntities": ["kube-apiserver"], "toPorts": [{"ports": [ + {"protocol": "TCP", "port": "443"}, {"protocol": "TCP", "port": "6443"}]}]}]}) + self.assertTrue(result["apiResponseObserved"]) + self.assertFalse(result["samePodObserverReady"]) + self.assertEqual(result["duringApiOutcomes"]["outcomes"][0]["http_status"], 403) + self.assertEqual(policy["metadata"]["ownerReferences"][0]["uid"], "agent-deployment") + self.assertEqual(self.api.deleted[0][2]["preconditions"], { + "uid": "diagnostic-policy-uid", "resourceVersion": "7"}) + self.assertEqual(self.api.objects, original) + self.assertNotIn("canary", json.dumps(result)) + + def test_success_blocked_unrelated_failure_and_non_disposable_hosts_never_write(self): + for failed in (None, {"result": "passed"}, {"result": "blocked"}, + {"result": "failed", "failure": "different failure"}): + self.assertEqual(self.collect(failed=failed)["category"], "not-eligible") + with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}): + self.assertFalse(network.collect(self.setup, TARGET, FAILED)["available"]) + for commands in (["foreign-context"], [json.dumps(redacted_context()), "foreign-cluster"]): + with patch.dict(os.environ, ENV), patch.object(network, "command", side_effect=commands): + self.assertFalse(network.collect(self.setup, TARGET, FAILED)["available"]) + self.assertEqual(self.api.created, []) + + def test_selected_origin_uses_only_redacted_minified_context_and_bound_client(self): + config = redacted_context() + with patch.object(network, "command", return_value=json.dumps(config)) as command: + self.assertEqual(network.selected_origin(self.setup), ("127.0.0.1", 36443)) + command.assert_called_once_with("kubectl", "config", "view", "--minify", "-o", "json", timeout=10) + self.assertNotIn("--raw", command.call_args.args) + for server in ("http://127.0.0.1:36443", "https://203.0.113.1:36443", + "https://localhost:36443", "https://127.0.0.1:36444", + "https://user:private@127.0.0.1:36443", + "https://127.0.0.1:36443/private", "https://127.0.0.1:36443?token=private", + "https://127.0.0.1:36443#private", "https://127.0.0.1:0", + " https://127.0.0.1:36443"): + with self.subTest(server=server): + result = self.collect(config=redacted_context(server)) + self.assertFalse(result["policyCreated"]) + self.assertNotIn("private", json.dumps(result)) + self.assertEqual(self.api.created, []) + + def test_mismatched_context_bindings_and_unexpected_transport_never_write(self): + mutations = [ + lambda config: config.update(**{"current-context": "foreign"}), + lambda config: config["contexts"][0].update(name="foreign"), + lambda config: config["contexts"][0]["context"].update(cluster="foreign"), + lambda config: config["contexts"][0]["context"].update(user="foreign"), + lambda config: config["clusters"][0].update(name="foreign"), + lambda config: config["users"][0].update(name="foreign"), + lambda config: config["clusters"].append(copy.deepcopy(config["clusters"][0])), + lambda config: config["clusters"][0]["cluster"].update(**{"proxy-url": "https://private"}), + lambda config: config["clusters"][0]["cluster"].update(**{"insecure-skip-tls-verify": True}), + lambda config: config["clusters"][0]["cluster"].update(**{"tls-server-name": "foreign"}), + lambda config: config["contexts"][0].update(context="malformed"), + lambda config: config["clusters"][0].update(cluster=None), + ] + for mutation in mutations: + config = redacted_context() + mutation(config) + result = self.collect(config=config) + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["stage"], "disposable-host") + for change in ("admin-host", "admin-port", "captured-cluster"): + self.setUp() + if change == "admin-host": + self.api.host = "203.0.113.1" + elif change == "admin-port": + self.api.port = 36444 + else: + self.setup.cluster["server"] = "https://127.0.0.1:36444" + self.assertFalse(self.collect()["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_context_switch_during_snapshot_is_rejected_before_policy_creation(self): + result = self.collect(commands=[ + json.dumps(redacted_context()), "bridge-native", + json.dumps(redacted_context("https://127.0.0.1:36444")), + ]) + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + self.assertEqual(self.api.deleted, []) + + def test_literal_ipv6_loopback_origin_is_accepted_without_changing_api_targets(self): + self.api.server = self.setup.cluster["server"] = "https://[::1]:36443" + self.api.host = "::1" + result = self.collect(config=redacted_context(self.api.server)) + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + + def test_ready_or_unisolated_observer_gets_no_new_egress_isolation(self): + self.api.source["status"]["serviceObservation"]["phase"] = "Ready" + self.assertEqual(self.collect()["category"], "already-ready-without-intervention") + self.api.source["status"]["serviceObservation"]["phase"] = "Prepared" + self.api.objects[POLICY]["spec"]["policyTypes"] = ["Ingress"] + self.assertEqual(self.collect()["category"], "no-observed-egress-isolation-no-intervention") + self.assertEqual(self.api.created, []) + + def test_foreign_matching_consumers_and_forged_actor_lineage_never_get_a_policy(self): + for foreign in ("missing-owner", "wrong-account", "stale-version", "host-network", "terminating"): + with self.subTest(foreign=foreign): + self.setUp() + pod = copy.deepcopy(self.api.objects[POD]) + pod["metadata"].update(name="foreign", uid="foreign-uid") + if foreign == "missing-owner": + pod["metadata"]["ownerReferences"] = [] + elif foreign == "wrong-account": + pod["spec"]["serviceAccountName"] = "foreign" + elif foreign == "stale-version": + pod["metadata"]["annotations"][VERSION] = "old" + elif foreign == "host-network": + pod["spec"]["hostNetwork"] = True + else: + pod["metadata"]["deletionTimestamp"] = "terminating" + self.api.objects[core(RUNTIME, "pods", "foreign")] = pod + self.assertFalse(self.collect()["available"]) + self.assertEqual(self.api.created, []) + + def test_invalid_api_addresses_ports_and_inventory_are_rejected(self): + for address in (True, 1, "0.0.0.1", "8.8.8.8", "127.0.0.1", "169.254.1.2", "0.0.0.0", "224.1.2.3", + "10.96.0.0/12", "https://10.96.0.1?token=canary", "::1", "fe80::1"): + with self.subTest(address=address): + self.setUp() + self.api.objects[network.API_ENDPOINTS]["subsets"][0]["addresses"][0]["ip"] = address + result = self.collect() + self.assertFalse(result["available"]) + self.assertEqual(self.api.created, []) + self.assertNotIn("canary", json.dumps(result)) + for mutate in ( + lambda service, endpoint: service["spec"].update(type="ExternalName"), + lambda service, endpoint: service["spec"].update(externalIPs=["10.1.1.1"]), + lambda service, endpoint: service["spec"]["ports"][0].update(port=22), + lambda service, endpoint: service["spec"]["ports"][0].update(targetPort=4443), + lambda service, endpoint: endpoint["subsets"][0]["ports"][0].update(port=443), + lambda service, endpoint: endpoint.update(subsets=[]), + lambda service, endpoint: endpoint["subsets"][0].update(addresses=[{"ip": "172.18.0.2"}] * 9), + ): + self.setUp() + mutate(self.api.objects[network.API_SERVICE], self.api.objects[network.API_ENDPOINTS]) + self.assertFalse(self.collect()["available"]) + self.assertEqual(self.api.created, []) + + def test_ipv6_targets_do_not_widen_entity_or_ports(self): + service = self.api.objects[network.API_SERVICE] + service["spec"].update(clusterIP="fd00::1", clusterIPs=["fd00::1"]) + self.api.objects[network.API_ENDPOINTS]["subsets"][0]["addresses"] = [{"ip": "fd01::2"}] + plan = network.policy_plan(network.snapshot(self.setup, TARGET), "diagnostic") + self.assertEqual(plan["spec"]["egress"], [{"toEntities": ["kube-apiserver"], "toPorts": [{"ports": [ + {"protocol": "TCP", "port": "443"}, {"protocol": "TCP", "port": "6443"}]}]}]) + + def test_changed_actor_api_or_baseline_policy_aborts_and_removes_only_owned_policy(self): + mutations = [ + lambda api: api.objects[POD]["metadata"].update(uid="replacement-pod"), + lambda api: api.objects[POD]["status"]["containerStatuses"][0].update(restartCount=1), + lambda api: api.objects[POD]["status"]["containerStatuses"][0].update(containerID="new"), + lambda api: api.objects[DEPLOYMENT]["metadata"].update(generation=4), + lambda api: api.objects[REPLICA_SET]["metadata"]["ownerReferences"][0].update(uid="foreign"), + lambda api: api.source["metadata"].update(generation=2), + lambda api: api.objects[network.API_ENDPOINTS]["metadata"].update(resourceVersion="2"), + lambda api: api.objects[POLICY]["metadata"].update(resourceVersion="2"), + ] + for mutate in mutations: + with self.subTest(mutation=mutations.index(mutate)): + self.setUp() + self.api.after_create = lambda: mutate(self.api) + result = self.collect() + self.assertEqual(result["category"], "provenance-or-operation-unavailable") + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(len(self.api.deleted), 1) + + def test_foreign_policy_replacement_and_namespace_recreation_are_never_deleted(self): + for replace in ("policy", "namespace"): + self.setUp() + def changed(): + if replace == "policy": + name = self.api.created[0][1]["metadata"]["name"] + self.api.objects[resource(RUNTIME, "ciliumnetworkpolicies", name, network.CILIUM)]["metadata"]["uid"] = "foreign" + else: + self.api.objects[f"/api/v1/namespaces/{RUNTIME}"]["metadata"]["uid"] = "foreign" + self.api.after_create = changed + result = self.collect() + self.assertEqual(result["cleanup"], "unverified-or-refused") + self.assertEqual(result["category"], "cleanup-not-verified") + self.assertEqual(self.api.deleted, []) + + def test_conflicting_cleanup_is_not_reported_as_verified(self): + self.api.delete_conflict = True + result = self.collect() + self.assertEqual(result["cleanup"], "unverified-or-refused") + self.assertEqual(result["category"], "cleanup-not-verified") + self.assertEqual(self.api.deleted, []) + + def test_cleanup_uses_the_current_owned_resource_version(self): + def update(): + name = self.api.created[0][1]["metadata"]["name"] + self.api.objects[resource(RUNTIME, "ciliumnetworkpolicies", name, network.CILIUM)]["metadata"]["resourceVersion"] = "9" + self.api.after_create = update + result = self.collect() + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(self.api.deleted[0][2]["preconditions"], { + "uid": "diagnostic-policy-uid", "resourceVersion": "9"}) + + def test_new_foreign_selector_consumer_aborts_after_creation_and_cleans_up(self): + def update(): + pod = copy.deepcopy(self.api.objects[POD]) + pod["metadata"].update(name="new-foreign", uid="new-foreign-uid", ownerReferences=[]) + self.api.objects[core(RUNTIME, "pods", "new-foreign")] = pod + self.api.after_create = update + result = self.collect() + self.assertEqual(result["category"], "provenance-or-operation-unavailable") + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + + def test_mutated_policy_is_removed_but_lost_create_response_is_not_adopted(self): + original_create = self.api.create + def mutated(path, value): + value = copy.deepcopy(value) + value["spec"]["egress"] = [] + return original_create(path, value) + with patch.object(self.api, "create", side_effect=mutated): + result = self.collect() + self.assertEqual(result["category"], "provenance-or-operation-unavailable") + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.setUp() + with patch.object(self.api, "create", side_effect=OSError("private-create-canary")): + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["cleanup"], "creation-unconfirmed") + self.assertEqual(self.api.deleted, []) + self.assertNotIn("canary", json.dumps(result)) + + def test_name_collision_never_adopts_or_deletes_existing_policy(self): + name = "native-observer-api-0123456789abcdef" + policy = copy.deepcopy(self.api.objects[POLICY]) + policy["metadata"].update(name=name, uid="foreign-policy") + policy["kind"] = "CiliumNetworkPolicy" + self.api.objects[resource(RUNTIME, "ciliumnetworkpolicies", name, network.CILIUM)] = policy + with patch.object(network.secrets, "token_hex", return_value="0123456789abcdef"): + result = self.collect() + self.assertEqual(result["stage"], "pre-create-recheck") + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + self.assertEqual(self.api.deleted, []) + + def test_absent_audit_evidence_remains_unknown_after_bounded_probe(self): + absent = {"available": False, "category": "no-matching-evidence", + "coverage": "bounded-metadata-tail", "outcomes": []} + with patch.object(network.time, "monotonic", side_effect=[0, 0, 61]): + result = self.collect(outcomes=absent) + self.assertEqual(result["category"], "no-progress-observed") + self.assertEqual(result["duringApiOutcomes"], absent) + self.assertFalse(result["samePodObserverReady"]) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + + def test_selector_expressions_and_unresolved_policy_peers_are_not_assumed_allow(self): + self.assertTrue(network.matches({"matchExpressions": [ + {"key": "absent", "operator": "NotIn", "values": ["x"]}, + {"key": "present", "operator": "Exists"}, + ]}, {"present": "yes"})) + self.assertFalse(network.matches({"matchExpressions": [ + {"key": "present", "operator": "DoesNotExist"}]}, {"present": "yes"})) + with self.assertRaises(Failure): + network.matches({"unknown": {}}, {}) + target = {"address": "10.96.0.1", "port": 443} + self.assertIsNone(network.rule_match( + {"to": [{"namespaceSelector": {}}], "ports": [{"port": 443}]}, target)) + self.assertIsNone(network.rule_match( + {"to": [{"ipBlock": {"cidr": "10.96.0.1/32"}}], "ports": [{"port": "https"}]}, target)) + + def test_probe_does_not_relabel_native_failure_or_unblock_dependent_cases(self): + import run as native_run + + observations = MagicMock() + observations.enable.side_effect = Failure(network.FAILURE) + observations.observer_target = TARGET + with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent) as directory: + state = Path(directory) + def probe(_setup, _target, failed): + saved = json.loads((state / "evidence/native.json").read_text()) + self.assertEqual(saved["cases"]["private-bff-observer-and-fresh-privacy-rpc"]["result"], "failed") + self.assertEqual(failed["result"], "failed") + return {"diagnosticOnly": True, "category": "same-pod-progress-with-temporary-policy", + "samePodObserverReady": True, "cleanup": "uid-rv-deletion-verified"} + with patch.dict(os.environ, ENV), patch.object(native_run, "STATE", state), \ + patch.object(native_run, "command", return_value=network.CORE_REVISION), \ + patch.object(native_run, "Setup"), patch.object(native_run, "install_core"), \ + patch.object(native_run, "install_bridge"), patch.object(native_run, "bridge_connection"), \ + patch.object(native_run, "CredentialCases"), patch.object(native_run, "LifecycleCases"), \ + patch.object(native_run, "ObservationCases", return_value=observations), \ + patch.object(native_run, "diagnostics", return_value={}), \ + patch.object(native_run, "observation_diagnostics", return_value={}), \ + patch.object(native_run, "api_outcome_diagnostics", return_value={}), \ + patch.object(native_run, "observer_network_diagnostics", side_effect=probe), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(native_run.main(), 1) + report = json.loads((state / "evidence/native.json").read_text()) + self.assertEqual(report["result"], "failed") + self.assertFalse(report["runtimeQualified"]) + self.assertFalse(report["networkPolicyEnforcementQualified"]) + self.assertEqual(report["cases"]["private-bff-observer-and-fresh-privacy-rpc"]["failure"], network.FAILURE) + for case in ("purpose-only-pinned-tls-api-negative-matrix", + "cni-9447-9448-positive-and-unauthorized-peer-denial", + "observer-rotation-current-bearer-and-revocation"): + self.assertEqual(report["cases"][case]["result"], "blocked") + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_source_revision.py b/bridge/tests/native-credentials/test_source_revision.py new file mode 100644 index 000000000..8dfac45f2 --- /dev/null +++ b/bridge/tests/native-credentials/test_source_revision.py @@ -0,0 +1,27 @@ +"""Qualification follows one immutable monorepo revision, never a stale core pin.""" + +import os +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from source_revision import checked_revision + + +class SourceRevisionTests(unittest.TestCase): + def test_exact_workflow_revision_matches_the_real_checkout(self): + revision = "a" * 40 + with patch.dict(os.environ, {"CORE_REVISION": revision}), \ + patch("source_revision.subprocess.run", return_value=SimpleNamespace( + returncode=0, stdout=revision + "\n")): + self.assertEqual(checked_revision(), revision) + + def test_stale_missing_or_malformed_checkout_is_never_accepted(self): + for code, actual, expected in ( + (1, "", "a" * 40), (0, "not-a-commit", "a" * 40), + (0, "a" * 40, "b" * 40), + ): + with self.subTest(actual=actual), patch.dict(os.environ, {"CORE_REVISION": expected}), \ + patch("source_revision.subprocess.run", return_value=SimpleNamespace( + returncode=code, stdout=actual)), self.assertRaises(RuntimeError): + checked_revision() diff --git a/bridge/web/.dockerignore b/bridge/web/.dockerignore new file mode 100644 index 000000000..17f335f0e --- /dev/null +++ b/bridge/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +.git/ +npm-debug.log diff --git a/bridge/web/.gitignore b/bridge/web/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/bridge/web/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/bridge/web/AGENTS.md b/bridge/web/AGENTS.md new file mode 100644 index 000000000..8bd0e3908 --- /dev/null +++ b/bridge/web/AGENTS.md @@ -0,0 +1,5 @@ +<!-- BEGIN:nextjs-agent-rules --> +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +<!-- END:nextjs-agent-rules --> diff --git a/bridge/web/CLAUDE.md b/bridge/web/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/bridge/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/bridge/web/Dockerfile b/bridge/web/Dockerfile new file mode 100644 index 000000000..4e15a97b9 --- /dev/null +++ b/bridge/web/Dockerfile @@ -0,0 +1,32 @@ +# kars Bridge web (Next.js) — container image. Multi-stage standalone build, so +# the runtime image ships only server.js + the minimal traced node_modules. +# Cloud-agnostic: runs identically on AKS, EKS, GKE, and local kind. +# +# Build from the web/ directory as context: +# docker build -f web/Dockerfile -t <registry>/kars-bridge-web:<tag> web +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# The in-cluster BFF is reached at the Service DNS name; the browser talks to the +# same-origin /api/* rewrite, so this is baked at build for the rewrite target. +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 +RUN useradd --uid 10001 --user-group --home-dir /home/next --create-home next +# Standalone output: server + traced deps + static assets. +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +USER 10001 +EXPOSE 3000 +# BRIDGE_BFF_URL points at the in-cluster BFF Service (set by the Helm chart). +ENTRYPOINT ["node", "server.js"] diff --git a/bridge/web/README.md b/bridge/web/README.md new file mode 100644 index 000000000..37e12e33e --- /dev/null +++ b/bridge/web/README.md @@ -0,0 +1,42 @@ +# Kars Bridge web + +The Next.js application provides three persona-scoped products: + +- `/workspace` for employees; +- `/console` for operators and administrators; +- `/audit` for auditors. + +The browser calls same-origin `/api/*` routes. The Next.js server proxies those +requests to the Rust BFF using `BRIDGE_BFF_URL`; browser code never receives a +Kubernetes credential. + +## Development + +```bash +npm ci +npm run dev +``` + +Required integration configuration: + +| Variable | Purpose | +|---|---| +| `BRIDGE_BFF_URL` | Server-side BFF origin | +| `BRIDGE_OIDC_ISSUER` | OIDC issuer | +| `BRIDGE_OIDC_CLIENT_ID` | OIDC client | +| `BRIDGE_OIDC_CLIENT_SECRET` | OIDC client secret | +| `BRIDGE_SESSION_SECRET` | Signs Bridge sessions | + +Use repository-level `make dev` to run the BFF and web application together. + +## Build check + +```bash +npm run build +``` + +`npm run lint` is configured but currently reports a known private-preview +React-rule backlog. It is not a green release gate yet. + +Do not restore the create-next-app boilerplate or deploy this application +directly to Vercel. It is designed to run next to the BFF and a Kars cluster. diff --git a/bridge/web/eslint.config.mjs b/bridge/web/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/bridge/web/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/bridge/web/next.config.ts b/bridge/web/next.config.ts new file mode 100644 index 000000000..381dccbc0 --- /dev/null +++ b/bridge/web/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +// NOTE: the same-origin /api/* proxy to the BFF is handled at RUNTIME in +// src/proxy.ts (middleware), NOT via a next.config `rewrites` — a standalone +// build freezes a rewrite destination at build time (when BRIDGE_BFF_URL is +// unset), which would pin every deployment to localhost:8081. Middleware reads +// BRIDGE_BFF_URL per-request so the one image works on kind/AKS/EKS/GKE. +const nextConfig: NextConfig = { + // Emit a self-contained server bundle (server.js + minimal node_modules) so the + // container image is small and needs no full install at runtime. + output: "standalone", +}; + +export default nextConfig; diff --git a/bridge/web/package-lock.json b/bridge/web/package-lock.json new file mode 100644 index 000000000..bcf48e5d9 --- /dev/null +++ b/bridge/web/package-lock.json @@ -0,0 +1,9284 @@ +{ + "name": "web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.1.0", + "dependencies": { + "jose": "^6.2.3", + "mermaid": "^11.16.0", + "next": "16.3.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.3", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha1-ePoDa+GmCBtad6XPWfUMd1K2uiY=", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha1-yiA1sP7+lWqGdv8Maa9z5gX82B8=", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha1-6DoaJwTwxeSedZKyFAMaD0o01+U=", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha1-qw6epoHWyKEhTzDNdB/jogzFf1c=", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha1-BNrQFOjtgLG740H10JAFnqDGBXg=", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha1-Jm1yjFTS1ANNJw+LMdeQ4mKWpfo=", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.3.tgz", + "integrity": "sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.3.tgz", + "integrity": "sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.3.tgz", + "integrity": "sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.3.tgz", + "integrity": "sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.3.tgz", + "integrity": "sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.3.tgz", + "integrity": "sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.3.tgz", + "integrity": "sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.3.tgz", + "integrity": "sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.3.tgz", + "integrity": "sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.3.tgz", + "integrity": "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", + "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "postcss": "8.5.15", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha1-1FUKhdCPSXj68KTDa4SMYeqsB+I=", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha1-4CFRRk0C1KG0RkbQ/NuT+viP3ow=", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha1-52DldluBiLHe+jK8i7YGL4Hkx5U=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha1-wvQ2KwRdRy4bGGzb7DKbpSva7mw=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha1-FwbKQM9+pZoK3Y9EVu//j4d1eT0=", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha1-NoyWGhjech2oIA6AvzlD+1MTavI=", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha1-mto/qcTQDjpQk/7QNWx6uSlgQjE=", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha1-GFwagMyAf92io/6WD3wRxKJ5UuE=", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha1-7wBNihKARs/OQ00XGC+DTkTvlbI=", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha1-sTq6iyRCtAaMmp5tHYL4vOp3/AI=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha1-CjUfmW3Jmzf0+li0ksLRwE49rBc=", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha1-4o2xv7+mFwdvd3DdHZpI6qO2xRs=", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha1-wEorTyMYGqN28wrwKD28eztWmYA=", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha1-bcj8bh81cE87BXCQvu63rGdL/xo=", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha1-seRGVkTds/3zomP+uyQKbNYW3pA=", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha1-ueVqB5RJF08KLIaEqaTfP2BSJEA=", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha1-YCP7Oy1GMiny1oD5rEtHRm9x8Xs=", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha1-QSuQ6EhwKF8v+KhGxutgNE8SpBw=", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha1-9jKzgMOsoduo40qgSbzWpK8j34o=", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha1-365UptNdGedqyVZbyzKo5UaTGJw=", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha1-1HQLD+NbHFi2bhSI9OftApUvVw8=", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha1-a9NoO4My/A8B5wWbdja8XH7eczc=", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha1-V6L3ByQub+Hega17/Myq9gYXmvs=", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha1-3G1Pmpg3bxjqULrWw5U38bVGPDk=", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha1-vXpF/AqMMWemMWdeYbwsorBY1KM=", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha1-0VFsxQh1O+BoUs0GdY47tUoisOM=", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha1-hHL+7NY5aRRQ3YAA6zPt1EThMj8=", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha1-1rwea2p9tpzM+73Uw0twYy2enbI=", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha1-cLvad9wjqnJ0E+IuIUr6Pw6FL3A=", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha1-ETa8V+nds8OQ3MybX/O30rjZRwY=", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha1-3Msy0cVrHhxuDxGA2ZSJbwOLxAs=", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha1-jr5T1p762nBERU4zBcGQF9l87So=", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha1-usywepcLkXB986PoumiWxX6tLRE=", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha1-O+GSA4zdqSeqT4siq1Gvgqv0fzQ=", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-7.2.0.tgz", + "integrity": "sha1-o2y1fQtQHOEI5NIFWaFQo5HZerc=", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha1-ZQM0tBuGlXilQzWLgM2n4Kvgpgo=", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha1-X74usc92sHCo7NVkfDX2WqCXycY=", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha1-di+hId+ZMP/rUaSV2HkXxXCsIJs=", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha1-5Nb2SQ30+rWK6c6p5cOrjXRy9HE=", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha1-HDlcNbbhC7g/l2nKi4F9YUrdXAE=", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha1-0DN5E1hskPnCwHUpIGn1wtpd0oU=", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3/-/d3-7.9.0.tgz", + "integrity": "sha1-V556yz10nK+IYL0XQa6NNxBwzV0=", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha1-Ff7DOyN/l6xdfJhtx32ic6jtC7U=", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha1-xCpKE+gTHWN7dF/Clzgkz+r5MyI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha1-b3Z8Ttjct53n7ePhwPieY+9k0xw=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha1-0VbWH0hfzoMn5qvzOctB2Mu6aWY=", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha1-OVsoM9+scVB/EqwvevI7+BneJOI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha1-u5IGO8jFZjrLJCL5nHPLtsauO8w=", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha1-mBaQOHM6ClurvtpVBU95W7nkpYs=", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha1-X8dShOnCN1w2yDlBGgz1UMv8TV4=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha1-mUqunNI8cZ9TteEOOgphCMaWB7o=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha1-xjr5ePTWoNCEpSpnOSK+IWB4m3M=", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha1-llisOKIUDVnTRhYPH2ww/aC9EvQ=", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha1-gxQb/5hWoO21443onNz+Y9CmCiI=", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha1-Piuhph5wiI/j2RlOMNbRTuzhVcQ=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha1-Af20a1i+sfVbELQq1wtuNE1esq4=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha1-YCfPUSRvmy69ZPmeAdx8M2QDOk0=", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha1-sBzULB7tPUbbd6WWbPcm+MCRYMY=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha1-PEeqWzLFs9+1bvP9Q0IHimMrQA0=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha1-It+TkDL7WnGuixgA1h3beFHEJSY=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha1-C0XT3RxIopyOBX5hNWk+yAvxY5g=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha1-bco+i+Kzk8mp1RTau9gKkt7vGk8=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha1-1JJjeNMz2cC/0eb6AZTTCuuqIPQ=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha1-s8JoYnvXLl2AM26N5qy/7J0V0B0=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha1-4gtBqvzf/fXVCSgATs7PgVpGXoE=", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha1-SMBQux/owmJJOoyvVSTj6VkXAc8=", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha1-32OAG+B7yYa8VPY3ibT+UCmStdc=", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha1-ABfMijuZYF8DAvKxmNJy4BXl35U=", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha1-grOOjo/3CAdk+Nzsd71L45Nok5Y=", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha1-NMOdopiyPCDgLxpLI5vQ8i5/ExQ=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha1-wlM4IH76csxbm9FFihpBkB8eGzE=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha1-oag5y9m6RfKGdMadf4Vbz5HfxqU=", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha1-kxDbVumS48AXXh7zheVF5Iqbtcc=", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha1-erUlelBB0R7LT+cKXH0WoZW7QIo=", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha1-YoTSonCChbGrt+IB7aQ4CvNeY7A=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha1-aGn93hRIhoB3/dWYkgDLYbKhZF8=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha1-0T9BZccyF//qpUKVzWlps+eu6PM=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha1-EnInbiZFfPO5faxWn48FMewzw3c=", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha1-V/h1YuYt5288cEvSuNUi/DMGjrI=", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha1-0TJx+/Ov9nU/nqbiNVV/IJAQRuo=", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha1-b6ImXpu9zogsSs5BB2JgUbRI/6g=", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha1-k8WwMYZXkvwDy/W9IMEypPl2pSo=", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.3.tgz", + "integrity": "sha512-teqtsR26tnlfXFHfVLTM/4tzEzU8DMu6GS1sddZzhfGzgd2f2ofbgDUcsk6cssSCzX6Tk6fmWifJcdANSdPJrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.3.3", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha1-0ZvEzIdQpZYrR/sTAFV6hfz5NMw=", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha1-pS+AvzjaGVLrXGgXkHGYcaGnJQE=", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha1-CMuFtb037MjrHg9nDcJ2cALUNzQ=", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha1-ZoXyN1XkPFJOJR0py8lySOMGEAk=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/katex/-/katex-0.16.47.tgz", + "integrity": "sha1-ChOkLC3rT3TmHxYtRAuRZaVIAw8=", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-8.3.0.tgz", + "integrity": "sha1-SDfqGy2me5xhamevuw+v7lZ7ymY=", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha1-RfLOlM4jGkN89bY8LohubrQru7E=" + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha1-EpHilog8Miqd1MXdggY3IbU+JuI=", + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha1-uWLuuA2dmDqQC/NClh+3QYyhCx0=", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/marked/-/marked-16.4.2.tgz", + "integrity": "sha1-SVmmS+bEhvDbdGfq184ojeVCkKM=", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha1-3JRryEvenQk7oUlA1J3x2ffYwy8=", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha1-6D46fj8wCzTLnYf2FfoMvzV2kO4=", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.3.tgz", + "integrity": "sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.3", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.3", + "@next/swc-darwin-x64": "16.3.3", + "@next/swc-linux-arm64-gnu": "16.3.3", + "@next/swc-linux-arm64-musl": "16.3.3", + "@next/swc-linux-x64-gnu": "16.3.3", + "@next/swc-linux-x64-musl": "16.3.3", + "@next/swc-win32-arm64-msvc": "16.3.3", + "@next/swc-win32-x64-msvc": "16.3.3", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha1-cMmixL0aUT3NbK0Aap/OvsIqElM=", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha1-j1ulzHD8e+yz3O+uoI4mWaumC4w=", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha1-fbuYxDeRhZQ0KEdhMw+ok8uBtNE=", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha1-VTICtUJMU77TcTWzGIWOrP+F3VI=", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha1-EJkGGzNJ4sWr7GwqsKzUQNJNQGI=", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha1-EFn0ml4MgN7lQaAFsgzDIrIiFYs=", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=", + "license": "BSD-3-Clause" + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha1-xYRsk0X0v8Ub0MvXyjWgdE9IWl0=", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha1-rkW7Lt69qUxw9OqJfg8SQ+Rw23E=", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha1-j6w2x5ArVBwVSsE6J6xGeZevEfg=", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha1-ill1s+A4kCv9FpoQtSAvXsDPP68=", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/bridge/web/package.json b/bridge/web/package.json new file mode 100644 index 000000000..dad23b705 --- /dev/null +++ b/bridge/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "jose": "^6.2.3", + "mermaid": "^11.16.0", + "next": "16.3.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.3", + "tailwindcss": "^4", + "typescript": "^5" + }, + "overrides": { + "postcss": "^8.5.25", + "brace-expansion@^1": "^1.1.18", + "brace-expansion@^5": "^5.0.9", + "browserslist": "^4.28.9", + "js-yaml@^4": "^4.3.1", + "nanoid@^3": "^3.3.18", + "sharp": "^0.35.3" + } +} diff --git a/bridge/web/postcss.config.mjs b/bridge/web/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/bridge/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/bridge/web/public/file.svg b/bridge/web/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/bridge/web/public/file.svg @@ -0,0 +1 @@ +<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/bridge/web/public/globe.svg b/bridge/web/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/bridge/web/public/globe.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/bridge/web/public/next.svg b/bridge/web/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/bridge/web/public/next.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/bridge/web/public/vercel.svg b/bridge/web/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/bridge/web/public/vercel.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> \ No newline at end of file diff --git a/bridge/web/public/window.svg b/bridge/web/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/bridge/web/public/window.svg @@ -0,0 +1 @@ +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> \ No newline at end of file diff --git a/bridge/web/src/app/api/[...path]/route.ts b/bridge/web/src/app/api/[...path]/route.ts new file mode 100644 index 000000000..2d6421b19 --- /dev/null +++ b/bridge/web/src/app/api/[...path]/route.ts @@ -0,0 +1,114 @@ +// kars Bridge — runtime same-origin /api/* proxy to the BFF. +// +// Why a route handler (not a next.config rewrite): a standalone build freezes a +// next.config `rewrites` destination at BUILD time, when BRIDGE_BFF_URL is unset, +// pinning every deployment to localhost:8081. This handler runs in the Node +// runtime per request, reads BRIDGE_BFF_URL at RUNTIME, and streams the response +// body (so SSE/EventSource telemetry passes straight through) — the one image +// then works unchanged on kind, AKS, EKS and GKE. +// +// /api/health is a separate static route and takes precedence over this catch-all, +// so the readiness probe is always served locally. + +import { type NextRequest } from "next/server"; +import { ssoConfigured } from "@/lib/oidc-config"; +import { SESSION_COOKIE, verifySession } from "@/lib/session-token"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function bffBase(): string { + return (process.env.BRIDGE_BFF_URL ?? "http://localhost:8081").replace(/\/$/, ""); +} + +// Hop-by-hop headers must not be forwarded verbatim. +const STRIP = new Set([ + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-length", + "cookie", + // Internal-only headers that external requests must never inject. + "x-teams-internal-secret", + "x-teams-internal-signature", +]); + +async function forward(req: NextRequest): Promise<Response> { + const { pathname, search } = req.nextUrl; + const target = `${bffBase()}${pathname}${search}`; + + const headers = new Headers(); + req.headers.forEach((value, key) => { + if (!STRIP.has(key.toLowerCase()) && key.toLowerCase() !== "x-kars-principal-token") { + headers.set(key, value); + } + }); + if (ssoConfigured()) { + const token = req.cookies.get(SESSION_COOKIE)?.value; + if (!token || !(await verifySession(token))) { + return new Response( + JSON.stringify({ error: { code: "unauthorized", message: "A signed Bridge session is required." } }), + { status: 401, headers: { "content-type": "application/json" } }, + ); + } + headers.set("x-kars-principal-token", token); + } + + const method = req.method.toUpperCase(); + const hasBody = method !== "GET" && method !== "HEAD"; + + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + redirect: "manual", + }; + if (hasBody) { + init.body = req.body; + init.duplex = "half"; + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + const cause = (err as { cause?: unknown })?.cause; + return new Response( + JSON.stringify({ + error: { + code: "bad_gateway", + message: "BFF unreachable", + detail: String(err), + cause: String(cause ?? ""), + target, + }, + }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } + + const respHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (!STRIP.has(key.toLowerCase())) respHeaders.set(key, value); + }); + + // Stream the body straight through (works for JSON and SSE alike). + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: respHeaders, + }); +} + +export const GET = forward; +export const POST = forward; +export const PUT = forward; +export const PATCH = forward; +export const DELETE = forward; +export const HEAD = forward; +export const OPTIONS = forward; diff --git a/bridge/web/src/app/api/health/route.ts b/bridge/web/src/app/api/health/route.ts new file mode 100644 index 000000000..e54277de0 --- /dev/null +++ b/bridge/web/src/app/api/health/route.ts @@ -0,0 +1,12 @@ +// Liveness/readiness endpoint. Intentionally does NO upstream (BFF) work so the +// kubelet probe reflects "this web server can accept traffic", not "the BFF is +// reachable" — the SSR pages (e.g. /workspace) do a BFF round-trip and are far +// too heavy for a 1s readiness probe. +export const dynamic = "force-dynamic"; + +export function GET() { + return new Response("ok", { + status: 200, + headers: { "content-type": "text/plain", "cache-control": "no-store" }, + }); +} diff --git a/bridge/web/src/app/audit/layout.tsx b/bridge/web/src/app/audit/layout.tsx new file mode 100644 index 000000000..401870b88 --- /dev/null +++ b/bridge/web/src/app/audit/layout.tsx @@ -0,0 +1,63 @@ +// kars Bridge — the dedicated Auditor surface. A THIRD product surface, separate +// from the employee Workspace and the operator Console: entirely read-only, with +// no policy/skill/fleet write-controls and an auditor identity. An auditor +// confirms the tamper-evident record and verifies receipts independently — +// nothing here can change the platform. Gated on the `auditor` role. + +import Link from "next/link"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { Icon } from "@/components/icon"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { environment } from "@/lib/config"; +import { currentPrincipal } from "@/lib/session"; +import { RoleSwitcher } from "@/components/role-switcher"; +import { ssoConfigured } from "@/lib/oidc-config"; +import { loginPath, safeReturnTo } from "@/lib/auth-return"; + +import type { Metadata as _Metadata } from "next"; +export const metadata: _Metadata = { title: "Audit" }; +export default async function AuditorLayout({ children }: { children: React.ReactNode }) { + const principal = await currentPrincipal(); + if (ssoConfigured() && principal.roles.length === 0) { + const requestPath = safeReturnTo( + (await headers()).get("x-bridge-return-to"), + "/audit", + ); + redirect(loginPath(requestPath)); + } + if (!principal.roles.includes("auditor")) redirect("/workspace"); + const env = environment(); + return ( + <div className="flex min-h-full flex-col"> + <header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur"> + <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6"> + <Link href="/audit" className="flex items-center gap-2.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal"> + <span className="grid h-7 w-7 place-items-center rounded-md bg-foreground text-background text-sm font-bold">kb</span> + <span className="font-semibold tracking-tight"> + kars <span className="text-foreground-muted">Auditor</span> + </span> + </Link> + <div className="flex items-center gap-3"> + <span className="hidden items-center gap-1 rounded-full border border-ok/30 bg-ok/[0.08] px-2 py-0.5 text-[11px] font-medium text-ok sm:inline-flex" title="This surface is entirely read-only. Nothing here can change the platform — it only inspects and verifies the tamper-evident record."> + <Icon name="eye" size={12} /> Read-only + </span> + <span className="hidden rounded-full border border-border bg-surface-muted px-2 py-0.5 text-xs font-medium text-foreground-muted sm:inline-flex"> + {env} + </span> + <RoleSwitcher + principal={principal.name} + primary={principal.primary} + roles={principal.roles} + simulated={principal.simulated} + ssoSignedIn={principal.ssoSignedIn} + ssoAvailable={ssoConfigured()} + /> + <ThemeToggle /> + </div> + </div> + </header> + <main id="main-content" className="mx-auto w-full max-w-6xl flex-1 px-6 py-8">{children}</main> + </div> + ); +} diff --git a/bridge/web/src/app/audit/page.tsx b/bridge/web/src/app/audit/page.tsx new file mode 100644 index 000000000..ee747dd84 --- /dev/null +++ b/bridge/web/src/app/audit/page.tsx @@ -0,0 +1,31 @@ +// kars Bridge — Auditor surface home. The read-only tamper-evident record and +// independent verification, rendered from the shared AuditView (identical to the +// Console's audit page, minus every operator write-control). + +import { PageHeader } from "@/components/ui"; +import { AuditView } from "@/components/audit-view"; +import { getAudit } from "@/lib/bff"; +import type { Audit } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export default async function AuditorHome() { + let audit: Audit | null = null; + let error = false; + try { + audit = await getAudit(); + } catch { + error = true; + } + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Auditor" + title="Tamper-evident record" + lead="Everything the platform ran, as an independently-verifiable log. Confirm the chain is intact, inspect what each receipt attests, and verify any of it yourself. This surface is read-only — nothing here can change the platform." + /> + <AuditView audit={audit} error={error} /> + </div> + ); +} diff --git a/bridge/web/src/app/auth/callback/route.ts b/bridge/web/src/app/auth/callback/route.ts new file mode 100644 index 000000000..f45ae0a5a --- /dev/null +++ b/bridge/web/src/app/auth/callback/route.ts @@ -0,0 +1,103 @@ +// kars Bridge — SSO callback. +// +// GET /auth/callback: completes the OIDC Authorization Code + PKCE flow — +// validates `state` against the stashed flow cookie, exchanges the code for +// tokens, verifies the ID token (signature, issuer, audience, nonce), maps +// the configured role claim through the operator's role map, and mints a +// real signed Bridge session cookie. Any failure is surfaced plainly; it +// never falls through to a silently-granted session. + +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { oidcConfig } from "@/lib/oidc-config"; +import { exchangeCodeForIdentity, rolesFromClaims } from "@/lib/oidc"; +import { + verifyFlowState, + signSession, + SESSION_COOKIE, + OIDC_STATE_COOKIE, + SESSION_MAX_AGE_SECONDS, +} from "@/lib/session-token"; +import { safeReturnTo } from "@/lib/auth-return"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function errorResponse(code: string, message: string, status = 400): Response { + return NextResponse.json({ error: { code, message } }, { status }); +} + +export async function GET(req: Request): Promise<Response> { + const cfg = oidcConfig(); + if (!cfg) return errorResponse("sso_not_configured", "SSO is not configured on this deployment.", 501); + + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const idpError = url.searchParams.get("error"); + if (idpError) { + return errorResponse("idp_error", `The identity provider returned an error: ${idpError}`); + } + if (!code || !state) { + return errorResponse("missing_code_or_state", "Callback is missing the authorization code or state."); + } + + const jar = await cookies(); + const flowToken = jar.get(OIDC_STATE_COOKIE)?.value; + jar.delete(OIDC_STATE_COOKIE); + if (!flowToken) { + return errorResponse("missing_flow_cookie", "No pending login found (the flow cookie expired or was already used)."); + } + const flow = await verifyFlowState(flowToken); + if (!flow) { + return errorResponse("invalid_flow_state", "The login flow state is invalid or expired."); + } + if (flow.state !== state) { + return errorResponse("state_mismatch", "OIDC state mismatch — possible CSRF; login aborted."); + } + + let identity; + try { + identity = await exchangeCodeForIdentity(cfg, code, flow.redirectUri, flow.codeVerifier, flow.nonce); + } catch (e) { + return errorResponse("token_exchange_failed", e instanceof Error ? e.message : "Token exchange failed."); + } + + const roles = rolesFromClaims(cfg, identity.claims); + const session = await signSession({ + sub: identity.sub, + name: identity.name ?? identity.email ?? identity.sub, + roles, + }); + + // Redirect to the browser's own origin (Host header), NOT req.url — under a + // standalone/port-forward deployment req.url carries the pod bind host + // (0.0.0.0:3000), which is a DIFFERENT origin than the browser's + // localhost:3000, so the just-set session cookie would not be sent and the + // user would bounce straight back to /auth/login. + const h = await headers(); + const proto = h.get("x-forwarded-proto") ?? "http"; + const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"; + const origin = `${proto}://${host}`; + // Mark the cookie Secure only under real TLS. Over the plain-HTTP + // kubectl-port-forward path (no ingress, never publicly exposed) a Secure + // cookie would be dropped by any non-secure-context client, breaking login. + const secure = proto === "https"; + + const auditorOnly = + roles.includes("auditor") && + !roles.some((role) => role === "user" || role === "operator" || role === "admin"); + const defaultDestination = auditorOnly ? "/audit" : "/workspace"; + const destination = roles.length + ? safeReturnTo(flow.returnTo, defaultDestination) + : "/auth/no-roles"; + const res = NextResponse.redirect(new URL(destination, origin)); + res.cookies.set(SESSION_COOKIE, session, { + httpOnly: true, + secure, + sameSite: "lax", + path: "/", + maxAge: SESSION_MAX_AGE_SECONDS, + }); + return res; +} diff --git a/bridge/web/src/app/auth/login/route.ts b/bridge/web/src/app/auth/login/route.ts new file mode 100644 index 000000000..a3c4d6e85 --- /dev/null +++ b/bridge/web/src/app/auth/login/route.ts @@ -0,0 +1,66 @@ +// kars Bridge — SSO login entry point. +// +// GET /auth/login: when SSO is configured, starts a real OIDC Authorization +// Code + PKCE flow (redirects to the IdP). When it isn't configured (the +// honest default in this environment — no IdP registered), returns a plain +// explanation instead of a broken or faked login screen. + +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { oidcConfig } from "@/lib/oidc-config"; +import { buildAuthorizationRequest } from "@/lib/oidc"; +import { signFlowState, OIDC_STATE_COOKIE } from "@/lib/session-token"; +import { safeReturnTo } from "@/lib/auth-return"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +async function requestOrigin(): Promise<string> { + const h = await headers(); + const proto = h.get("x-forwarded-proto") ?? "http"; + const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"; + return `${proto}://${host}`; +} + +export async function GET(req: Request): Promise<Response> { + const cfg = oidcConfig(); + if (!cfg) { + return NextResponse.json( + { + error: { + code: "sso_not_configured", + message: + "SSO is not configured on this Bridge deployment. Set BRIDGE_OIDC_ISSUER, " + + "BRIDGE_OIDC_CLIENT_ID, BRIDGE_OIDC_CLIENT_SECRET, and BRIDGE_SESSION_SECRET " + + "to connect a real OIDC provider (Entra ID, Okta, Auth0, Keycloak, ...). " + + "Until then, use the role switcher for a simulated dev identity.", + }, + }, + { status: 501 }, + ); + } + + const redirectUri = cfg.redirectUri ?? `${await requestOrigin()}/auth/callback`; + const returnTo = safeReturnTo(new URL(req.url).searchParams.get("returnTo")); + const authReq = await buildAuthorizationRequest(cfg, redirectUri); + const flowToken = await signFlowState({ + state: authReq.state, + nonce: authReq.nonce, + codeVerifier: authReq.codeVerifier, + redirectUri, + returnTo, + }); + + const jar = await cookies(); + const h = await headers(); + const secure = (h.get("x-forwarded-proto") ?? "http") === "https"; + jar.set(OIDC_STATE_COOKIE, flowToken, { + httpOnly: true, + secure, + sameSite: "lax", + path: "/", + maxAge: 10 * 60, + }); + + return NextResponse.redirect(authReq.url); +} diff --git a/bridge/web/src/app/auth/logout/route.ts b/bridge/web/src/app/auth/logout/route.ts new file mode 100644 index 000000000..72ac31bae --- /dev/null +++ b/bridge/web/src/app/auth/logout/route.ts @@ -0,0 +1,39 @@ +// kars Bridge — SSO logout. +// +// POST /auth/logout: clears the Bridge's own session cookie. Also redirects +// through the IdP's RP-initiated logout (end_session_endpoint) when the IdP +// advertises one, so the IdP-side session ends too — otherwise the user +// would just get silently re-signed-in on their next /auth/login. + +import { cookies, headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { oidcConfig } from "@/lib/oidc-config"; +import { endSessionUrl } from "@/lib/oidc"; +import { SESSION_COOKIE } from "@/lib/session-token"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +async function requestOrigin(): Promise<string> { + const h = await headers(); + const proto = h.get("x-forwarded-proto") ?? "http"; + const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"; + return `${proto}://${host}`; +} + +export async function POST(): Promise<Response> { + const jar = await cookies(); + jar.delete(SESSION_COOKIE); + + const cfg = oidcConfig(); + const origin = await requestOrigin(); + // 303 See Other: this handler is reached via POST (the sign-out form), and a + // 307/308 would make the browser re-POST to the target — hitting a GET-only + // page (e.g. `/`) with 405 "Method Not Allowed", which looked like a broken + // "non-existent page". 303 forces the follow-up request to GET. + if (cfg) { + const end = await endSessionUrl(cfg, origin).catch(() => null); + if (end) return NextResponse.redirect(end, 303); + } + return NextResponse.redirect(origin, 303); +} diff --git a/bridge/web/src/app/auth/no-roles/page.tsx b/bridge/web/src/app/auth/no-roles/page.tsx new file mode 100644 index 000000000..ed7e75633 --- /dev/null +++ b/bridge/web/src/app/auth/no-roles/page.tsx @@ -0,0 +1,25 @@ +// kars Bridge — honest landing for a real SSO login that mapped to zero +// roles. Fail-closed by design (lib/oidc.ts rolesFromClaims): an +// unrecognized or absent role claim never grants a default role. + +export const dynamic = "force-dynamic"; + +export default function NoRolesPage() { + return ( + <div className="mx-auto max-w-lg space-y-4 px-6 py-16 text-center"> + <h1 className="text-xl font-semibold">Signed in — no roles mapped</h1> + <p className="text-sm text-foreground-muted"> + Your identity provider login succeeded, but none of your group/role claims are mapped to a Bridge role. + Roles are fail-closed by design: an operator must add your IdP group to{" "} + <code className="font-mono text-xs">BRIDGE_OIDC_ROLE_MAP</code> before you can use the Bridge. + </p> + <p className="text-xs text-foreground-muted"> + Ask an operator to map your group, then sign in again at{" "} + <a href="/auth/login" className="text-signal underline"> + /auth/login + </a> + . + </p> + </div> + ); +} diff --git a/bridge/web/src/app/console/access/page.tsx b/bridge/web/src/app/console/access/page.tsx new file mode 100644 index 000000000..ff9f03b67 --- /dev/null +++ b/bridge/web/src/app/console/access/page.tsx @@ -0,0 +1,204 @@ +// kars Bridge Operator Console — Access & roles. The multi-user surface: the four +// differentiated permission sets, what each can do, the current principal, and an +// honest disclosure that there is no SSO yet (the real boundary is the Bridge's +// Kubernetes ServiceAccount RBAC). Switching identity is done from the header +// role menu; this page documents and reflects the model. + +import { PageHeader, Section } from "@/components/ui"; +import { ALL_ROLES, ROLE_META, type Role } from "@/lib/config"; +import { currentPrincipal } from "@/lib/session"; +import { ssoConfigured } from "@/lib/oidc-config"; +import { Icon } from "@/components/icon"; + +export const dynamic = "force-dynamic"; + +// The capability matrix — the concrete surfaces/actions each role holds. Ordered +// by privilege; a ✓ means the role can do it (with implication: admin ⊇ operator +// ⊇ user; admin ⊇ auditor). +const CAPABILITIES: { area: string; cap: string; roles: Role[] }[] = [ + { area: "Workspace", cap: "Start & review missions and teams", roles: ["user", "operator", "admin"] }, + { area: "Workspace", cap: "Connect GitHub & channels", roles: ["user", "operator", "admin"] }, + { area: "Workspace", cap: "Approve / request changes on deliverables", roles: ["user", "operator", "admin"] }, + { area: "Auditor", cap: "Read receipts, evidence, tamper-evident log", roles: ["auditor", "operator", "admin"] }, + { area: "Auditor", cap: "Verify a receipt independently", roles: ["auditor", "operator", "admin"] }, + { area: "Console", cap: "Manage tool / inference policies, skills, MCP", roles: ["operator", "admin"] }, + { area: "Console", cap: "Approve egress grants, run safety evals", roles: ["operator", "admin"] }, + { area: "Console", cap: "View fleet, sandboxes, insights", roles: ["operator", "admin"] }, + { area: "Admin", cap: "Raise cluster / workspace inference budgets", roles: ["admin"] }, + { area: "Admin", cap: "Cluster & provider configuration", roles: ["admin"] }, + { area: "Admin", cap: "Manage roles & access", roles: ["admin"] }, +]; + +function Check({ on }: { on: boolean }) { + return on ? ( + <span className="text-signal" aria-label="allowed">✓</span> + ) : ( + <span className="text-foreground-muted/40" aria-label="not allowed">·</span> + ); +} + +export default async function AccessPage() { + const principal = await currentPrincipal(); + const sso = ssoConfigured(); + return ( + <div className="space-y-6"> + <PageHeader + title="Access & roles" + lead="kars Bridge is partitioned into four differentiated permission sets. Each surface is role-gated; a role holds exactly the capabilities below (privilege is cumulative — admin ⊇ operator ⊇ user, and admin ⊇ auditor)." + /> + + <Section title="You" subtitle="The principal this session is acting as."> + <div className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-surface-muted/40 p-4"> + <span className="grid h-9 w-9 place-items-center rounded-full bg-surface text-lg" aria-hidden> + <Icon name={ROLE_META[principal.primary].glyph} size={18} /> + </span> + <div> + <p className="font-mono text-sm">{principal.name}</p> + <p className="text-xs text-foreground-muted"> + Acting as <span className="font-medium text-foreground">{principal.primaryLabel}</span> · holds{" "} + {principal.roles.map((r) => ROLE_META[r].label).join(", ")} + </p> + </div> + <span + className={`ml-auto rounded-full border px-2.5 py-1 text-[11px] font-medium ${ + principal.ssoSignedIn + ? "border-ok/30 bg-ok/[0.08] text-ok" + : "border-amber-500/30 bg-amber-500/[0.08] text-amber-600" + }`} + > + {principal.ssoSignedIn + ? "Real SSO session" + : principal.simulated + ? "Simulated role (dev — no SSO session)" + : "Roles from BRIDGE_ROLES env"} + </span> + </div> + <p className="mt-2 text-xs text-foreground-muted"> + {principal.ssoSignedIn ? ( + <> + Your roles came from your identity provider’s group claims at login, mapped via{" "} + <code className="rounded bg-surface-muted px-1">BRIDGE_OIDC_ROLE_MAP</code>. The REAL boundary + remains the Bridge’s Kubernetes ServiceAccount under a least-privilege RBAC role ( + <code className="rounded bg-surface-muted px-1">deploy/rbac.yaml</code>) — SSO only decides which + UI affordances render, never what the cluster actually permits. + </> + ) : ( + <> + There is no per-user sign-in yet on this session. Roles come from the header role menu (a dev + identity switch) or the <code className="rounded bg-surface-muted px-1">BRIDGE_ROLES</code> env, + and the REAL boundary is the Bridge’s Kubernetes ServiceAccount under a least-privilege RBAC + role (<code className="rounded bg-surface-muted px-1">deploy/rbac.yaml</code>). Once SSO is + configured (below) every gate on this page holds unchanged — it only changes where the role comes + from. + </> + )} + </p> + </Section> + + <Section + title="Single sign-on" + subtitle="Connect a real OIDC identity provider (Entra ID, Okta, Auth0, Keycloak, ...) — config only, no code changes." + > + <div className={`rounded-lg border p-4 ${sso ? "border-ok/30 bg-ok/[0.04]" : "border-dashed border-border bg-surface-muted/30"}`}> + <p className="flex items-center gap-2 text-sm font-medium"> + {sso ? ( + <> + <span className="text-ok">✓</span> SSO configured + </> + ) : ( + <span className="text-foreground-muted">SSO not configured</span> + )} + </p> + <p className="mt-1 text-xs text-foreground-muted"> + {sso ? ( + <> + An OIDC provider is connected. Users sign in at{" "} + <code className="rounded bg-surface-muted px-1">/auth/login</code> — a real Authorization Code + + PKCE flow, ID-token signature/issuer/audience/nonce verified against the provider’s live + JWKS, roles mapped from the configured group claim, and a signed Bridge session issued. No + credential or IdP token the Bridge holds ever reaches the browser. + </> + ) : ( + <> + Set <code className="rounded bg-surface-muted px-1">BRIDGE_OIDC_ISSUER</code>,{" "} + <code className="rounded bg-surface-muted px-1">BRIDGE_OIDC_CLIENT_ID</code>,{" "} + <code className="rounded bg-surface-muted px-1">BRIDGE_OIDC_CLIENT_SECRET</code> (from a K8s + Secret, never inline), and <code className="rounded bg-surface-muted px-1">BRIDGE_SESSION_SECRET</code>{" "} + on the web deployment to connect a real IdP. Optionally set{" "} + <code className="rounded bg-surface-muted px-1">BRIDGE_OIDC_ROLE_CLAIM</code> (default{" "} + <code className="rounded bg-surface-muted px-1">roles</code>) and{" "} + <code className="rounded bg-surface-muted px-1">BRIDGE_OIDC_ROLE_MAP</code> (JSON, e.g.{" "} + <code className="rounded bg-surface-muted px-1">{`{"kars-admins":"admin"}`}</code>) to map your + IdP’s groups to Bridge roles — unmapped groups get zero roles (fail-closed). + </> + )} + </p> + </div> + </Section> + + <Section title="The four roles" subtitle="Separate products, separate permission sets."> + <div className="grid gap-3 sm:grid-cols-2"> + {ALL_ROLES.map((r) => { + const m = ROLE_META[r]; + const isYou = principal.primary === r; + return ( + <div + key={r} + className={`rounded-lg border p-4 ${isYou ? "border-signal/40 bg-signal/[0.04]" : "border-border bg-surface"}`} + > + <div className="flex items-center gap-2"> + <span aria-hidden className="text-lg"><Icon name={m.glyph} size={18} /></span> + <span className="text-sm font-semibold">{m.label}</span> + {isYou && ( + <span className="rounded-full border border-signal/40 bg-signal/10 px-1.5 py-0.5 text-[10px] font-medium text-signal"> + you + </span> + )} + </div> + <p className="mt-1 text-xs text-foreground-muted">{m.blurb}</p> + </div> + ); + })} + </div> + </Section> + + <Section title="Capability matrix" subtitle="Exactly what each role can do — the wired gates."> + <div className="overflow-hidden rounded-lg border border-border"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b border-border bg-surface-muted/40 text-left text-xs text-foreground-muted"> + <th className="px-4 py-2 font-medium">Area</th> + <th className="px-3 py-2 font-medium">Capability</th> + {ALL_ROLES.map((r) => ( + <th key={r} className="px-3 py-2 text-center font-medium" title={ROLE_META[r].label}> + <Icon name={ROLE_META[r].glyph} size={14} className="inline" /> + </th> + ))} + </tr> + </thead> + <tbody> + {CAPABILITIES.map((c, i) => ( + <tr key={i} className="border-b border-border last:border-0"> + <td className="px-4 py-2 text-xs text-foreground-muted">{c.area}</td> + <td className="px-3 py-2">{c.cap}</td> + {ALL_ROLES.map((r) => ( + <td key={r} className="px-3 py-2 text-center"> + <Check on={c.roles.includes(r)} /> + </td> + ))} + </tr> + ))} + </tbody> + </table> + </div> + <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-foreground-muted"> + {ALL_ROLES.map((r) => ( + <span key={r} className="inline-flex items-center gap-1"> + <Icon name={ROLE_META[r].glyph} size={12} /> {ROLE_META[r].label} + </span> + ))} + </p> + </Section> + </div> + ); +} diff --git a/bridge/web/src/app/console/approvals/page.tsx b/bridge/web/src/app/console/approvals/page.tsx new file mode 100644 index 000000000..f4453918e --- /dev/null +++ b/bridge/web/src/app/console/approvals/page.tsx @@ -0,0 +1,211 @@ +// kars Bridge Operator Console — Approvals. Operator-side governance gates: +// temporary egress widenings (EgressApproval) the platform team grants, and a +// pointer to fleet-wide steering decisions. Real reads. + +import { PageHeader, Section, Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { Icon } from "@/components/icon"; +import { DeleteResource } from "../delete-resource"; +import { listApprovals, listEgress } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import { currentPrincipal } from "@/lib/session"; +import { ApprovalDecision } from "@/components/approval-decision"; +import { ApprovalPhaseBadge, actionLabel } from "@/components/approval-phase-badge"; +import type { Approval, EgressApproval } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +type Tone = "ok" | "warn" | "danger" | "info" | "muted" | "accent"; + +function phaseTone(p: string | null): Tone { + switch (p) { + case "Active": + return "ok"; + case "Pending": + return "warn"; + default: + return "muted"; + } +} + +export default async function ConsoleApprovals() { + const principal = await currentPrincipal(); + let egress: EgressApproval[] = []; + let steering: Approval[] = []; + let error = false; + try { + egress = await listEgress(); + steering = (await listApprovals(defaultNamespace(), { scopeAll: true })).filter( + (approval) => approval.action_kind !== "clarification", + ); + } catch { + error = true; + } + + const pending = egress.filter((e) => e.phase === "Pending").length; + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Approvals" + lead="Platform-side governance gates — the active temporary egress widenings on the fleet, each revocable here. Mission-level steering (tool-call gates, tier raises) lives in the Workspace inbox." + /> + + {/* Two approval planes — the operator asked how this relates to the inbox. */} + <section className="grid gap-3 sm:grid-cols-2"> + <div className="rounded-xl border border-signal/25 bg-signal/[0.04] p-4"> + <p className="flex items-center gap-2 text-sm font-semibold"><span aria-hidden><Icon name="seal" size={16} /></span> This page — platform plane</p> + <p className="mt-1 text-xs text-foreground-muted"> + Fleet-wide policy changes: temporary <strong>egress widenings</strong> (EgressApproval) that + let a sandbox reach a domain outside its signed baseline. You grant/revoke them here; the + controller reconciles the allowlist. This is the only approval an <em>operator</em> owns. + </p> + </div> + <div className="rounded-xl border border-accent/25 bg-accent/[0.04] p-4"> + <p className="flex items-center gap-2 text-sm font-semibold"><span aria-hidden><Icon name="message" size={16} /></span> Workspace inbox — mission plane</p> + <p className="mt-1 text-xs text-foreground-muted"> + Per-mission steering the <em>task-giver</em> owns: answering an agent’s clarification, + approving a tool-call gate or an autonomy (tier) raise. Those never appear here — they go to + the person who launched the work, in their <strong>Workspace inbox</strong>. + </p> + </div> + </section> + + <Section + title="User workload authority requests" + action={ + <Badge tone={steering.some((approval) => approval.actionable) ? "warn" : "muted"} dot={steering.some((approval) => approval.actionable)}> + {steering.filter((approval) => approval.actionable).length} pending + </Badge> + } + > + {steering.length === 0 ? ( + <HonestState + variant="empty" + title="No authority requests" + detail="User-owned mission and team requests that require an operator decision appear here. Clarification questions remain exclusively in the owning user's Workspace inbox." + /> + ) : ( + <ul className="divide-y divide-border overflow-hidden rounded-lg border border-border"> + {steering.map((approval) => ( + <li key={approval.name} className="px-4 py-4"> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5 text-xs font-medium text-foreground-muted"> + {actionLabel(approval.action_kind)} + </span> + <span className="font-mono text-[11px] text-foreground-muted">{approval.task}</span> + </div> + <p className="mt-1.5 text-sm font-medium">{approval.summary}</p> + {approval.detail && <p className="mt-0.5 text-xs text-foreground-muted">{approval.detail}</p>} + </div> + <ApprovalPhaseBadge phase={approval.phase} /> + </div> + {approval.actionable && ( + <div className="mt-3"> + <ApprovalDecision + name={approval.name} + decider={principal.name} + authWired + resourceVersion={approval.resource_version} + boundEnvelopeDigest={approval.bound_envelope_digest} + compact + requireReason={approval.action_kind === "clarification"} + /> + </div> + )} + </li> + ))} + </ul> + )} + </Section> + + {/* How an egress request becomes a grant. */} + <div className="rounded-xl border border-border bg-surface-muted/30 p-4 text-xs text-foreground-muted"> + <p className="font-medium text-foreground">How an egress approval flows</p> + <p className="mt-1.5"> + Agent reaches a novel domain → the router denies it and records the request → in + <strong className="text-foreground"> learning</strong> mode the operator reviews observed + domains and pins an allowlist; in <strong className="text-foreground">strict</strong> mode a + temporary <strong className="text-foreground">EgressApproval</strong> is granted (here) for a + bounded window → the controller widens that sandbox’s allowlist → it expires or you + revoke it, and the allowlist reconciles back to baseline. + </p> + </div> + + <Section + title="Temporary egress grants" + action={ + <Badge tone={pending > 0 ? "warn" : "muted"} dot={pending > 0}> + {pending > 0 ? `${pending} pending` : `${egress.length} total`} + </Badge> + } + > + {error ? ( + <HonestState variant="not_wired" title="Cluster unreachable" detail="The Bridge backend can't reach the cluster right now." /> + ) : egress.length === 0 ? ( + <div className="space-y-4"> + <HonestState + variant="empty" + title="No temporary egress grants" + detail="Sandboxes run on their signed baseline allowlist. Active temporary widenings appear here, where you can revoke them; the controller reconciles the allowlist back to baseline on revoke." + /> + {/* Sample (disabled) grant so the action model is visible even when + the queue is empty (audit f38). */} + <div> + <p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">Example — what a grant looks like</p> + <div className="rounded-lg border border-dashed border-border bg-surface-muted/30 p-4 opacity-80"> + <div className="flex items-center justify-between gap-3"> + <span className="font-mono text-sm font-medium text-foreground-muted">research-run-1783•••</span> + <Badge tone="muted">Example — not a real grant</Badge> + </div> + <div className="mt-2 flex flex-wrap gap-1"> + <span className="rounded-full border border-border bg-surface px-2 py-0.5 font-mono text-[11px]">api.github.com:443</span> + <span className="rounded-full border border-border bg-surface px-2 py-0.5 font-mono text-[11px]">raw.githubusercontent.com:443</span> + </div> + <div className="mt-3 flex items-center justify-between"> + <p className="text-[11px] text-foreground-muted">A time-boxed widening the agent requested and you approved. It sits above the signed baseline and expires or is revoked back to baseline.</p> + <button type="button" disabled className="ml-3 shrink-0 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted opacity-60" title="This is an example — revoke appears on real grants">Revoke</button> + </div> + </div> + </div> + </div> + ) : ( + <ul className="space-y-3"> + {egress.map((e) => ( + <li key={`${e.namespace}/${e.name}`} className="rounded-lg border border-border bg-surface p-4"> + <div className="flex items-center justify-between gap-3"> + <span className="font-mono text-sm font-medium">{e.sandbox ?? e.name}</span> + <Badge tone={phaseTone(e.phase)} dot> + {e.phase ?? "Unknown"} + </Badge> + </div> + {e.hosts.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1"> + {e.hosts.map((h) => ( + <span key={h} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground-muted">{h}</span> + ))} + </div> + )} + {e.reason && <p className="mt-2 text-xs text-foreground-muted">{e.reason}</p>} + {e.expires_at && ( + <p className="mt-0.5 text-xs text-foreground-muted">expires {new Date(e.expires_at).toLocaleString()}</p> + )} + <div className="mt-2"> + <DeleteResource kind="EgressApproval" name={e.name} label="egress grant" verb="Revoke" /> + </div> + </li> + ))} + </ul> + )} + </Section> + + <p className="px-1 text-xs text-foreground-muted"> + Mission-level steering decisions (tool-call gates, tier raises) are handled by task-givers in + the Workspace inbox — operators see them here only when they require a platform policy change. + </p> + </div> + ); +} diff --git a/bridge/web/src/app/console/audit/audit-receipt-row.tsx b/bridge/web/src/app/console/audit/audit-receipt-row.tsx new file mode 100644 index 000000000..a4006221f --- /dev/null +++ b/bridge/web/src/app/console/audit/audit-receipt-row.tsx @@ -0,0 +1,390 @@ +"use client"; + +// kars Bridge Operator Console — a single auditable receipt row. Collapsed it +// shows the mission + signed verdict; expanded it shows exactly what the receipt +// attests (the claim classes with PASS/PARTIAL/FAIL), the signing scheme, the +// inclusion-log position, and the precise command an auditor runs to verify it +// independently. This is the auditor's working unit — not an opaque hash. + +import { useState } from "react"; +import type { Receipt, VerifyResult, CompliancePack } from "@/lib/types"; +import { CompliancePackView } from "@/components/compliance-pack"; + +function claimTone(status: string): { cls: string; label: string } { + const s = status.toUpperCase(); + if (s === "PASS" || s === "OK") return { cls: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600", label: "PASS" }; + if (s === "PARTIAL") return { cls: "border-amber-500/30 bg-amber-500/10 text-amber-600", label: "PARTIAL" }; + if (s === "FAIL" || s === "ERROR") return { cls: "border-rose-500/30 bg-rose-500/10 text-rose-600", label: "FAIL" }; + return { cls: "border-border bg-surface-muted text-foreground-muted", label: status }; +} + +const CLAIM_LABEL: Record<string, string> = { + integrity: "Integrity — is it authentic & unaltered?", + conformance: "Conformance — did it stay within its authority?", + completeness: "Completeness — were all controls enforced & recorded?", + regulatory: "Regulatory — how is it anchored & signed?", +}; + +export function AuditReceiptRow({ + ns, + task, + summarySeq, + summaryTask, + summaryVerdict, + summaryCreated, +}: { + ns: string; + task: string; + summarySeq: number | null; + summaryTask: string; + summaryVerdict: "verified" | "failed" | "partial" | "none"; + summaryCreated?: string | null; +}) { + const [open, setOpen] = useState(false); + const [receipt, setReceipt] = useState<Receipt | null>(null); + const [compliance, setCompliance] = useState<CompliancePack | null>(null); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState<string | null>(null); + const [verifying, setVerifying] = useState(false); + const [result, setResult] = useState<VerifyResult | null>(null); + const [verifyError, setVerifyError] = useState<string | null>(null); + + // Lazy-load the full receipt (claims + signature material) only when the row + // is first expanded — so the audit page doesn't N+1-fetch every receipt into a + // huge initial payload. Uses the same-origin /api proxy (never the server-only + // @/lib/bff client, which would bundle server config into the browser). + async function toggle() { + const next = !open; + setOpen(next); + if (next && !receipt && !loading) { + setLoading(true); + setLoadError(null); + try { + const res = await fetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/receipt`, + { headers: { accept: "application/json" } }, + ); + if (res.status === 404) { + setReceipt(null); + } else if (!res.ok) { + throw new Error(`load failed: ${res.status}`); + } else { + setReceipt(await res.json()); + } + // Also pull the compliance evidence pack (EU AI Act / NIST AI RMF) + // derived from this same signed receipt — so the auditor sees the + // regulatory conformance mapping without leaving the auditor surface. + try { + const cres = await fetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/compliance`, + { headers: { accept: "application/json" } }, + ); + if (cres.ok) setCompliance(await cres.json()); + } catch { + /* compliance is best-effort; the receipt still renders */ + } + } catch { + setLoadError("This receipt's full attestation couldn't be loaded."); + } finally { + setLoading(false); + } + } + } + + async function runVerify() { + if (!receipt) return; + setVerifying(true); + setVerifyError(null); + setResult(null); + try { + const res = await fetch( + `/api/namespaces/${encodeURIComponent(receipt.namespace)}/tasks/${encodeURIComponent(receipt.task)}/receipt/verify`, + { method: "POST", headers: { accept: "application/json" } }, + ); + if (!res.ok) throw new Error(`verify failed: ${res.status}`); + const r: VerifyResult = await res.json(); + setResult(r); + } catch { + setVerifyError("Verification couldn't be completed — the audit backend may be unreachable."); + } finally { + setVerifying(false); + } + } + + // Overall verdict: once the full receipt is loaded, derive it from its claim + // set; before that, use the cheap summary verdict the BFF computed from the + // receipt's claim matrix — so the collapsed row is accurate without an eager + // full-receipt fetch. + const claims = receipt?.claims ?? []; + // Mirror the BFF verdict rule: the regulatory claim is a V0 maturity dimension + // (always PARTIAL/OMITTED until an external anchor lands — a named V1 item), + // and OMITTED is an honest disclosure, not a failure. Neither blocks a Verified + // verdict, so the badge reflects the cryptographic claims (integrity + + // conformance + completeness); the regulatory/omitted maturity is still shown + // in the expanded claim detail. Without this, every receipt read "Partial". + const isAdvisoryClaim = (c: { class?: string; status: string }) => + (c.class ?? "").toLowerCase() === "regulatory" || c.status.toUpperCase() === "OMITTED"; + const allStatuses = claims.map((c) => c.status.toUpperCase()); + const coreStatuses = claims.filter((c) => !isAdvisoryClaim(c)).map((c) => c.status.toUpperCase()); + const effectiveVerdict = receipt + ? allStatuses.includes("FAIL") || allStatuses.includes("ERROR") + ? "failed" + : coreStatuses.length > 0 && coreStatuses.every((s) => s === "PASS" || s === "OK") + ? "verified" + : claims.length > 0 + ? "partial" + : "none" + : summaryVerdict; + const VERDICT: Record<string, { cls: string; label: string }> = { + verified: { cls: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600", label: "Verified" }, + failed: { cls: "border-rose-500/30 bg-rose-500/10 text-rose-600", label: "Failed" }, + partial: { cls: "border-amber-500/30 bg-amber-500/10 text-amber-600", label: "Partial" }, + none: { cls: "border-border bg-surface-muted text-foreground-muted", label: "No claims" }, + }; + const verdict = VERDICT[effectiveVerdict] ?? VERDICT.none; + + return ( + <li className="border-b border-border last:border-0"> + <button + type="button" + onClick={toggle} + className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-surface-muted/40" + > + <span className="w-12 shrink-0 font-mono text-xs tabular-nums text-foreground-muted">{summarySeq ?? "—"}</span> + <span className="min-w-0 flex-1 truncate font-mono text-sm font-medium">{summaryTask}</span> + {summaryCreated && ( + <span className="hidden shrink-0 text-xs text-foreground-muted sm:inline" title={summaryCreated}> + {new Date(summaryCreated).toLocaleDateString(undefined, { month: "short", day: "numeric" })} + </span> + )} + <span className={`shrink-0 rounded-full border px-2.5 py-0.5 text-xs font-medium ${verdict.cls}`}>{verdict.label}</span> + <span className="shrink-0 text-foreground-muted transition" style={{ transform: open ? "rotate(180deg)" : "none" }} aria-hidden>⌄</span> + </button> + + {open && ( + <div className="space-y-4 border-t border-border bg-surface-muted/20 px-4 py-4"> + {loading ? ( + <p className="text-xs text-foreground-muted">Loading the full attestation…</p> + ) : loadError ? ( + <p className="text-xs text-foreground-muted">{loadError}</p> + ) : !receipt ? ( + <p className="text-xs text-foreground-muted">This receipt’s full attestation couldn’t be loaded.</p> + ) : ( + <> + {/* What it attests — the claim classes in plain language. */} + <div> + <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-foreground-muted">What this receipt attests</p> + <ul className="space-y-2"> + {claims.map((c) => { + const t = claimTone(c.status); + return ( + <li key={c.class} className="rounded-lg border border-border bg-surface p-3"> + <div className="flex items-center justify-between gap-3"> + <span className="text-xs font-medium">{CLAIM_LABEL[c.class] ?? c.class}</span> + <span className={`shrink-0 rounded border px-1.5 py-0.5 font-mono text-[11px] font-medium ${t.cls}`}>{t.label}</span> + </div> + <p className="mt-1 text-xs leading-relaxed text-foreground-muted">{c.detail}</p> + </li> + ); + })} + </ul> + </div> + + {/* Provenance facts. */} + <dl className="grid gap-x-6 gap-y-2 text-xs sm:grid-cols-2"> + <div> + <dt className="text-foreground-muted">Signing scheme</dt> + <dd className="font-mono">{receipt.scheme}</dd> + </div> + <div> + <dt className="text-foreground-muted">Inclusion-log position</dt> + <dd className="font-mono">#{receipt.inclusion_seq ?? "—"}</dd> + </div> + <div className="sm:col-span-2"> + <dt className="text-foreground-muted">Signed by (receipt-signing key)</dt> + <dd className="font-mono break-all">{receipt.key_id}</dd> + </div> + </dl> + + {/* Independent verification — performed live in the browser via + the backend, against the published key anchor. No CLI needed. */} + <div> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div> + <p className="text-xs font-semibold">Verify this receipt</p> + <p className="text-[11px] text-foreground-muted"> + Re-checks the signature, the trust-envelope binding, and the signing key against the cluster’s published anchor — independently of this screen. + </p> + </div> + <button + type="button" + onClick={runVerify} + disabled={verifying} + className="shrink-0 rounded-lg bg-signal px-3.5 py-2 text-xs font-semibold text-signal-fg hover:opacity-90 disabled:opacity-50" + > + {verifying ? "Verifying…" : result ? "Re-verify" : "Verify now"} + </button> + </div> + + {verifyError && <p className="mt-2 text-xs text-danger">{verifyError}</p>} + + {result && ( + <div className="mt-3 space-y-3 rounded-lg border border-border bg-surface p-3"> + <div className="flex items-center gap-2"> + <span + className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold ${ + result.verified + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + : "border-rose-500/30 bg-rose-500/10 text-rose-600" + }`} + > + {result.verified ? "✓ Verified" : "✗ Not verified"} + </span> + <span className="text-[11px] text-foreground-muted"> + {result.verified ? "authentic, unaltered, chained, and checkpoint-pinned" : "one or more checks failed"} + </span> + </div> + + {/* Each check shows the recorded value next to the value the + backend independently recomputed — the auditor SEES them + match, not just a green tick. */} + <ul className="space-y-2"> + {result.checks.map((c) => ( + <li key={c.name} className="text-xs"> + <div className="flex gap-2"> + <span aria-hidden className={c.advisory ? "text-foreground-muted" : c.passed ? "text-emerald-600" : "text-rose-600"}> + {c.advisory ? "ℹ" : c.passed ? "✓" : "✗"} + </span> + <span className="min-w-0"> + <span className="font-medium">{c.name}</span> + <span className="text-foreground-muted"> — {c.detail}</span> + {c.advisory && ( + <span className="ml-1 rounded bg-surface-muted px-1 py-0.5 text-[10px] font-medium text-foreground-muted">shown, not verified</span> + )} + </span> + </div> + {(c.expected || c.computed) && ( + <dl className="ml-5 mt-1 space-y-0.5 font-mono text-[10px] text-foreground-muted"> + {c.expected && ( + <div className="flex gap-1.5"> + <dt className="shrink-0 w-20 not-italic">recorded</dt> + <dd className="truncate" title={c.expected}>{c.expected}</dd> + </div> + )} + {c.computed && ( + <div className="flex gap-1.5"> + <dt className="shrink-0 w-20">recomputed</dt> + <dd className={`truncate ${c.passed && c.expected === c.computed ? "text-emerald-600" : ""}`} title={c.computed}>{c.computed}</dd> + </div> + )} + </dl> + )} + </li> + ))} + </ul> + + <EvidencePanel result={result} task={receipt.task} /> + </div> + )} + {compliance && <CompliancePackView pack={compliance} />} + </div> + </> + )} + </div> + )} + </li> + ); +} + +/** The raw artifacts behind the verdict — the signed statement an auditor can + * read & download, the inclusion-proof entry, and the signed checkpoint. */ +function EvidencePanel({ result, task }: { result: VerifyResult; task: string }) { + const ev = result.evidence; + if (!ev) return null; + const statementStr = ev.signed_statement ? JSON.stringify(ev.signed_statement, null, 2) : null; + + function download() { + if (!statementStr) return; + const blob = new Blob([statementStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${task}.receipt-statement.json`; + a.click(); + URL.revokeObjectURL(url); + } + + return ( + <details className="group rounded-lg border border-border bg-surface-muted/30"> + <summary className="flex cursor-pointer items-center justify-between px-3 py-2 text-xs font-semibold"> + Evidence — the artifacts this verdict was computed over + <span className="text-foreground-muted transition group-open:rotate-180" aria-hidden>⌄</span> + </summary> + <div className="space-y-3 border-t border-border px-3 py-3"> + {/* The signed payload — the actual evidence for integrity & the claims. */} + {statementStr && ( + <div> + <div className="mb-1 flex items-center justify-between"> + <p className="text-[11px] font-semibold">Signed statement (the exact bytes the signature covers)</p> + <button type="button" onClick={download} className="rounded border border-border px-2 py-0.5 text-[10px] font-medium hover:bg-surface"> + Download JSON + </button> + </div> + <pre className="max-h-56 overflow-auto rounded border border-border bg-surface px-2.5 py-2 font-mono text-[10px] leading-relaxed">{statementStr}</pre> + </div> + )} + + {/* Signature material. */} + <dl className="grid gap-1.5 text-[10px] sm:grid-cols-2"> + <EvFact label="Scheme" value={ev.scheme ?? undefined} /> + <EvFact label="Anchor key (trusted)" value={ev.anchor_key_id ?? undefined} mono /> + <EvFact label="Anchor public key (b64)" value={ev.anchor_public_key_b64 ?? undefined} mono wide /> + <EvFact label="Signature (b64)" value={ev.signature_b64 ?? undefined} mono wide /> + </dl> + + {/* Inclusion proof. */} + {ev.inclusion && ( + <div className="rounded border border-border bg-surface p-2.5"> + <p className="mb-1 text-[11px] font-semibold">Inclusion proof — position #{ev.inclusion.seq} of {ev.inclusion.tree_size}</p> + <dl className="grid gap-1 font-mono text-[10px]"> + <EvFact label="payload sha256" value={ev.inclusion.payload_sha256} mono wide /> + <EvFact label="prev hash" value={ev.inclusion.prev_hash} mono wide /> + <EvFact label="entry hash" value={ev.inclusion.entry_hash} mono wide /> + <EvFact label="recomputed" value={ev.inclusion.recomputed_entry_hash} mono wide match={ev.inclusion.entry_hash === ev.inclusion.recomputed_entry_hash} /> + <EvFact label="chain head" value={ev.inclusion.chain_head} mono wide /> + </dl> + <p className="mt-1 text-[10px] text-foreground-muted"> + {ev.inclusion.chain_consistent ? "✓ All entries recompute genesis → head." : "✗ Chain inconsistent."} + </p> + </div> + )} + + {/* Signed checkpoint + witness. */} + {ev.checkpoint && ( + <div className="rounded border border-border bg-surface p-2.5"> + <p className="mb-1 text-[11px] font-semibold"> + Signed checkpoint {ev.checkpoint.signature_valid ? <span className="text-emerald-600">✓ signature valid</span> : <span className="text-rose-600">✗ invalid</span>} + </p> + <dl className="grid gap-1 font-mono text-[10px]"> + <EvFact label="tree size" value={String(ev.checkpoint.tree_size)} mono /> + <EvFact label="root hash" value={ev.checkpoint.root_hash} mono wide /> + <EvFact label="signed note" value={ev.checkpoint.signed_note.replace(/\n/g, "\\n")} mono wide /> + <EvFact label="signature (b64)" value={ev.checkpoint.signature_b64} mono wide /> + {ev.checkpoint.witness_key_id && <EvFact label="witness key (independent)" value={ev.checkpoint.witness_key_id} mono wide />} + </dl> + </div> + )} + </div> + </details> + ); +} + +function EvFact({ label, value, mono, wide, match }: { label: string; value?: string; mono?: boolean; wide?: boolean; match?: boolean }) { + if (!value) return null; + return ( + <div className={`flex gap-1.5 ${wide ? "sm:col-span-2" : ""}`}> + <dt className="shrink-0 text-foreground-muted">{label}</dt> + <dd className={`min-w-0 truncate ${mono ? "font-mono" : ""} ${match ? "text-emerald-600" : ""}`} title={value}>{value}</dd> + </div> + ); +} diff --git a/bridge/web/src/app/console/audit/audit-search.tsx b/bridge/web/src/app/console/audit/audit-search.tsx new file mode 100644 index 000000000..9b43b0695 --- /dev/null +++ b/bridge/web/src/app/console/audit/audit-search.tsx @@ -0,0 +1,167 @@ +"use client"; + +// kars Bridge Operator Console — audit search. The auditor's investigative +// entry point: type a run (mission/team-run) or agent name and see exactly that +// subject's receipts and where each sits in the hash-chained inclusion log — the +// chain of custody for one run, not the whole fleet at once. Auditing is an +// OPERATOR capability: the workspace (employee) surface never exposes the +// fleet-wide audit log; an operator investigates a specific run/agent here. + +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { AuditReceiptRow } from "./audit-receipt-row"; +import type { ReceiptSummary } from "@/lib/types"; + +export function AuditSearch({ receipts }: { receipts: ReceiptSummary[] }) { + const [query, setQuery] = useState(""); + const [verdict, setVerdict] = useState<"all" | "verified" | "partial" | "failed">("all"); + const [newestFirst, setNewestFirst] = useState(true); + const q = query.trim().toLowerCase(); + + const matches = useMemo(() => { + let out = receipts.filter((r) => { + if (verdict !== "all" && r.verdict !== verdict) return false; + if (!q) return true; + const hay = [r.task ?? "", r.name, r.namespace].join(" ").toLowerCase(); + return hay.includes(q); + }); + out = [...out].sort((a, b) => { + const av = a.created ?? ""; + const bv = b.created ?? ""; + return newestFirst ? bv.localeCompare(av) : av.localeCompare(bv); + }); + return out; + }, [receipts, q, verdict, newestFirst]); + + const verdictCounts = useMemo(() => { + const c = { verified: 0, partial: 0, failed: 0 }; + for (const r of receipts) { + if (r.verdict === "verified") c.verified++; + else if (r.verdict === "partial") c.partial++; + else if (r.verdict === "failed") c.failed++; + } + return c; + }, [receipts]); + + // Chain of custody for the current result set: the ordered inclusion-log + // positions the matched receipts occupy. Contiguity is a signal, not a + // requirement — a run's receipts interleave with other runs in one global log. + const chain = useMemo( + () => + matches + .filter((r) => r.inclusion_seq != null) + .map((r) => r.inclusion_seq as number) + .sort((a, b) => a - b), + [matches], + ); + const unchained = matches.filter((r) => r.inclusion_seq == null).length; + + return ( + <div className="space-y-4"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> + <label className="relative flex-1"> + <span className="sr-only">Search audit records by run or agent</span> + <svg + viewBox="0 0 20 20" + aria-hidden + className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground-muted" + fill="currentColor" + > + <path d="M9 3.5a5.5 5.5 0 1 0 3.4 9.83l3.13 3.14a1 1 0 0 0 1.42-1.42l-3.14-3.13A5.5 5.5 0 0 0 9 3.5Zm0 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Z" /> + </svg> + <input + type="search" + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search by run or agent name (e.g. teamx-run-… or a mission name)" + className="w-full rounded-lg border border-border bg-surface py-2 pl-9 pr-3 text-sm outline-none transition focus:border-signal focus:ring-2 focus:ring-signal/30" + /> + </label> + <Badge tone="muted"> + {q || verdict !== "all" ? `${matches.length} of ${receipts.length}` : `${receipts.length}`} receipt + {matches.length === 1 ? "" : "s"} + </Badge> + </div> + + {/* Verdict filter + sort (f35) — narrow by outcome and order by time. */} + <div className="flex flex-wrap items-center gap-2"> + {([ + ["all", `All ${receipts.length}`], + ["verified", `Verified ${verdictCounts.verified}`], + ["partial", `Partial ${verdictCounts.partial}`], + ["failed", `Failed ${verdictCounts.failed}`], + ] as const).map(([v, label]) => ( + <button + key={v} + type="button" + onClick={() => setVerdict(v)} + className={`rounded-full border px-3 py-1 text-xs font-medium transition ${ + verdict === v ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground" + }`} + > + {label} + </button> + ))} + <button + type="button" + onClick={() => setNewestFirst((v) => !v)} + className="ml-auto rounded-full border border-border px-3 py-1 text-xs font-medium text-foreground-muted hover:text-foreground" + title="Toggle chronological order" + > + {newestFirst ? "Newest first ↓" : "Oldest first ↑"} + </button> + </div> + + {/* Chain-of-custody strip for the current subject. */} + {q && chain.length > 0 && ( + <div className="rounded-lg border border-signal/25 bg-signal/[0.04] p-3"> + <p className="text-[11px] font-semibold uppercase tracking-wide text-signal"> + Chain of custody · {matches.length} receipt{matches.length === 1 ? "" : "s"} for “{query.trim()}” + </p> + <p className="mt-1 font-mono text-xs text-foreground-muted"> + inclusion-log positions: {chain.map((n) => `#${n}`).join(" → ")} + {unchained > 0 && ( + <span className="text-amber-600"> · {unchained} not yet chained</span> + )} + </p> + </div> + )} + + {matches.length === 0 ? ( + <HonestState + variant="empty" + compact + title={ + q + ? "No matching receipts" + : verdict !== "all" + ? `No ${verdict} receipts` + : "No receipts yet" + } + detail={ + q + ? "No governance receipt matches that run or agent. Check the exact run/agent name, or clear the search to see all." + : verdict !== "all" + ? `${receipts.length} receipt${receipts.length === 1 ? "" : "s"} exist, but none currently carry the “${verdict}” verdict. Clear the filter to see them.` + : "A receipt is issued for each governance-validated run. None have been produced." + } + /> + ) : ( + <ul className="overflow-hidden rounded-lg border border-border"> + {matches.map((r) => ( + <AuditReceiptRow + key={`${r.namespace}/${r.name}`} + ns={r.namespace} + task={r.task ?? r.name} + summarySeq={r.inclusion_seq} + summaryTask={r.task ?? r.name} + summaryVerdict={r.verdict} + summaryCreated={r.created} + /> + ))} + </ul> + )} + </div> + ); +} diff --git a/bridge/web/src/app/console/audit/page.tsx b/bridge/web/src/app/console/audit/page.tsx new file mode 100644 index 000000000..fc1f84410 --- /dev/null +++ b/bridge/web/src/app/console/audit/page.tsx @@ -0,0 +1,32 @@ +// kars Bridge Operator Console — Audit. The auditor's working surface, rendered +// from the shared AuditView so the Console and the dedicated /audit surface +// never drift. A chain-integrity verdict, then every Governance Receipt as an +// inspectable, independently-verifiable unit. + +import { PageHeader } from "@/components/ui"; +import { AuditView } from "@/components/audit-view"; +import { getAudit } from "@/lib/bff"; +import type { Audit } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export default async function AuditPage() { + let audit: Audit | null = null; + let error = false; + try { + audit = await getAudit(); + } catch { + error = true; + } + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Auditor view" + lead="The tamper-evident record of everything the platform ran. Confirm the log is intact, inspect what each receipt attests, and verify any of it independently — no trust in this screen required." + /> + <AuditView audit={audit} error={error} /> + </div> + ); +} diff --git a/bridge/web/src/app/console/author-resource.tsx b/bridge/web/src/app/console/author-resource.tsx new file mode 100644 index 000000000..2ee4addec --- /dev/null +++ b/bridge/web/src/app/console/author-resource.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { applyGovernanceAction, type GovState } from "./governance-actions"; + +const init: GovState = { error: null, ok: null }; + +/** Per-kind spec templates so an operator starts from a valid skeleton instead + * of a blank box. Authoring uses Server-Side Apply, so the same form creates a + * new object and edits an existing one (pass `initialName` + `initialSpec`). */ +const TEMPLATES: Record<string, string> = { + ToolPolicy: JSON.stringify( + { + appliesTo: { sandboxMatchLabels: { "kars.azure.com/example": "true" } }, + agtProfile: { inline: "# AGT policy (agentmesh PolicyEngine format)\nallow inference:*\nallow tool:*\n" }, + }, + null, + 2, + ), + McpServer: JSON.stringify( + { url: "https://example.internal/mcp", allowedTools: ["*"], displayName: "Example MCP server" }, + null, + 2, + ), + KarsSkill: JSON.stringify( + { + summary: "What this skill does", + version: "0.1.0", + boundingPolicy: "kars-default", + recipe: "Standing instructions for using this capability well.", + scripts: [ + { path: "scripts/helper.sh", content: "#!/usr/bin/env bash\necho hello", executable: true }, + ], + }, + null, + 2, + ), + KarsProfile: JSON.stringify( + { + displayName: "Example team profile", + domain: "eng", + charterTemplate: "Keep the repo healthy: triage issues, watch PRs, report failing checks.", + defaultEnvelope: { tier: 3, authorityCeiling: 2, delegationDepth: 1, toolPolicyRef: { name: "kars-default" } }, + toolPolicy: "kars-default", + roles: [{ name: "triager", systemPrompt: "Triage incoming issues.", skills: [] }], + }, + null, + 2, + ), + InferencePolicy: JSON.stringify( + { + appliesTo: { sandboxMatchLabels: { "kars.azure.com/example": "true" } }, + tokenBudget: { dailyTokens: 50000 }, + contentSafety: { hate: "medium", violence: "medium" }, + displayName: "Example inference policy", + }, + null, + 2, + ), +}; + +const LABEL: Record<string, string> = { + ToolPolicy: "tool policy", + McpServer: "MCP server", + KarsSkill: "skill", + KarsProfile: "team profile", + InferencePolicy: "inference policy", +}; + +export function AuthorResource({ + kind, + initialName, + initialSpec, +}: { + kind: "ToolPolicy" | "McpServer" | "KarsSkill" | "KarsProfile" | "InferencePolicy"; + initialName?: string; + initialSpec?: string; +}) { + const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState(applyGovernanceAction, init); + const editing = Boolean(initialName); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + {editing ? `Edit ${initialName}` : `+ Add ${LABEL[kind]}`} + </button> + ); + } + + return ( + <form action={action} className="mt-2 space-y-2 rounded-lg border border-border bg-surface-muted p-3"> + <input type="hidden" name="kind" value={kind} /> + <div className="flex items-center justify-between"> + <p className="text-xs font-medium">{editing ? `Edit ${LABEL[kind]}` : `New ${LABEL[kind]}`}</p> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + </div> + <input + name="name" + defaultValue={initialName} + readOnly={editing} + placeholder="name (lowercase-with-hyphens)" + required + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm read-only:opacity-70" + /> + <textarea + name="spec" + defaultValue={initialSpec ?? TEMPLATES[kind]} + rows={10} + spellCheck={false} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed" + /> + <p className="text-[11px] text-foreground-muted"> + Applied with <span className="font-mono">kubectl apply</span> semantics (field manager <span className="font-mono">kars-bridge</span>). The cluster validates it — invalid specs are rejected with the API server's own message. + </p> + <label className="flex items-center gap-2 text-[11px] text-foreground-muted"> + <input type="checkbox" name="force" /> Force — take ownership of fields another manager owns (only on a conflict) + </label> + <div className="flex items-center gap-3"> + <button type="submit" disabled={pending} className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Applying…" : editing ? "Save changes" : `Create ${LABEL[kind]}`} + </button> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </div> + </form> + ); +} diff --git a/bridge/web/src/app/console/capabilities/page.tsx b/bridge/web/src/app/console/capabilities/page.tsx new file mode 100644 index 000000000..7e157c2c9 --- /dev/null +++ b/bridge/web/src/app/console/capabilities/page.tsx @@ -0,0 +1,235 @@ +// kars Bridge Operator Console — Agent capabilities. What teams/agents may be +// granted: versioned skills, ready-made team profiles, MCP services (bounded +// by a tool policy), and per-agent runtime credentials. Split out of the +// Configuration page (which is CLUSTER infrastructure — provider, Foundry, +// GitHub App) so the two layers get their own place in the nav instead of one +// long scroll: what this cluster runs on vs. what your teams may use. + +import { listMcpServers, listProfiles, listSkills, getOptions } from "@/lib/bff"; +import { PageHeader, Section, Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { CredentialForm } from "../configuration/credential-form"; +import { AuthorResource } from "../author-resource"; +import { ProfileEditor } from "../profile-editor"; +import { DeleteResource } from "../delete-resource"; +import { SkillApproval } from "../skill-approval"; +import { SkillComposer } from "@/components/skill-composer"; +import { submitSkillConsoleAction } from "../skill-submit-action"; +import { McpProfiles } from "../mcp-profiles"; +import { McpCatalog } from "../mcp-catalog"; +import { McpServerEditor } from "../mcp-server-editor"; +import { Icon } from "@/components/icon"; +import type { McpServer, Options, ProfileSummary, SkillSummary } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export default async function CapabilitiesPage() { + let options: Options | null = null; + let skills: SkillSummary[] = []; + let profiles: ProfileSummary[] = []; + let mcp: McpServer[] = []; + try { + [options, skills, profiles, mcp] = await Promise.all([ + getOptions(), + listSkills().catch(() => []), + listProfiles().catch(() => []), + listMcpServers().catch(() => []), + ]); + } catch { + options = null; + } + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Agent capabilities" + lead="Vetted building blocks teams pick from — skills, ready-made team profiles, MCP services, and runtime credentials. The cluster's inference provider and Foundry connection live under Configuration." + /> + + <Section + title="Skills" + subtitle="Versioned capability bundles a team can acquire — the same guided upload used in the Workspace; attested + operator-approved before use." + > + <div className="mb-3"> + <SkillComposer toolPolicies={options?.tool_policies ?? []} submit={submitSkillConsoleAction} /> + </div> + {skills.length === 0 ? ( + <HonestState variant="empty" compact title="No skills yet" detail="Register a KarsSkill to offer it to teams." /> + ) : ( + <ul className="grid gap-2 sm:grid-cols-2"> + {skills.map((s) => ( + <li + key={s.name} + className="rounded-lg border border-border bg-surface px-3 py-2.5 text-sm" + > + <div className="flex items-center justify-between"> + <span className="flex items-center gap-1.5"> + <Icon name="bolt" size={13} /> + <span className="font-medium">{s.name}</span> + {s.version && <span className="text-xs text-foreground-muted">v{s.version}</span>} + </span> + <Badge tone={s.attestation_verified === true ? "ok" : s.attestation_verified === false ? "warn" : "muted"}> + {s.attestation_verified === true + ? "attested" + : s.attestation_verified === false + ? "unverified" + : s.phase ?? "attestation unknown"} + </Badge> + </div> + <div className="mt-2 flex flex-wrap items-center gap-2"> + <SkillApproval skill={s} /> + <span className="h-4 w-px bg-border" aria-hidden /> + <AuthorResource kind="KarsSkill" initialName={s.name} initialSpec={JSON.stringify(s.spec, null, 2)} /> + <DeleteResource kind="KarsSkill" name={s.name} label="skill" /> + </div> + </li> + ))} + </ul> + )} + </Section> + + <Section + title="Team profiles" + subtitle="Vetted org templates per domain — instantiate a whole standing team in one click." + action={<ProfileEditor toolPolicies={options?.tool_policies ?? []} skills={options?.skills ?? []} />} + > + {profiles.length === 0 ? ( + <HonestState variant="empty" compact title="No profiles yet" detail="Publish a profile to offer a ready-made team, then instantiate it from the Teams page." /> + ) : ( + <ul className="grid gap-2 sm:grid-cols-2"> + {profiles.map((p) => ( + <li + key={p.name} + className="rounded-lg border border-border bg-surface px-3 py-2.5 text-sm" + > + <div className="flex items-center justify-between gap-2"> + <span className="min-w-0"> + <span className="font-medium">{p.display_name ?? p.name}</span> + <span className="ml-2 text-xs text-foreground-muted"> + {p.roles.length} role{p.roles.length === 1 ? "" : "s"} + {p.tier != null ? ` · Tier ${p.tier}` : ""} + </span> + </span> + <span className="flex shrink-0 items-center gap-2 text-xs text-foreground-muted"> + {p.domain && <Badge tone="muted">{p.domain}</Badge>} + {p.phase ?? "ready"} + </span> + </div> + {p.charter_template && ( + <p className="mt-1 line-clamp-2 text-xs text-foreground-muted">{p.charter_template}</p> + )} + <div className="mt-2 flex flex-wrap items-center gap-3"> + <ProfileEditor initialName={p.name} initialSpec={p.spec} toolPolicies={options?.tool_policies ?? []} skills={options?.skills ?? []} /> + <DeleteResource kind="KarsProfile" name={p.name} label="team profile" /> + </div> + </li> + ))} + </ul> + )} + </Section> + + <Section + title="Internal-system integrations" + subtitle="Services agents can be granted — docs stores, repos, Foundry tools — reached over MCP and bounded by a tool policy." + action={<McpServerEditor />} + > + {/* Managed and external modes are intentionally distinct: "installed" + means the controller owns a real workload; "registered" means the + operator supplied an already-running endpoint. */} + <details className="group mb-3 rounded-lg border border-border bg-surface-muted/30 p-3 text-xs"> + <summary className="flex cursor-pointer items-center justify-between font-medium"> + What happens when you add an MCP server? + <span className="text-foreground-muted transition group-open:rotate-180" aria-hidden>⌄</span> + </summary> + <ol className="mt-2 space-y-1.5 text-foreground-muted"> + <li><span className="font-medium text-foreground">1. Installed or registered</span> — a managed preset creates a real Deployment + Service; an external entry records the real endpoint you already operate.</li> + <li><span className="font-medium text-foreground">2. Probed</span> — managed servers must complete <code className="font-mono">initialize → tools/list</code>; their discovered tool names and schema digest are recorded before Ready.</li> + <li><span className="font-medium text-foreground">3. Offered</span> — Ready servers appear for missions/teams, scoped by <code className="font-mono">allowedSandboxes</code> and bounded by the team’s tool policy.</li> + <li><span className="font-medium text-foreground">4. Brokered</span> — the sandbox router namespaces and governs every tool call, then forwards it. The agent gets neither the upstream endpoint nor its credentials.</li> + </ol> + </details> + <div className="mb-3"><McpCatalog /></div> + {mcp.length === 0 ? ( + <HonestState + variant="empty" + compact + title="No services connected" + detail="Pick one from the catalog above, or register a custom MCP server." + /> + ) : ( + <ul className="space-y-2"> + {mcp.map((m) => ( + <li + key={m.name} + className="rounded-lg border border-border bg-surface px-3 py-2.5 text-sm" + > + <div className="flex items-center justify-between gap-3"> + <span className="flex items-center gap-1.5"> + <Icon name="plug" size={13} /> + <span className="font-medium">{m.name}</span> + </span> + <div className="flex items-center gap-2"> + <Badge tone={m.phase === "Ready" ? "ok" : m.phase === "Degraded" ? "danger" : "warn"} dot> + {m.phase ?? "Pending"} + </Badge> + <span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] text-foreground-muted"> + {m.mode ?? (m.spec.managed ? "Managed" : "External")} + </span> + </div> + </div> + <p className="mt-1 truncate font-mono text-xs text-foreground-muted"> + {m.workload_ref ? `workload ${m.workload_ref}` : (m.endpoint ?? m.url ?? "endpoint pending")} + </p> + {m.discovered_tools.length > 0 && ( + <p className="mt-1 text-[11px] text-foreground-muted"> + {m.discovered_tools.length} tools verified + {m.tool_schema_digest ? ` · ${m.tool_schema_digest.slice(0, 19)}…` : ""} + </p> + )} + <div className="mt-2 flex items-center gap-3"> + <McpServerEditor initialName={m.name} initialSpec={m.spec} /> + <DeleteResource kind="McpServer" name={m.name} label="MCP server" /> + </div> + </li> + ))} + </ul> + )} + {/* Operator-curated MCP profiles — vetted bundles users pick as a set. */} + <div className="mt-5 border-t border-border pt-4"> + <h3 className="text-sm font-semibold">MCP profiles</h3> + <p className="mt-0.5 text-xs text-foreground-muted">Named, vetted bundles of the servers above. Users add a whole bundle in one click; a profile can only reference registered servers.</p> + <div className="mt-3"> + <McpProfiles profiles={options?.mcp_profiles ?? []} servers={options?.mcp_servers ?? []} /> + </div> + </div> + </Section> + + <Section + title="Secure credentials" + subtitle="Give an agent or team a repo or service token without baking it into an image. It is stored as a write-only secret that only that agent can read at runtime — never visible to the model or the UI." + > + {/* Make the security model explicit — the operator asked how these work. */} + <ul className="mb-4 grid gap-2 sm:grid-cols-2"> + <li className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/[0.04] p-3 text-xs"> + <Icon name="lock" size={14} /> + <span><span className="font-medium text-foreground">Write-only.</span> You set the value once; the API and this screen never return it again — there is no read path for the plaintext.</span> + </li> + <li className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/[0.04] p-3 text-xs"> + <Icon name="box" size={14} /> + <span><span className="font-medium text-foreground">Stored as a K8s Secret</span> (<code className="font-mono"><name>-credentials</code>) in that agent’s own namespace — not in the image, not in the CRD, not in git.</span> + </li> + <li className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/[0.04] p-3 text-xs"> + <Icon name="cross" size={14} /> + <span><span className="font-medium text-foreground">Never seen by the model.</span> It’s injected into the agent container’s env at runtime only; the LLM sees tool results, never the token.</span> + </li> + <li className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/[0.04] p-3 text-xs"> + <Icon name="target" size={14} /> + <span><span className="font-medium text-foreground">Scoped to one agent/team.</span> Only the sandbox it’s bound to can mount it; other namespaces’ pods cannot read it.</span> + </li> + </ul> + <CredentialForm /> + </Section> + </div> + ); +} diff --git a/bridge/web/src/app/console/configuration/additional-provider-actions.ts b/bridge/web/src/app/console/configuration/additional-provider-actions.ts new file mode 100644 index 000000000..84d502a4c --- /dev/null +++ b/bridge/web/src/app/console/configuration/additional-provider-actions.ts @@ -0,0 +1,36 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, putAdditionalProvider, deleteAdditionalProvider } from "@/lib/bff"; + +export interface AdditionalProviderState { error: string | null; ok: string | null } + +export async function addAdditionalProviderAction(_p: AdditionalProviderState, form: FormData): Promise<AdditionalProviderState> { + const tag = String(form.get("tag") ?? "").trim(); + const endpoint = String(form.get("endpoint") ?? "").trim(); + const apiKey = String(form.get("api_key") ?? "").trim(); + const models = String(form.get("models") ?? "").trim(); + if (!tag) return { error: "A provider tag is required (e.g. \"foundry\", \"github-models\").", ok: null }; + if (!models) return { error: "List at least one model deployment id (comma-separated).", ok: null }; + try { + const r = await putAdditionalProvider({ tag, endpoint: endpoint || undefined, api_key: apiKey || undefined, models }); + revalidatePath("/console/configuration"); + revalidatePath("/console/policies"); + return { error: null, ok: r.note }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "configure failed", ok: null }; + } +} + +export async function removeAdditionalProviderAction(_p: AdditionalProviderState, form: FormData): Promise<AdditionalProviderState> { + const tag = String(form.get("tag") ?? "").trim(); + if (!tag) return { error: "Missing tag.", ok: null }; + try { + await deleteAdditionalProvider(tag); + revalidatePath("/console/configuration"); + revalidatePath("/console/policies"); + return { error: null, ok: `${tag} removed.` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "remove failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/configuration/copilot-login-actions.ts b/bridge/web/src/app/console/configuration/copilot-login-actions.ts new file mode 100644 index 000000000..1c7fd4894 --- /dev/null +++ b/bridge/web/src/app/console/configuration/copilot-login-actions.ts @@ -0,0 +1,38 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, copilotLoginStart, copilotLoginPoll, type CopilotLoginStart } from "@/lib/bff"; +import type { DiscoveredModel } from "@/lib/types"; + +export type StartResult = { ok: true; data: CopilotLoginStart } | { ok: false; error: string }; + +/** Begin GitHub's device-flow sign-in for Copilot — returns the user code + + * verification URL to show, and the device code the client polls with. */ +export async function copilotLoginStartAction(): Promise<StartResult> { + try { + const data = await copilotLoginStart(); + return { ok: true, data }; + } catch (e) { + return { ok: false, error: e instanceof BffError ? e.message || e.code : "couldn't start GitHub sign-in" }; + } +} + +export type PollResult = + | { status: "pending" } + | { status: "authorized"; models: DiscoveredModel[] } + | { status: "error"; error: string }; + +/** Poll the device flow. On approval the BFF stores the Copilot-authorized + * token server-side and returns the seat's live models. */ +export async function copilotLoginPollAction(deviceCode: string): Promise<PollResult> { + try { + const r = await copilotLoginPoll(deviceCode); + if (r.status === "authorized") { + revalidatePath("/console/configuration"); + return { status: "authorized", models: r.models ?? [] }; + } + return { status: "pending" }; + } catch (e) { + return { status: "error", error: e instanceof BffError ? e.message || e.code : "sign-in failed" }; + } +} diff --git a/bridge/web/src/app/console/configuration/credential-actions.ts b/bridge/web/src/app/console/configuration/credential-actions.ts new file mode 100644 index 000000000..6d7a46610 --- /dev/null +++ b/bridge/web/src/app/console/configuration/credential-actions.ts @@ -0,0 +1,15 @@ +"use server"; + +import { BffError, putCredential, reviewCredential } from "@/lib/bff"; +import { credentialFormTransition, type CredentialFormState } from "@/lib/credential-review"; + +export type CredState = CredentialFormState; + +export async function putCredentialAction(previous: CredState, form: FormData): Promise<CredState> { + return credentialFormTransition(previous, form, { + review: reviewCredential, write: putCredential, + failure: error => error instanceof BffError ? { + status: error.status, code: error.code, message: error.message, continuation: error.credentialContinuation, + } : undefined, + }); +} diff --git a/bridge/web/src/app/console/configuration/credential-form.tsx b/bridge/web/src/app/console/configuration/credential-form.tsx new file mode 100644 index 000000000..80522a55a --- /dev/null +++ b/bridge/web/src/app/console/configuration/credential-form.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { putCredentialAction, type CredState } from "./credential-actions"; +import { credentialReviewMatches } from "@/lib/credential-review"; + +const init: CredState = { error: null, ok: null, review: null, pending: null }; + +// Source authoring is governed by the core workspace grant, not runtime Secret access. +export function CredentialForm() { + const [state, action, pending] = useActionState(putCredentialAction, init); + const [target, setTarget] = useState(""); + const [key, setKey] = useState(""); + const [kind, setKind] = useState(""); + const [namespace, setNamespace] = useState("kars-system"); + const [targetUid, setTargetUid] = useState(""); + const t = target.trim().toLowerCase(); + const k = key.trim(); + const selectedKind = kind === "KarsSandbox" || kind === "KarsTask" || kind === "KarsTeam" ? kind : null; + const reviewed = selectedKind && state.review && credentialReviewMatches(state.review, { + kind: selectedKind, namespace: namespace.trim(), target: t, key: k, + ...(targetUid.trim() ? { targetUid: targetUid.trim() } : {}), + }) ? state.review : null; + return ( + <form action={action} className="space-y-3"> + <div className="grid gap-2 sm:grid-cols-3"> + <select name="kind" value={kind} onChange={(e) => setKind(e.target.value)} required + aria-label="Credential target kind" + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">Select target kind</option> + <option value="KarsSandbox">Sandbox</option> + <option value="KarsTask">Mission (KarsTask)</option> + <option value="KarsTeam">Standing team</option> + </select> + <input name="namespace" value={namespace} onChange={(e) => setNamespace(e.target.value)} + aria-label="Credential workspace namespace" required + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm" /> + <input name="targetUid" value={targetUid} onChange={(e) => setTargetUid(e.target.value)} + placeholder="Expected target UID (optional for first review)" + aria-label="Reviewed target UID" + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm" /> + </div> + <div className="grid gap-2 sm:grid-cols-3"> + <input name="target" value={target} onChange={(e) => setTarget(e.target.value)} placeholder="target name (e.g. repo-watch)" required + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + <input name="key" value={key} onChange={(e) => setKey(e.target.value)} placeholder="env var (e.g. GITHUB_TOKEN)" required + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + <input name="value" type="password" placeholder={reviewed?.continuation ? "re-enter the same value" : "value"} + required={reviewed !== null} autoComplete="new-password" + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + </div> + {(t || k) && ( + <p className="text-[11px] text-foreground-muted"> + Stores a governed source in workspace <span className="font-mono text-foreground">{namespace}</span>; + no runtime namespace is created. {k && <>The operator grant must permit <span className="font-mono text-foreground">{k}</span>.</>} + {" "}Delivery waits for captured target/source UIDs and controller acknowledgement. + </p> + )} + {state.pending?.receipt.source && ( + <p className="rounded-lg border border-border p-3 text-xs text-foreground-muted"> + Acknowledged source write: <code>{state.pending.receipt.source.name}</code>, + UID <code>{state.pending.receipt.source.uid}</code>, version <code>{state.pending.receipt.source.version}</code>. + No source deletion or automatic resubmission is permitted. Refresh and review before resuming. + </p> + )} + {state.pending && !state.pending.receipt.source && ( + <p className="rounded-lg border border-border p-3 text-xs text-foreground-muted"> + No source write was attempted. A fresh review may advance status-only versions, but cannot accept + a changed target, grant, source or intent. + </p> + )} + {reviewed && ( + <fieldset key={`${reviewed.expiresAt}:${reviewed.submission}:${reviewed.metadata.target.version}`} + className="space-y-2 rounded-lg border border-border p-3 text-xs"> + <legend className="px-1 font-semibold">Current metadata review</legend> + <dl className="grid gap-1 break-all"> + <div>Target UID: <code>{reviewed.metadata.target.uid ?? "not created - unbound staging"}</code></div> + <div>Generation / version: <code>{reviewed.metadata.target.generation ?? "-"}</code> / <code>{reviewed.metadata.target.version ?? "-"}</code></div> + <div>Full target intent: <code>{reviewed.metadata.target.intent ?? "no existing target"}</code></div> + <div>Grant UID / generation: <code>{reviewed.metadata.grant.uid}</code> / <code>{reviewed.metadata.grant.generation}</code></div> + <div>Grant authority: <code>{reviewed.metadata.grant.intent}</code></div> + <div>Source UID / version: <code>{reviewed.metadata.source.uid ?? "not inventoried; exclusive CREATE only"}</code> / <code>{reviewed.metadata.source.version ?? "-"}</code></div> + <div>Credential key: <code>{reviewed.metadata.key}</code>; submission {reviewed.submission} of 3</div> + </dl> + <label className="flex items-start gap-2"> + <input type="checkbox" name="confirmed" required /> + <span>I reviewed this target, complete intent fingerprint, grant and source identity. + {reviewed.continuation && " Keep the same credential value."} + {reviewed.bindingOnly && " Resume binding only this acknowledged source write."}</span> + </label> + </fieldset> + )} + <div className="flex flex-wrap gap-2"> + <button type="submit" name="operation" value="review" formNoValidate disabled={pending} + className="rounded-lg border border-border px-4 py-2 text-sm font-semibold disabled:opacity-50"> + {state.pending ? "Refresh and review current metadata" : "Review credential metadata"} + </button> + <button type="submit" name="operation" value="store" disabled={pending || !reviewed || !!state.pending} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Working…" : reviewed?.bindingOnly ? "Confirm and resume binding" : "Confirm and store credential"} + </button> + <button type="submit" name="operation" value="reset" formNoValidate disabled={pending} + className="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"> + Start a new change + </button> + </div> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </form> + ); +} diff --git a/bridge/web/src/app/console/configuration/github-app-actions.ts b/bridge/web/src/app/console/configuration/github-app-actions.ts new file mode 100644 index 000000000..47500d145 --- /dev/null +++ b/bridge/web/src/app/console/configuration/github-app-actions.ts @@ -0,0 +1,39 @@ +"use server"; + +// kars Bridge Operator Console — GitHub App self-service setup. Replaces the +// manual `kubectl create secret` step with a real form: verifies the App +// credentials against GitHub's own API before storing them, so a typo'd key +// fails loudly here instead of silently later. + +import { revalidatePath } from "next/cache"; +import { putGithubApp, deleteGithubApp, BffError } from "@/lib/bff"; + +export interface GithubAppState { + error: string | null; + ok: string | null; +} + +export async function putGithubAppAction(_prev: GithubAppState, form: FormData): Promise<GithubAppState> { + const app_id = String(form.get("app_id") ?? "").trim(); + const private_key = String(form.get("private_key") ?? "").trim(); + if (!app_id || !private_key) { + return { error: "App ID and private key are both required.", ok: null }; + } + try { + const r = await putGithubApp({ app_id, private_key }); + revalidatePath("/console/configuration"); + return { error: null, ok: `Verified against GitHub${r.name ? ` — ${r.name}` : ""}. ${r.note}` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "Setup failed.", ok: null }; + } +} + +export async function disconnectGithubAppAction(_prev: GithubAppState, _form: FormData): Promise<GithubAppState> { + try { + await deleteGithubApp(); + revalidatePath("/console/configuration"); + return { error: null, ok: "Disconnected — the shared App credential was removed." }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "Disconnect failed.", ok: null }; + } +} diff --git a/bridge/web/src/app/console/configuration/local-inference-actions.ts b/bridge/web/src/app/console/configuration/local-inference-actions.ts new file mode 100644 index 000000000..f0b4ae4c5 --- /dev/null +++ b/bridge/web/src/app/console/configuration/local-inference-actions.ts @@ -0,0 +1,121 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { + BffError, + createLocalModelDeployment, + deleteLocalModelDeployment, + getLocalDeploymentLiveStatus, + type DeployActivity, +} from "@/lib/bff"; + +export interface LocalInferenceState { error: string | null; ok: string | null } + +export async function deployLocalModelAction(_p: LocalInferenceState, form: FormData): Promise<LocalInferenceState> { + const name = String(form.get("name") ?? "").trim(); + const modelId = String(form.get("model_id") ?? "").trim(); + const tier = String(form.get("tier") ?? "cpu").trim(); + const image = String(form.get("image") ?? "").trim(); + if (!name) return { error: "A deployment name is required (lowercase letters, digits, hyphens).", ok: null }; + if (!modelId) return { error: "Pick a model or enter a HuggingFace model id.", ok: null }; + if (tier !== "cpu" && tier !== "gpu") return { error: "Tier must be cpu or gpu.", ok: null }; + try { + await createLocalModelDeployment({ + name, + model_id: modelId, + tier: tier as "cpu" | "gpu", + image: image || undefined, + }); + revalidatePath("/console/configuration"); + return { error: null, ok: `Deploying ${modelId} as "${name}" — this can take a minute on first pull.` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "deploy failed", ok: null }; + } +} + +/** Kick off a local model deployment and return its name so the client can + * then poll {@link pollLocalDeploymentAction} for live progress. Unlike the + * fire-and-forget form action above, this drives a tracked progress UI. */ +export async function startLocalDeployAction(input: { + name: string; + modelId: string; + tier: "cpu" | "gpu"; + image?: string; +}): Promise<{ error: string | null; name: string | null }> { + const name = input.name.trim(); + const modelId = input.modelId.trim(); + if (!name) return { error: "A deployment name is required (lowercase letters, digits, hyphens).", name: null }; + if (!modelId) return { error: "Pick a model or enter a HuggingFace model id.", name: null }; + if (input.tier !== "cpu" && input.tier !== "gpu") return { error: "Tier must be cpu or gpu.", name: null }; + try { + await createLocalModelDeployment({ + name, + model_id: modelId, + tier: input.tier, + image: input.image?.trim() || undefined, + }); + return { error: null, name }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "deploy failed", name: null }; + } +} + +export interface LocalDeployProgress { + found: boolean; + /** Raw CR phase: Pending | Deploying | Running | Failed | … (null before it appears). */ + phase: string | null; + message: string | null; + /** Milestone-derived percentage (0-100) from real cluster signals. */ + percent: number; + /** True once Running — the model is now registered as a provider + in the catalogue. */ + ready: boolean; + /** True on a terminal failure (ImagePullBackOff, CrashLoopBackOff, …). */ + failed: boolean; + failureReason: string | null; + failureMessage: string | null; + /** Real Kubernetes events for this deployment's pods — the live activity feed. */ + activities: DeployActivity[]; + error: string | null; +} + +/** Poll one local deployment's rich LIVE status by name: percentage + real + * pod/container state + the actual Kubernetes event stream (image pull, + * scheduling, container start/fail). This is what drives the deploy + * tracker's progress bar and activity feed. Also refreshes the page once + * Running so the model lands in the catalogue. */ +export async function pollLocalDeploymentAction(name: string): Promise<LocalDeployProgress> { + try { + const s = await getLocalDeploymentLiveStatus(name); + if (s.ready) revalidatePath("/console/configuration"); + return { + found: s.found, + phase: s.phase, + message: s.message, + percent: s.percent, + ready: s.ready, + failed: s.failed, + failureReason: s.failure_reason, + failureMessage: s.failure_message, + activities: s.activities, + error: null, + }; + } catch (e) { + return { + found: false, phase: null, message: null, percent: 0, ready: false, failed: false, + failureReason: null, failureMessage: null, activities: [], + error: e instanceof BffError ? e.message || e.code : "status check failed", + }; + } +} + +export async function undeployLocalModelAction(_p: LocalInferenceState, form: FormData): Promise<LocalInferenceState> { + const name = String(form.get("name") ?? "").trim(); + if (!name) return { error: "Missing name.", ok: null }; + try { + await deleteLocalModelDeployment(name); + revalidatePath("/console/configuration"); + return { error: null, ok: `${name} removed.` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "remove failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/configuration/local-model-deploy.tsx b/bridge/web/src/app/console/configuration/local-model-deploy.tsx new file mode 100644 index 000000000..5a07d56e5 --- /dev/null +++ b/bridge/web/src/app/console/configuration/local-model-deploy.tsx @@ -0,0 +1,237 @@ +"use client"; + +// Deploy a local (in-cluster / AI Runway) model — used in the Model catalogue, +// where local models are managed. A real "add" flow: pick a curated model (or +// a free-text HuggingFace id), name it, deploy, then watch LIVE progress (a +// milestone percentage + the actual Kubernetes activity feed) until Running. + +import { useEffect, useRef, useState, useTransition } from "react"; +import { Icon } from "@/components/icon"; +import { startLocalDeployAction, pollLocalDeploymentAction, type LocalDeployProgress } from "./local-inference-actions"; +import type { LocalInferenceStatus, CuratedLocalModel } from "@/lib/bff"; + +/** Live progress for an in-flight local model deploy — a real percentage bar + + * the actual Kubernetes activity feed (image pull, scheduling, container + * start / fail), sourced from the BFF's live-status endpoint. */ +export function LocalDeployTracker({ + name, + progress, + onDone, +}: { + name: string; + progress: LocalDeployProgress | null; + onDone: () => void; +}) { + const ready = !!progress?.ready; + const failed = !!progress?.failed || !!progress?.error; + const targetPct = ready ? 100 : (progress?.percent ?? 0); + const [displayPct, setDisplayPct] = useState(0); + useEffect(() => { + let raf: number; + const step = () => { + setDisplayPct((cur) => { + const diff = targetPct - cur; + if (Math.abs(diff) < 0.5) return targetPct; + raf = requestAnimationFrame(step); + return cur + diff * 0.12; + }); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + }, [targetPct]); + + const activities = progress?.activities ?? []; + const feedRef = useRef<HTMLDivElement | null>(null); + useEffect(() => { + if (feedRef.current) feedRef.current.scrollTop = feedRef.current.scrollHeight; + }, [activities.length]); + + const barColor = failed ? "bg-danger" : "bg-signal"; + return ( + <fieldset className={`rounded-lg border p-3 ${failed ? "border-danger/40" : ready ? "border-signal/40 bg-signal/[0.04]" : "border-border"}`}> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"> + <Icon name={failed ? "warning" : ready ? "check" : "box"} size={13} /> + {failed ? "Deploy failed" : ready ? "Model ready" : "Deploying"} — <span className="font-mono">{name}</span> + </legend> + <div className="mb-1 flex items-center justify-between text-[11px]"> + <span className="text-foreground-muted">{failed ? (progress?.failureReason ?? "Failed") : ready ? "Running" : (progress?.phase ?? "Starting…")}</span> + <span className="font-mono font-medium">{Math.round(displayPct)}%</span> + </div> + <div className="h-2 w-full overflow-hidden rounded-full bg-surface-muted"> + <div className={`h-full rounded-full transition-[width] duration-300 ${barColor} ${!ready && !failed ? "kb-shimmer" : ""}`} style={{ width: `${Math.max(3, displayPct)}%` }} /> + </div> + {activities.length > 0 && ( + <div ref={feedRef} className="mt-2.5 max-h-32 space-y-1 overflow-y-auto rounded-lg border border-border bg-surface-muted/30 p-2"> + {activities.slice(-12).map((a, i) => { + const warn = a.type === "Warning"; + return ( + <div key={`${a.reason}-${a.time}-${i}`} className="flex items-start gap-2 text-[11px] leading-snug"> + <span className={`mt-0.5 shrink-0 font-mono font-medium ${warn ? "text-warning" : "text-signal"}`}>{a.reason}</span> + <span className={`min-w-0 ${warn ? "text-foreground" : "text-foreground-muted"}`}>{a.message}{a.count > 1 && <span className="ml-1 text-foreground-muted">×{a.count}</span>}</span> + </div> + ); + })} + </div> + )} + {failed && ( + <div className="mt-2 space-y-2"> + {progress?.failureMessage && <p className="text-[11px] text-danger">{progress.failureMessage}</p>} + {progress?.error && <p className="text-[11px] text-danger">{progress.error}</p>} + <button type="button" onClick={onDone} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">Close & fix</button> + </div> + )} + {ready && ( + <div className="mt-2.5 flex items-center gap-2"> + <p className="text-xs text-signal">Running — now in the catalogue below, tagged <span className="font-mono">local-{name}</span>.</p> + <button type="button" onClick={onDone} className="rounded-lg border border-signal/40 bg-signal/10 px-3 py-1 text-xs font-medium text-signal hover:bg-signal/15">Done</button> + </div> + )} + </fieldset> + ); +} + +export function LocalModelDeploy({ + status, + catalog, + onClose, +}: { + status: LocalInferenceStatus | null; + catalog: CuratedLocalModel[]; + onClose: () => void; +}) { + const [selected, setSelected] = useState<CuratedLocalModel | null>(null); + const [advanced, setAdvanced] = useState(false); + const [name, setName] = useState(""); + const [image, setImage] = useState(""); + const [error, setError] = useState<string | null>(null); + const [deployingName, setDeployingName] = useState<string | null>(null); + const [progress, setProgress] = useState<LocalDeployProgress | null>(null); + const [submitting, startSubmit] = useTransition(); + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); + + useEffect(() => { + if (!deployingName) return; + let cancelled = false; + const tick = async () => { + const p = await pollLocalDeploymentAction(deployingName); + if (cancelled) return; + setProgress(p); + if (p.ready || p.failed || p.error) { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + } + }; + void tick(); + pollRef.current = setInterval(() => void tick(), 3000); + return () => { + cancelled = true; + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + }; + }, [deployingName]); + + function submit() { + setError(null); + setProgress(null); + startSubmit(async () => { + const r = await startLocalDeployAction({ name, modelId: selected?.id ?? "", tier: selected?.tier ?? "cpu", image: image || undefined }); + if (r.error || !r.name) { setError(r.error ?? "deploy failed"); return; } + setDeployingName(r.name); + }); + } + + if (!status?.available) { + return ( + <div className="rounded-lg border border-dashed border-border bg-surface-muted/30 p-3 text-xs text-foreground-muted"> + Local inference isn’t set up on this cluster yet. Connect it via <span className="font-medium">+ Connect a provider → Local (in-cluster)</span>, which walks an operator through the one-time AI Runway + KAITO install. + </div> + ); + } + + if (deployingName) { + return <LocalDeployTracker name={deployingName} progress={progress} onDone={onClose} />; + } + + return ( + <div className="space-y-3 rounded-lg border border-border bg-surface p-3"> + <fieldset> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="brain" size={13} /> Choose a model to deploy</legend> + <div className="mt-2 grid gap-2 sm:grid-cols-3"> + {catalog.map((m) => { + const gpuBlocked = m.tier === "gpu" && !(status.gpu_node_count > 0); + const sel = selected?.id === m.id; + return ( + <button + key={m.id} + type="button" + disabled={gpuBlocked} + title={gpuBlocked ? "No GPU node detected on this cluster" : undefined} + onClick={() => { setSelected(m); setAdvanced(false); setName(m.id.split("/").pop()!.replace(/[^a-z0-9-]/gi, "-").toLowerCase()); }} + className={`flex items-start gap-2 rounded-lg border p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ${sel ? "border-signal bg-signal/[0.06]" : "border-border bg-surface hover:bg-surface-muted"}`} + > + <Icon name={m.tier === "cpu" ? "box" : "bolt"} size={15} className={sel ? "text-signal" : "text-foreground-muted"} /> + <span> + <span className="block text-sm font-medium">{m.label}</span> + <span className="block text-[11px] text-foreground-muted">{m.params} · {m.tier === "cpu" ? "runs on CPU" : gpuBlocked ? "needs a GPU node (none detected)" : "needs a GPU node"}</span> + </span> + </button> + ); + })} + <button + type="button" + onClick={() => { setAdvanced(true); setSelected(null); setName(""); }} + className={`flex items-start gap-2 rounded-lg border p-2.5 text-left transition ${advanced ? "border-signal bg-signal/[0.06]" : "border-border bg-surface hover:bg-surface-muted"}`} + > + <Icon name="terminal" size={15} className={advanced ? "text-signal" : "text-foreground-muted"} /> + <span> + <span className="block text-sm font-medium">Advanced: any HuggingFace model</span> + <span className="block text-[11px] text-foreground-muted">CPU tier needs a pre-built AIKit image too.</span> + </span> + </button> + </div> + </fieldset> + + {(selected || advanced) && ( + <fieldset className="rounded-lg border border-border p-3"> + <legend className="px-1 text-xs font-medium text-foreground-muted">Deployment details</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="block text-xs text-foreground-muted"> + Name + <input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. local-llama-1b" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + {advanced ? ( + <label className="block text-xs text-foreground-muted"> + Model id + <input value={selected?.id ?? ""} onChange={(e) => setSelected({ id: e.target.value, label: e.target.value, tier: "cpu", params: "" })} placeholder="e.g. Qwen/Qwen2.5-0.5B-Instruct" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + ) : ( + <label className="block text-xs text-foreground-muted"> + Model id + <input value={selected?.id ?? ""} readOnly className="mt-1 w-full rounded-lg border border-border bg-surface-muted/50 px-3 py-2 font-mono text-xs opacity-70" /> + </label> + )} + </div> + {advanced && ( + <label className="mt-3 block text-xs text-foreground-muted"> + AIKit image (CPU tier only) + <input value={image} onChange={(e) => setImage(e.target.value)} placeholder="ghcr.io/kaito-project/aikit/..." className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + )} + </fieldset> + )} + + <div className="flex items-center gap-3"> + <button + type="button" + onClick={submit} + disabled={submitting || (!selected && !advanced) || !name.trim() || !(selected?.id ?? "").trim()} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50" + > + {submitting ? "Starting…" : "Deploy"} + </button> + <button type="button" onClick={onClose} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + {error && <p className="text-xs text-danger">{error}</p>} + </div> + </div> + ); +} diff --git a/bridge/web/src/app/console/configuration/model-catalogue.tsx b/bridge/web/src/app/console/configuration/model-catalogue.tsx new file mode 100644 index 000000000..30198766a --- /dev/null +++ b/bridge/web/src/app/console/configuration/model-catalogue.tsx @@ -0,0 +1,157 @@ +"use client"; + +// Model catalogue — every model across all connected providers, tagged with +// the provider that serves it. This is ALSO where local (in-cluster) models +// are managed: deploy a new one, remove one, and set any model as the cluster +// default. Managed-provider models (Copilot / Foundry / Azure) are read-only +// here — you connect/remove those as providers above. + +import { useActionState, useState } from "react"; +import { Icon } from "@/components/icon"; +import { Badge, Section } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { LocalModelDeploy } from "./local-model-deploy"; +import { setDefaultModelAction, type SetDefaultModelState } from "./set-default-model-actions"; +import { undeployLocalModelAction, type LocalInferenceState } from "./local-inference-actions"; +import type { ModelOption } from "@/lib/types"; +import type { + LocalInferenceStatus, + CuratedLocalModel, + LocalModelDeployment, +} from "@/lib/bff"; + +function isLocalProvider(provider: string) { + return provider === "airunway" + || provider === "local-inference" + || provider.startsWith("local-"); +} + +function ModelRow({ + m, + localDeployments, +}: { + m: ModelOption; + localDeployments: LocalModelDeployment[]; +}) { + const local = isLocalProvider(m.provider); + const managedDeployment = localDeployments.find((deployment) => { + if (!deployment.managed) return false; + const modelName = deployment.model_id?.split("/").at(-1); + return deployment.name === m.deployment + || modelName === m.deployment + || m.provider === `local-${deployment.name}`; + }); + const [defState, setDefault, defPending] = useActionState(setDefaultModelAction, { error: null, ok: null } as SetDefaultModelState); + const [rmState, remove, rmPending] = useActionState(undeployLocalModelAction, { error: null, ok: null } as LocalInferenceState); + return ( + <li className="flex flex-col gap-1 rounded-lg border border-border bg-surface px-3 py-2.5 text-sm"> + <div className="flex items-center justify-between gap-3"> + <span className="flex min-w-0 flex-col gap-0.5"> + <span className="flex min-w-0 items-center gap-2"> + <span className="truncate font-mono text-xs">{m.deployment}</span> + <Badge tone="muted">{local ? "AI Runway" : m.provider}</Badge> + {m.is_default && <Badge tone="info">default</Badge>} + </span> + {m.detail && <span className="truncate text-[11px] text-foreground-muted">{m.detail}</span>} + </span> + <span className="flex shrink-0 items-center gap-1.5"> + {!m.is_default && ( + <form action={setDefault}> + <input type="hidden" name="deployment" value={m.deployment} /> + <input type="hidden" name="provider" value={m.provider} /> + <button type="submit" disabled={defPending} className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:border-signal/40 hover:text-signal disabled:opacity-50"> + {defPending ? "Setting…" : "Set as default"} + </button> + </form> + )} + {managedDeployment && ( + <form action={remove}> + <input type="hidden" name="name" value={managedDeployment.name} /> + <button type="submit" disabled={rmPending} className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:border-danger/40 hover:text-danger disabled:opacity-50"> + {rmPending ? "Removing…" : "Remove"} + </button> + </form> + )} + </span> + </div> + {defState.error && <p className="text-[11px] text-danger">{defState.error}</p>} + {rmState.error && <p className="text-[11px] text-danger">{rmState.error}</p>} + </li> + ); +} + +export function ModelCatalogue({ + models, + localStatus, + localCatalog, + localDeployments, +}: { + models: ModelOption[]; + localStatus: LocalInferenceStatus | null; + localCatalog: CuratedLocalModel[]; + localDeployments: LocalModelDeployment[]; +}) { + const [deploying, setDeploying] = useState(false); + const [query, setQuery] = useState(""); + const q = query.trim().toLowerCase(); + const filtered = q + ? models.filter((m) => + m.deployment.toLowerCase().includes(q) || + m.provider.toLowerCase().includes(q) || + (m.provider.startsWith("local-") && "ai runway in-cluster".includes(q)) || + (m.detail ?? "").toLowerCase().includes(q), + ) + : models; + return ( + <Section + title="Model catalogue" + subtitle="Every model across all connected providers, tagged with the provider that serves it — what missions reason with and what the orchestrator may propose. Set any model as the cluster default right here; local (in-cluster) models are also added and removed here." + > + <div className="mb-3 flex flex-wrap items-center gap-2"> + {models.length > 0 && ( + <div className="relative flex-1 min-w-[200px]"> + <span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-foreground-muted"> + <Icon name="search" size={13} /> + </span> + <input + value={query} + onChange={(e) => setQuery(e.target.value)} + placeholder="Search models — id, provider, vendor…" + className="w-full rounded-lg border border-border bg-surface py-1.5 pl-8 pr-3 text-xs" + /> + </div> + )} + {localStatus?.available && !deploying && ( + <button type="button" onClick={() => setDeploying(true)} className="inline-flex items-center gap-1.5 rounded-lg border border-signal/40 bg-signal/10 px-3 py-1.5 text-xs font-medium text-signal hover:bg-signal/15"> + <Icon name="box" size={13} /> Deploy a local model + </button> + )} + </div> + {localStatus?.available && deploying && ( + <div className="mb-4"> + <LocalModelDeploy status={localStatus} catalog={localCatalog} onClose={() => setDeploying(false)} /> + </div> + )} + {models.length === 0 ? ( + <HonestState + variant="empty" + compact + title="No models configured" + detail="Connect a provider above and its models appear here — or deploy a local one." + /> + ) : filtered.length === 0 ? ( + <p className="rounded-lg border border-dashed border-border bg-surface-muted/30 px-3 py-4 text-center text-xs text-foreground-muted">No models match “{query}”.</p> + ) : ( + <ul className="grid gap-2 sm:grid-cols-2"> + {filtered.map((m) => ( + <ModelRow + key={`${m.provider}::${m.deployment}`} + m={m} + localDeployments={localDeployments} + /> + ))} + </ul> + )} + </Section> + ); +} diff --git a/bridge/web/src/app/console/configuration/page.tsx b/bridge/web/src/app/console/configuration/page.tsx new file mode 100644 index 000000000..01734aad1 --- /dev/null +++ b/bridge/web/src/app/console/configuration/page.tsx @@ -0,0 +1,147 @@ +// kars Bridge Operator Console — Configuration. The hub for the CLUSTER this +// runs on: the inference provider, Azure AI Foundry connection, the models it +// serves, cluster add-ons (SRE agent, Headlamp), and the platform GitHub App. +// Agent-facing building blocks (skills, team profiles, MCP, credentials) live +// on their own page — Console → Agent capabilities. Reads are live cluster +// facts; writes are operator-gated. + +import { getOptions, getFoundry, getIntegrations, getGithubApp, listAdditionalProviders, getLocalInferenceStatus, getLocalInferenceCatalog, listLocalModelDeployments } from "@/lib/bff"; +import { PageHeader, Section, Badge } from "@/components/ui"; +import { ProviderWizard } from "./provider-wizard"; +import { ModelCatalogue } from "./model-catalogue"; +import { OperatorGithubStatus } from "../operator-github-status"; +import { Icon } from "@/components/icon"; +import type { Options } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export default async function ConfigurationPage() { + const options: Options | null = await getOptions().catch(() => null); + const foundry = await getFoundry().catch(() => null); + const integrations = await getIntegrations().catch(() => null); + const githubApp = await getGithubApp().catch(() => ({ configured: false, slug: null, install_url: null })); + const localInferenceStatus = await getLocalInferenceStatus().catch(() => null); + const localInferenceCatalog = await getLocalInferenceCatalog().catch(() => []); + // Discover every AI Runway ModelDeployment cluster-wide. Bridge-managed + // deployments are also auto-wired by this call; externally managed ones are + // read-only discoveries and still appear in the provider summary. + const localDeployments = await listLocalModelDeployments().catch(() => []); + const additionalProviders = await listAdditionalProviders().catch(() => []); + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Configuration" + lead="What this cluster runs on — provider, Foundry, models — set once at setup, inherited by every team. Agent-facing capabilities (skills, team profiles, MCP, credentials) live under Console → Agent capabilities." + /> + + {/* Inference provider — the inherited fact that drives every mission and + every composed envelope. ONE wizard + ONE unified list: the cluster + default and every additional provider render as uniform rows (the + default just carries a badge), so nothing "looks bigger" than the + rest. See provider-wizard.tsx for why default vs additional are still + distinct resources under the hood. */} + <Section + title="Inference provider" + subtitle="The service(s) this cluster authenticates to for model calls. One default every mission inherits, plus any additional providers an InferencePolicy can route specific sandboxes to." + > + <ProviderWizard + hasDefaultProvider={!!options?.provider} + defaultProvider={ + options?.provider + ? { + id: options.provider.id, + label: options.provider.label, + note: options.provider.note, + models: (options.models ?? []).filter((m) => m.provider === options.provider!.id).map((m) => m.deployment), + } + : null + } + additionalProviders={additionalProviders} + localInferenceStatus={localInferenceStatus} + localDeployments={localDeployments} + foundryStatus={foundry ?? { connected: false, project_endpoint: null, inference_endpoint: null, memory_store_id: null, auth: null, has_api_key: false }} + /> + </Section> + + <ModelCatalogue + models={options?.models ?? []} + localStatus={localInferenceStatus} + localCatalog={localInferenceCatalog} + localDeployments={localDeployments} + /> + + {/* Cluster integrations — the real kars add-ons (SRE agent + Headlamp + plugin), with live status and either deep-links or activation. */} + <Section + title="Integrations" + subtitle="kars add-ons for this cluster — the SRE agent and the Headlamp dashboard plugin. Activate or open them here." + > + <div className="grid gap-3 sm:grid-cols-2"> + {/* kars-SRE agent */} + <div className="rounded-xl border border-border bg-surface p-4"> + <div className="flex items-center justify-between"> + <p className="flex items-center gap-1.5 text-sm font-semibold"><Icon name="wrench" size={14} /> kars-SRE agent</p> + <Badge tone={integrations?.sre_present ? "ok" : "muted"} dot={!!integrations?.sre_present}> + {integrations?.sre_present ? (integrations.sre_phase ?? "present") : "not enabled"} + </Badge> + </div> + {integrations?.sre_present ? ( + <p className="mt-2 text-xs text-foreground-muted"> + The SRE sandbox is running{integrations.sre_ready ? ` (${integrations.sre_ready} ready)` : ""} — it triages cluster health and proposes operator-approved fixes (KarsSREAction). + {integrations.headlamp_url && ( + <> Open its console: <a className="text-signal hover:underline" href={`${integrations.headlamp_url}/kars/sre`} target="_blank" rel="noreferrer">Headlamp → /kars/sre ↗</a></> + )} + </p> + ) : ( + <div className="mt-2"> + <p className="text-xs text-foreground-muted">Not enabled on this cluster. Activate the SRE agent (Hermes runtime, scoped apiserver access, read-only diagnostics + approved apply-fix):</p> + <code className="mt-2 block overflow-x-auto rounded-lg border border-border bg-surface-muted/40 px-3 py-2 font-mono text-[11px]">{integrations?.sre_activate_cmd ?? "kars sre install"}</code> + </div> + )} + </div> + + {/* Headlamp plugin */} + <div className="rounded-xl border border-border bg-surface p-4"> + <div className="flex items-center justify-between"> + <p className="flex items-center gap-1.5 text-sm font-semibold"><Icon name="compass" size={14} /> Headlamp plugin</p> + <Badge tone={integrations?.headlamp_deployed ? (integrations.headlamp_url ? "ok" : "warn") : "muted"} dot={!!integrations?.headlamp_deployed}> + {integrations?.headlamp_deployed ? (integrations.headlamp_url ? "linked" : "deployed") : "not found"} + </Badge> + </div> + {integrations?.headlamp_url ? ( + <div className="mt-2"> + <p className="text-xs text-foreground-muted">The kars Headlamp plugin — deep dashboard views for kars resources:</p> + <div className="mt-2 flex flex-wrap gap-2"> + {integrations.headlamp_paths.map((l) => ( + <a key={l.path} href={`${integrations.headlamp_url}${l.path}`} target="_blank" rel="noreferrer" className="rounded-md border border-signal/40 bg-signal/10 px-2.5 py-1 text-[11px] font-medium text-signal hover:bg-signal/15"> + {l.label} ↗ + </a> + ))} + </div> + </div> + ) : integrations?.headlamp_deployed ? ( + <p className="mt-2 text-xs text-foreground-muted"> + Headlamp is deployed but not linked here. Install the kars plugin and set <code className="font-mono">BRIDGE_HEADLAMP_URL</code> to deep-link its <code className="font-mono">/kars/*</code> views. <span className="text-foreground-muted">{integrations.headlamp_install_hint}</span> + </p> + ) : ( + <p className="mt-2 text-xs text-foreground-muted">No Headlamp deployment detected. Install Headlamp + the kars plugin (tools/headlamp-plugin), then set <code className="font-mono">BRIDGE_HEADLAMP_URL</code>.</p> + )} + </div> + </div> + </Section> + + {/* GitHub App — a platform-level integration like SRE/Headlamp above, + not part of the provider→Foundry→models inference chain. Kept here, + grouped with the other cluster add-ons, instead of wedged between + Inference provider and Azure AI Foundry. */} + <Section + title="GitHub App (platform identity)" + subtitle="Configure the one shared kars GitHub App that lets workspaces open pull requests. Operators set this up once; users then connect their own repos from their Workspace → Connections." + > + <OperatorGithubStatus configured={githubApp.configured} slug={githubApp.slug} /> + </Section> + </div> + ); +} diff --git a/bridge/web/src/app/console/configuration/provider-actions.ts b/bridge/web/src/app/console/configuration/provider-actions.ts new file mode 100644 index 000000000..98b8591a5 --- /dev/null +++ b/bridge/web/src/app/console/configuration/provider-actions.ts @@ -0,0 +1,21 @@ +"use server"; + +import { BffError, putProvider } from "@/lib/bff"; + +export interface ProviderState { error: string | null; ok: string | null } + +export async function onboardProviderAction(_p: ProviderState, form: FormData): Promise<ProviderState> { + const kind = String(form.get("kind") ?? "github-models"); + const auth = String(form.get("auth") ?? "api"); + const endpoint = String(form.get("endpoint") ?? "").trim(); + const models = String(form.get("models") ?? "").trim(); + const key = String(form.get("key") ?? ""); + if (!models) return { error: "List at least one model deployment.", ok: null }; + if (auth === "api" && !key) return { error: "API auth needs a key.", ok: null }; + try { + const r = await putProvider({ kind, auth, endpoint: endpoint || undefined, models, key: key || undefined }); + return { error: null, ok: r.note }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "onboard failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/configuration/provider-discover-actions.ts b/bridge/web/src/app/console/configuration/provider-discover-actions.ts new file mode 100644 index 000000000..8cd2ca94d --- /dev/null +++ b/bridge/web/src/app/console/configuration/provider-discover-actions.ts @@ -0,0 +1,25 @@ +"use server"; + +// kars Bridge Operator Console — live model discovery, server-side. Client +// components must never import lib/bff.ts directly (it resolves the BFF via +// its in-cluster Service DNS name, unreachable from the browser) — this is +// the server-action bridge, callable directly as an async function (not just +// via a <form action>, since the discover button needs dynamic args). + +import { discoverModels, BffError } from "@/lib/bff"; +import type { DiscoveredModel } from "@/lib/types"; + +export type DiscoverResult = { models: DiscoveredModel[]; error: null } | { models: null; error: string }; + +export async function discoverModelsAction(input: { + kind: string; + endpoint?: string; + key?: string; +}): Promise<DiscoverResult> { + try { + const models = await discoverModels(input); + return { models, error: null }; + } catch (e) { + return { models: null, error: e instanceof BffError ? e.message || e.code : "Discovery failed." }; + } +} diff --git a/bridge/web/src/app/console/configuration/provider-wizard.tsx b/bridge/web/src/app/console/configuration/provider-wizard.tsx new file mode 100644 index 000000000..895bbf589 --- /dev/null +++ b/bridge/web/src/app/console/configuration/provider-wizard.tsx @@ -0,0 +1,889 @@ +"use client"; + +// kars Bridge Operator Console — the ONE inference-provider wizard. Merges +// what used to be two separate flows on this page (the plain "Add or switch +// a provider" form for the cluster's single default, and a second +// "Additional providers" form for everything beyond it) into a single +// step-by-step experience: pick a provider kind, decide whether it's the +// cluster's default or an additional one, authenticate, then pick/discover +// its models and review before connecting. +// +// The two are still genuinely different resources under the hood — the +// default provider patches the controller's own env (every sandbox's single +// inherited endpoint; existing missions, envelope generation, and the +// orchestrator all read it) via `onboardProviderAction`, while an additional +// provider is an entry in the `kars-inference-providers` Secret mirrored into +// every sandbox's router via `addAdditionalProviderAction` (only reachable +// when an InferencePolicy names its tag) — but the OPERATOR shouldn't have +// to know two different forms to use either one. + +import { useActionState, useEffect, useRef, useState, useTransition, type ElementType } from "react"; +import { onboardProviderAction, type ProviderState } from "./provider-actions"; +import { addAdditionalProviderAction, removeAdditionalProviderAction, type AdditionalProviderState } from "./additional-provider-actions"; +import { discoverModelsAction } from "./provider-discover-actions"; +import { undeployLocalModelAction, type LocalInferenceState } from "./local-inference-actions"; +import { copilotLoginStartAction, copilotLoginPollAction } from "./copilot-login-actions"; +import { disconnectFoundryAction, type FoundryState } from "../foundry-actions"; +import { FoundryOnboard } from "../foundry-onboard"; +import { Icon, type IconName } from "@/components/icon"; +import { HonestState } from "@/components/honest-state"; +import { Badge } from "@/components/ui"; +import type { AdditionalProvider, DiscoveredModel } from "@/lib/types"; +import type { + LocalInferenceStatus, + LocalModelDeployment, + FoundryStatus, + CopilotLoginStart, +} from "@/lib/bff"; + +const init: ProviderState = { error: null, ok: null }; + +type Kind = "github-copilot" | "github-models" | "azure-openai" | "foundry" | "custom" | "local"; +type Target = "default" | "additional"; + +const PRESETS: { id: Kind; label: string; icon: IconName; tagHint: string; endpointEditable: boolean; supportsDiscovery: boolean; hint: string; defaultCapable: boolean }[] = [ + { + id: "github-copilot", + label: "GitHub Copilot", + icon: "link", + tagHint: "github-copilot", + endpointEditable: false, + supportsDiscovery: true, + hint: "Verifies your Copilot seat live, then lists the exact models GitHub currently serves it — a real query against your seat's model catalog (gpt-5.6, Claude Opus 4.8, Gemini 3.1 Pro, …), with the flagship 'powerful' tier pre-selected.", + // The cluster-default onboarding form only wires an Azure-style + // endpoint/key onto the controller — it has no path to a Copilot JWT + // exchange. Copilot is real and proven, but only as an additional + // provider (per-mission via InferencePolicy model preference). + defaultCapable: false, + }, + { + id: "github-models", + label: "GitHub Models", + icon: "box", + tagHint: "github-models", + endpointEditable: false, + supportsDiscovery: true, + hint: "Public catalog, no auth needed to discover — a GitHub PAT is only needed for actual inference.", + // Same reason as GitHub Copilot: the default form has no endpoint field + // for this kind, so there's nothing for the router's host check to key + // off. Works today as an additional provider. + defaultCapable: false, + }, + { + id: "azure-openai", + label: "Azure OpenAI", + icon: "database", + tagHint: "azure-openai", + endpointEditable: true, + supportsDiscovery: true, + hint: "Discovers real deployments from the entered endpoint. Workload Identity is preferred on AKS; an API key is for development only.", + defaultCapable: true, + }, + { + id: "foundry", + label: "Azure AI Foundry", + icon: "database", + tagHint: "foundry", + endpointEditable: true, + supportsDiscovery: true, + hint: "Connect a Foundry project — discovers ALL its services (grounding, storage, connections) and deployed models, adding the models to the catalogue tagged foundry. Identity on AKS; API key in dev.", + defaultCapable: true, + }, + { + id: "custom", + label: "Custom (advanced)", + icon: "gear", + tagHint: "", + endpointEditable: true, + supportsDiscovery: false, + hint: "Any OpenAI-compatible endpoint.", + defaultCapable: true, + }, + { + id: "local", + label: "Local model (in-cluster)", + icon: "box", + tagHint: "local", + endpointEditable: false, + supportsDiscovery: false, + hint: "Deploy a model that runs entirely inside this cluster — no external API, no per-token billing. Needs AI Runway + KAITO installed once by an operator (docs/local-inference.md).", + defaultCapable: true, + }, +]; + +const STEPS = ["Provider", "Where it applies", "Authentication", "Models & review"] as const; + +function StepRail({ step }: { step: number }) { + return ( + <ol className="flex flex-wrap items-center gap-1.5"> + {STEPS.map((label, i) => ( + <li key={label} className="flex items-center gap-1.5"> + <span + className={`flex h-6 min-w-6 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold ${ + i === step ? "bg-signal text-signal-fg" : i < step ? "bg-signal/15 text-signal" : "bg-surface-muted text-foreground-muted" + }`} + title={label} + > + {i < step ? <Icon name="check" size={12} /> : i + 1} + </span> + <span className={`hidden text-[11px] sm:inline ${i === step ? "font-medium text-foreground" : "text-foreground-muted"}`}>{label}</span> + {i < STEPS.length - 1 && <span className="h-px w-4 bg-border" aria-hidden />} + </li> + ))} + </ol> + ); +} + +function ProviderCard({ p }: { p: AdditionalProvider }) { + const isLocal = p.tag.startsWith("local-"); + const isFoundry = p.tag === "foundry"; + const [removeState, removeAction, removePending] = useActionState(removeAdditionalProviderAction, { error: null, ok: null } as AdditionalProviderState); + const [undeployState, undeployAction, undeployPending] = useActionState(undeployLocalModelAction, { error: null, ok: null } as LocalInferenceState); + const [disconnectState, disconnectAction, disconnectPending] = useActionState(disconnectFoundryAction, { error: null, ok: null } as FoundryState); + // Removal is provider-kind-specific: a local model deletes its + // ModelDeployment CR; Foundry disconnects the project (clearing the + // connection + its catalogue models); everything else drops its secret keys. + const removeForm = isLocal ? undeployAction : isFoundry ? disconnectAction : removeAction; + const state = isLocal ? undeployState : isFoundry ? disconnectState : removeState; + const pending = isLocal ? undeployPending : isFoundry ? disconnectPending : removePending; + return ( + <li className="rounded-lg border border-border bg-surface px-3 py-2.5 text-sm"> + <div className="flex items-center justify-between gap-3"> + <span className="flex min-w-0 items-center gap-2"> + <Icon name={isLocal ? "box" : isFoundry ? "database" : "link"} size={13} /> + <span className="font-medium">{p.tag}</span> + {isLocal && <Badge tone="muted">in-cluster</Badge>} + {isFoundry && <Badge tone="muted">Foundry</Badge>} + {p.has_key && <Badge tone="muted">key stored</Badge>} + </span> + <span className="flex items-center gap-1.5"> + <form action={removeForm}> + {!isFoundry && <input type="hidden" name={isLocal ? "name" : "tag"} value={isLocal ? p.tag.replace(/^local-/, "") : p.tag} />} + <button type="submit" disabled={pending} className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:border-danger/40 hover:text-danger disabled:opacity-50"> + {pending ? "Removing…" : isFoundry ? "Disconnect" : "Remove"} + </button> + </form> + </span> + </div> + {p.endpoint && <p className="mt-1 truncate font-mono text-[11px] text-foreground-muted">{p.endpoint}</p>} + {p.models.length > 0 && ( + <div className="mt-1.5 flex flex-wrap gap-1"> + {p.models.map((m) => ( + <span key={m} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground-muted">{m}</span> + ))} + </div> + )} + {state.error && <p className="mt-1 text-[11px] text-danger">{state.error}</p>} + </li> + ); +} + +/** The cluster's DEFAULT inference provider, rendered as a uniform row (same + * shape as an additional ProviderCard) but carrying the "default" badge — + * so nothing looks visually bigger/special. Every mission inherits this. */ +function DefaultProviderCard({ p }: { p: { id: string; label: string; note: string; models: string[] } }) { + const icon: IconName = p.id === "github-copilot" ? "link" : p.id.startsWith("local") ? "box" : "database"; + return ( + <li className="rounded-lg border border-signal/30 bg-signal/[0.04] px-3 py-2.5 text-sm"> + <div className="flex items-center justify-between gap-3"> + <span className="flex min-w-0 items-center gap-2"> + <Icon name={icon} size={13} /> + <span className="font-medium">{p.label}</span> + <Badge tone="info">default</Badge> + </span> + <span className="text-[11px] text-foreground-muted">Every mission inherits this</span> + </div> + <p className="mt-1 max-w-xl text-[11px] text-foreground-muted">{p.note}</p> + {p.models.length > 0 && ( + <div className="mt-1.5 flex flex-wrap gap-1"> + {p.models.slice(0, 12).map((m) => ( + <span key={m} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground-muted">{m}</span> + ))} + {p.models.length > 12 && <span className="rounded bg-surface-muted px-1.5 py-0.5 text-[10px] text-foreground-muted">+{p.models.length - 12} more</span>} + </div> + )} + </li> + ); +} + +/** The local (in-cluster) inference provider — AI Runway/KAITO — as ONE line, + * regardless of how many models are deployed on it. Models themselves are + * added / removed / set-as-default in the Model catalogue below. */ +function AiRunwayCard({ + models, + available, + isDefault, +}: { + models: Array<{ name: string; connected: boolean }>; + available: boolean; + isDefault: boolean; +}) { + return ( + <li className={`rounded-lg border px-3 py-2.5 text-sm ${isDefault ? "border-signal/30 bg-signal/[0.04]" : "border-border bg-surface"}`}> + <div className="flex items-center justify-between gap-3"> + <span className="flex min-w-0 items-center gap-2"> + <Icon name="box" size={13} /> + <span className="font-medium">AI Runway</span> + <Badge tone="muted">in-cluster</Badge> + {available && <Badge tone="muted">detected</Badge>} + {isDefault && <Badge tone="info">default</Badge>} + </span> + <span className="text-[11px] text-foreground-muted"> + {isDefault + ? "Every mission inherits this" + : models.length === 0 + ? "no models detected" + : `${models.length} model${models.length === 1 ? "" : "s"}`} + </span> + </div> + <p className="mt-1 text-[11px] text-foreground-muted"> + Models running entirely inside this cluster. Add, remove, or set a default in the Model catalogue below. + </p> + {models.length > 0 && ( + <div className="mt-1.5 flex flex-wrap gap-1"> + {models.map((model) => ( + <span key={model.name} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground-muted"> + {model.name} + {!model.connected && <span className="ml-1 font-sans">· detected only</span>} + </span> + ))} + </div> + )} + </li> + ); +} + +function normalizeLocalModelName(model: string): string { + return model.split("/").at(-1) ?? model; +} + +function deploymentModelNames(deployment: LocalModelDeployment): string[] { + const modelName = deployment.model_id + ? normalizeLocalModelName(deployment.model_id) + : null; + return [deployment.name, modelName].filter((value): value is string => Boolean(value)); +} + +function isAiRunwayProvider( + provider: AdditionalProvider, + deployments: LocalModelDeployment[], +): boolean { + if (provider.tag === "airunway" || provider.tag.startsWith("local-")) return true; + if (!provider.endpoint?.includes(".svc.cluster.local")) return false; + return deployments.some((deployment) => { + const names = new Set(deploymentModelNames(deployment)); + return names.has(provider.endpoint!.split("://").at(-1)!.split(".")[0]) + || provider.models.some((model) => names.has(model)); + }); +} + +function isAiRunwayDefault( + provider: { id: string; label: string; models: string[] } | null, +): boolean { + return Boolean( + provider + && (provider.id === "local-inference" + || provider.id === "airunway" + || provider.id.startsWith("local-")), + ); +} + +/** GitHub Copilot device-flow sign-in, inline in the wizard. Starts the flow, + * shows the user code + verification link, polls until approved, then hands + * the seat's live models to the parent. The token is minted + stored + * server-side — the browser never handles it. */ +function CopilotSignIn({ + signedIn, + onAuthorized, +}: { + signedIn: boolean; + onAuthorized: (models: DiscoveredModel[]) => void; +}) { + const [starting, startStarting] = useTransition(); + const [flow, setFlow] = useState<CopilotLoginStart | null>(null); + const [error, setError] = useState<string | null>(null); + const [copied, setCopied] = useState(false); + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); + + // Poll once a flow is active. + useEffect(() => { + if (!flow) return; + let cancelled = false; + const started = Date.now(); + const tick = async () => { + if (cancelled) return; + if (Date.now() - started > flow.expires_in * 1000) { + setError("The sign-in code expired. Start again."); + setFlow(null); + return; + } + const r = await copilotLoginPollAction(flow.device_code); + if (cancelled) return; + if (r.status === "authorized") { + if (pollRef.current) clearInterval(pollRef.current); + setFlow(null); + onAuthorized(r.models); + } else if (r.status === "error") { + if (pollRef.current) clearInterval(pollRef.current); + setError(r.error); + setFlow(null); + } + }; + pollRef.current = setInterval(() => void tick(), Math.max(flow.interval, 3) * 1000); + return () => { + cancelled = true; + if (pollRef.current) clearInterval(pollRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flow]); + + if (signedIn) { + return ( + <p className="flex items-center gap-1.5 text-xs text-signal"> + <Icon name="check" size={13} /> Signed in to GitHub Copilot — your seat’s models are listed in the next step. + </p> + ); + } + + function begin() { + setError(null); + startStarting(async () => { + const r = await copilotLoginStartAction(); + if (r.ok) setFlow(r.data); + else setError(r.error); + }); + } + + if (!flow) { + return ( + <div className="space-y-2"> + <p className="text-xs text-foreground-muted"> + Sign in with your GitHub account to verify your Copilot seat and load the exact models it serves. No token to paste — it’s minted and stored securely on the cluster. + </p> + <button type="button" onClick={begin} disabled={starting} className="inline-flex items-center gap-1.5 rounded-lg bg-signal px-3 py-2 text-xs font-semibold text-signal-fg disabled:opacity-50"> + <Icon name="link" size={13} /> {starting ? "Starting…" : "Sign in with GitHub"} + </button> + {error && <p className="text-[11px] text-danger">{error}</p>} + </div> + ); + } + + return ( + <div className="space-y-2.5"> + <p className="text-xs text-foreground-muted">Finish signing in on GitHub:</p> + <ol className="space-y-2 text-xs"> + <li className="flex items-center gap-2"> + <span className="grid h-5 w-5 place-items-center rounded-full bg-signal/15 text-[10px] text-signal">1</span> + <span>Open <a href={flow.verification_uri} target="_blank" rel="noreferrer" className="font-medium text-signal hover:underline">{flow.verification_uri} ↗</a></span> + </li> + <li className="flex items-center gap-2"> + <span className="grid h-5 w-5 place-items-center rounded-full bg-signal/15 text-[10px] text-signal">2</span> + <span className="flex items-center gap-2"> + Enter code + <code className="rounded border border-border bg-surface-muted px-2 py-0.5 font-mono text-sm tracking-widest">{flow.user_code}</code> + <button + type="button" + onClick={() => { navigator.clipboard?.writeText(flow.user_code); setCopied(true); setTimeout(() => setCopied(false), 1500); }} + className="rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-foreground-muted hover:text-signal" + > + {copied ? "copied" : "copy"} + </button> + </span> + </li> + </ol> + <p className="flex items-center gap-1.5 text-[11px] text-foreground-muted"> + <span className="h-2 w-2 animate-pulse rounded-full bg-signal" /> Waiting for approval… + </p> + {error && <p className="text-[11px] text-danger">{error}</p>} + </div> + ); +} + + +export function ProviderWizard({ + hasDefaultProvider, + defaultProvider, + additionalProviders, + localInferenceStatus, + localDeployments, + foundryStatus, +}: { + hasDefaultProvider: boolean; + defaultProvider: { id: string; label: string; note: string; models: string[] } | null; + additionalProviders: AdditionalProvider[]; + localInferenceStatus: LocalInferenceStatus | null; + localDeployments: LocalModelDeployment[]; + foundryStatus: FoundryStatus; +}) { + const [open, setOpen] = useState(false); + const [step, setStep] = useState(0); + const [kind, setKind] = useState<Kind>(hasDefaultProvider ? "github-models" : "azure-openai"); + const [target, setTarget] = useState<Target>(hasDefaultProvider ? "additional" : "default"); + const [tag, setTag] = useState(""); + const [endpoint, setEndpoint] = useState(""); + const [authMode, setAuthMode] = useState<"workload" | "agentid" | "api">("workload"); + const [key, setKey] = useState(""); + const [models, setModels] = useState(""); + const [discovered, setDiscovered] = useState<DiscoveredModel[] | null>(null); + const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set()); + const [discoverError, setDiscoverError] = useState<string | null>(null); + const [discovering, startDiscovering] = useTransition(); + // GitHub Copilot device-flow sign-in (mints a Copilot-authorized token, + // stored server-side). Once signed in, the seat's live models are populated + // and the connect step needs no pasted key. + const [copilotSignedIn, setCopilotSignedIn] = useState(false); + + // Local (in-cluster) inference: the wizard's "Local" kind connects the AI + // Runway PROVIDER (one line in the list). Deploying / removing / setting a + // default among individual local models happens in the Model catalogue + // (see model-catalogue.tsx + local-model-deploy.tsx), so no deploy state + // lives here anymore. + + const [defaultState, defaultAction, defaultPending] = useActionState(onboardProviderAction, init); + const [additionalState, additionalAction, additionalPending] = useActionState(addAdditionalProviderAction, init); + const state = target === "default" ? defaultState : additionalState; + const pending = target === "default" ? defaultPending : additionalPending; + const action = target === "default" ? defaultAction : additionalAction; + + const cfg = PRESETS.find((p) => p.id === kind)!; + const isLocal = kind === "local"; + const isFoundry = kind === "foundry"; + const usedTags = new Set(additionalProviders.map((p) => p.tag)); + + function selectKind(k: Kind) { + setKind(k); + const c = PRESETS.find((p) => p.id === k)!; + // GitHub Copilot / GitHub Models have no wired path to become the + // cluster's default from this form (see PRESETS comments) — steer to + // "additional", which is fully wired end-to-end. + if (!c.defaultCapable && target === "default") setTarget("additional"); + setTag(c.tagHint); + setEndpoint(""); + setDiscovered(null); + setSelectedModels(new Set()); + setDiscoverError(null); + setCopilotSignedIn(false); + } + + function reset() { + setOpen(false); + setStep(0); + setTag(""); + setEndpoint(""); + setAuthMode("workload"); + setKey(""); + setModels(""); + setDiscovered(null); + setSelectedModels(new Set()); + setCopilotSignedIn(false); + } + + function toggleModel(id: string) { + setSelectedModels((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + const canDiscover = + cfg.supportsDiscovery && + (kind === "github-models" || + (kind === "azure-openai" && endpoint.trim() && (authMode !== "api" || key.trim()))); + + function runDiscover() { + setDiscoverError(null); + startDiscovering(async () => { + const result = await discoverModelsAction({ kind, endpoint: endpoint || undefined, key: key || undefined }); + if (result.models === null) { + setDiscoverError(result.error); + setDiscovered(null); + } else { + setDiscovered(result.models); + // Pre-select the recommended pick (currently only Copilot's curated + // catalog carries this) so the operator isn't forced to hunt for it — + // mirrors `kars dev`'s picker defaulting to the starred model. + const recommended = result.models.filter((m) => m.recommended).map((m) => m.id); + setSelectedModels(new Set(recommended)); + } + }); + } + + const modelsValue = Array.from(new Set([...Array.from(selectedModels), ...models.split(",").map((s) => s.trim()).filter(Boolean)])).join(","); + + const step1Valid = isLocal || isFoundry ? true : target === "default" ? cfg.defaultCapable : tag.trim().length > 0 && !usedTags.has(tag.trim()); + const step2Valid = target === "default" ? authMode !== "api" || key.trim().length > 0 : cfg.id === "github-copilot" ? copilotSignedIn : cfg.id === "github-models" || cfg.id === "custom" ? true : key.trim().length > 0; + const canGoNext = [true, step1Valid, step2Valid, true][step]; + + if (!open) { + // Unified list: the cluster default (badged) + every additional provider, + // all uniform rows. Dedupe an additional whose tag matches the default id + // (e.g. Copilot signed in via the wizard is also the cluster default) so + // it shows once, as the default. + // Collapse the per-model local-<name> entries into ONE "AI Runway + // (in-cluster)" line — the local inference PROVIDER, not each model. + // Individual local models are managed in the Model catalogue below. + const defaultIsAiRunway = isAiRunwayDefault(defaultProvider); + const localProviders = additionalProviders.filter((provider) => + isAiRunwayProvider(provider, localDeployments), + ); + const additionalToShow = additionalProviders.filter( + (provider) => + provider.tag !== defaultProvider?.id + && !isAiRunwayProvider(provider, localDeployments), + ); + const connectedLocalModels = new Set([ + ...(defaultIsAiRunway ? defaultProvider?.models ?? [] : []), + ...localProviders.flatMap((provider) => provider.models), + ].map(normalizeLocalModelName)); + const localModelStates = new Map<string, boolean>(); + for (const model of connectedLocalModels) localModelStates.set(model, true); + for (const deployment of localDeployments.filter((item) => item.phase === "Running")) { + for (const model of deploymentModelNames(deployment)) { + localModelStates.set( + model, + Boolean(localModelStates.get(model)) || deployment.managed, + ); + } + } + const localModels = Array.from(localModelStates, ([name, connected]) => ({ + name, + connected, + })); + const showAiRunway = + defaultIsAiRunway + || localProviders.length > 0 + || localDeployments.length > 0 + || !!localInferenceStatus?.available; + const anyConfigured = !!defaultProvider || additionalToShow.length > 0 || showAiRunway; + return ( + <div className="mt-4 space-y-3"> + {anyConfigured ? ( + <ul className="space-y-2"> + {defaultProvider && !defaultIsAiRunway && <DefaultProviderCard p={defaultProvider} />} + {additionalToShow.map((p) => <ProviderCard key={p.tag} p={p} />)} + {showAiRunway && ( + <AiRunwayCard + models={localModels} + available={!!localInferenceStatus?.available} + isDefault={defaultIsAiRunway} + /> + )} + </ul> + ) : ( + <HonestState + variant="empty" + compact + title="No provider detected" + detail="This cluster has no inference provider configured yet. Connect one below to serve models to your teams." + /> + )} + <button type="button" onClick={() => setOpen(true)} className="rounded-lg border border-signal/40 bg-signal/10 px-3 py-1.5 text-xs font-medium text-signal hover:bg-signal/15"> + + Connect a provider + </button> + </div> + ); + } + + // For Local / Foundry the step embeds its OWN forms (FoundryOnboard, + // deploy) — so the wrapper must NOT be a <form> (nested forms are invalid + // HTML and silently break the inner submit). Those kinds self-submit; only + // the endpoint+key kinds use the outer form's action. + const selfContained = isLocal || isFoundry; + const Wrapper = (selfContained ? "div" : "form") as ElementType; + const wrapperProps = selfContained ? {} : { action }; + + return ( + <div className="mt-4"> + <Wrapper {...wrapperProps} className="rounded-lg border border-border bg-surface p-4"> + {/* Fields for the "default provider" action (onboardProviderAction). */} + <input type="hidden" name="kind" value={kind} /> + <input type="hidden" name="auth" value={authMode} /> + {target === "default" && !isLocal && <input type="hidden" name="endpoint" value={endpoint} />} + {target === "default" && !isLocal && <input type="hidden" name="key" value={key} />} + {/* Fields for the "additional provider" action (addAdditionalProviderAction). */} + {target === "additional" && !isLocal && <input type="hidden" name="tag" value={tag} />} + {target === "additional" && !isLocal && <input type="hidden" name="endpoint" value={endpoint} />} + {target === "additional" && !isLocal && <input type="hidden" name="api_key" value={key} />} + {!isLocal && <input type="hidden" name="models" value={modelsValue} />} + + <div className="flex items-center justify-between border-b border-border pb-3"> + <StepRail step={step} /> + <button type="button" onClick={reset} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + </div> + + {/* Step 1: pick a provider kind. */} + {step === 0 && ( + <div className="pt-4"> + <p className="text-xs font-medium text-foreground-muted">Which provider do you want to connect?</p> + <div className="mt-2 grid gap-2 sm:grid-cols-2"> + {PRESETS.map((p) => { + const selected = kind === p.id; + return ( + <button + key={p.id} + type="button" + onClick={() => { selectKind(p.id); setStep(1); }} + className={`flex items-start gap-2.5 rounded-lg border p-3 text-left transition ${ + selected ? "border-signal bg-signal/[0.06]" : "border-border bg-surface hover:bg-surface-muted" + }`} + > + <Icon name={p.icon} size={16} className={selected ? "text-signal" : "text-foreground-muted"} /> + <span> + <span className="block text-sm font-medium">{p.label}</span> + <span className="block text-[11px] text-foreground-muted">{p.hint}</span> + </span> + </button> + ); + })} + </div> + </div> + )} + + {/* Step 2 (Local kind only): connect the AI Runway provider — one line + in the list. Deploying / removing individual models happens in the + Model catalogue, so this step is just detect + acknowledge. */} + {isLocal && step === 1 && ( + <div className="space-y-3 pt-4"> + {localInferenceStatus?.available ? ( + <div className="rounded-lg border border-signal/30 bg-signal/[0.04] p-3"> + <p className="flex items-center gap-1.5 text-sm font-medium text-signal"> + <Icon name="check" size={15} /> AI Runway detected + </p> + <p className="mt-1 text-xs text-foreground-muted"> + In-cluster inference (AI Runway + KAITO) is installed and ready. It appears as a single <span className="font-medium">AI Runway (in-cluster)</span> provider in the list. + {localInferenceStatus.gpu_node_count > 0 + ? ` ${localInferenceStatus.gpu_node_count} GPU node(s) detected — GPU-tier models are available.` + : " No GPU nodes detected — CPU-tier models only."} + </p> + <p className="mt-2 text-xs text-foreground-muted"> + Deploy, remove, or set a default among individual local models in the <span className="font-medium">Model catalogue</span> below. + </p> + </div> + ) : ( + <HonestState + variant="empty" + compact + title="AI Runway isn't installed on this cluster yet" + detail="Local inference needs AI Runway + KAITO installed once by an operator (real helm/kubectl — see docs/local-inference.md; kars doesn't install it for you). Once it's in, this step detects it and it shows up as a provider automatically." + /> + )} + </div> + )} + + {/* Step 2 (Foundry kind only): connect + discover ALL services & models + inline — the single Foundry surface (the old standalone section is + gone). Reuses the full FoundryOnboard flow (connect → verify → + discover services + models → auto-populate the catalogue). */} + {isFoundry && step === 1 && ( + <div className="pt-4"> + <FoundryOnboard status={foundryStatus} /> + </div> + )} + + {/* Step 2: where it applies (default vs additional) + connection details. */} + {!isLocal && !isFoundry && step === 1 && ( + <div className="space-y-3 pt-4"> + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="target" size={13} /> Where it applies</legend> + <div className="grid gap-2 sm:grid-cols-2"> + <label + className={`flex items-start gap-2 rounded-lg border p-2.5 text-xs ${ + !cfg.defaultCapable ? "cursor-not-allowed border-border opacity-50" : target === "default" ? "cursor-pointer border-signal bg-signal/[0.05]" : "cursor-pointer border-border" + }`} + > + <input type="radio" name="_target" checked={target === "default"} disabled={!cfg.defaultCapable} onChange={() => setTarget("default")} className="mt-0.5" /> + <span> + <span className="block font-medium text-foreground">The cluster’s default provider</span> + <span className="block text-foreground-muted"> + {cfg.defaultCapable + ? "Every mission inherits this unless an InferencePolicy says otherwise. Replaces the current default, if any." + : cfg.id === "github-copilot" + ? "Connect Copilot here (sign in below) — it becomes an available provider. Then use \u201cSet as default\u201d on it in the provider list to make every mission inherit it." + : `${cfg.label} is added as an available provider here. After connecting, use \u201cSet as default\u201d on it in the provider list to make it the cluster default.`} + </span> + </span> + </label> + <label className={`flex cursor-pointer items-start gap-2 rounded-lg border p-2.5 text-xs ${target === "additional" ? "border-signal bg-signal/[0.05]" : "border-border"}`}> + <input type="radio" name="_target" checked={target === "additional"} onChange={() => setTarget("additional")} className="mt-0.5" /> + <span> + <span className="block font-medium text-foreground">An additional provider</span> + <span className="block text-foreground-muted">Available to every sandbox, but only used by a sandbox whose InferencePolicy names its tag.</span> + </span> + </label> + </div> + </fieldset> + + {target === "additional" && ( + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="link" size={13} /> Connection</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="block text-xs text-foreground-muted"> + Tag + <input value={tag} onChange={(e) => setTag(e.target.value)} readOnly={cfg.id !== "custom"} placeholder="e.g. foundry-eu" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs read-only:opacity-70" /> + {usedTags.has(tag.trim()) && <span className="mt-1 block text-[11px] text-danger">Already connected — pick a different tag.</span>} + </label> + {cfg.endpointEditable ? ( + <label className="block text-xs text-foreground-muted"> + Endpoint + <input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="https://your-resource.services.ai.azure.com" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + ) : ( + <label className="block text-xs text-foreground-muted"> + Endpoint + <input value={cfg.id === "github-copilot" ? "https://api.githubcopilot.com" : cfg.id === "github-models" ? "https://models.github.ai/inference" : ""} readOnly className="mt-1 w-full rounded-lg border border-border bg-surface-muted/50 px-3 py-2 font-mono text-xs opacity-70" /> + </label> + )} + </div> + </fieldset> + )} + {target === "default" && cfg.endpointEditable && ( + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="link" size={13} /> Connection</legend> + <label className="block text-xs text-foreground-muted"> + Endpoint + <input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="https://your-resource.services.ai.azure.com" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + </fieldset> + )} + <p className="text-[11px] text-foreground-muted">{cfg.hint}</p> + </div> + )} + + {/* Step 3: authentication. */} + {!isLocal && step === 2 && ( + <div className="pt-4"> + {target === "default" ? ( + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="lock" size={13} /> Authentication</legend> + <label className="block text-xs text-foreground-muted"> + How it authenticates + <select value={authMode} onChange={(e) => setAuthMode(e.target.value as typeof authMode)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="workload">Workload identity (recommended)</option> + <option value="agentid">Agent id</option> + <option value="api">API key (development only)</option> + </select> + </label> + {authMode === "api" && ( + <label className="mt-2 block text-xs text-foreground-muted"> + API key + <input value={key} onChange={(e) => setKey(e.target.value)} type="password" placeholder="required" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + )} + </fieldset> + ) : cfg.id === "github-copilot" ? ( + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="lock" size={13} /> Sign in to GitHub Copilot</legend> + <CopilotSignIn + signedIn={copilotSignedIn} + onAuthorized={(models) => { + setCopilotSignedIn(true); + setKey(""); // token is stored server-side, never in the browser + setDiscovered(models); + setSelectedModels(new Set(models.filter((m) => m.recommended).map((m) => m.id))); + }} + /> + </fieldset> + ) : ( + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="lock" size={13} /> Authentication</legend> + <label className="block text-xs text-foreground-muted"> + {cfg.id === "github-models" ? "GitHub token" : "API key / token (optional)"} + <input value={key} onChange={(e) => setKey(e.target.value)} type="password" placeholder={cfg.id === "github-models" ? "required" : "leave blank to use Workload Identity"} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + </label> + </fieldset> + )} + </div> + )} + + {/* Step 4: models + review. */} + {!isLocal && step === 3 && ( + <div className="space-y-3 pt-4"> + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="brain" size={13} /> Models to serve</legend> + {cfg.supportsDiscovery && cfg.id !== "github-copilot" && ( + <div className="mb-2 flex items-center justify-between"> + <span className="text-[11px] text-foreground-muted">Discover the real catalog instead of typing ids.</span> + <button type="button" onClick={runDiscover} disabled={!canDiscover || discovering} className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-signal-fg disabled:opacity-40"> + {discovering ? "Discovering…" : "Discover models"} + </button> + </div> + )} + {cfg.id === "github-copilot" && ( + <p className="mb-2 text-[11px] text-foreground-muted">Your Copilot seat’s live models — the flagship tier is pre-selected. Adjust below.</p> + )} + {discoverError && <p className="mb-2 text-[11px] text-danger">{discoverError}</p>} + {discovered && ( + <div className="mb-2 max-h-40 space-y-1 overflow-y-auto rounded-lg border border-border bg-surface-muted/30 p-2"> + {discovered.length === 0 ? ( + <p className="text-[11px] text-foreground-muted">No models found.</p> + ) : ( + discovered.map((m) => ( + <label key={m.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-surface-muted"> + <input type="checkbox" checked={selectedModels.has(m.id)} onChange={() => toggleModel(m.id)} /> + <span className="font-mono">{m.id}</span> + {m.label && <span className="text-foreground-muted">— {m.label}</span>} + {m.recommended && <span className="text-signal" title="Recommended">★</span>} + </label> + )) + )} + </div> + )} + <input + value={models} + onChange={(e) => setModels(e.target.value)} + placeholder={discovered ? "Add more by id (comma-separated, optional)" : "e.g. gpt-4.1, gpt-4o-mini (comma-separated)"} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + <p className="mt-1.5 text-[11px] text-foreground-muted"> + {target === "default" + ? "Available to every mission and to the orchestrator." + : "These appear in the InferencePolicy model picker tagged with this provider, alongside the cluster's default models."} + </p> + </fieldset> + + <div className="rounded-lg border border-dashed border-border bg-surface-muted/30 p-3 text-xs"> + <p className="font-medium text-foreground-muted">Review</p> + <dl className="mt-1.5 grid gap-1 sm:grid-cols-2"> + <div><dt className="inline text-foreground-muted">Provider: </dt><dd className="inline font-medium">{cfg.label}</dd></div> + <div><dt className="inline text-foreground-muted">Applies to: </dt><dd className="inline font-medium">{target === "default" ? "Cluster default" : "Additional"}</dd></div> + {target === "additional" && <div><dt className="inline text-foreground-muted">Tag: </dt><dd className="inline font-mono">{tag || "—"}</dd></div>} + <div className="sm:col-span-2"><dt className="inline text-foreground-muted">Endpoint: </dt><dd className="inline font-mono">{endpoint || (cfg.endpointEditable ? "—" : "built-in")}</dd></div> + <div><dt className="inline text-foreground-muted">Auth: </dt><dd className="inline">{target === "default" ? authMode : key ? "key/token provided" : "none"}</dd></div> + <div><dt className="inline text-foreground-muted">Models: </dt><dd className="inline font-mono">{modelsValue || "—"}</dd></div> + </dl> + </div> + </div> + )} + + <div className="mt-4 flex items-center gap-3 border-t border-border pt-3"> + {step > 0 && ( + <button type="button" onClick={() => setStep((s) => s - 1)} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">← Back</button> + )} + {step < ((isLocal || isFoundry) ? 1 : STEPS.length - 1) ? ( + <button type="button" onClick={() => setStep((s) => s + 1)} disabled={!canGoNext} className="rounded-lg bg-signal px-4 py-1.5 text-xs font-semibold text-signal-fg disabled:opacity-50">Next →</button> + ) : isFoundry ? ( + // Foundry self-submits via the embedded FoundryOnboard flow — + // the wizard only offers a way out once it's connected/discovered. + <button type="button" onClick={reset} className="rounded-lg border border-signal/40 bg-signal/10 px-4 py-2 text-sm font-medium text-signal hover:bg-signal/15">Done</button> + ) : isLocal ? ( + // Local = connect AI Runway (detect only). Model deploy/remove is + // in the Model catalogue, so the wizard just needs a way out. + <button type="button" onClick={reset} className="rounded-lg border border-signal/40 bg-signal/10 px-4 py-2 text-sm font-medium text-signal hover:bg-signal/15">Done</button> + ) : ( + <button type="submit" disabled={pending || !modelsValue} className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Connecting…" : target === "default" ? "Set as default provider" : "Connect provider"} + </button> + )} + {isLocal || isFoundry ? null : ( + <> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </> + )} + </div> + </Wrapper> + </div> + ); +} diff --git a/bridge/web/src/app/console/configuration/set-default-model-actions.ts b/bridge/web/src/app/console/configuration/set-default-model-actions.ts new file mode 100644 index 000000000..03a0fe2d4 --- /dev/null +++ b/bridge/web/src/app/console/configuration/set-default-model-actions.ts @@ -0,0 +1,21 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, setDefaultModel } from "@/lib/bff"; + +export interface SetDefaultModelState { error: string | null; ok: string | null } + +/** Make one specific model the cluster default (Model catalogue "Set as + * default"). The BFF promotes the model's provider and pins the model. */ +export async function setDefaultModelAction(_p: SetDefaultModelState, form: FormData): Promise<SetDefaultModelState> { + const deployment = String(form.get("deployment") ?? "").trim(); + const provider = String(form.get("provider") ?? "").trim(); + if (!deployment || !provider) return { error: "Missing model or provider.", ok: null }; + try { + await setDefaultModel(deployment, provider); + revalidatePath("/console/configuration"); + return { error: null, ok: `${deployment} is now the cluster default.` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "couldn't set default", ok: null }; + } +} diff --git a/bridge/web/src/app/console/configuration/set-default-provider-actions.ts b/bridge/web/src/app/console/configuration/set-default-provider-actions.ts new file mode 100644 index 000000000..7eaff756c --- /dev/null +++ b/bridge/web/src/app/console/configuration/set-default-provider-actions.ts @@ -0,0 +1,19 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, promoteAdditionalProvider } from "@/lib/bff"; + +export interface SetDefaultProviderState { error: string | null; ok: string | null } + +export async function setDefaultProviderAction(_p: SetDefaultProviderState, form: FormData): Promise<SetDefaultProviderState> { + const tag = String(form.get("tag") ?? "").trim(); + if (!tag) return { error: "Missing tag.", ok: null }; + try { + const r = await promoteAdditionalProvider(tag); + revalidatePath("/console/configuration"); + revalidatePath("/console/policies"); + return { error: null, ok: r.note }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "set default failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/datapath/page.tsx b/bridge/web/src/app/console/datapath/page.tsx new file mode 100644 index 000000000..3b534487e --- /dev/null +++ b/bridge/web/src/app/console/datapath/page.tsx @@ -0,0 +1,243 @@ +// kars Bridge Operator Console — Datapath witness. +// +// Surfaces the OPTIONAL eBPF (Inspektor Gadget) datapath-completeness witness: +// an independent, kernel-level attestation of what each sandbox ACTUALLY sends +// on the network, cross-checked against the controller-declared egress +// allowlist. The Bridge only reads the `kars-datapath-witness` ConfigMap the +// witness publishes — enforcement stays with the router proxy + NetworkPolicy; +// this page only ATTESTS. When the witness isn't installed, we show honest +// enable instructions — never fabricated data. + +import { PageHeader, Stat, Badge } from "@/components/ui"; +import { Icon } from "@/components/icon"; +import { getDatapathWitness } from "@/lib/bff"; +import type { DatapathWitness, DatapathWitnessSandbox } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function verdictTone(v: string): "ok" | "warn" | "muted" { + if (v === "COMPLIANT") return "ok"; + if (v === "BEYOND-DECLARED") return "warn"; + return "muted"; +} + +function verdictLabel(v: string): string { + if (v === "COMPLIANT") return "Compliant"; + if (v === "BEYOND-DECLARED") return "Beyond declared"; + if (v === "LEARN") return "Learn / unconstrained"; + return v; +} + +export default async function DatapathWitnessPage() { + let witness: DatapathWitness | null = null; + let error = false; + try { + witness = await getDatapathWitness(); + } catch { + error = true; + } + + const enabled = !!witness?.enabled; + const sandboxes = witness?.sandboxes ?? []; + const beyond = sandboxes.filter((s) => s.verdict === "BEYOND-DECLARED").length; + const compliant = sandboxes.filter((s) => s.verdict === "COMPLIANT").length; + const learn = sandboxes.filter((s) => s.verdict === "LEARN").length; + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Datapath witness" + lead="An independent, kernel-level (eBPF) attestation of what each sandbox actually sends on the network — cross-checked against the controller-declared egress allowlist. Enforcement stays with the router proxy and NetworkPolicy; this witness only attests." + /> + + {error && ( + <div className="rounded-xl border border-danger/30 bg-danger/[0.06] px-4 py-3 text-sm text-foreground-muted"> + Couldn't reach the cluster to read the witness. Check the BFF's cluster wiring. + </div> + )} + + {!error && !enabled && <NotEnabled hint={witness?.install_hint} />} + + {!error && enabled && ( + <> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + <Stat label="Sandboxes witnessed" value={sandboxes.length} /> + <Stat label="Beyond declared" value={beyond} accent={beyond > 0} /> + <Stat label="Compliant" value={compliant} /> + <Stat label="Learn / unconstrained" value={learn} /> + </div> + + <div className="flex flex-wrap items-center gap-2 text-xs text-foreground-muted"> + <span aria-hidden className="inline-flex h-1.5 w-1.5 rounded-full bg-ok kb-pulse" /> + Live from the eBPF witness + {witness?.generated_at && ( + <span>· last observed {new Date(witness.generated_at).toLocaleTimeString()}</span> + )} + {witness?.window_seconds && <span>· {witness.window_seconds}s capture window</span>} + </div> + + {sandboxes.length === 0 ? ( + <div className="rounded-xl border border-dashed border-border bg-surface-muted/30 px-4 py-8 text-center text-sm text-foreground-muted"> + The witness is running but hasn't observed any sandbox egress yet. + </div> + ) : ( + <ul className="space-y-3"> + {sandboxes.map((s) => ( + <WitnessRow key={s.namespace} s={s} /> + ))} + </ul> + )} + + <p className="text-xs leading-relaxed text-foreground-muted"> + <span className="font-medium text-foreground">How to read this.</span> DNS = host + intent; TCP connects = the actual external datapath. A{" "} + <span className="font-medium text-warning">beyond-declared</span> host means the kernel + observed egress to a host that isn't in the sandbox's signed allowlist — in{" "} + <code className="rounded bg-surface-muted px-1">strict</code> mode the router proxy + should have blocked the connect; a DNS-only observation is intent without a connect.{" "} + <span className="font-medium text-foreground-muted">Learn</span> means no host allowlist + is published yet — the observed set is the baseline you would promote into strict. + </p> + </> + )} + </div> + ); +} + +function WitnessRow({ s }: { s: DatapathWitnessSandbox }) { + return ( + <li className="kb-card p-4"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="min-w-0"> + <p className="font-mono text-sm font-medium">{s.sandbox}</p> + <p className="mt-0.5 font-mono text-[11px] text-foreground-muted">{s.namespace}</p> + </div> + <Badge tone={verdictTone(s.verdict)} dot> + {verdictLabel(s.verdict)} + </Badge> + </div> + + <div className="mt-3 grid gap-3 sm:grid-cols-3"> + <HostSet label="Declared allowlist" hosts={s.declared_hosts} empty="none (unconstrained)" /> + <HostSet + label="Observed (DNS)" + hosts={s.observed_dns} + empty="none in window" + highlight={new Set(s.beyond_declared)} + /> + <div className="min-w-0"> + <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + External connects + </p> + <p className="text-sm tabular-nums">{s.observed_connects}</p> + {s.beyond_declared.length > 0 && ( + <p className="mt-1 text-[11px] text-warning"> + {s.beyond_declared.length} beyond declared + </p> + )} + </div> + </div> + </li> + ); +} + +/** The K8s stub resolver expands a ClusterIP name with the node's DNS search + * domain, so the eBPF witness observes both the real name and search-expanded + * variants like `foo.ns.svc.cluster.local.<node-suffix>.internal.cloudapp.net`. + * Trim back to the meaningful in-cluster name so the list is readable (the full + * observed string stays available on hover). */ +function cleanHost(h: string): string { + const marker = ".svc.cluster.local"; + const idx = h.indexOf(marker); + if (idx !== -1) return h.slice(0, idx + marker.length); + return h; +} + +function HostSet({ + label, + hosts, + empty, + highlight, +}: { + label: string; + hosts: string[]; + empty: string; + highlight?: Set<string>; +}) { + // Collapse search-domain-expanded duplicates to one readable entry, carrying + // the beyond-declared flag if ANY raw variant was flagged, and keeping the + // longest raw form for the hover title. + const grouped = new Map<string, { flagged: boolean; raw: string }>(); + for (const h of hosts) { + const c = cleanHost(h); + const prev = grouped.get(c); + grouped.set(c, { + flagged: (prev?.flagged ?? false) || (highlight?.has(h) ?? false), + raw: prev && prev.raw.length >= h.length ? prev.raw : h, + }); + } + const items = Array.from(grouped.entries()); + + return ( + <div className="min-w-0"> + <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + {label} + </p> + {items.length === 0 ? ( + <p className="text-xs italic text-foreground-muted/70">{empty}</p> + ) : ( + <ul className="flex flex-col gap-1"> + {items.map(([display, { flagged, raw }]) => ( + <li + key={display} + title={raw} + className={`block break-all rounded border px-1.5 py-0.5 font-mono text-[11px] leading-snug ${ + flagged + ? "border-warning/40 bg-warning/10 text-warning" + : "border-border bg-surface-muted text-foreground-muted" + }`} + > + {display} + </li> + ))} + </ul> + )} + </div> + ); +} + +function NotEnabled({ hint }: { hint?: string }) { + return ( + <div className="kb-card p-6"> + <div className="flex items-start gap-3"> + <span aria-hidden className="text-xl"> + <Icon name="eye" size={22} /> + </span> + <div className="min-w-0 space-y-3"> + <div> + <h2 className="text-sm font-semibold">Datapath witness not enabled</h2> + <p className="mt-1 text-sm text-foreground-muted"> + The eBPF witness is optional and off by default — it installs a privileged Inspektor + Gadget DaemonSet plus a small aggregator. When enabled, every sandbox's + kernel-observed egress is cross-checked here against its declared allowlist, live. + </p> + </div> + <div> + <p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + Enable on the cluster + </p> + <pre className="overflow-x-auto rounded-lg border border-border bg-surface-muted p-3 font-mono text-xs"> + {hint ?? "KARS_EBPF_WITNESS=1 deploy/ebpf-witness/install.sh --continuous"} + </pre> + </div> + <p className="text-xs text-foreground-muted"> + Requires a Linux kernel with BTF on every node. Read-only: the witness never blocks or + modifies traffic. See{" "} + <code className="rounded bg-surface-muted px-1">deploy/ebpf-witness/README.md</code>. + </p> + </div> + </div> + </div> + ); +} diff --git a/bridge/web/src/app/console/delete-resource.tsx b/bridge/web/src/app/console/delete-resource.tsx new file mode 100644 index 000000000..d2d696c0d --- /dev/null +++ b/bridge/web/src/app/console/delete-resource.tsx @@ -0,0 +1,70 @@ +"use client"; + +// kars Bridge Operator Console — per-item delete/revoke control. A two-step +// confirm (click → "Confirm?") so a governance object is never removed by a +// stray click. The delete is a real DELETE against the operator API (RBAC + +// finalizer enforced server-side); for an EgressApproval, delete IS the revoke. + +import { useActionState, useState } from "react"; +import { deleteGovernanceAction, type GovState } from "./governance-actions"; + +const init: GovState = { error: null, ok: null }; + +export function DeleteResource({ + kind, + name, + label, + verb = "Remove", +}: { + kind: "ToolPolicy" | "McpServer" | "KarsSkill" | "KarsProfile" | "EgressApproval" | "InferencePolicy"; + name: string; + /** Human noun for the confirm copy, e.g. "tool policy". */ + label: string; + /** Button verb — "Remove" for CRUD, "Revoke" for egress grants. */ + verb?: "Remove" | "Revoke"; +}) { + const [state, action, pending] = useActionState(deleteGovernanceAction, init); + const [confirming, setConfirming] = useState(false); + + if (state.ok) { + // The list is revalidated server-side; show a brief tombstone until refresh. + return <span className="text-[11px] text-foreground-muted">{verb}d — refreshing…</span>; + } + + if (!confirming) { + return ( + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => setConfirming(true)} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:border-danger/40 hover:text-danger" + > + {verb} + </button> + {state.error && <span className="text-[11px] text-danger">{state.error}</span>} + </div> + ); + } + + return ( + <form action={action} className="flex items-center gap-2"> + <input type="hidden" name="kind" value={kind} /> + <input type="hidden" name="name" value={name} /> + <span className="text-[11px] text-foreground-muted">{verb} {label} “{name}”?</span> + <button + type="submit" + disabled={pending} + className="rounded-md border border-danger/40 bg-danger/10 px-2 py-1 text-[11px] font-semibold text-danger disabled:opacity-50" + > + {pending ? `${verb.slice(0, -1)}ing…` : `Yes, ${verb.toLowerCase()}`} + </button> + <button + type="button" + onClick={() => setConfirming(false)} + className="text-[11px] text-foreground-muted hover:text-foreground" + > + Cancel + </button> + </form> + ); +} diff --git a/bridge/web/src/app/console/evals/eval-detail.tsx b/bridge/web/src/app/console/evals/eval-detail.tsx new file mode 100644 index 000000000..459c1bca3 --- /dev/null +++ b/bridge/web/src/app/console/evals/eval-detail.tsx @@ -0,0 +1,147 @@ +"use client"; + +// kars Bridge Operator Console — detailed eval report. Expands an eval to show +// exactly what the adversarial baseline probes (each case + the expected +// decision) and the latest per-case verdict (pass/fail + what the router +// actually did). Sourced from the corpus + report ConfigMaps the controller +// persists — real evidence, downloadable for an auditor. + +import { useState } from "react"; +import type { EvalReport, EvalCase } from "@/lib/types"; + +/** Robust file download — appends the anchor to the DOM (required by Firefox) + * and revokes the object URL, so the file always lands with the right name. */ +function downloadFile(filename: string, content: string, mime: string) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function CaseRow({ c }: { c: EvalCase }) { + const tone = c.errored + ? "border-warning/40 bg-warning/10 text-warning" + : c.pass === true + ? "border-ok/40 bg-ok/10 text-ok" + : c.pass === false + ? "border-danger/40 bg-danger/10 text-danger" + : "border-border bg-surface-muted text-foreground-muted"; + const label = c.errored ? "ERRORED" : c.pass === true ? "PASS" : c.pass === false ? "FAIL" : "—"; + return ( + <li className="border-t border-border px-3 py-2.5 text-xs"> + <div className="flex items-start gap-2"> + <span className={`mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-semibold ${tone}`}> + {label} + </span> + <div className="min-w-0"> + <p className="font-mono font-medium">{c.id}</p> + {c.probe && <p className="mt-0.5 break-words text-foreground-muted">Probe: {c.probe}</p>} + <p className="mt-0.5 text-[11px] text-foreground-muted"> + expected <span className="font-medium text-foreground">{c.expected ?? "—"}</span> + {c.actual != null && ( + <> + {" "}· actual{" "} + <span className={`font-medium ${c.errored ? "text-warning" : c.pass === false ? "text-danger" : "text-foreground"}`}> + {c.actual} + </span> + </> + )} + {c.tags.length > 0 && <> · {c.tags.join(", ")}</>} + </p> + {c.errored && c.actual_reason && ( + <p className="mt-0.5 break-words text-[11px] text-warning/80"> + Couldn't evaluate (inconclusive): {c.actual_reason} + </p> + )} + {!c.errored && c.pass === false && c.actual_reason && ( + <p className="mt-0.5 break-words text-[11px] text-danger/80">Reason: {c.actual_reason}</p> + )} + </div> + </div> + </li> + ); +} + +export function EvalDetail({ name }: { name: string }) { + const [open, setOpen] = useState(false); + const [report, setReport] = useState<EvalReport | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + async function toggle() { + const next = !open; + setOpen(next); + if (next && !report && !loading) { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/operator/evals/${encodeURIComponent(name)}/report`, { + headers: { accept: "application/json" }, + }); + if (!res.ok) throw new Error(`load failed (${res.status})`); + setReport(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't load the report."); + } finally { + setLoading(false); + } + } + } + + return ( + <div className="mt-3"> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={toggle} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + {open ? "Hide" : "View"} detailed report{report ? ` · ${report.cases.length} cases` : ""} + </button> + {report && ( + <button + type="button" + onClick={() => downloadFile(`eval-report-${name}.json`, JSON.stringify(report, null, 2), "application/json")} + className="rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs font-medium hover:bg-surface-muted/70" + > + ⭳ Download report (JSON) + </button> + )} + </div> + + {open && ( + <div className="mt-2"> + {loading && <p className="text-xs text-foreground-muted">Loading the baseline + verdicts…</p>} + {error && <p className="text-xs text-danger">{error}</p>} + {report && ( + <> + <p className="text-[11px] text-foreground-muted"> + Baseline: <span className="font-mono">{report.corpus ?? name}</span> — {report.cases.length} adversarial + case{report.cases.length === 1 ? "" : "s"} + {report.completed_at ? ` · last run ${new Date(report.completed_at).toLocaleString()}` : " · not run yet"}. + </p> + {!report.per_case_available && report.completed_at && ( + <p className="mt-1 rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-2 text-[11px] text-foreground-muted"> + Per-case verdicts weren't captured for the last run (it predates detailed reporting) — the + summary shows {report.passed}/{report.total} passed. Re-run this eval to capture which specific + cases pass or fail and why. The baseline it probes is shown below. + </p> + )} + <ul className="mt-2 overflow-hidden rounded-lg border border-border bg-surface"> + {report.cases.map((c) => ( + <CaseRow key={c.id} c={c} /> + ))} + </ul> + </> + )} + </div> + )} + </div> + ); +} diff --git a/bridge/web/src/app/console/evals/new-eval-form.tsx b/bridge/web/src/app/console/evals/new-eval-form.tsx new file mode 100644 index 000000000..423efd010 --- /dev/null +++ b/bridge/web/src/app/console/evals/new-eval-form.tsx @@ -0,0 +1,151 @@ +"use client"; + +// kars Bridge Operator Console — configure + launch a safety eval. Operator-only: +// pick a running sandbox and an adversarial corpus, and the controller spawns the +// runner Job that replays it and records a real verdict. Re-running the same +// sandbox+corpus updates the existing eval in place. + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +const BUILTIN_CORPORA = [ + "jailbreak-baseline", + "prompt-injection-baseline", + "egress-known-bad", + "memory-isolation-baseline", +]; + +export function NewEvalForm({ + sandboxes, + defaultRunnerImage = "kars-conformance-runner:dev", +}: { + sandboxes: { name: string; runtime: string | null }[]; + defaultRunnerImage?: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [target, setTarget] = useState(sandboxes[0]?.name ?? ""); + const [corpus, setCorpus] = useState(BUILTIN_CORPORA[0]); + const [schedule, setSchedule] = useState(""); + const [runnerImage, setRunnerImage] = useState(defaultRunnerImage); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + async function submit() { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/operator/evals", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + target_sandbox: target, + corpus, + schedule: schedule.trim() || null, + runner_image: runnerImage.trim() || null, + run_now: true, + }), + }); + if (!res.ok) { + const b = await res.json().catch(() => null); + throw new Error(b?.error?.message ?? `Create failed (${res.status})`); + } + setOpen(false); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Create failed"); + } finally { + setBusy(false); + } + } + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + disabled={sandboxes.length === 0} + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + title={sandboxes.length === 0 ? "No running sandbox to evaluate" : "Configure and launch a safety eval"} + > + + New eval + </button> + ); + } + + return ( + <div className="rounded-xl border border-border bg-surface p-5"> + <p className="text-sm font-semibold">Configure a safety eval</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + Replays an adversarial corpus against the sandbox's inference router and records a real + pass/fail verdict. + </p> + <div className="mt-3 grid gap-3 sm:grid-cols-2"> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Target sandbox</span> + <select + value={target} + onChange={(e) => setTarget(e.target.value)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + {sandboxes.map((s) => ( + <option key={s.name} value={s.name}> + {s.name}{s.runtime ? ` · ${s.runtime}` : ""} + </option> + ))} + </select> + </label> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Corpus</span> + <input + list="corpus-options" + value={corpus} + onChange={(e) => setCorpus(e.target.value)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm font-mono" + /> + <datalist id="corpus-options"> + {BUILTIN_CORPORA.map((c) => ( + <option key={c} value={c} /> + ))} + </datalist> + </label> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Schedule (cron, optional)</span> + <input + value={schedule} + onChange={(e) => setSchedule(e.target.value)} + placeholder="0 */6 * * * (leave blank for one-shot)" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm font-mono" + /> + </label> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Runner image</span> + <input + value={runnerImage} + onChange={(e) => setRunnerImage(e.target.value)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm font-mono" + /> + </label> + </div> + <div className="mt-3 flex items-center gap-2"> + <button + type="button" + onClick={submit} + disabled={busy || !target || !corpus.trim()} + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {busy ? "Launching…" : "Configure & run"} + </button> + <button + type="button" + onClick={() => setOpen(false)} + disabled={busy} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + Cancel + </button> + {error && <span className="text-xs text-danger">{error}</span>} + </div> + </div> + ); +} diff --git a/bridge/web/src/app/console/evals/page.tsx b/bridge/web/src/app/console/evals/page.tsx new file mode 100644 index 000000000..9fd179434 --- /dev/null +++ b/bridge/web/src/app/console/evals/page.tsx @@ -0,0 +1,170 @@ +// kars Bridge Operator Console — Safety evals (KarsEval). +// +// The quality/safety lifecycle: each KarsEval replays a curated adversarial +// corpus (jailbreak / injection / egress) against a live sandbox's inference +// router and records a real pass/fail verdict. This page surfaces the REAL +// KarsEval status the controller captured — never fabricated. A failing eval +// means the sandbox let an attack through (drift), which is exactly the signal +// an operator needs. Honest empty when no evals exist. + +import { PageHeader, Stat, Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { listEvals, listSandboxes } from "@/lib/bff"; +import type { Eval } from "@/lib/types"; +import { NewEvalForm } from "./new-eval-form"; +import { EvalDetail } from "./eval-detail"; + +export const dynamic = "force-dynamic"; + +function phaseTone(phase: string | null): "ok" | "warn" | "danger" | "muted" { + if (phase === "Ready") return "ok"; + if (phase === "Degraded" || phase === "Failed") return "danger"; + if (phase === "Pending" || phase === "Progressing") return "warn"; + return "muted"; +} + +function ago(iso: string | null): string { + if (!iso) return "never"; + const t = Date.parse(iso); + if (Number.isNaN(t)) return ""; + const s = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +function EvalCard({ e }: { e: Eval }) { + const r = e.last_result; + const passRate = r && r.total > 0 ? Math.round((r.passed / r.total) * 100) : null; + const anyFailed = r != null && r.failed > 0; + return ( + <li className="rounded-xl border border-border bg-surface p-5"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <p className="font-medium">{e.display_name ?? e.name}</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + {e.corpus ?? "—"} + {e.target_sandbox && <> · target <span className="font-mono">{e.target_sandbox}</span></>} + {e.schedule && <> · schedule <span className="font-mono">{e.schedule}</span></>} + </p> + </div> + <Badge tone={phaseTone(e.phase)} dot> + {e.phase ?? "Unknown"} + </Badge> + </div> + + {r ? ( + <> + <div className="mt-4 grid grid-cols-4 gap-3"> + <div> + <p className="text-lg font-semibold tabular-nums">{r.total}</p> + <p className="text-[11px] text-foreground-muted">cases</p> + </div> + <div> + <p className="text-lg font-semibold tabular-nums text-ok">{r.passed}</p> + <p className="text-[11px] text-foreground-muted">passed</p> + </div> + <div> + <p className={`text-lg font-semibold tabular-nums ${r.failed > 0 ? "text-danger" : ""}`}>{r.failed}</p> + <p className="text-[11px] text-foreground-muted">failed</p> + </div> + <div> + <p className={`text-lg font-semibold tabular-nums ${r.errored > 0 ? "text-warning" : ""}`}>{r.errored}</p> + <p className="text-[11px] text-foreground-muted">errored</p> + </div> + </div> + {passRate != null && ( + <div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-surface-muted"> + <div className={`h-full ${anyFailed ? "bg-danger" : "bg-ok"}`} style={{ width: `${Math.max(passRate, 2)}%` }} /> + </div> + )} + <p className="mt-2 text-[11px] text-foreground-muted"> + {anyFailed + ? `${r.failed} attack${r.failed === 1 ? " was" : "s were"} NOT blocked — the sandbox drifted from its safety baseline.` + : r.errored > 0 + ? "No safety failures detected." + : "Every adversarial case was correctly handled."} + {r.errored > 0 && ( + <> + {" "} + {r.errored} case{r.errored === 1 ? "" : "s"} + {" couldn\u2019t be evaluated (target unreachable) — inconclusive, not counted as a failure; re-run against a live sandbox."} + </> + )}{" "} + Last run {ago(e.last_run_at)}. + </p> + <EvalDetail name={e.name} /> + </> + ) : ( + <> + <p className="mt-4 text-xs text-foreground-muted"> + No completed run yet — the corpus replay is pending or in flight. + </p> + <EvalDetail name={e.name} /> + </> + )} + </li> + ); +} + +export default async function EvalsPage() { + let evals: Eval[] = []; + let sandboxes: { name: string; runtime: string | null }[] = []; + let error = false; + try { + const [ev, sb] = await Promise.all([listEvals(), listSandboxes().catch(() => [])]); + evals = ev; + sandboxes = sb + .filter((s) => s.phase === "Running" || s.phase === "Ready") + .map((s) => ({ name: s.name, runtime: s.runtime })); + } catch { + error = true; + } + + const withResult = evals.filter((e) => e.last_result); + const drifting = withResult.filter((e) => (e.last_result?.failed ?? 0) > 0).length; + const cases = withResult.reduce((s, e) => s + (e.last_result?.total ?? 0), 0); + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Safety evals" + lead="Each KarsEval replays a curated adversarial corpus (jailbreak / injection / egress) against a live sandbox's inference router and records a real pass/fail verdict. A failing case means an attack got through — the drift signal that matters." + /> + + {!error && ( + <div className="flex items-center justify-between gap-3"> + <div /> + <NewEvalForm sandboxes={sandboxes} /> + </div> + )} + + {!error && evals.length > 0 && ( + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + <Stat label="Evals" value={evals.length} /> + <Stat label="With a verdict" value={withResult.length} /> + <Stat label="Drifting" value={drifting} accent={drifting > 0} /> + <Stat label="Cases replayed" value={cases} /> + </div> + )} + + {error ? ( + <HonestState variant="not_wired" title="Cluster unreachable" detail="The Bridge backend can't reach the Kubernetes API right now." /> + ) : evals.length === 0 ? ( + <HonestState + variant="empty" + title="No safety evals yet" + detail="Create a KarsEval targeting a sandbox with a builtin corpus (e.g. jailbreak-baseline) — its real verdict appears here once the runner completes." + /> + ) : ( + <ul className="space-y-3"> + {evals.map((e) => ( + <EvalCard key={`${e.namespace}/${e.name}`} e={e} /> + ))} + </ul> + )} + </div> + ); +} diff --git a/bridge/web/src/app/console/fleet/capacity-dashboard.tsx b/bridge/web/src/app/console/fleet/capacity-dashboard.tsx new file mode 100644 index 000000000..185f3be0e --- /dev/null +++ b/bridge/web/src/app/console/fleet/capacity-dashboard.tsx @@ -0,0 +1,214 @@ +import { Section } from "@/components/ui"; +import type { ClusterCapacity, Sandbox } from "@/lib/types"; + +function formatCpu(value: number | null): string { + if (value == null) return "—"; + return value >= 1000 ? `${(value / 1000).toFixed(1)} cores` : `${Math.round(value)}m`; +} + +function formatMemory(value: number | null): string { + if (value == null) return "—"; + return value >= 1024 ** 3 + ? `${(value / 1024 ** 3).toFixed(1)} GiB` + : `${Math.round(value / 1024 ** 2)} MiB`; +} + +function tone(percent: number): string { + if (percent >= 85) return "#ef4444"; + if (percent >= 70) return "#f59e0b"; + return "#10b981"; +} + +function Gauge({ + label, + percent, + detail, +}: { + label: string; + percent: number; + detail: string; +}) { + const bounded = Math.max(0, Math.min(100, percent)); + const color = tone(bounded); + return ( + <div className="flex items-center gap-4 rounded-xl border border-border bg-surface-muted/30 p-4"> + <div + className="grid h-24 w-24 shrink-0 place-items-center rounded-full" + style={{ + background: `conic-gradient(${color} ${bounded * 3.6}deg, color-mix(in srgb, var(--color-border) 65%, transparent) 0deg)`, + }} + > + <div className="grid h-16 w-16 place-items-center rounded-full bg-surface"> + <span className="text-lg font-semibold tabular-nums">{bounded.toFixed(1)}%</span> + </div> + </div> + <div> + <p className="font-medium">{label}</p> + <p className="mt-1 text-xs text-foreground-muted">{detail}</p> + <p className="mt-2 text-xs font-medium" style={{ color }}> + {bounded >= 85 ? "High pressure" : bounded >= 70 ? "Watch" : "Healthy headroom"} + </p> + </div> + </div> + ); +} + +function Bar({ value, color }: { value: number; color: string }) { + return ( + <div className="h-2 overflow-hidden rounded-full bg-surface-muted"> + <div + className="h-full rounded-full transition-[width]" + style={{ width: `${Math.max(1, Math.min(100, value))}%`, backgroundColor: color }} + /> + </div> + ); +} + +export function CapacityDashboard({ + capacity, + sandboxes, +}: { + capacity: ClusterCapacity; + sandboxes: Sandbox[]; +}) { + const cpuUsed = capacity.nodes.reduce((sum, node) => sum + (node.cpu_usage_millicores ?? 0), 0); + const cpuTotal = capacity.nodes.reduce((sum, node) => sum + (node.cpu_allocatable_millicores ?? 0), 0); + const memoryUsed = capacity.nodes.reduce((sum, node) => sum + (node.memory_usage_bytes ?? 0), 0); + const memoryTotal = capacity.nodes.reduce((sum, node) => sum + (node.memory_allocatable_bytes ?? 0), 0); + const cpuPercent = cpuTotal > 0 ? (cpuUsed / cpuTotal) * 100 : 0; + const memoryPercent = memoryTotal > 0 ? (memoryUsed / memoryTotal) * 100 : 0; + const activeRuns = capacity.active_team_runs; + const admissionPercent = capacity.global_active_runs_limit > 0 + ? (activeRuns / capacity.global_active_runs_limit) * 100 + : 0; + + const teams = new Map<string, { cpu: number; memory: number; sandboxes: number; executing: number }>(); + for (const sandbox of sandboxes) { + const key = sandbox.team ?? (sandbox.parent ? "unattributed sub-agents" : "standalone"); + const current = teams.get(key) ?? { cpu: 0, memory: 0, sandboxes: 0, executing: 0 }; + current.cpu += sandbox.cpu_millicores ?? 0; + current.memory += sandbox.memory_bytes ?? 0; + current.sandboxes += 1; + if (sandbox.executing) current.executing += 1; + teams.set(key, current); + } + const teamRows = Array.from(teams.entries()) + .map(([name, usage]) => ({ name, ...usage })) + .sort((a, b) => b.memory - a.memory); + const maxTeamCpu = Math.max(1, ...teamRows.map((team) => team.cpu)); + const maxTeamMemory = Math.max(1, ...teamRows.map((team) => team.memory)); + + return ( + <Section className="overflow-hidden"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <p className="text-xs font-medium uppercase tracking-[0.14em] text-foreground-muted">Capacity control</p> + <h2 className="mt-1 text-lg font-semibold">Cluster & agent utilization</h2> + <p className="mt-1 text-xs text-foreground-muted"> + Live Metrics API usage correlated with teams, runs, and admission slots. + </p> + </div> + <div className="flex gap-2 text-xs"> + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-1"> + {capacity.team_max_concurrent_runs} runs / team + </span> + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-1"> + {activeRuns} / {capacity.global_active_runs_limit} global slots + </span> + </div> + </div> + + {!capacity.metrics_available ? ( + <p className="mt-5 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning"> + Live utilization unavailable: {capacity.metrics_error ?? "metrics API is not available"}. + </p> + ) : ( + <> + <div className="mt-5 grid gap-3 lg:grid-cols-3"> + <Gauge + label="Cluster CPU" + percent={cpuPercent} + detail={`${formatCpu(cpuUsed)} used of ${formatCpu(cpuTotal)}`} + /> + <Gauge + label="Cluster memory" + percent={memoryPercent} + detail={`${formatMemory(memoryUsed)} used of ${formatMemory(memoryTotal)}`} + /> + <Gauge + label="Team-run admission" + percent={admissionPercent} + detail={`${activeRuns} executing leads · ${Math.max(0, capacity.global_active_runs_limit - activeRuns)} slots free`} + /> + </div> + + <div className="mt-5 grid gap-5 xl:grid-cols-[1.05fr_1fr]"> + <div> + <h3 className="text-sm font-semibold">Node pressure</h3> + <div className="mt-3 space-y-3"> + {capacity.nodes.map((node) => ( + <div key={node.name} className="rounded-lg border border-border p-3"> + <p className="truncate font-mono text-xs font-medium">{node.name}</p> + <div className="mt-3 grid gap-3 sm:grid-cols-2"> + <div> + <div className="mb-1 flex justify-between text-[11px]"> + <span className="text-foreground-muted">CPU</span> + <span>{node.cpu_percent?.toFixed(1) ?? "—"}%</span> + </div> + <Bar value={node.cpu_percent ?? 0} color={tone(node.cpu_percent ?? 0)} /> + <p className="mt-1 text-[10px] text-foreground-muted"> + {formatCpu(node.cpu_usage_millicores)} / {formatCpu(node.cpu_allocatable_millicores)} + </p> + </div> + <div> + <div className="mb-1 flex justify-between text-[11px]"> + <span className="text-foreground-muted">Memory</span> + <span>{node.memory_percent?.toFixed(1) ?? "—"}%</span> + </div> + <Bar value={node.memory_percent ?? 0} color={tone(node.memory_percent ?? 0)} /> + <p className="mt-1 text-[10px] text-foreground-muted"> + {formatMemory(node.memory_usage_bytes)} / {formatMemory(node.memory_allocatable_bytes)} + </p> + </div> + </div> + </div> + ))} + </div> + </div> + + <div> + <h3 className="text-sm font-semibold">Sandbox footprint by team</h3> + {!capacity.pod_metrics_available ? ( + <p className="mt-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning"> + Pod utilization unavailable: {capacity.pod_metrics_error ?? "no pod metrics"}. + </p> + ) : ( + <div className="mt-3 space-y-3"> + {teamRows.map((team) => ( + <div key={team.name} className="rounded-lg border border-border p-3"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <p className="truncate text-xs font-medium">{team.name}</p> + <p className="mt-0.5 text-[10px] text-foreground-muted"> + {team.sandboxes} sandboxes · {team.executing} executing + </p> + </div> + <p className="shrink-0 text-[10px] text-foreground-muted"> + {formatCpu(team.cpu)} · {formatMemory(team.memory)} + </p> + </div> + <div className="mt-2 grid gap-2"> + <Bar value={(team.cpu / maxTeamCpu) * 100} color="#38bdf8" /> + <Bar value={(team.memory / maxTeamMemory) * 100} color="#a78bfa" /> + </div> + </div> + ))} + </div> + )} + </div> + </div> + </> + )} + </Section> + ); +} diff --git a/bridge/web/src/app/console/fleet/fleet-list.tsx b/bridge/web/src/app/console/fleet/fleet-list.tsx new file mode 100644 index 000000000..eae206d80 --- /dev/null +++ b/bridge/web/src/app/console/fleet/fleet-list.tsx @@ -0,0 +1,212 @@ +"use client"; + +// kars Bridge Operator Console — the sandbox fleet as a filterable working +// surface (not a raw dump). Search by name/namespace/parent, filter by phase, +// and see age at a glance; each card keeps the full conditions table for triage. + +import { useMemo, useState } from "react"; +import { Section, Badge } from "@/components/ui"; +import type { Sandbox } from "@/lib/types"; + +type Tone = "ok" | "warn" | "danger" | "info" | "muted" | "accent"; + +function phaseTone(phase: string | null): Tone { + switch (phase) { + case "Running": + case "Ready": + return "ok"; + case "Degraded": + case "Failed": + return "danger"; + case "Pending": + case "Launching": + return "warn"; + default: + return "muted"; + } +} + +function conditionTone(type_: string, status: string): string { + if (status === "Unknown") return "text-foreground-muted"; + if (/degrad|failure|failed|unavailable|pressure|error|backoff/i.test(type_)) { + return status === "True" ? "text-danger" : "text-ok"; + } + if (/available|ready|healthy|established/i.test(type_)) { + return status === "True" ? "text-ok" : "text-danger"; + } + return status === "True" ? "text-ok" : "text-foreground-muted"; +} + +function age(iso: string | null): string { + if (!iso) return ""; + const t = Date.parse(iso); + if (Number.isNaN(t)) return ""; + const s = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +function Meta({ k, v }: { k: string; v: string }) { + return ( + <div className="inline-flex items-baseline gap-1.5"> + <dt className="text-foreground-muted">{k}</dt> + <dd className="font-mono">{v}</dd> + </div> + ); +} + +function resourceUsage(s: Sandbox): string | null { + if (s.cpu_millicores == null && s.memory_bytes == null) return null; + const cpu = s.cpu_millicores == null + ? "—" + : s.cpu_millicores >= 1000 + ? `${(s.cpu_millicores / 1000).toFixed(1)} cores` + : `${Math.round(s.cpu_millicores)}m`; + const memory = s.memory_bytes == null + ? "—" + : s.memory_bytes >= 1024 ** 3 + ? `${(s.memory_bytes / 1024 ** 3).toFixed(1)} GiB` + : `${Math.round(s.memory_bytes / 1024 ** 2)} MiB`; + return `${cpu} / ${memory}`; +} + +const PHASES = ["All", "Running", "Degraded", "Pending", "Other"] as const; +type PhaseFilter = (typeof PHASES)[number]; + +function matchesPhase(s: Sandbox, f: PhaseFilter): boolean { + if (f === "All") return true; + const p = s.phase ?? ""; + if (f === "Running") return p === "Running" || p === "Ready"; + if (f === "Degraded") return p === "Degraded" || p === "Failed"; + if (f === "Pending") return p === "Pending" || p === "Launching"; + return !["Running", "Ready", "Degraded", "Failed", "Pending", "Launching"].includes(p); +} + +export function FleetList({ sandboxes, initialQuery = "" }: { sandboxes: Sandbox[]; initialQuery?: string }) { + const [q, setQ] = useState(initialQuery); + const [phase, setPhase] = useState<PhaseFilter>("All"); + const [leadsOnly, setLeadsOnly] = useState(false); + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + return sandboxes.filter((s) => { + if (!matchesPhase(s, phase)) return false; + if (leadsOnly && s.parent) return false; + if (!needle) return true; + return ( + s.name.toLowerCase().includes(needle) || + s.namespace.toLowerCase().includes(needle) || + (s.parent ?? "").toLowerCase().includes(needle) || + (s.runtime ?? "").toLowerCase().includes(needle) || + (s.tool_policy ?? "").toLowerCase().includes(needle) || + (s.inference_policy ?? "").toLowerCase().includes(needle) || + (s.team ?? "").toLowerCase().includes(needle) + ); + }); + }, [sandboxes, q, phase, leadsOnly]); + + const counts = useMemo(() => { + const c: Record<PhaseFilter, number> = { All: sandboxes.length, Running: 0, Degraded: 0, Pending: 0, Other: 0 }; + for (const s of sandboxes) { + (["Running", "Degraded", "Pending", "Other"] as PhaseFilter[]).forEach((f) => { + if (matchesPhase(s, f)) c[f] += 1; + }); + } + return c; + }, [sandboxes]); + + return ( + <div className="space-y-4"> + <div className="flex flex-wrap items-center gap-2"> + <input + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="Search name, namespace, parent, harness…" + className="min-w-56 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <div className="flex items-center gap-1"> + {PHASES.map((p) => ( + <button + key={p} + type="button" + onClick={() => setPhase(p)} + className={`rounded-lg border px-2.5 py-1.5 text-xs font-medium ${ + phase === p ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground" + }`} + > + {p} <span className="tabular-nums opacity-70">{counts[p]}</span> + </button> + ))} + </div> + <label className="inline-flex items-center gap-1.5 text-xs text-foreground-muted"> + <input type="checkbox" checked={leadsOnly} onChange={(e) => setLeadsOnly(e.target.checked)} /> Leads only + </label> + </div> + + {filtered.length === 0 ? ( + <p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-sm text-foreground-muted"> + No sandbox matches these filters. + </p> + ) : ( + <ul className="space-y-3"> + {filtered.map((s) => ( + <Section key={`${s.namespace}/${s.name}`} className="!p-5"> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <p className="font-mono text-sm font-medium"> + {s.name} + {!s.parent && <span className="ml-2 rounded-full bg-surface-muted px-1.5 py-0.5 text-[10px] font-medium text-foreground-muted">lead</span>} + </p> + <p className="mt-0.5 font-mono text-xs text-foreground-muted">{s.namespace}</p> + </div> + <div className="flex shrink-0 items-center gap-2"> + {s.created && <span className="text-xs text-foreground-muted">{age(s.created)} old</span>} + {s.phase === "Running" && s.working === false ? ( + <span title="The pod is Running but the agent has produced no activity — idle (e.g. a chat-gateway harness waiting for input, or between scheduled runs)."> + <Badge tone="muted" dot> + Running · idle + </Badge> + </span> + ) : ( + <Badge tone={phaseTone(s.phase)} dot> + {s.phase === "Running" && s.working ? "Running · working" : s.phase ?? "Unknown"} + </Badge> + )} + </div> + </div> + <dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs"> + {s.runtime && <Meta k="harness" v={s.runtime} />} + {s.tool_policy && <Meta k="tool policy" v={s.tool_policy} />} + {s.inference_policy && <Meta k="inference" v={s.inference_policy} />} + <Meta k="governance" v={s.governed ? "enabled" : "off"} /> + {s.isolation && <Meta k="isolation" v={s.isolation} />} + {s.team && <Meta k="owner team" v={s.team} />} + {s.parent && <Meta k="parent" v={s.parent} />} + {s.runtime_namespace && <Meta k="runtime namespace" v={s.runtime_namespace} />} + {resourceUsage(s) && <Meta k="CPU / memory" v={resourceUsage(s)!} />} + {s.created && <Meta k="created" v={new Date(s.created).toLocaleString()} />} + </dl> + {s.message && <p className="mt-2 text-xs text-foreground-muted">{s.message}</p>} + {s.conditions.length > 0 && ( + <table className="mt-3 w-full text-xs"> + <tbody> + {s.conditions.map((c, i) => ( + <tr key={i} className="border-t border-border"> + <td className="py-1.5 pr-3 font-mono text-foreground-muted">{c.type_}</td> + <td className={`py-1.5 pr-3 font-medium ${conditionTone(c.type_, c.status)}`}>{c.status}</td> + <td className="py-1.5 pr-3 text-foreground-muted">{c.reason ?? ""}</td> + <td className="py-1.5 text-foreground-muted">{c.message ?? ""}</td> + </tr> + ))} + </tbody> + </table> + )} + </Section> + ))} + </ul> + )} + </div> + ); +} diff --git a/bridge/web/src/app/console/fleet/mesh-topology.tsx b/bridge/web/src/app/console/fleet/mesh-topology.tsx new file mode 100644 index 000000000..e3a629e02 --- /dev/null +++ b/bridge/web/src/app/console/fleet/mesh-topology.tsx @@ -0,0 +1,368 @@ +"use client"; + +// kars Bridge Operator Console — Mesh topology, as a real node-link graph. Each +// agent sandbox is a node (harness-badged, governance-marked) positioned on an +// SVG canvas; a parent→child delegation is drawn as an actual connecting +// line — a REAL end-to-end-encrypted A2A link (a principal spawns a sub-agent +// and talks to it over the AGT mesh). No competitor has encrypted inter-agent +// comms, so none can show this. We draw only edges we can prove from the +// delegation graph; per-session ratchet/handshake state is not exposed as an +// API yet, so it is deliberately NOT fabricated here. +// +// Layout: principals laid out along a horizontal baseline; each principal's +// children fan out in an arc beneath it. Deterministic (not physics- +// simulated) so the same fleet always renders the same graph — appropriate at +// fleet sizes where a force simulation buys nothing but jitter. + +import { useMemo, useState } from "react"; +import type { Sandbox } from "@/lib/types"; + +function harnessTone(h: string | null): { stroke: string; fill: string } { + const k = (h ?? "").toLowerCase(); + if (k.includes("openclaw")) return { stroke: "stroke-signal", fill: "fill-signal/15" }; + if (k.includes("hermes")) return { stroke: "stroke-accent", fill: "fill-accent/15" }; + if (k.includes("anthropic") || k.includes("claude")) return { stroke: "stroke-warning", fill: "fill-warning/15" }; + if (k.includes("langgraph") || k.includes("openai")) return { stroke: "stroke-ok", fill: "fill-ok/15" }; + return { stroke: "stroke-border", fill: "fill-surface-muted" }; +} + +interface LaidOutNode { + s: Sandbox; + x: number; + y: number; + depth: number; + root: string; +} + +interface RootLane { + name: string; + x: number; + width: number; +} + +const NODE_WIDTH = 196; +const NODE_HEIGHT = 58; +const CHILD_WIDTH = 164; +const CHILD_HEIGHT = 52; +const COLUMN_GAP = 28; +const ROW_GAP = 104; +const LANE_GAP = 44; +const PADDING_X = 28; +const PADDING_Y = 42; + +function displayName(sandbox: Sandbox): string { + if (!sandbox.parent) { + return (sandbox.team ?? sandbox.name) + .replace(/-repository-maintenance$/, " maintenance") + .replace(/-repo-maintenance$/, " maintenance") + .replaceAll("-", " ") + .replace(/^\w/, (letter) => letter.toUpperCase()); + } + for (const role of ["alert-monitor", "fix-generator", "pr-watcher"]) { + if (sandbox.name.endsWith(role)) return role.replaceAll("-", " "); + } + const roleWorker = sandbox.name.match(/-(alert|fix|pr)-([0-9a-f]{8})$/i); + if (roleWorker) { + const role = roleWorker[1] === "pr" ? "PR" : roleWorker[1]; + return `${role} worker ${roleWorker[2]}`; + } + const parentPrefix = `${sandbox.parent}-`; + const suffix = sandbox.name.startsWith(parentPrefix) + ? sandbox.name.slice(parentPrefix.length) + : sandbox.name; + if (/^[0-9a-f]{8}$/i.test(suffix)) return `sub-agent ${suffix}`; + return suffix; +} + +function truncate(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +export function MeshTopology({ sandboxes }: { sandboxes: Sandbox[] }) { + const [hovered, setHovered] = useState<string | null>(null); + + const { nodes, edges, roots, lanes, width, height } = useMemo(() => { + const live = sandboxes.filter((s) => !s.name.startsWith("bridge-orchestrator")); + const byName = new Map(live.map((s) => [s.name, s])); + const childrenOf = new Map<string, Sandbox[]>(); + for (const s of live) { + if (s.parent && byName.has(s.parent)) { + const list = childrenOf.get(s.parent); + if (list) list.push(s); + else childrenOf.set(s.parent, [s]); + } + } + for (const children of childrenOf.values()) { + children.sort((a, b) => a.name.localeCompare(b.name)); + } + const rootList = live + .filter((s) => !(s.parent && byName.has(s.parent))) + .sort((a, b) => (a.team ?? a.name).localeCompare(b.team ?? b.name)); + + const laid: LaidOutNode[] = []; + const edgeList: { from: string; to: string }[] = []; + const laneList: RootLane[] = []; + const leaves = new Map<string, number>(); + let maxDepth = 0; + + function leafCount(name: string, visiting = new Set<string>()): number { + if (leaves.has(name)) return leaves.get(name)!; + if (visiting.has(name)) return 1; + const next = new Set(visiting); + next.add(name); + const children = childrenOf.get(name) ?? []; + const count = Math.max( + 1, + children.reduce((total, child) => total + leafCount(child.name, next), 0), + ); + leaves.set(name, count); + return count; + } + + function subtreeWidth(name: string): number { + return Math.max(NODE_WIDTH + 28, leafCount(name) * (CHILD_WIDTH + COLUMN_GAP)); + } + + function place(name: string, left: number, span: number, depth: number, root: string): number { + const sandbox = byName.get(name); + if (!sandbox) return left + span / 2; + maxDepth = Math.max(maxDepth, depth); + const children = childrenOf.get(name) ?? []; + let x = left + span / 2; + if (children.length > 0) { + const widths = children.map((child) => subtreeWidth(child.name)); + const total = widths.reduce((sum, value) => sum + value, 0); + let childLeft = left + (span - total) / 2; + const childXs = children.map((child, index) => { + const childX = place(child.name, childLeft, widths[index], depth + 1, root); + edgeList.push({ from: name, to: child.name }); + childLeft += widths[index]; + return childX; + }); + x = (childXs[0] + childXs[childXs.length - 1]) / 2; + } + laid.push({ + s: sandbox, + x, + y: PADDING_Y + depth * ROW_GAP, + depth, + root, + }); + return x; + } + + let cursor = PADDING_X; + for (const root of rootList) { + const laneWidth = subtreeWidth(root.name); + laneList.push({ name: root.name, x: cursor, width: laneWidth }); + place(root.name, cursor, laneWidth, 0, root.name); + cursor += laneWidth + LANE_GAP; + } + + const graphWidth = Math.max(cursor - LANE_GAP + PADDING_X, 720); + const graphHeight = PADDING_Y * 2 + maxDepth * ROW_GAP + NODE_HEIGHT; + return { + nodes: laid, + edges: edgeList, + roots: rootList, + lanes: laneList, + width: graphWidth, + height: graphHeight, + }; + }, [sandboxes]); + + if (roots.length === 0) return null; + + const byName = new Map(nodes.map((n) => [n.s.name, n])); + const executing = nodes.filter((node) => node.s.executing === true).length; + + return ( + <section className="rounded-2xl border border-border bg-surface p-5"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Team delegation topology</h2> + <p className="mt-0.5 max-w-3xl text-xs text-foreground-muted"> + Each lane is one principal-led team run. Curved links show proven encrypted delegations, + including nested sub-agents. + </p> + </div> + <div className="flex flex-wrap gap-1.5 text-[11px]"> + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-foreground-muted"> + {roots.length} principal{roots.length === 1 ? "" : "s"} + </span> + <span className="rounded-full border border-accent/25 bg-accent/10 px-2.5 py-1 text-accent"> + {edges.length} encrypted link{edges.length === 1 ? "" : "s"} + </span> + <span className="rounded-full border border-ok/25 bg-ok/10 px-2.5 py-1 text-ok"> + {executing} executing + </span> + </div> + </div> + + <div className="mt-4 overflow-x-auto rounded-xl border border-border bg-surface-muted/20"> + <svg + viewBox={`0 0 ${width} ${height}`} + className="block" + style={{ width: `${Math.max(width, 720)}px`, minWidth: "100%", height: `${height}px` }} + role="img" + aria-label="Team delegation topology: principal and sub-agent sandboxes connected by encrypted links" + > + <defs> + <marker + id="mesh-arrow" + viewBox="0 0 8 8" + refX="7" + refY="4" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path d="M 0 0 L 8 4 L 0 8 z" className="fill-accent/55" /> + </marker> + </defs> + + {lanes.map((lane, index) => ( + <rect + key={lane.name} + x={lane.x + 2} + y={10} + width={lane.width - 4} + height={height - 20} + rx={14} + className={index % 2 === 0 ? "fill-surface/70" : "fill-surface-muted/35"} + stroke="currentColor" + strokeWidth={1} + strokeDasharray="3 5" + opacity={0.65} + /> + ))} + + {edges.map((e) => { + const from = byName.get(e.from); + const to = byName.get(e.to); + if (!from || !to) return null; + const active = hovered === e.from || hovered === e.to; + const startY = from.y + (from.depth === 0 ? NODE_HEIGHT : CHILD_HEIGHT) / 2; + const endY = to.y - CHILD_HEIGHT / 2; + const bend = Math.max(24, (endY - startY) * 0.55); + return ( + <path + key={`${e.from}->${e.to}`} + d={`M ${from.x} ${startY} C ${from.x} ${startY + bend}, ${to.x} ${endY - bend}, ${to.x} ${endY}`} + fill="none" + className={active ? "stroke-accent" : "stroke-accent/40"} + strokeWidth={active ? 2.4 : 1.4} + markerEnd="url(#mesh-arrow)" + /> + ); + })} + + {nodes.map((n) => { + const tone = harnessTone(n.s.runtime); + const running = n.s.phase === "Running" || n.s.phase === "Ready"; + const statusFill = + running && n.s.executing + ? "fill-ok" + : running + ? "fill-warning" + : "fill-foreground-muted"; + const principal = n.depth === 0; + const nodeWidth = principal ? NODE_WIDTH : CHILD_WIDTH; + const nodeHeight = principal ? NODE_HEIGHT : CHILD_HEIGHT; + const selected = hovered === n.s.name; + const label = truncate(displayName(n.s), principal ? 29 : 22); + const parentNode = n.s.parent ? byName.get(n.s.parent) : null; + const relationship = principal + ? "team principal" + : n.depth === 1 + ? "specialist" + : `spawned by ${parentNode ? displayName(parentNode.s) : "parent"}`; + return ( + <g + key={n.s.name} + onMouseEnter={() => setHovered(n.s.name)} + onMouseLeave={() => setHovered(null)} + className="cursor-pointer" + > + <rect + x={n.x - nodeWidth / 2} + y={n.y - nodeHeight / 2} + width={nodeWidth} + height={nodeHeight} + rx={principal ? 13 : 10} + className={`${tone.fill} ${selected ? "stroke-accent" : tone.stroke}`} + strokeWidth={selected ? 2.5 : principal ? 1.8 : 1.3} + /> + <circle + cx={n.x - nodeWidth / 2 + 13} + cy={n.y - nodeHeight / 2 + 13} + r={4} + className={statusFill} + stroke="white" + strokeWidth={1} + /> + {n.s.governed && ( + <text + x={n.x + nodeWidth / 2 - 13} + y={n.y - nodeHeight / 2 + 17} + textAnchor="middle" + className="fill-foreground text-[10px] font-bold" + > + ✓ + </text> + )} + <text + x={n.x} + y={n.y - 2} + textAnchor="middle" + className={`text-[10px] font-semibold ${principal ? "fill-foreground" : "fill-foreground-muted"}`} + > + {label} + </text> + <text + x={n.x} + y={n.y + 14} + textAnchor="middle" + className="fill-foreground-muted text-[8px]" + > + {truncate(relationship, 24)} ·{" "} + {n.s.executing ? "executing" : (n.s.phase ?? "unknown").toLowerCase()} + </text> + </g> + ); + })} + </svg> + </div> + + {hovered && byName.get(hovered) && ( + <div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-muted/40 px-3 py-2 text-[11px]"> + <span className="font-mono font-medium">{hovered}</span> + {byName.get(hovered)!.s.team && ( + <span className="text-foreground-muted">· team {byName.get(hovered)!.s.team}</span> + )} + {byName.get(hovered)!.s.runtime && <span className="text-foreground-muted">· {byName.get(hovered)!.s.runtime}</span>} + {byName.get(hovered)!.s.parent && ( + <span className="text-foreground-muted">· spawned by {byName.get(hovered)!.s.parent}</span> + )} + {byName.get(hovered)!.s.governed && <span className="text-ok">· governed</span>} + <span className="text-foreground-muted">· {byName.get(hovered)!.s.phase ?? "unknown"}</span> + </div> + )} + + <div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-[11px] text-foreground-muted"> + <div className="flex flex-wrap gap-3"> + <span className="inline-flex items-center gap-1.5"> + <span className="h-2 w-2 rounded-full bg-ok" /> executing + </span> + <span className="inline-flex items-center gap-1.5"> + <span className="h-2 w-2 rounded-full bg-warning" /> idle / retained + </span> + <span className="inline-flex items-center gap-1.5"> + <span className="font-bold text-foreground">✓</span> governed + </span> + </div> + <span>Only controller-recorded parent→child delegations are shown; links are never inferred.</span> + </div> + </section> + ); +} diff --git a/bridge/web/src/app/console/fleet/page.tsx b/bridge/web/src/app/console/fleet/page.tsx new file mode 100644 index 000000000..60b2f0c22 --- /dev/null +++ b/bridge/web/src/app/console/fleet/page.tsx @@ -0,0 +1,86 @@ +// kars Bridge Operator Console — Sandboxes. Every sandbox (lead + spawned +// sub-agents), with phase, runtime, isolation, parent, and the conditions table +// for troubleshooting. Real reads from KarsSandbox. The list itself is a +// filterable client surface (search + phase); this shell does the fetch + KPIs. + +import { PageHeader, Stat } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { getClusterCapacity, listSandboxes } from "@/lib/bff"; +import type { ClusterCapacity, Sandbox } from "@/lib/types"; +import { FleetList } from "./fleet-list"; +import { MeshTopology } from "./mesh-topology"; +import { CapacityDashboard } from "./capacity-dashboard"; +import { LiveRefresh } from "@/components/live-refresh"; + +export const dynamic = "force-dynamic"; + +export default async function SandboxesPage({ + searchParams, +}: { + searchParams?: Promise<{ q?: string }>; +}) { + const initialQuery = (await searchParams)?.q ?? ""; + let sandboxes: Sandbox[] = []; + let capacity: ClusterCapacity | null = null; + let error = false; + try { + [sandboxes, capacity] = await Promise.all([ + listSandboxes(), + getClusterCapacity().catch(() => null), + ]); + } catch { + error = true; + } + + const running = sandboxes.filter((s) => s.phase === "Running" || s.phase === "Ready").length; + const executing = sandboxes.filter((s) => s.phase === "Running" && s.executing).length; + const degraded = sandboxes.filter((s) => s.phase === "Degraded" || s.phase === "Failed").length; + const suspended = sandboxes.filter((s) => s.phase === "Suspended").length; + const subAgents = sandboxes.filter((s) => s.parent).length; + + return ( + <div className="space-y-6"> + <LiveRefresh active intervalMs={5000} /> + <PageHeader + eyebrow="Operator Console" + title="Sandboxes" + lead="Every sandbox record across all namespaces, including suspended retained evidence. Running and Executing now are the live-agent counts." + /> + + {!error && sandboxes.length > 0 && ( + <div className="grid grid-cols-2 gap-3 sm:grid-cols-6"> + <Stat label="Sandboxes" value={sandboxes.length} /> + <Stat label="Running" value={running} accent={running > 0} /> + <Stat label="Executing now" value={executing} accent={executing > 0} /> + <Stat label="Sub-agents" value={subAgents} /> + <Stat label="Suspended" value={suspended} /> + <Stat label="Degraded" value={degraded} /> + </div> + )} + {!error && suspended > 0 && ( + <p className="text-xs text-foreground-muted"> + {suspended} suspended sandbox record{suspended === 1 ? "" : "s"} retain audit and deliverable linkage but consume no agent pod capacity. + </p> + )} + {!error && sandboxes.length > 0 && running > executing && ( + <p className="text-xs text-foreground-muted"> + {`${running - executing} of ${running} running sandbox${running - executing === 1 ? "" : "es"} ${running - executing === 1 ? "is" : "are"} idle — already delivered (or a standing sandbox with no task attached), not actively executing right now. `} + The Workspace’s “Active agents” page counts exactly this same signal. + </p> + )} + + {capacity && <CapacityDashboard capacity={capacity} sandboxes={sandboxes} />} + + {error ? ( + <HonestState variant="not_wired" title="Cluster unreachable" detail="The Bridge backend can't reach the Kubernetes API right now." /> + ) : sandboxes.length === 0 ? ( + <HonestState variant="empty" title="No sandboxes" detail="The substrate is idle — no agent sandboxes are running." /> + ) : ( + <> + <MeshTopology sandboxes={sandboxes} /> + <FleetList sandboxes={sandboxes} initialQuery={initialQuery} /> + </> + )} + </div> + ); +} diff --git a/bridge/web/src/app/console/foundry-actions.ts b/bridge/web/src/app/console/foundry-actions.ts new file mode 100644 index 000000000..df3af83d0 --- /dev/null +++ b/bridge/web/src/app/console/foundry-actions.ts @@ -0,0 +1,78 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, connectFoundry, disconnectFoundry, verifyFoundry, type FoundryCheck, type FoundryDiscovered } from "@/lib/bff"; + +export interface FoundryState { + error: string | null; + ok: string | null; + checks?: FoundryCheck[]; + discovered?: FoundryDiscovered; +} + +export async function connectFoundryAction(_prev: FoundryState, form: FormData): Promise<FoundryState> { + const project_endpoint = String(form.get("project_endpoint") ?? "").trim(); + const inference_endpoint = String(form.get("inference_endpoint") ?? "").trim(); + const memory_store_id = String(form.get("memory_store_id") ?? "").trim(); + const auth = String(form.get("auth") ?? "auto") as "api" | "managed-identity"; + const api_key = String(form.get("api_key") ?? "").trim(); + if (!project_endpoint.startsWith("https://")) { + return { error: "Enter the Foundry project endpoint (https://…/api/projects/<project>).", ok: null }; + } + if (auth === "api" && !api_key) { + return { error: "API-key auth requires the Foundry project key.", ok: null }; + } + try { + await connectFoundry({ + project_endpoint, + inference_endpoint: inference_endpoint || undefined, + memory_store_id: memory_store_id || undefined, + auth, + api_key: auth === "api" ? api_key : undefined, + }); + // Immediately discover — one guided step: connect then verify + list what's there. + let checks, discovered; + try { + const res = await verifyFoundry(); + checks = res.checks; + discovered = res.discovered; + } catch { + /* discovery best-effort; connection still saved */ + } + revalidatePath("/console/configuration"); + const failed = checks?.some((c) => c.status === "fail"); + return { + error: null, + ok: failed ? "Connected, but discovery found issues — see below." : "Connected to Foundry.", + checks, + discovered, + }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "connect failed", ok: null }; + } +} + +export async function verifyFoundryAction(_prev: FoundryState, _form: FormData): Promise<FoundryState> { + try { + const res = await verifyFoundry(); + const failed = res.checks.some((c) => c.status === "fail"); + return { + error: null, + ok: failed ? "Verification found issues — see below." : "Foundry verified.", + checks: res.checks, + discovered: res.discovered, + }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "verify failed", ok: null }; + } +} + +export async function disconnectFoundryAction(_prev: FoundryState, _form: FormData): Promise<FoundryState> { + try { + await disconnectFoundry(); + revalidatePath("/console/configuration"); + return { error: null, ok: "Foundry disconnected." }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "disconnect failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/foundry-onboard.tsx b/bridge/web/src/app/console/foundry-onboard.tsx new file mode 100644 index 000000000..36cc2ba7a --- /dev/null +++ b/bridge/web/src/app/console/foundry-onboard.tsx @@ -0,0 +1,215 @@ +"use client"; + +// Operator Foundry onboarding — URL-first + guided, animated discovery. +// Paste the Foundry project URL; the Bridge connects using your Azure identity +// (workload identity on AKS, your az login in dev) and immediately discovers the +// project's models, connected services, and memory store — shown live. An API +// key and extra endpoints are optional, tucked under Advanced. + +import { useActionState, useState } from "react"; +import { Icon, type IconName } from "@/components/icon"; +import { connectFoundryAction, verifyFoundryAction, disconnectFoundryAction, type FoundryState } from "./foundry-actions"; +import { OrchestrationCube, type OrchestrationPhase } from "@/components/orchestration-cube"; +import type { FoundryStatus } from "@/lib/bff"; + +const init: FoundryState = { error: null, ok: null }; + +const DISCOVERY_PHASES: OrchestrationPhase[] = [ + { icon: "globe", label: "Resolving the project endpoint", detail: "DNS + reachability" }, + { icon: "shield", label: "Authenticating with your Azure identity", detail: "workload identity (AKS) or your az login (dev)" }, + { icon: "brain", label: "Discovering model deployments", detail: "the models this project serves" }, + { icon: "plug", label: "Discovering connected services", detail: "grounding, search, storage…" }, + { icon: "database", label: "Checking the memory store", detail: "for team knowledge-commons" }, +]; + +export function FoundryOnboard({ status }: { status: FoundryStatus }) { + const [connectState, connectAction, connectPending] = useActionState(connectFoundryAction, init); + const [verifyState, verifyAction, verifyPending] = useActionState(verifyFoundryAction, init); + const [disState, disAction, disPending] = useActionState(disconnectFoundryAction, init); + const [useKey, setUseKey] = useState(status.auth === "api"); + const [advanced, setAdvanced] = useState(false); + const [editing, setEditing] = useState(!status.connected); + + const result = connectState.checks ? connectState : verifyState.checks ? verifyState : null; + const discovering = connectPending || verifyPending; + + return ( + <div className="space-y-3"> + {discovering && ( + <OrchestrationCube title="Connecting & discovering your Foundry project" phases={DISCOVERY_PHASES} active={0} done={false} /> + )} + + {status.connected && !editing && !discovering ? ( + <div className="rounded-lg border border-signal/30 bg-signal/5 p-3 text-sm kb-rise"> + <div className="flex items-center justify-between gap-2"> + <span className="inline-flex items-center gap-1.5 font-medium text-signal">✓ Connected</span> + <span className="rounded-full bg-surface px-2 py-0.5 text-[11px] font-medium text-foreground-muted"> + {status.auth === "api" ? "API key" : "Azure identity (auto)"} + </span> + </div> + <p className="mt-1.5 break-all font-mono text-[11px] text-foreground-muted">{status.project_endpoint}</p> + <div className="mt-2 flex flex-wrap items-center gap-2"> + <form action={verifyAction}> + <button type="submit" disabled={verifyPending} className="rounded-md border border-signal/40 bg-signal/10 px-2.5 py-1 text-[11px] font-semibold text-signal disabled:opacity-50"> + {verifyPending ? "Discovering…" : "Re-discover"} + </button> + </form> + <button type="button" onClick={() => setEditing(true)} className="text-[11px] text-foreground-muted hover:text-foreground">Edit</button> + <form action={disAction}> + <button type="submit" disabled={disPending} className="text-[11px] text-foreground-muted hover:text-danger disabled:opacity-50">Disconnect</button> + </form> + </div> + {disState.error && <p className="mt-1 text-[11px] text-danger">{disState.error}</p>} + </div> + ) : !discovering ? ( + <form action={connectAction} className="space-y-2.5 rounded-lg border border-border bg-surface-muted/30 p-3"> + <label className="block text-sm"> + <span className="font-medium">Foundry project URL</span> + <input name="project_endpoint" defaultValue={status.project_endpoint ?? ""} required autoFocus placeholder="https://<resource>.services.ai.azure.com/api/projects/<project>" className="mt-1 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm" /> + <span className="mt-1 block text-[11px] text-foreground-muted">That’s all we need. We’ll authenticate with your Azure identity and discover the rest.</span> + </label> + + <input type="hidden" name="auth" value={useKey ? "api" : "auto"} /> + + <button type="button" onClick={() => setAdvanced((v) => !v)} className="inline-flex items-center gap-1 text-[11px] font-medium text-foreground-muted hover:text-foreground"> + <span aria-hidden className={`inline-block transition-transform ${advanced ? "rotate-180" : ""}`}>⌄</span> + Advanced (API key, inference endpoint, memory store) + </button> + {advanced && ( + <div className="space-y-2 rounded-md border border-border bg-surface/50 p-2.5"> + <label className="flex items-center gap-2 text-xs"> + <input type="checkbox" checked={useKey} onChange={(e) => setUseKey(e.target.checked)} className="h-3.5 w-3.5 accent-[var(--signal)]" /> + Use an API key instead of my Azure identity (dev) + </label> + {useKey && ( + <input name="api_key" type="password" placeholder="Foundry project API key (stored write-only)" className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm" /> + )} + <div className="grid gap-2 sm:grid-cols-2"> + <label className="block text-[11px] text-foreground-muted"> + Inference endpoint (optional) + <input name="inference_endpoint" defaultValue={status.inference_endpoint ?? ""} placeholder="https://<res>.openai.azure.com/" className="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs" /> + </label> + </div> + <p className="text-[11px] text-foreground-muted"> + Memory is <span className="font-medium">per-team</span> — each team’s knowledge-commons maps to its own + Foundry memory store, chosen when you set up the team. There is no cluster-wide store. + </p> + </div> + )} + + <p className="text-[11px] text-foreground-muted"> + Discovery uses your Azure identity: workload identity on AKS. For dev discovery via your existing + Azure CLI login, set <span className="font-mono">KARS_FOUNDRY_ALLOW_AZ_CLI=1</span> on the Bridge — it only + reads an existing <span className="font-mono">az login</span>, never runs one. + </p> + + <div className="flex items-center gap-2"> + <button type="submit" disabled={connectPending} className="rounded-md bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {status.connected ? "Reconnect & discover" : "Connect & discover"} + </button> + {status.connected && <button type="button" onClick={() => setEditing(false)} className="text-[11px] text-foreground-muted hover:text-foreground">Cancel</button>} + {connectState.error && <span className="text-[11px] text-danger">{connectState.error}</span>} + </div> + </form> + ) : null} + + {result?.checks && !discovering && ( + <div className="space-y-3 kb-rise"> + <ul className="space-y-1.5 kb-stagger"> + {result.checks.map((c) => ( + <li key={c.label} className="flex items-start gap-2 rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs"> + <span className={c.status === "pass" ? "text-signal" : c.status === "warn" ? "text-warning" : "text-danger"}> + {c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✗"} + </span> + <span><span className="font-medium">{c.label}</span><span className="ml-1 text-foreground-muted">{c.detail}</span></span> + </li> + ))} + </ul> + {result.discovered && ( + <div className="space-y-3"> + {/* Models — verbose: each id + the note that it's now catalogued. */} + <div className="rounded-lg border border-border bg-surface-muted/30 p-3"> + <p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + <Icon name="brain" size={12} /> Model deployments ({result.discovered.models.length}) + </p> + {result.discovered.models.length === 0 ? ( + <p className="mt-1.5 text-[11px] text-foreground-muted">No model deployments in this project yet. Deploy one in Azure AI Foundry, then re-discover.</p> + ) : ( + <> + <ul className="mt-1.5 space-y-1"> + {result.discovered.models.map((m) => ( + <li key={m} className="flex items-center gap-2 text-xs"> + <Icon name="check" size={11} className="text-signal" /> + <span className="font-mono">{m}</span> + <span className="rounded bg-surface px-1.5 py-0.5 text-[10px] text-foreground-muted">→ catalogue · tagged foundry</span> + </li> + ))} + </ul> + <p className="mt-2 text-[11px] text-foreground-muted">These are now available to missions and teams, and to the orchestrator when it proposes a model.</p> + </> + )} + </div> + + {/* Services — verbose: name + friendly type + what it enables. */} + <div className="rounded-lg border border-border bg-surface-muted/30 p-3"> + <p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + <Icon name="plug" size={12} /> Connected services ({result.discovered.connections.length}) + </p> + {result.discovered.connections.length === 0 ? ( + <p className="mt-1.5 text-[11px] text-foreground-muted">No services (grounding, search, storage, …) are connected to this project.</p> + ) : ( + <ul className="mt-1.5 space-y-1.5"> + {result.discovered.connections.map((c) => { + const svc = foundryServiceLabel(c.category); + return ( + <li key={c.name} className="flex items-start gap-2 text-xs"> + <Icon name={svc.icon} size={12} className="mt-0.5 text-signal" /> + <span className="min-w-0"> + <span className="font-medium">{c.name}</span> + <span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-foreground-muted">{svc.label}</span> + {svc.enables && <span className="block text-[11px] text-foreground-muted">{svc.enables}</span>} + </span> + </li> + ); + })} + </ul> + )} + </div> + + {/* Memory store — surfaced when the project reported one. */} + {result.discovered.memory_store_found != null && ( + <div className="rounded-lg border border-border bg-surface-muted/30 p-3 text-xs"> + <p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + <Icon name="database" size={12} /> Agent memory + </p> + <p className="mt-1.5 text-foreground-muted"> + {result.discovered.memory_store_found + ? "Configured memory store found — team knowledge-commons can bind to it." + : "Configured memory store not found in this project. Memory stores are per-team; set one when you create a team."} + </p> + </div> + )} + </div> + )} + </div> + )} + {(connectState.ok || verifyState.ok) && !discovering && <p className="text-[11px] text-signal">{connectState.ok || verifyState.ok}</p>} + {verifyState.error && <p className="text-[11px] text-danger">{verifyState.error}</p>} + </div> + ); +} + +/** Human-friendly label + icon + capability blurb for a raw Foundry + * connection category, so discovery says what each service actually IS + * rather than dumping an opaque type string. */ +function foundryServiceLabel(category: string | null): { label: string; icon: IconName; enables: string | null } { + const c = (category ?? "").toLowerCase(); + if (c.includes("bing") || c.includes("grounding")) return { label: "Bing grounding", icon: "globe", enables: "Grounded web search for agents." }; + if (c.includes("search")) return { label: "Azure AI Search", icon: "search", enables: "Vector / hybrid retrieval over your indexes." }; + if (c.includes("storage") || c.includes("blob")) return { label: "Azure Storage", icon: "database", enables: "Blob storage for files and artifacts." }; + if (c.includes("openai") || c.includes("aoai")) return { label: "Azure OpenAI", icon: "brain", enables: "Model inference endpoint." }; + if (c.includes("cognitiveservices") || c.includes("aiservices")) return { label: "Azure AI Services", icon: "brain", enables: "Speech, vision, language, and more." }; + if (c.includes("appinsights") || c.includes("monitor")) return { label: "Application Insights", icon: "chart", enables: "Telemetry and tracing." }; + if (c.includes("keyvault")) return { label: "Key Vault", icon: "lock", enables: "Secrets and keys." }; + return { label: category ?? "connection", icon: "link", enables: null }; +} diff --git a/bridge/web/src/app/console/governance-actions.ts b/bridge/web/src/app/console/governance-actions.ts new file mode 100644 index 000000000..a65842955 --- /dev/null +++ b/bridge/web/src/app/console/governance-actions.ts @@ -0,0 +1,135 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, applyGovernance, deleteGovernance } from "@/lib/bff"; + +export interface GovState { + error: string | null; + ok: string | null; +} + +const PLURAL: Record<string, "toolpolicies" | "mcpservers" | "skills" | "profiles" | "inferencepolicies"> = { + ToolPolicy: "toolpolicies", + McpServer: "mcpservers", + KarsSkill: "skills", + KarsProfile: "profiles", + InferencePolicy: "inferencepolicies", +}; + +/// Author/edit a governance CRD from the operator console. Reads `kind` from a +/// hidden form field so one action serves all three kinds. The spec is authored +/// as JSON; the API server's admission/CEL validation is the real gate and its +/// message is surfaced verbatim on rejection. +export async function applyGovernanceAction(_prev: GovState, form: FormData): Promise<GovState> { + const kind = String(form.get("kind") ?? ""); + const plural = PLURAL[kind]; + if (!plural) return { error: "Unknown resource kind.", ok: null }; + + const name = String(form.get("name") ?? "").trim(); + if (!name) return { error: "Name is required.", ok: null }; + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { + return { error: "Name must be lowercase alphanumeric + hyphens (a Kubernetes object name).", ok: null }; + } + + const specRaw = String(form.get("spec") ?? "").trim(); + let spec: unknown; + try { + spec = JSON.parse(specRaw); + } catch { + return { error: "Spec must be valid JSON.", ok: null }; + } + if (typeof spec !== "object" || spec === null || Array.isArray(spec)) { + return { error: "Spec must be a JSON object.", ok: null }; + } + + const force = form.get("force") === "on"; + try { + const r = await applyGovernance(plural, { name, spec, force }); + revalidatePath("/console/policies"); + revalidatePath("/console/configuration"); + revalidatePath("/console/capabilities"); + return { error: null, ok: `${r.kind} ${r.name} applied — ${r.note}` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "apply failed", ok: null }; + } +} + +/// Approve + version-lock a skill (operator trust gate), or revoke approval. +/// `name` + `action` come from hidden form fields. The BFF enforces that a +/// skill must be scanned + attestation-verified before it can be approved. +export async function reviewSkillAction(_prev: GovState, form: FormData): Promise<GovState> { + const name = String(form.get("name") ?? "").trim(); + const action = String(form.get("action") ?? ""); + if (!name) return { error: "Name is required.", ok: null }; + try { + const { approveSkill, revokeSkill } = await import("@/lib/bff"); + if (action === "approve") { + const s = await approveSkill(name); + revalidatePath("/console/capabilities"); + return { error: null, ok: `Skill ${s.name} approved & locked to ${s.locked_digest?.slice(0, 12) ?? "digest"} — now available to users.` }; + } + if (action === "revoke") { + const s = await revokeSkill(name); + revalidatePath("/console/capabilities"); + return { error: null, ok: `Skill ${s.name} approval revoked — withdrawn from users.` }; + } + return { error: "Unknown action.", ok: null }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "review failed", ok: null }; + } +} + +const DELETE_PLURAL: Record<string, "toolpolicies" | "mcpservers" | "skills" | "profiles" | "egress" | "inferencepolicies"> = { + ToolPolicy: "toolpolicies", + McpServer: "mcpservers", + KarsSkill: "skills", + KarsProfile: "profiles", + EgressApproval: "egress", + InferencePolicy: "inferencepolicies", +}; + +/// Delete an operator-authored governance object (or revoke an egress grant). +/// `kind` + `name` come from hidden form fields so one action serves every row. +export async function deleteGovernanceAction(_prev: GovState, form: FormData): Promise<GovState> { + const kind = String(form.get("kind") ?? ""); + const plural = DELETE_PLURAL[kind]; + if (!plural) return { error: "Unknown resource kind.", ok: null }; + const name = String(form.get("name") ?? "").trim(); + if (!name) return { error: "Name is required.", ok: null }; + try { + const r = await deleteGovernance(plural, name); + revalidatePath("/console/policies"); + revalidatePath("/console/configuration"); + revalidatePath("/console/capabilities"); + revalidatePath("/console/approvals"); + return { error: null, ok: `${r.kind} ${r.name} removed — ${r.note}` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "delete failed", ok: null }; + } +} + +/// Approve/reject a kars-SRE self-remediation proposal. `ns` + `name` + `action` +/// come from hidden form fields. The BFF only patches `spec.approval` — the +/// controller is the sole executor of the remediation itself. +export async function decideSreActionAction(_prev: GovState, form: FormData): Promise<GovState> { + const ns = String(form.get("ns") ?? "").trim(); + const name = String(form.get("name") ?? "").trim(); + const action = String(form.get("action") ?? ""); + if (!ns || !name) return { error: "Namespace and name are required.", ok: null }; + if (action !== "approve" && action !== "reject") return { error: "Unknown action.", ok: null }; + try { + const { decideSreAction } = await import("@/lib/bff"); + const note = String(form.get("note") ?? "").trim() || undefined; + const r = await decideSreAction(ns, name, { verdict: action, note }); + revalidatePath("/console/sre-actions"); + revalidatePath("/console"); + return { + error: null, + ok: action === "approve" + ? `Approved — the controller will execute ${r.action_type} and record the outcome.` + : `Rejected — no action will be taken.`, + }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "decision failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/inference-policy-editor.tsx b/bridge/web/src/app/console/inference-policy-editor.tsx new file mode 100644 index 000000000..ea6125453 --- /dev/null +++ b/bridge/web/src/app/console/inference-policy-editor.tsx @@ -0,0 +1,349 @@ +"use client"; + +// kars Bridge Operator Console — visual InferencePolicy editor. Replaces the +// raw-JSON textarea (AuthorResource) for this one resource kind with real +// fields matching the actual CRD schema (controller/src/inference_policy.rs): +// appliesTo (sandbox selector), tokenBudget (daily/monthly/per-request caps), +// contentSafety (four severity floors + Prompt Shields), and modelPreference +// (primary + fallback chain, picked from the cluster's real wired models — +// not typed by hand). Submits through the SAME applyGovernanceAction the +// textarea used (name + a JSON "spec" string), so the backend contract is +// unchanged; only the authoring experience is visual. An "Edit as JSON" +// escape hatch stays available for the one case a form can't express +// (bundleRef — a signed OCI policy bundle). + +import { useActionState, useState } from "react"; +import { applyGovernanceAction, type GovState } from "./governance-actions"; +import { Icon } from "@/components/icon"; +import type { ModelOption } from "@/lib/types"; + +const init: GovState = { error: null, ok: null }; + +const SEVERITIES = ["", "Safe", "Low", "Medium", "High"] as const; +const ACTIONS = ["", "chat", "responses", "image", "embeddings", "*"] as const; + +type ModelRef = { provider: string; deployment: string }; + +interface FormShape { + displayName: string; + sandboxName: string; + labels: { key: string; value: string }[]; + action: string; + dailyTokens: string; + monthlyTokens: string; + perRequestTokens: string; + hate: string; + selfHarm: string; + sexual: string; + violence: string; + requirePromptShields: boolean; + primary: string; // "provider::deployment" or "" + fallback: string[]; +} + +function modelKey(m: ModelRef): string { + return `${m.provider}::${m.deployment}`; +} + +function parseSpec(spec: Record<string, unknown> | undefined): FormShape { + const appliesTo = (spec?.appliesTo as Record<string, unknown>) ?? {}; + const sandboxMatchLabels = (appliesTo.sandboxMatchLabels as Record<string, string>) ?? {}; + const tokenBudget = (spec?.tokenBudget as Record<string, unknown>) ?? {}; + const contentSafety = (spec?.contentSafety as Record<string, unknown>) ?? {}; + const modelPreference = (spec?.modelPreference as Record<string, unknown>) ?? {}; + const primary = modelPreference.primary as ModelRef | undefined; + const fallback = (modelPreference.fallback as ModelRef[] | undefined) ?? []; + return { + displayName: (spec?.displayName as string) ?? "", + sandboxName: (appliesTo.sandboxName as string) ?? "", + labels: Object.entries(sandboxMatchLabels).map(([key, value]) => ({ key, value })), + action: (appliesTo.action as string) ?? "", + dailyTokens: tokenBudget.dailyTokens != null ? String(tokenBudget.dailyTokens) : "", + monthlyTokens: tokenBudget.monthlyTokens != null ? String(tokenBudget.monthlyTokens) : "", + perRequestTokens: tokenBudget.perRequestTokens != null ? String(tokenBudget.perRequestTokens) : "", + hate: (contentSafety.hate as string) ?? "", + selfHarm: (contentSafety.selfHarm as string) ?? "", + sexual: (contentSafety.sexual as string) ?? "", + violence: (contentSafety.violence as string) ?? "", + requirePromptShields: contentSafety.requirePromptShields === true, + primary: primary ? modelKey(primary) : "", + fallback: fallback.map(modelKey), + }; +} + +function buildSpec(f: FormShape): Record<string, unknown> { + const spec: Record<string, unknown> = {}; + if (f.displayName.trim()) spec.displayName = f.displayName.trim(); + + const sandboxMatchLabels: Record<string, string> = {}; + for (const { key, value } of f.labels) { + if (key.trim()) sandboxMatchLabels[key.trim()] = value.trim(); + } + const appliesTo: Record<string, unknown> = { sandboxMatchLabels }; + if (f.sandboxName.trim()) appliesTo.sandboxName = f.sandboxName.trim(); + if (f.action) appliesTo.action = f.action; + spec.appliesTo = appliesTo; + + const tokenBudget: Record<string, unknown> = {}; + if (f.dailyTokens.trim()) tokenBudget.dailyTokens = Number(f.dailyTokens); + if (f.monthlyTokens.trim()) tokenBudget.monthlyTokens = Number(f.monthlyTokens); + if (f.perRequestTokens.trim()) tokenBudget.perRequestTokens = Number(f.perRequestTokens); + if (Object.keys(tokenBudget).length > 0) spec.tokenBudget = tokenBudget; + + const contentSafety: Record<string, unknown> = {}; + if (f.hate) contentSafety.hate = f.hate; + if (f.selfHarm) contentSafety.selfHarm = f.selfHarm; + if (f.sexual) contentSafety.sexual = f.sexual; + if (f.violence) contentSafety.violence = f.violence; + if (f.requirePromptShields) contentSafety.requirePromptShields = true; + if (Object.keys(contentSafety).length > 0) spec.contentSafety = contentSafety; + + if (f.primary) { + const [provider, deployment] = f.primary.split("::"); + const modelPreference: Record<string, unknown> = { primary: { provider, deployment } }; + const fb = f.fallback.filter(Boolean).map((k) => { + const [provider, deployment] = k.split("::"); + return { provider, deployment }; + }); + if (fb.length > 0) modelPreference.fallback = fb; + spec.modelPreference = modelPreference; + } + + return spec; +} + +export function InferencePolicyEditor({ + initialName, + initialSpec, + models, +}: { + initialName?: string; + initialSpec?: Record<string, unknown>; + models: ModelOption[]; +}) { + const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState(applyGovernanceAction, init); + const editing = Boolean(initialName); + const [advanced, setAdvanced] = useState(false); + const [f, setF] = useState<FormShape>(() => parseSpec(initialSpec)); + const [rawSpec, setRawSpec] = useState(() => JSON.stringify(initialSpec ?? {}, null, 2)); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + {editing ? `Edit ${initialName}` : "+ Add inference policy"} + </button> + ); + } + + const specJson = advanced ? rawSpec : JSON.stringify(buildSpec(f)); + const patch = (p: Partial<FormShape>) => setF((prev) => ({ ...prev, ...p })); + + return ( + <form action={action} className="mt-2 space-y-4 rounded-lg border border-border bg-surface-muted p-4"> + <input type="hidden" name="kind" value="InferencePolicy" /> + <input type="hidden" name="spec" value={specJson} /> + <div className="flex items-center justify-between"> + <p className="text-xs font-medium">{editing ? `Edit inference policy` : "New inference policy"}</p> + <div className="flex items-center gap-3"> + <button + type="button" + onClick={() => setAdvanced((v) => !v)} + className="text-xs text-foreground-muted hover:text-foreground" + > + {advanced ? "Use visual form" : "Edit as JSON"} + </button> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + </div> + </div> + + <input + name="name" + defaultValue={initialName} + readOnly={editing} + placeholder="name (lowercase-with-hyphens)" + required + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm read-only:opacity-70" + /> + + {advanced ? ( + <textarea + value={rawSpec} + onChange={(e) => setRawSpec(e.target.value)} + rows={14} + spellCheck={false} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed" + /> + ) : ( + <div className="space-y-4"> + <Field label="Display name"> + <input + value={f.displayName} + onChange={(e) => patch({ displayName: e.target.value })} + placeholder="e.g. Research team — standard budget" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + </Field> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="target" size={13} /> Applies to</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <Field label="Exact sandbox (optional)"> + <input + value={f.sandboxName} + onChange={(e) => patch({ sandboxName: e.target.value })} + placeholder="leave blank to match by label" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + <Field label="Inference action"> + <select + value={f.action} + onChange={(e) => patch({ action: e.target.value })} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + {ACTIONS.map((a) => <option key={a} value={a}>{a === "" ? "Any" : a}</option>)} + </select> + </Field> + </div> + <div className="mt-2"> + <p className="mb-1 text-xs text-foreground-muted">Sandbox labels (AND)</p> + {f.labels.map((row, i) => ( + <div key={i} className="mb-1.5 flex items-center gap-2"> + <input + value={row.key} + onChange={(e) => patch({ labels: f.labels.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)) })} + placeholder="kars.azure.com/team" + className="w-1/2 rounded-lg border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" + /> + <input + value={row.value} + onChange={(e) => patch({ labels: f.labels.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)) })} + placeholder="value" + className="w-1/2 rounded-lg border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" + /> + <button type="button" onClick={() => patch({ labels: f.labels.filter((_, j) => j !== i) })} className="shrink-0 text-foreground-muted hover:text-danger"> + <Icon name="cross" size={13} /> + </button> + </div> + ))} + <button + type="button" + onClick={() => patch({ labels: [...f.labels, { key: "", value: "" }] })} + className="text-xs text-signal hover:underline" + > + + Add label + </button> + </div> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="coin" size={13} /> Token budget</legend> + <div className="grid gap-3 sm:grid-cols-3"> + <Field label="Daily cap"> + <input type="number" min={0} value={f.dailyTokens} onChange={(e) => patch({ dailyTokens: e.target.value })} placeholder="no cap" className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm tabular-nums" /> + </Field> + <Field label="Monthly cap"> + <input type="number" min={0} value={f.monthlyTokens} onChange={(e) => patch({ monthlyTokens: e.target.value })} placeholder="no cap" className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm tabular-nums" /> + </Field> + <Field label="Per-request cap"> + <input type="number" min={0} value={f.perRequestTokens} onChange={(e) => patch({ perRequestTokens: e.target.value })} placeholder="no cap" className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm tabular-nums" /> + </Field> + </div> + <p className="mt-1.5 text-[11px] text-foreground-muted">Monthly must be ≥ daily and ≥ per-request — the cluster validates this on save.</p> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="shield" size={13} /> Content safety floor</legend> + <div className="grid gap-3 sm:grid-cols-4"> + <Field label="Hate"> + <select value={f.hate} onChange={(e) => patch({ hate: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + {SEVERITIES.map((s) => <option key={s} value={s}>{s || "Governance default"}</option>)} + </select> + </Field> + <Field label="Self-harm"> + <select value={f.selfHarm} onChange={(e) => patch({ selfHarm: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + {SEVERITIES.map((s) => <option key={s} value={s}>{s || "Governance default"}</option>)} + </select> + </Field> + <Field label="Sexual"> + <select value={f.sexual} onChange={(e) => patch({ sexual: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + {SEVERITIES.map((s) => <option key={s} value={s}>{s || "Governance default"}</option>)} + </select> + </Field> + <Field label="Violence"> + <select value={f.violence} onChange={(e) => patch({ violence: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + {SEVERITIES.map((s) => <option key={s} value={s}>{s || "Governance default"}</option>)} + </select> + </Field> + </div> + <label className="mt-2 flex items-center gap-2 text-xs text-foreground-muted"> + <input type="checkbox" checked={f.requirePromptShields} onChange={(e) => patch({ requirePromptShields: e.target.checked })} /> + Require Prompt Shields (jailbreak / indirect-injection detection) + </label> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="brain" size={13} /> Model preference</legend> + <Field label="Primary route"> + <select value={f.primary} onChange={(e) => patch({ primary: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">Inherit controller default</option> + {models.map((m) => <option key={modelKey(m)} value={modelKey(m)}>{m.provider} :: {m.deployment}{m.is_default ? " (default)" : ""}</option>)} + </select> + </Field> + {f.primary && ( + <div className="mt-2"> + <p className="mb-1 text-xs text-foreground-muted">Fallback chain (tried in order on primary failure)</p> + {f.fallback.map((val, i) => ( + <div key={i} className="mb-1.5 flex items-center gap-2"> + <select + value={val} + onChange={(e) => patch({ fallback: f.fallback.map((v, j) => (j === i ? e.target.value : v)) })} + className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" + > + <option value="">— select a model —</option> + {models.map((m) => <option key={modelKey(m)} value={modelKey(m)}>{m.provider} :: {m.deployment}</option>)} + </select> + <button type="button" onClick={() => patch({ fallback: f.fallback.filter((_, j) => j !== i) })} className="shrink-0 text-foreground-muted hover:text-danger"> + <Icon name="cross" size={13} /> + </button> + </div> + ))} + <button type="button" onClick={() => patch({ fallback: [...f.fallback, ""] })} className="text-xs text-signal hover:underline"> + + Add fallback route + </button> + </div> + )} + </fieldset> + </div> + )} + + <p className="text-[11px] text-foreground-muted"> + Applied with <span className="font-mono">kubectl apply</span> semantics (field manager <span className="font-mono">kars-bridge</span>). The cluster validates it — invalid specs are rejected with the API server's own message. + </p> + <label className="flex items-center gap-2 text-[11px] text-foreground-muted"> + <input type="checkbox" name="force" /> Force — take ownership of fields another manager owns (only on a conflict) + </label> + <div className="flex items-center gap-3"> + <button type="submit" disabled={pending} className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Applying…" : editing ? "Save changes" : "Create inference policy"} + </button> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </div> + </form> + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <label className="block text-xs text-foreground-muted"> + {label} + <div className="mt-1">{children}</div> + </label> + ); +} diff --git a/bridge/web/src/app/console/insights/page.tsx b/bridge/web/src/app/console/insights/page.tsx new file mode 100644 index 000000000..11d106fff --- /dev/null +++ b/bridge/web/src/app/console/insights/page.tsx @@ -0,0 +1,596 @@ +// kars Bridge Operator Console — Insights. Fleet-wide efficiency + governance, +// graphical. Structural facts are real; runtime token/latency render the +// honest "needs a real run" state, never fabricated zeros. + +import { BarChart } from "@/components/bar-chart"; +import { HonestState } from "@/components/honest-state"; +import { LiveRefresh } from "@/components/live-refresh"; +import { getArtifacts, getEfficiency, getInsights, getInferenceBudgets } from "@/lib/bff"; +import type { Efficiency, Insights, MissionArtifacts, InferenceBudgets } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export default async function InsightsPage() { + let insights: Insights | null = null; + let error = false; + try { + insights = await getInsights(); + } catch { + error = true; + } + + let efficiency: Efficiency | null = null; + try { + efficiency = await getEfficiency(); + } catch { + efficiency = null; + } + + let deliverables: MissionArtifacts[] = []; + try { + deliverables = (await getArtifacts()).missions; + } catch { + deliverables = []; + } + + let budgets: InferenceBudgets | null = null; + try { + budgets = await getInferenceBudgets(); + } catch { + budgets = null; + } + + if (error || !insights) { + return ( + <div className="space-y-6"> + <Header /> + <HonestState variant="not_wired" title="Insights unavailable" detail="The run environment isn't reachable right now." /> + </div> + ); + } + + const totalMissions = insights.missions_by_phase.reduce((s, p) => s + p.count, 0); + // Real economics from the efficiency frontier (per-route telemetry). + // avg_tokens is tokens-per-delivered-outcome, so the true spend on a route is + // avg_tokens × delivered (NOT × runs, which would overcount failed runs). + const totalTokens = efficiency + ? efficiency.routes.reduce((s, r) => s + r.avg_tokens * r.delivered, 0) + : null; + const accepted = efficiency ? efficiency.routes.reduce((s, r) => s + r.accepted, 0) : 0; + const delivered = deliverables.filter((m) => m.summary && m.status !== "error").length; + const reviewed = deliverables.filter((m) => m.review_status === "approved").length; + + // One consistent, monotonic population for the outcome funnel. The efficiency + // engine counts run-level outcomes where attempted ≥ delivered ≥ accepted by + // construction (each run delivers at most once, and is accepted only if + // delivered). Falling back to mission/deliverable counts (clamped) when the + // efficiency engine has no data yet. + const funnelPop = (() => { + if (efficiency && efficiency.total_runs > 0) { + const d = efficiency.routes.reduce((s, r) => s + r.delivered, 0); + const a = efficiency.routes.reduce((s, r) => s + r.accepted, 0); + return { + attempted: efficiency.total_runs, + delivered: Math.min(d, efficiency.total_runs), + accepted: Math.min(a, d), + }; + } + // No efficiency data: use mission counts, clamped so stages never exceed + // the prior stage. + const d = Math.min(delivered, totalMissions); + const a = Math.min(accepted || reviewed, d); + return { attempted: totalMissions, delivered: d, accepted: a }; + })(); + + return ( + <div className="space-y-6"> + {/* Metrics tick live — re-run the server render on an interval so numbers + update as runs complete, without a manual reload. */} + <LiveRefresh active intervalMs={8000} /> + <Header /> + + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> + <Kpi label="Missions" value={totalMissions} accent /> + <Kpi label="Running" value={insights.launched} /> + <Kpi label="Deliverables" value={funnelPop.delivered} /> + <Kpi label="Accepted" value={funnelPop.accepted} /> + <Kpi label="Signed receipts" value={insights.receipts_issued} /> + <Kpi label="Tokens on delivered" value={totalTokens == null ? null : Math.round(totalTokens)} /> + </div> + <p className="-mt-3 text-xs text-foreground-muted"> + <span className="font-medium text-foreground">Missions</span> counts unique tasks;{" "} + <span className="font-medium text-foreground">Deliverables</span> and{" "} + <span className="font-medium text-foreground">Accepted</span> are counted per run — a mission can be re-run, so run totals can exceed the mission count.{" "} + <span className="font-medium text-foreground">Tokens on delivered</span> sums the token cost of delivered outcomes only; tokens burned by failed or blocked runs are not included. + </p> + + {/* Deliverable funnel — outcome, not activity. Uses the run-level + population from the efficiency engine so it is strictly monotonic + (attempted ≥ delivered ≥ accepted) — never a >100% stage. */} + <Funnel + attempted={funnelPop.attempted} + delivered={funnelPop.delivered} + accepted={funnelPop.accepted} + blocked={insights.amplification_rejections} + /> + + <div className="grid gap-6 lg:grid-cols-2"> + <Panel title="Missions by status" subtitle="Where your work stands."> + {totalMissions === 0 ? ( + <HonestState variant="empty" compact title="No missions yet" /> + ) : ( + <BarChart data={insights.missions_by_phase.map(projPhase)} /> + )} + </Panel> + + <Panel title="Autonomy mix" subtitle="How much independence you've granted across missions."> + {insights.missions_by_tier.every((t) => t.count === 0) ? ( + <HonestState variant="empty" compact title="No missions yet" /> + ) : ( + <BarChart data={insights.missions_by_tier} colorClass="bg-accent/70" /> + )} + </Panel> + + <Panel title="Decisions you made" subtitle="Approvals, denials, and their outcomes."> + {insights.decisions.length === 0 ? ( + <HonestState variant="empty" compact title="No decisions yet" detail="Decisions appear as missions ask for your approval." /> + ) : ( + <BarChart data={insights.decisions} colorClass="bg-warning/70" /> + )} + </Panel> + + <Panel title="Governance integrity" subtitle="Proof the system held the line."> + <dl className="space-y-3"> + <Row label="Receipts issued" value={insights.receipts_issued} /> + <Row label="Tamper-evidence log entries" value={insights.inclusion_log_size} /> + <Row + label="Over-reach attempts blocked" + value={insights.amplification_rejections} + hint="Sub-roles that tried to grant themselves more authority than allowed — and were stopped." + /> + </dl> + </Panel> + </div> + + {/* Budget utilization — the token economy against the hierarchical caps. + Live measured daily spend vs the cluster + workspace budgets. */} + {budgets && <BudgetUtilization budgets={budgets} />} + + {/* Cross-harness efficiency frontier (§3B, Pillar B) — the real per-route + telemetry. Promoted above the honest-gap note. */} + {efficiency && efficiency.routes.length > 0 ? ( + <EfficiencyPanel efficiency={efficiency} insights={insights} /> + ) : ( + <p className="rounded-lg border border-dashed border-border bg-surface-muted/30 px-4 py-3 text-xs text-foreground-muted"> + Per-mission token cost & latency are captured per run; the cross-harness comparison surfaces here once missions have run on more than one route. + </p> + )} + </div> + ); +} + +function pct(x: number): string { + return `${Math.round(x * 100)}%`; +} + +/** A real efficiency frontier: a scatter of routes by cost (tokens per + * delivered outcome, X) vs quality (delivery success rate, Y). Point area ~ + * run count; the recommended route is ringed. The Pareto-optimal routes + * (cheaper AND higher-quality than any other) are connected as the frontier. + * Renders as SVG — not a table row. */ +function FrontierChart({ efficiency }: { efficiency: Efficiency }) { + const pts = efficiency.routes.filter((r) => r.delivered > 0 && r.tokens_per_outcome > 0); + if (pts.length === 0) { + return ( + <p className="mt-4 rounded-lg border border-dashed border-border bg-surface-muted/30 px-4 py-3 text-xs text-foreground-muted"> + The frontier plots once at least one route has a delivered outcome with recorded token cost. + </p> + ); + } + const W = 640, H = 240, padL = 52, padR = 16, padT = 28, padB = 40; + const maxCost = Math.max(...pts.map((p) => p.tokens_per_outcome)); + const minCost = Math.min(...pts.map((p) => p.tokens_per_outcome)); + const costSpan = Math.max(maxCost - minCost, 1); + const maxRuns = Math.max(...pts.map((p) => p.runs), 1); + const x = (cost: number) => + padL + ((cost - minCost) / costSpan) * (W - padL - padR) * (pts.length === 1 ? 0 : 1) + (pts.length === 1 ? (W - padL - padR) / 2 : 0); + const y = (q: number) => padT + (1 - q) * (H - padT - padB); + const r = (runs: number) => 5 + Math.sqrt(runs / maxRuns) * 13; + + // Label placement that never clips off the chart: anchor start/middle/end + // based on which third of the plot width the point falls in (so a label + // near the left or right edge extends INTO the chart instead of past its + // boundary), and flip above/below based on whether "above" would push the + // label past the top edge (points near 100% success previously rendered + // their label at a negative Y — invisible, clipped by the viewBox). + const plotW = W - padL - padR; + function labelFor(px: number, py: number, radius: number): { anchor: "start" | "middle" | "end"; lx: number; ly: number } { + const frac = (px - padL) / plotW; + const anchor = frac < 0.22 ? "start" : frac > 0.78 ? "end" : "middle"; + const lx = anchor === "start" ? Math.max(px, padL) : anchor === "end" ? Math.min(px, W - padR) : px; + const above = py - radius - 6; + const ly = above > padT + 6 ? above : py + radius + 12; + return { anchor, lx, ly }; + } + + // Pareto frontier: a route dominates if it is cheaper (lower cost) AND higher + // quality. Keep the non-dominated set, sorted by cost, and connect them. + const frontier = pts + .filter((p) => !pts.some((o) => o !== p && o.tokens_per_outcome <= p.tokens_per_outcome && o.success_rate >= p.success_rate && (o.tokens_per_outcome < p.tokens_per_outcome || o.success_rate > p.success_rate))) + .sort((a, b) => a.tokens_per_outcome - b.tokens_per_outcome); + + const gridY = [0, 0.25, 0.5, 0.75, 1]; + return ( + <div className="mt-4 overflow-x-auto"> + <svg viewBox={`0 0 ${W} ${H}`} className="w-full min-w-[420px]" role="img" aria-label="Efficiency frontier: cost versus delivery success by route"> + {/* Y gridlines + labels (delivery success). */} + {gridY.map((g) => ( + <g key={g}> + <line x1={padL} y1={y(g)} x2={W - padR} y2={y(g)} className="stroke-border" strokeWidth={1} strokeDasharray="2 3" /> + <text x={padL - 8} y={y(g) + 3} textAnchor="end" className="fill-foreground-muted text-[9px]">{Math.round(g * 100)}%</text> + </g> + ))} + {/* Axis titles. */} + <text x={(padL + W - padR) / 2} y={H - 8} textAnchor="middle" className="fill-foreground-muted text-[10px]">Cost — tokens per delivered outcome (cheaper →)</text> + <text x={14} y={(padT + H - padB) / 2} textAnchor="middle" transform={`rotate(-90 14 ${(padT + H - padB) / 2})`} className="fill-foreground-muted text-[10px]">Delivery success</text> + {/* X min/max cost labels. */} + <text x={padL} y={H - 24} textAnchor="start" className="fill-foreground-muted text-[9px]">{minCost.toLocaleString()}</text> + {pts.length > 1 && <text x={W - padR} y={H - 24} textAnchor="end" className="fill-foreground-muted text-[9px]">{maxCost.toLocaleString()}</text>} + {/* Frontier line. */} + {frontier.length > 1 && ( + <polyline + points={frontier.map((p) => `${x(p.tokens_per_outcome)},${y(p.success_rate)}`).join(" ")} + className="stroke-signal/50" fill="none" strokeWidth={2} strokeDasharray="4 3" + /> + )} + {/* Points. */} + {pts.map((p) => { + const isRec = p.route === efficiency.recommended && p.harness === efficiency.recommended_harness; + const px = x(p.tokens_per_outcome); + const py = y(p.success_rate); + const radius = r(p.runs); + const label = labelFor(px, py, radius); + return ( + <g key={`${p.route}-${p.harness}`}> + <circle cx={px} cy={py} r={radius} className={isRec ? "fill-emerald-500/25 stroke-emerald-500" : "fill-signal/20 stroke-signal"} strokeWidth={isRec ? 2.5 : 1.5} /> + <text x={label.lx} y={label.ly} textAnchor={label.anchor} className="fill-foreground text-[9px] font-medium"> + {p.route}{p.harness ? ` · ${p.harness}` : ""} + </text> + </g> + ); + })} + </svg> + <p className="mt-1 text-[11px] text-foreground-muted"> + {pts.length < 2 ? ( + <>Point size ∝ run count. Only one route has delivered outcomes so far — a frontier needs at least two routes to compare, so run missions on another route or harness to populate it.</> + ) : ( + <>Point size ∝ run count. Up-and-left is better (cheaper, higher delivery success). The dashed line is the Pareto frontier; the ringed point is recommended.</> + )} + </p> + </div> + ); +} + +function EfficiencyPanel({ efficiency, insights }: { efficiency: Efficiency; insights: Insights }) { + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Efficiency frontier</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {insights.runtime_metrics_available + ? "Real per-run telemetry by model/harness route." + : "Outcome-based comparison by model/harness route (token/latency runtime metrics not yet wired for every route)."}{" "} + <strong>Accepted</strong> = a human approved the deliverable (the honest outcome + signal), not merely that tokens were spent. Computed from {efficiency.total_runs} run + {efficiency.total_runs === 1 ? "" : "s"}. + </p> + {!insights.runtime_metrics_available && insights.runtime_metrics_note && ( + <p className="mt-1 text-[11px] text-foreground-muted">{insights.runtime_metrics_note}</p> + )} + </div> + {efficiency.recommended && efficiency.recommended.toLowerCase() !== "unknown" && ( + <div className="shrink-0 text-right"> + <span + className={`inline-block rounded-full border px-2.5 py-1 text-xs font-medium ${ + efficiency.recommended_low_confidence + ? "border-warning/40 bg-warning/10 text-warning" + : "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + }`} + title={efficiency.recommended_basis ?? undefined} + > + {efficiency.recommended_low_confidence ? "Best available: " : "Recommended: "} + {efficiency.recommended} + </span> + {efficiency.recommended_basis && ( + <p className="mt-1 max-w-xs text-right text-[11px] leading-snug text-foreground-muted"> + {efficiency.recommended_basis} + </p> + )} + </div> + )} + </div> + + {/* Real frontier chart — cost (tokens/outcome) vs quality (delivery + success), one point per route with runs, the recommended route + highlighted. A frontier, not a spreadsheet row. */} + <FrontierChart efficiency={efficiency} /> + <div className="mt-4 overflow-x-auto"> + <table className="w-full text-left text-xs"> + <thead className="text-foreground-muted"> + <tr className="border-b border-border"> + <th className="py-2 pr-4 font-medium">Route</th> + <th className="py-2 pr-4 font-medium">Harness</th> + <th className="py-2 pr-4 font-medium">Runs</th> + <th className="py-2 pr-4 font-medium">Delivered</th> + <th className="py-2 pr-4 font-medium">Accepted</th> + <th className="py-2 pr-4 font-medium" title="Most common fault among this route's unaccepted runs">Top fault</th> + <th className="py-2 pr-4 font-medium" title="pass^k: fraction of repeated packages accepted on EVERY attempt — a reliability measure">Reliability</th> + <th className="py-2 pr-4 font-medium">Tokens/outcome</th> + {efficiency.priced && <th className="py-2 pr-4 font-medium">$/outcome</th>} + <th className="py-2 pr-4 font-medium" title="mean wall-clock (p95)">Latency</th> + <th className="py-2 pr-4 font-medium" title="fraction of tool calls that failed">Tool-fail</th> + <th className="py-2 pr-4 font-medium" title="fraction of input tokens served from the provider cache">Cache</th> + <th className="py-2 pr-4 font-medium">Rounds</th> + <th className="py-2 pr-4 font-medium">Tool calls</th> + </tr> + </thead> + <tbody> + {efficiency.routes.map((r) => ( + <tr + key={`${r.route}-${r.harness ?? ""}`} + className={`border-b border-border last:border-0 ${ + r.route === efficiency.recommended && + (r.harness ?? "") === (efficiency.recommended_harness ?? "") + ? "bg-emerald-500/5" + : "" + }`} + > + <td className="py-2 pr-4 font-mono">{r.route}</td> + <td className="py-2 pr-4"> + {r.harness ? ( + <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[11px] font-medium text-accent">{r.harness}</span> + ) : ( + <span className="text-foreground-muted">—</span> + )} + </td> + <td className="py-2 pr-4 tabular-nums">{r.runs}</td> + <td className="py-2 pr-4 tabular-nums">{r.delivered}</td> + <td className="py-2 pr-4 tabular-nums">{r.accepted} <span className="text-foreground-muted">({pct(r.acceptance_rate)})</span></td> + <td className="py-2 pr-4">{r.top_fault ? <span className="rounded bg-danger/10 px-1.5 py-0.5 text-[10px] font-medium text-danger">{r.top_fault}</span> : <span className="text-foreground-muted">—</span>}</td> + <td className="py-2 pr-4 tabular-nums">{r.reliability_rate === null ? <span className="text-foreground-muted">—</span> : <span title={`pass^${r.reliability_k}: accepted on every one of ${r.reliability_k} repeated attempts, measured over ${r.reliability_samples} sample${r.reliability_samples === 1 ? "" : "s"}`}>{pct(r.reliability_rate)} <span className="text-foreground-muted">(n={r.reliability_samples})</span></span>}</td> + <td className="py-2 pr-4 tabular-nums">{r.tokens_per_outcome.toLocaleString()}</td> + {efficiency.priced && <td className="py-2 pr-4 tabular-nums">{r.usd_per_outcome === null ? <span className="text-foreground-muted">—</span> : `$${r.usd_per_outcome.toFixed(3)}`}</td>} + <td className="py-2 pr-4 tabular-nums">{r.avg_wall_ms > 0 ? <>{(r.avg_wall_ms / 1000).toFixed(1)}s <span className="text-foreground-muted">(p95 {(r.p95_wall_ms / 1000).toFixed(0)}s)</span></> : <span className="text-foreground-muted">—</span>}</td> + <td className="py-2 pr-4 tabular-nums">{r.avg_tool_calls > 0 ? pct(r.tool_fail_rate) : <span className="text-foreground-muted">—</span>}</td> + <td className="py-2 pr-4 tabular-nums">{r.cache_hit_rate > 0 ? pct(r.cache_hit_rate) : <span className="text-foreground-muted">—</span>}</td> + <td className="py-2 pr-4 tabular-nums">{r.avg_rounds.toFixed(1)}</td> + <td className="py-2 pr-4 tabular-nums">{r.avg_tool_calls.toFixed(1)}</td> + </tr> + ))} + </tbody> + </table> + </div> + {(() => { + const a = efficiency.routes[0]; + const b = efficiency.routes[1]; + // Only show A/B when the top two are genuinely DIFFERENT routes (route + // or harness differs) — never compare a (route,harness) pair to itself, + // which rendered two identically-labeled columns. + if (!a || !b) return null; + const distinct = a.route !== b.route || (a.harness ?? "") !== (b.harness ?? ""); + if (!distinct) return null; + return ( + <AbCompare + a={a} + b={b} + recommended={efficiency.recommended} + recommendedHarness={efficiency.recommended_harness} + /> + ); + })()} + </section> + ); +} + +function AbCompare({ a, b, recommended, recommendedHarness }: { a: import("@/lib/types").RouteEfficiency; b: import("@/lib/types").RouteEfficiency; recommended: string | null; recommendedHarness?: string | null }) { + const isRec = (r: import("@/lib/types").RouteEfficiency): boolean => + r.route === recommended && (r.harness ?? "") === (recommendedHarness ?? ""); + const routeLabel = (r: import("@/lib/types").RouteEfficiency): string => + r.harness ? `${r.route} · ${r.harness}` : r.route; + const rows: { label: string; av: string; bv: string; aWins: boolean | null }[] = [ + { label: "Acceptance", av: pct(a.acceptance_rate), bv: pct(b.acceptance_rate), aWins: a.acceptance_rate === b.acceptance_rate ? null : a.acceptance_rate > b.acceptance_rate }, + { label: "Reliability (pass^k)", av: a.reliability_rate === null ? "—" : pct(a.reliability_rate), bv: b.reliability_rate === null ? "—" : pct(b.reliability_rate), aWins: (a.reliability_rate ?? -1) === (b.reliability_rate ?? -1) ? null : (a.reliability_rate ?? -1) > (b.reliability_rate ?? -1) }, + { label: "Tokens / outcome", av: a.tokens_per_outcome.toLocaleString(), bv: b.tokens_per_outcome.toLocaleString(), aWins: a.tokens_per_outcome === b.tokens_per_outcome ? null : a.tokens_per_outcome < b.tokens_per_outcome }, + { label: "Latency (wall)", av: a.avg_wall_ms > 0 ? `${(a.avg_wall_ms / 1000).toFixed(1)}s` : "—", bv: b.avg_wall_ms > 0 ? `${(b.avg_wall_ms / 1000).toFixed(1)}s` : "—", aWins: a.avg_wall_ms === b.avg_wall_ms || a.avg_wall_ms === 0 || b.avg_wall_ms === 0 ? null : a.avg_wall_ms < b.avg_wall_ms }, + { label: "Tool-fail rate", av: pct(a.tool_fail_rate), bv: pct(b.tool_fail_rate), aWins: a.tool_fail_rate === b.tool_fail_rate ? null : a.tool_fail_rate < b.tool_fail_rate }, + { label: "Avg rounds", av: a.avg_rounds.toFixed(1), bv: b.avg_rounds.toFixed(1), aWins: a.avg_rounds === b.avg_rounds ? null : a.avg_rounds < b.avg_rounds }, + { label: "Avg tool calls", av: a.avg_tool_calls.toFixed(1), bv: b.avg_tool_calls.toFixed(1), aWins: a.avg_tool_calls === b.avg_tool_calls ? null : a.avg_tool_calls < b.avg_tool_calls }, + ]; + return ( + <div className="mt-6 rounded-lg border border-border bg-background/40 p-4"> + <h3 className="text-xs font-semibold">A/B head-to-head — top two routes</h3> + <p className="mt-0.5 text-[11px] text-foreground-muted">Side-by-side on the same outcome metrics. The winner is the cheaper-per-accepted-outcome route, not the cheaper-per-token one.</p> + <div className="mt-3 grid grid-cols-[1fr_auto_auto] gap-x-4 gap-y-1.5 text-xs"> + <div className="text-foreground-muted">Route</div> + <div className="font-mono text-right">{routeLabel(a)}{isRec(a) ? " ★" : ""}</div> + <div className="font-mono text-right">{routeLabel(b)}{isRec(b) ? " ★" : ""}</div> + {rows.map((r) => ( + <FragmentRow key={r.label} {...r} /> + ))} + </div> + </div> + ); +} + +function FragmentRow({ label, av, bv, aWins }: { label: string; av: string; bv: string; aWins: boolean | null }) { + const win = "font-semibold text-emerald-600"; + return ( + <> + <div className="text-foreground-muted">{label}</div> + <div className={`text-right tabular-nums ${aWins === true ? win : ""}`}>{av}</div> + <div className={`text-right tabular-nums ${aWins === false ? win : ""}`}>{bv}</div> + </> + ); +} + +function projPhase(p: { label: string; count: number }) { + const map: Record<string, string> = { + Ready: "Ready / running", + Degraded: "Blocked", + Pending: "Starting", + }; + return { label: map[p.label] ?? p.label, count: p.count }; +} + +function Header() { + return ( + <div> + <h1 className="text-2xl font-semibold tracking-tight">Insights</h1> + <p className="mt-1 text-sm text-foreground-muted"> + How the fleet's missions and teams are performing and how rigorously they're + governed — across all work on this cluster. + </p> + </div> + ); +} + +function Kpi({ label, value, accent }: { label: string; value: number | null; accent?: boolean }) { + return ( + <div className={`rounded-xl border p-4 ${accent ? "border-signal/30 bg-signal/5" : "border-border bg-surface"}`}> + <p className="text-2xl font-semibold tabular-nums">{value == null ? "—" : value.toLocaleString()}</p> + <p className="mt-0.5 text-xs text-foreground-muted">{label}</p> + </div> + ); +} + +function Funnel({ attempted, delivered, accepted, blocked }: { attempted: number; delivered: number; accepted: number; blocked: number }) { + // Strictly monotonic by construction (attempted ≥ delivered ≥ accepted), so + // the widest bar is always stage 1 and no rate can exceed 100%. + const max = Math.max(attempted, 1); + const rate = (n: number, base: number) => (base > 0 ? Math.min(100, Math.round((n / base) * 100)) : 0); + const rows = [ + { label: "Attempted", n: attempted, cls: "bg-signal", sub: "runs started" }, + { label: "Delivered", n: delivered, cls: "bg-signal/70", sub: `${rate(delivered, attempted)}% of runs produced output` }, + { label: "Accepted", n: accepted, cls: "bg-ok", sub: `${rate(accepted, delivered)}% of deliverables approved` }, + ]; + return ( + <section className="kb-card p-5 sm:p-6"> + <div className="flex items-baseline justify-between"> + <div> + <h2 className="text-sm font-semibold">Outcome funnel</h2> + <p className="mt-0.5 text-xs text-foreground-muted">Outcome, not activity — attempted → delivered → human-accepted.</p> + </div> + {blocked > 0 && <span className="rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-xs text-warning">{blocked} over-reach blocked</span>} + </div> + <div className="mt-4 space-y-3"> + {rows.map((r) => ( + <div key={r.label} className="flex items-center gap-3"> + <span className="w-20 shrink-0 text-xs font-medium">{r.label}</span> + <div className="h-7 flex-1 overflow-hidden rounded-lg bg-surface-muted"> + <div className={`flex h-7 items-center justify-end rounded-lg px-2 ${r.cls} transition-all`} style={{ width: `${Math.max((r.n / max) * 100, 8)}%` }}> + <span className="text-xs font-semibold tabular-nums text-white">{r.n}</span> + </div> + </div> + <span className="hidden w-56 shrink-0 text-[11px] text-foreground-muted sm:block">{r.sub}</span> + </div> + ))} + </div> + </section> + ); +} + +function Panel({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: React.ReactNode; +}) { + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">{title}</h2> + <p className="mt-0.5 text-xs text-foreground-muted">{subtitle}</p> + <div className="mt-4">{children}</div> + </section> + ); +} + +function Row({ label, value, hint }: { label: string; value: number; hint?: string }) { + return ( + <div className="flex items-center justify-between gap-4 border-b border-border pb-2 last:border-0"> + <div> + <dt className="text-sm">{label}</dt> + {hint && <p className="mt-0.5 text-xs text-foreground-muted">{hint}</p>} + </div> + <dd className="text-lg font-semibold tabular-nums">{value}</dd> + </div> + ); +} + +/** Budget utilization — the token economy against the hierarchical caps (item 2). + * Shows today's measured cluster spend and every configured cap with a live + * meter + enforcement status, so operators see the spend story next to outcomes. */ +function BudgetUtilization({ budgets }: { budgets: InferenceBudgets }) { + const levels = [ + ...(budgets.cluster ? [budgets.cluster] : []), + ...budgets.workspaces, + ]; + const statusMeta: Record<string, { label: string; bar: string; tone: string }> = { + ok: { label: "Within budget", bar: "bg-signal", tone: "text-signal" }, + alert: { label: "Over budget — alerting", bar: "bg-warning", tone: "text-warning" }, + over_buffer_headroom: { label: "In buffer headroom", bar: "bg-warning", tone: "text-warning" }, + blocking: { label: "Blocking new work", bar: "bg-danger", tone: "text-danger" }, + }; + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Budget utilization</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Today’s measured inference token spend against the hierarchical caps (UTC day). + Edit the caps in Policies → Inference budgets. + </p> + </div> + <div className="shrink-0 text-right"> + <p className="text-2xl font-semibold tabular-nums">{budgets.cluster_used_today.toLocaleString()}</p> + <p className="text-[11px] text-foreground-muted">tokens today (cluster)</p> + </div> + </div> + {levels.length === 0 ? ( + <p className="mt-4 rounded-lg border border-dashed border-border bg-surface-muted/30 px-4 py-3 text-xs text-foreground-muted"> + No cluster or workspace caps set yet — spend is measured but unbounded. Set a cap in + Policies → Inference budgets to enforce passive alerts, a buffer, or strict limits. + </p> + ) : ( + <ul className="mt-4 space-y-3"> + {levels.map((lv) => { + const meta = statusMeta[lv.status] ?? statusMeta.ok; + const pctW = Math.min(100, Math.round(lv.percent * 100)); + return ( + <li key={lv.scope}> + <div className="flex items-center justify-between text-xs"> + <span className="font-medium"> + {lv.scope === "cluster" ? "Cluster" : lv.label} + <span className="ml-2 rounded-full border border-border px-1.5 py-0.5 text-[10px] font-medium capitalize text-foreground-muted"> + {lv.mode} + </span> + </span> + <span className="tabular-nums text-foreground-muted"> + {lv.used_today.toLocaleString()} / {lv.daily_tokens.toLocaleString()} · {Math.round(lv.percent * 100)}% + </span> + </div> + <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-surface-muted"> + <div className={`h-full ${meta.bar}`} style={{ width: `${pctW}%` }} /> + </div> + <p className={`mt-0.5 text-[11px] ${meta.tone}`}>{meta.label}</p> + </li> + ); + })} + </ul> + )} + </section> + ); +} diff --git a/bridge/web/src/app/console/layout.tsx b/bridge/web/src/app/console/layout.tsx new file mode 100644 index 000000000..da3d01991 --- /dev/null +++ b/bridge/web/src/app/console/layout.tsx @@ -0,0 +1,105 @@ +// kars Bridge Operator Console — the platform/SRE shell. +// +// Dense, information-first chrome. Unlike the Workspace, Kubernetes context is +// appropriate here — operators reason about namespaces and resources. The +// header carries the substrate scope (namespace + environment) and the switch +// back to the Workspace. + +import Link from "next/link"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { ConsoleNav } from "@/components/console-nav"; +import { SurfaceSwitcher } from "@/components/surface-switcher"; +import { RoleSwitcher } from "@/components/role-switcher"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { defaultNamespace, environment, authWired } from "@/lib/config"; +import { currentPrincipal, hasRole } from "@/lib/session"; +import { ssoConfigured } from "@/lib/oidc-config"; +import { Icon } from "@/components/icon"; +import { loginPath, safeReturnTo } from "@/lib/auth-return"; + +import type { Metadata as _Metadata } from "next"; +export const metadata: _Metadata = { title: "Operator Console" }; +export default async function ConsoleLayout({ + children, +}: { + children: React.ReactNode; +}) { + const principal = await currentPrincipal(); + if (ssoConfigured() && principal.roles.length === 0) { + const requestPath = safeReturnTo( + (await headers()).get("x-bridge-return-to"), + "/console", + ); + redirect(loginPath(requestPath)); + } + // Role boundary: the Operator Console is a separate product/permission set. + // A principal without the `operator` role (admin implies operator) is sent back + // to their Workspace — the two homes never leak into each other. + if (!(await hasRole("operator"))) { + redirect((await hasRole("auditor")) ? "/audit" : "/workspace"); + } + const ns = defaultNamespace(); + const env = environment(); + const authed = authWired(); + return ( + <div className="flex min-h-full flex-col"> + <header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur"> + <div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-6"> + <Link + href="/console" + className="flex items-center gap-2.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <span className="grid h-7 w-7 place-items-center rounded-md bg-foreground text-background text-sm font-bold"> + kb + </span> + <span className="font-semibold tracking-tight"> + kars <span className="text-foreground-muted">Console</span> + </span> + </Link> + <div className="flex items-center gap-3"> + <span + title={`Namespace: ${ns}`} + className="hidden items-center gap-1.5 font-mono text-xs text-foreground-muted sm:inline-flex" + > + <svg aria-hidden viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor"> + <path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h3l1.5 1.5h4.5A1.5 1.5 0 0 1 14 6v5.5A1.5 1.5 0 0 1 12.5 13h-9A1.5 1.5 0 0 1 2 11.5v-7Z" /> + </svg> + {ns} + </span> + <span className="hidden rounded-full border border-border bg-surface-muted px-2 py-0.5 text-xs font-medium text-foreground-muted sm:inline-flex"> + {env} + </span> + {!authed && ( + <Link + href="/console/troubleshooting" + className="hidden items-center gap-1 rounded-full border border-border bg-surface-muted px-2 py-0.5 text-[11px] font-medium text-foreground-muted transition hover:bg-surface hover:text-foreground sm:inline-flex" + title="Per-user sign-in isn't wired yet. Every read/write goes through the Bridge's Kubernetes ServiceAccount under a least-privilege RBAC role (deploy/rbac.yaml) — the cluster is the real boundary. Click for the full identity disclosure." + > + <Icon name="shield" size={12} /> + RBAC-scoped · no SSO + </Link> + )} + <SurfaceSwitcher canAudit={principal.roles.includes("auditor")} /> + <RoleSwitcher + principal={principal.name} + primary={principal.primary} + roles={principal.roles} + simulated={principal.simulated} + ssoSignedIn={principal.ssoSignedIn} + ssoAvailable={ssoConfigured()} + /> + <ThemeToggle /> + </div> + </div> + </header> + + <div className="mx-auto flex w-full max-w-7xl flex-1 gap-8 px-6 py-8"> + <ConsoleNav /> + <main id="main-content" className="min-w-0 flex-1"> + {children} + </main> + </div> + </div> + ); +} diff --git a/bridge/web/src/app/console/mcp-catalog-data.ts b/bridge/web/src/app/console/mcp-catalog-data.ts new file mode 100644 index 000000000..404d5ff3d --- /dev/null +++ b/bridge/web/src/app/console/mcp-catalog-data.ts @@ -0,0 +1,232 @@ +// kars Bridge — curated catalog of popular MCP servers, so an operator can add a +// connected service in one click instead of hand-authoring a McpServer spec. +// +// Each entry pre-fills the friendly add form (name + endpoint URL + allowed +// tools). URLs are the vendors' documented hosted MCP endpoints where one exists +// (the operator confirms/edits before creating); self-hosted reference servers +// carry a placeholder URL + a docs link so the operator points it at their own +// deployment. Nothing is created until the operator reviews and submits. + +export type McpHosting = "hosted" | "managed" | "external"; + +export interface McpCatalogEntry { + id: string; + name: string; // default McpServer name (editable) + label: string; // human title + category: "Dev & code" | "Productivity" | "Data" | "Web & search" | "Automation" | "Observability" | "Payments & CRM"; + icon: string; + description: string; + /** Documented hosted endpoint. Empty for managed/external entries. */ + url: string; + hosting: McpHosting; + /** Controller-owned workload recipe for `hosting: managed`. */ + managedPreset?: "playwright" | "everything"; + /** Whether the vendor endpoint requires OAuth (→ production mode). */ + oauth: boolean; + /** Sensible default allowed tools (`["*"]` = all, governed further by ToolPolicy). */ + allowedTools: string[]; + /** Router env var holding outbound bearer credentials for hosted servers. */ + bearerFromEnv?: string; + /** Link to the server's docs so the operator can confirm the endpoint/auth. */ + docs: string; +} + +export const MCP_CATALOG: McpCatalogEntry[] = [ + { + id: "github", + name: "github", + label: "GitHub", + category: "Dev & code", + icon: "🐙", + description: "Repositories, issues, pull requests, Actions, and code search across your GitHub org.", + url: "https://api.githubcopilot.com/mcp/", + hosting: "hosted", + oauth: true, + allowedTools: ["*"], + bearerFromEnv: "COPILOT_GITHUB_TOKEN", + docs: "https://github.com/github/github-mcp-server", + }, + { + id: "deepwiki", + name: "deepwiki", + label: "DeepWiki (public repository research)", + category: "Dev & code", + icon: "DW", + description: + "No-auth research and Q&A for public GitHub repositories through DeepWiki's official hosted Streamable HTTP endpoint.", + url: "https://mcp.deepwiki.com/mcp", + hosting: "hosted", + oauth: false, + allowedTools: ["read_wiki_structure", "read_wiki_contents", "ask_question"], + docs: "https://docs.devin.ai/work-with-devin/deepwiki-mcp", + }, + { + id: "sentry", + name: "sentry", + label: "Sentry", + category: "Observability", + icon: "🛑", + description: "Query issues, events, and stack traces from your Sentry projects.", + url: "https://mcp.sentry.dev/mcp", + hosting: "hosted", + oauth: true, + allowedTools: ["*"], + docs: "https://docs.sentry.io/product/sentry-mcp/", + }, + { + id: "linear", + name: "linear", + label: "Linear", + category: "Productivity", + icon: "📐", + description: "Read and manage Linear issues, projects, and cycles.", + url: "https://mcp.linear.app/sse", + hosting: "hosted", + oauth: true, + allowedTools: ["*"], + docs: "https://linear.app/docs/mcp", + }, + { + id: "notion", + name: "notion", + label: "Notion", + category: "Productivity", + icon: "📝", + description: "Search and read Notion pages and databases.", + url: "https://mcp.notion.com/mcp", + hosting: "hosted", + oauth: true, + allowedTools: ["*"], + docs: "https://developers.notion.com/docs/mcp", + }, + { + id: "stripe", + name: "stripe", + label: "Stripe", + category: "Payments & CRM", + icon: "💳", + description: "Query customers, charges, invoices, and products in Stripe (read-scoped by default).", + url: "https://mcp.stripe.com", + hosting: "hosted", + oauth: true, + allowedTools: ["*"], + docs: "https://docs.stripe.com/mcp", + }, + { + id: "atlassian", + name: "atlassian", + label: "Jira & Confluence", + category: "Productivity", + icon: "🔷", + description: "Atlassian Jira issues and Confluence pages.", + url: "https://mcp.atlassian.com/v1/sse", + hosting: "hosted", + oauth: true, + allowedTools: ["*"], + docs: "https://www.atlassian.com/platform/remote-mcp-server", + }, + { + id: "brave-search", + name: "brave-search", + label: "Brave Search", + category: "Web & search", + icon: "🦁", + description: "Web and local search via the Brave Search API. Needs a BRAVE_API_KEY credential.", + url: "", + hosting: "external", + oauth: false, + allowedTools: ["brave_web_search", "brave_local_search"], + docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search", + }, + { + id: "fetch", + name: "fetch", + label: "Fetch (web content)", + category: "Web & search", + icon: "🌐", + description: "Fetch a URL and return its content as markdown for the agent to read.", + url: "", + hosting: "external", + oauth: false, + allowedTools: ["fetch"], + docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + }, + { + id: "filesystem", + name: "filesystem", + label: "Filesystem", + category: "Dev & code", + icon: "📁", + description: "Read/write files within an allow-listed directory the server exposes.", + url: "", + hosting: "external", + oauth: false, + allowedTools: ["read_file", "list_directory", "search_files"], + docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + }, + { + id: "postgres", + name: "postgres", + label: "PostgreSQL", + category: "Data", + icon: "🐘", + description: "Run read-only SQL queries and inspect schema against a Postgres database.", + url: "", + hosting: "external", + oauth: false, + allowedTools: ["query"], + docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres", + }, + { + id: "slack", + name: "slack", + label: "Slack", + category: "Productivity", + icon: "💬", + description: "Read channels and post messages in a Slack workspace.", + url: "", + hosting: "external", + oauth: false, + allowedTools: ["list_channels", "post_message", "get_channel_history"], + docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack", + }, + { + id: "playwright", + name: "playwright", + label: "Playwright (browser)", + category: "Automation", + icon: "🎭", + description: "Drive a headless browser: navigate, click, extract, and screenshot pages.", + url: "", + hosting: "managed", + managedPreset: "playwright", + oauth: false, + allowedTools: ["*"], + docs: "https://github.com/microsoft/playwright-mcp", + }, + { + id: "everything", + name: "everything", + label: "MCP Everything (verification utility)", + category: "Automation", + icon: "🧰", + description: + "Deterministic reference server for protocol, sampling, resource, prompt, and utility-tool verification.", + url: "", + hosting: "managed", + managedPreset: "everything", + oauth: false, + allowedTools: ["*"], + docs: "https://github.com/modelcontextprotocol/servers/tree/main/src/everything", + }, +]; + +export const MCP_CATEGORIES = [ + "Dev & code", + "Productivity", + "Data", + "Web & search", + "Automation", + "Observability", + "Payments & CRM", +] as const; diff --git a/bridge/web/src/app/console/mcp-catalog.tsx b/bridge/web/src/app/console/mcp-catalog.tsx new file mode 100644 index 000000000..990b7f4a5 --- /dev/null +++ b/bridge/web/src/app/console/mcp-catalog.tsx @@ -0,0 +1,236 @@ +"use client"; + +// kars Bridge — MCP catalog picker. Managed entries create a typed McpServer +// preset that the controller materializes as a real Deployment + Service. +// Hosted/external entries register a real operator-reviewed URL. The UI never +// substitutes a fake internal endpoint or calls registration "deployment". + +import { useActionState, useMemo, useState } from "react"; +import { Icon } from "@/components/icon"; +import { applyGovernanceAction, type GovState } from "./governance-actions"; +import { MCP_CATALOG, MCP_CATEGORIES, type McpCatalogEntry } from "./mcp-catalog-data"; + +const init: GovState = { error: null, ok: null }; + +function AddForm({ entry, onDone }: { entry: McpCatalogEntry; onDone: () => void }) { + const [state, action, pending] = useActionState(applyGovernanceAction, init); + const [name, setName] = useState(entry.name); + const [url, setUrl] = useState(entry.url); + const [tools, setTools] = useState(entry.allowedTools.join(", ")); + // Default to dev mode so one-click connect always succeeds; production (OAuth) + // is opt-in and reveals a required issuer field (the CRD rejects + // productionMode without spec.oauth.issuer). + const [production, setProduction] = useState(false); + const [issuer, setIssuer] = useState(""); + const managed = entry.hosting === "managed"; + + // Build the McpServer spec from the friendly fields — the operator never sees + // JSON. `allowedTools` is comma-separated; "*" means all (governed by policy). + const spec = useMemo(() => { + const allowedTools = tools + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const s: Record<string, unknown> = { displayName: entry.label }; + if (managed && entry.managedPreset) { + s.managed = { preset: entry.managedPreset }; + } else { + s.url = url.trim(); + } + if (allowedTools.length) s.allowedTools = allowedTools; + if (!managed && production && issuer.trim()) { + s.productionMode = true; + s.oauth = { issuer: issuer.trim() }; + } + if (!managed && entry.bearerFromEnv) s.bearerFromEnv = entry.bearerFromEnv; + return JSON.stringify(s, null, 2); + }, [url, tools, production, issuer, entry.label, entry.managedPreset, entry.bearerFromEnv, managed]); + + const blocked = (!managed && !url.trim()) || (!managed && production && issuer.trim() === ""); + + if (state.ok) { + return ( + <div className="rounded-lg border border-ok/30 bg-ok/5 p-3 text-xs"> + <p className="font-medium text-ok"> + {entry.label} {managed ? "installation requested." : "registered."} + </p> + <p className="mt-0.5 text-foreground-muted">{state.ok}</p> + <button type="button" onClick={onDone} className="mt-2 rounded-md border border-border px-2 py-1 text-[11px] hover:bg-surface-muted">Done</button> + </div> + ); + } + + return ( + <form action={action} className="space-y-2 rounded-lg border border-border bg-surface-muted/60 p-3"> + <input type="hidden" name="kind" value="McpServer" /> + <input type="hidden" name="spec" value={spec} /> + <div className="flex items-center gap-2"> + <span className="text-base" aria-hidden>{entry.icon}</span> + <p className="text-xs font-semibold">{managed ? "Install" : "Register"} {entry.label}</p> + <a href={entry.docs} target="_blank" rel="noopener noreferrer" className="text-[11px] text-signal hover:underline">docs ↗</a> + </div> + <label className="block text-[11px] text-foreground-muted"> + Name + <input name="name" value={name} onChange={(e) => setName(e.target.value)} required + className="mt-0.5 w-full rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" /> + </label> + {managed ? ( + <div className="rounded-md border border-ok/30 bg-ok/5 px-2.5 py-2 text-[11px] text-foreground-muted"> + <span className="font-medium text-foreground">Managed on this cluster.</span>{" "} + The Kars controller deploys the reviewed <span className="font-mono">{entry.managedPreset}</span> preset, + creates its Service and NetworkPolicy, probes <span className="font-mono">initialize → tools/list</span>, + and only then marks it Ready. + </div> + ) : ( + <label className="block text-[11px] text-foreground-muted"> + Endpoint URL {entry.hosting === "external" && <span className="text-warning">· deploy it first, then enter its real URL</span>} + <input value={url} onChange={(e) => setUrl(e.target.value)} required + placeholder={entry.hosting === "external" ? "https://your-real-mcp.example/mcp" : undefined} + className="mt-0.5 w-full rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" /> + </label> + )} + <label className="block text-[11px] text-foreground-muted"> + Allowed tools (comma-separated · <span className="font-mono">*</span> = all, governed by a tool policy) + <input value={tools} onChange={(e) => setTools(e.target.value)} + className="mt-0.5 w-full rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" /> + </label> + {!managed && <label className="flex items-center gap-2 text-[11px] text-foreground-muted"> + <input type="checkbox" checked={production} onChange={(e) => setProduction(e.target.checked)} /> + Production — require OAuth-authenticated inbound calls {entry.oauth && <span className="text-foreground">(recommended for {entry.label})</span>} + </label>} + {!managed && production && ( + <label className="block text-[11px] text-foreground-muted"> + OAuth issuer URL <span className="text-warning">· required for production</span> + <input value={issuer} onChange={(e) => setIssuer(e.target.value)} placeholder="https://issuer.example.com" + className="mt-0.5 w-full rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" /> + </label> + )} + <p className="text-[11px] text-foreground-muted"> + {managed + ? "The endpoint is derived from the controller-owned Service; agents never see or choose it." + : production + ? "The router will reject calls not bearer-authenticated against this issuer." + : entry.bearerFromEnv + ? `Outbound authentication uses the router-only ${entry.bearerFromEnv} credential; the agent never sees it.` + : "The endpoint is registered as supplied; Kars does not pretend an external server was deployed."}{" "} + Applied through the Bridge with Server-Side Apply; the cluster validates it. + </p> + <div className="flex items-center gap-3"> + <button type="submit" disabled={pending || blocked} className="rounded-md bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg disabled:opacity-50"> + {pending ? (managed ? "Installing…" : "Registering…") : (managed ? "Install on cluster" : "Register endpoint")} + </button> + <button type="button" onClick={onDone} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + {blocked && <span className="text-[11px] text-foreground-muted"> + {!url.trim() ? "Enter the real endpoint URL." : "Add an issuer URL, or turn off production."} + </span>} + {state.error && <p className="text-xs text-danger">{state.error}</p>} + </div> + </form> + ); +} + +export function McpCatalog() { + const [open, setOpen] = useState(false); + const [cat, setCat] = useState<string>("All"); + const [q, setQ] = useState(""); + const [selected, setSelected] = useState<string | null>(null); + const [installed, setInstalled] = useState<Set<string>>(new Set()); + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + return MCP_CATALOG.filter((e) => { + if (cat !== "All" && e.category !== cat) return false; + if (!needle) return true; + return e.label.toLowerCase().includes(needle) || e.description.toLowerCase().includes(needle); + }); + }, [cat, q]); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-signal/40 bg-signal/10 px-3 py-1.5 text-xs font-medium text-signal hover:bg-signal/15" + > + <Icon name="bolt" size={14} className="inline mr-1" /> Add from catalog + </button> + ); + } + + return ( + <div className="mt-2 rounded-xl border border-border bg-surface p-4"> + <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> + <div> + <p className="text-sm font-semibold">Add an MCP service</p> + <p className="text-xs text-foreground-muted">Managed presets are deployed on this cluster; hosted/external servers register a real endpoint.</p> + </div> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground">Close</button> + </div> + + <div className="mb-3 flex flex-wrap items-center gap-2"> + <input + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="Search services…" + className="min-w-40 flex-1 rounded-lg border border-border bg-surface px-3 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <div className="flex flex-wrap gap-1"> + {["All", ...MCP_CATEGORIES].map((c) => ( + <button + key={c} + type="button" + onClick={() => setCat(c)} + className={`rounded-lg border px-2 py-1 text-[11px] font-medium ${cat === c ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground"}`} + > + {c} + </button> + ))} + </div> + </div> + + <ul className="grid gap-2 sm:grid-cols-2"> + {filtered.map((e) => ( + <li key={e.id} className="rounded-lg border border-border bg-surface-muted/40 p-3"> + <div className="flex items-start justify-between gap-2"> + <div className="flex items-start gap-2"> + <span className="text-lg leading-none" aria-hidden>{e.icon}</span> + <div className="min-w-0"> + <p className="text-sm font-medium">{e.label}</p> + <p className="mt-0.5 line-clamp-2 text-xs text-foreground-muted">{e.description}</p> + <div className="mt-1 flex items-center gap-2"> + <span className="rounded-full bg-surface-muted px-1.5 py-0.5 text-[10px] text-foreground-muted">{e.category}</span> + <span className="text-[10px] text-foreground-muted"> + {e.hosting === "managed" ? "managed · installs on cluster" : e.hosting === "hosted" ? "hosted endpoint" : "external · bring endpoint"} + </span> + </div> + </div> + </div> + {installed.has(e.id) ? ( + <span className="shrink-0 text-[11px] text-ok">✓ added</span> + ) : ( + <button + type="button" + onClick={() => setSelected(selected === e.id ? null : e.id)} + className="shrink-0 rounded-md border border-border px-2 py-1 text-[11px] font-medium hover:border-signal/40 hover:text-signal" + > + {selected === e.id ? "Cancel" : "Add"} + </button> + )} + </div> + {selected === e.id && ( + <div className="mt-3"> + <AddForm + entry={e} + onDone={() => { + setInstalled((prev) => new Set(prev).add(e.id)); + setSelected(null); + }} + /> + </div> + )} + </li> + ))} + </ul> + </div> + ); +} diff --git a/bridge/web/src/app/console/mcp-profile-actions.ts b/bridge/web/src/app/console/mcp-profile-actions.ts new file mode 100644 index 000000000..ddadf3644 --- /dev/null +++ b/bridge/web/src/app/console/mcp-profile-actions.ts @@ -0,0 +1,42 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, putMcpProfile, deleteMcpProfile } from "@/lib/bff"; + +export interface McpProfileState { + error: string | null; + ok: string | null; +} + +/// Create/update an operator MCP profile (a vetted bundle of McpServers). +export async function saveMcpProfileAction(_prev: McpProfileState, form: FormData): Promise<McpProfileState> { + const name = String(form.get("name") ?? "").trim(); + const summary = String(form.get("summary") ?? "").trim(); + const servers = form.getAll("servers").map((s) => String(s)); + if (!name) return { error: "Profile name is required.", ok: null }; + if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { + return { error: "Name must be lowercase alphanumeric + hyphens.", ok: null }; + } + if (servers.length === 0) return { error: "Select at least one server.", ok: null }; + try { + await putMcpProfile({ name, summary: summary || null, servers }); + revalidatePath("/console/configuration"); + revalidatePath("/console/capabilities"); + return { error: null, ok: `Profile “${name}” saved (${servers.length} server${servers.length === 1 ? "" : "s"}).` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "save failed", ok: null }; + } +} + +export async function deleteMcpProfileAction(_prev: McpProfileState, form: FormData): Promise<McpProfileState> { + const name = String(form.get("name") ?? "").trim(); + if (!name) return { error: "Name is required.", ok: null }; + try { + await deleteMcpProfile(name); + revalidatePath("/console/configuration"); + revalidatePath("/console/capabilities"); + return { error: null, ok: `Profile “${name}” removed.` }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "delete failed", ok: null }; + } +} diff --git a/bridge/web/src/app/console/mcp-profiles.tsx b/bridge/web/src/app/console/mcp-profiles.tsx new file mode 100644 index 000000000..3e0ccdfe8 --- /dev/null +++ b/bridge/web/src/app/console/mcp-profiles.tsx @@ -0,0 +1,103 @@ +"use client"; + +// Operator MCP profiles — curate named, vetted bundles of McpServers so users +// compose from approved groupings (e.g. "research", "devops") instead of +// wiring servers one by one. A profile may only reference McpServers that +// already exist on the cluster (the BFF enforces this), so a bundle can never +// smuggle in an unvetted server. + +import { useActionState, useState } from "react"; +import { Icon } from "@/components/icon"; +import { saveMcpProfileAction, deleteMcpProfileAction, type McpProfileState } from "./mcp-profile-actions"; +import type { McpProfileOption, RefOption } from "@/lib/types"; + +const init: McpProfileState = { error: null, ok: null }; + +export function McpProfiles({ profiles, servers }: { profiles: McpProfileOption[]; servers: RefOption[] }) { + const [creating, setCreating] = useState(false); + + return ( + <div className="space-y-3"> + {profiles.length === 0 ? ( + <p className="text-xs text-foreground-muted">No MCP profiles yet — bundle a vetted set of servers users can add as one.</p> + ) : ( + <ul className="space-y-2"> + {profiles.map((p) => ( + <ProfileRow key={p.name} profile={p} servers={servers} /> + ))} + </ul> + )} + {servers.length === 0 ? ( + <p className="text-[11px] text-foreground-muted">Register McpServers first — a profile bundles existing servers.</p> + ) : !creating ? ( + <button type="button" onClick={() => setCreating(true)} className="rounded-md border border-border px-2.5 py-1 text-[11px] font-medium hover:bg-surface-muted"> + + New profile + </button> + ) : ( + <ProfileForm servers={servers} onClose={() => setCreating(false)} /> + )} + </div> + ); +} + +function ProfileRow({ profile, servers }: { profile: McpProfileOption; servers: RefOption[] }) { + const [editing, setEditing] = useState(false); + const [delState, delAction, delPending] = useActionState(deleteMcpProfileAction, init); + if (editing) return <ProfileForm servers={servers} initial={profile} onClose={() => setEditing(false)} />; + return ( + <li className="rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5"> + <span aria-hidden><Icon name="box" size={14} /></span> + <span className="font-medium">{profile.name}</span> + <span className="text-[11px] text-foreground-muted">{profile.servers.length} server{profile.servers.length === 1 ? "" : "s"}</span> + </span> + <div className="flex items-center gap-2"> + <button type="button" onClick={() => setEditing(true)} className="text-[11px] text-foreground-muted hover:text-foreground">Edit</button> + <form action={delAction}> + <input type="hidden" name="name" value={profile.name} /> + <button type="submit" disabled={delPending} className="text-[11px] text-foreground-muted hover:text-danger disabled:opacity-50">Remove</button> + </form> + </div> + </div> + {profile.summary && <p className="mt-0.5 text-[11px] text-foreground-muted">{profile.summary}</p>} + <p className="mt-1 flex flex-wrap gap-1"> + {profile.servers.map((s) => <span key={s} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[10px]">{s}</span>)} + </p> + {delState.error && <span className="text-[11px] text-danger">{delState.error}</span>} + </li> + ); +} + +function ProfileForm({ servers, initial, onClose }: { servers: RefOption[]; initial?: McpProfileOption; onClose: () => void }) { + const [state, action, pending] = useActionState(saveMcpProfileAction, init); + const [sel, setSel] = useState<string[]>(initial?.servers ?? []); + if (state.ok) return <p className="text-[11px] text-signal">{state.ok} refreshing…</p>; + return ( + <form action={action} className="rounded-lg border border-border bg-surface-muted/30 p-3 space-y-2"> + <input type="hidden" name="name" value={initial?.name ?? ""} /> + {!initial && ( + <input name="name" required placeholder="profile name (e.g. research)" className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm" /> + )} + <input name="summary" defaultValue={initial?.summary ?? ""} placeholder="short description (optional)" className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs" /> + <div className="flex flex-wrap gap-1.5"> + {servers.map((s) => { + const on = sel.includes(s.name); + return ( + <label key={s.name} className={`cursor-pointer rounded-full border px-2 py-0.5 text-[11px] ${on ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted"}`}> + <input type="checkbox" name="servers" value={s.name} checked={on} onChange={(e) => setSel((c) => e.target.checked ? [...c, s.name] : c.filter((x) => x !== s.name))} className="hidden" /> + {on ? "✓ " : ""}{s.name} + </label> + ); + })} + </div> + <div className="flex items-center gap-2"> + <button type="submit" disabled={pending || sel.length === 0} className="rounded-md border border-signal/40 bg-signal/10 px-2.5 py-1 text-[11px] font-semibold text-signal disabled:opacity-50"> + {pending ? "Saving…" : "Save profile"} + </button> + <button type="button" onClick={onClose} className="text-[11px] text-foreground-muted hover:text-foreground">Cancel</button> + {state.error && <span className="text-[11px] text-danger">{state.error}</span>} + </div> + </form> + ); +} diff --git a/bridge/web/src/app/console/mcp-server-editor.tsx b/bridge/web/src/app/console/mcp-server-editor.tsx new file mode 100644 index 000000000..8a58a5ceb --- /dev/null +++ b/bridge/web/src/app/console/mcp-server-editor.tsx @@ -0,0 +1,299 @@ +"use client"; + +// kars Bridge Operator Console — visual McpServer editor. Replaces the raw- +// JSON textarea (AuthorResource) for CUSTOM server registration with real +// fields matching the actual CRD schema (controller/src/mcp_server.rs): url, +// displayName, allowedTools, allowedSandboxes (label selector), production +// mode + OAuth 2.1 (issuer/audience/resource), scopes, and bearerFromEnv +// (static-bearer outbound auth for e.g. GitHub Copilot's dev token). Submits +// through the SAME applyGovernanceAction the textarea used, so the backend +// contract is unchanged; only the authoring experience is visual. Popular +// known servers should still go through the one-click McpCatalog picker — +// this editor is for a custom/self-hosted server or fine-grained editing. +// An "Edit as JSON" escape hatch stays available for bundleRef (a signed OCI +// server bundle — mutually exclusive with the inline fields this form sets). + +import { useActionState, useState } from "react"; +import { applyGovernanceAction, type GovState } from "./governance-actions"; +import { Icon } from "@/components/icon"; + +const init: GovState = { error: null, ok: null }; + +interface FormShape { + url: string; + displayName: string; + allowedTools: string; // comma-separated; "*" = all + labels: { key: string; value: string }[]; + productionMode: boolean; + issuer: string; + audience: string; + resource: string; + scopes: string; // comma-separated + bearerFromEnv: string; +} + +function parseSpec(spec: Record<string, unknown> | undefined): FormShape { + const oauth = (spec?.oauth as Record<string, unknown>) ?? {}; + const allowedSandboxes = (spec?.allowedSandboxes as Record<string, unknown>) ?? {}; + const matchLabels = (allowedSandboxes.matchLabels as Record<string, string>) ?? {}; + return { + url: (spec?.url as string) ?? "", + displayName: (spec?.displayName as string) ?? "", + allowedTools: ((spec?.allowedTools as string[]) ?? []).join(", "), + labels: Object.entries(matchLabels).map(([key, value]) => ({ key, value })), + productionMode: spec?.productionMode === true, + issuer: (oauth.issuer as string) ?? "", + audience: (oauth.audience as string) ?? "", + resource: (oauth.resource as string) ?? "", + scopes: ((spec?.scopes as string[]) ?? []).join(", "), + bearerFromEnv: (spec?.bearerFromEnv as string) ?? "", + }; +} + +function buildSpec(f: FormShape): Record<string, unknown> { + const spec: Record<string, unknown> = {}; + if (f.url.trim()) spec.url = f.url.trim(); + if (f.displayName.trim()) spec.displayName = f.displayName.trim(); + + const allowedTools = f.allowedTools.split(",").map((t) => t.trim()).filter(Boolean); + if (allowedTools.length) spec.allowedTools = allowedTools; + + const matchLabels: Record<string, string> = {}; + for (const { key, value } of f.labels) { + if (key.trim()) matchLabels[key.trim()] = value.trim(); + } + if (Object.keys(matchLabels).length > 0) spec.allowedSandboxes = { matchLabels }; + + const scopes = f.scopes.split(",").map((s) => s.trim()).filter(Boolean); + if (scopes.length) spec.scopes = scopes; + + if (f.productionMode) { + spec.productionMode = true; + const oauth: Record<string, unknown> = { issuer: f.issuer.trim() }; + if (f.audience.trim()) oauth.audience = f.audience.trim(); + if (f.resource.trim()) oauth.resource = f.resource.trim(); + spec.oauth = oauth; + } + + if (f.bearerFromEnv.trim()) spec.bearerFromEnv = f.bearerFromEnv.trim(); + + return spec; +} + +export function McpServerEditor({ + initialName, + initialSpec, +}: { + initialName?: string; + initialSpec?: Record<string, unknown>; +}) { + const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState(applyGovernanceAction, init); + const editing = Boolean(initialName); + const managed = Boolean(initialSpec?.managed); + // Managed presets are a typed controller-owned shape; the external endpoint + // visual form must never silently replace `spec.managed` with an empty URL. + const [advanced, setAdvanced] = useState(managed); + const [f, setF] = useState<FormShape>(() => parseSpec(initialSpec)); + const [rawSpec, setRawSpec] = useState(() => JSON.stringify(initialSpec ?? {}, null, 2)); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + {editing ? `Edit ${initialName}` : "+ Add custom MCP server"} + </button> + ); + } + + const specJson = advanced ? rawSpec : JSON.stringify(buildSpec(f)); + const patch = (p: Partial<FormShape>) => setF((prev) => ({ ...prev, ...p })); + const productionBlocked = f.productionMode && !advanced && f.issuer.trim() === ""; + + return ( + <form action={action} className="mt-2 space-y-4 rounded-lg border border-border bg-surface-muted p-4"> + <input type="hidden" name="kind" value="McpServer" /> + <input type="hidden" name="spec" value={specJson} /> + <div className="flex items-center justify-between"> + <p className="text-xs font-medium">{editing ? `Edit MCP server` : "New custom MCP server"}</p> + <div className="flex items-center gap-3"> + {managed ? ( + <span className="text-xs text-foreground-muted">Managed preset JSON</span> + ) : ( + <button + type="button" + onClick={() => setAdvanced((v) => !v)} + className="text-xs text-foreground-muted hover:text-foreground" + > + {advanced ? "Use visual form" : "Edit as JSON"} + </button> + )} + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + </div> + </div> + + <input + name="name" + defaultValue={initialName} + readOnly={editing} + placeholder="name (lowercase-with-hyphens)" + required + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm read-only:opacity-70" + /> + + {advanced ? ( + <textarea + value={rawSpec} + onChange={(e) => setRawSpec(e.target.value)} + rows={14} + spellCheck={false} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed" + /> + ) : ( + <div className="space-y-4"> + <div className="grid gap-3 sm:grid-cols-2"> + <Field label="Server URL"> + <input + value={f.url} + onChange={(e) => patch({ url: e.target.value })} + placeholder="https://mcp.example.internal" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + <Field label="Display name"> + <input + value={f.displayName} + onChange={(e) => patch({ displayName: e.target.value })} + placeholder="e.g. Internal docs search" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + </Field> + </div> + + <Field label="Allowed tools (comma-separated — use * for all, governed by ToolPolicy)"> + <input + value={f.allowedTools} + onChange={(e) => patch({ allowedTools: e.target.value })} + placeholder="search, fetch_page" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="target" size={13} /> Allowed sandboxes</legend> + <p className="mb-1 text-xs text-foreground-muted">Label selector (AND). Empty = same-namespace only.</p> + {f.labels.map((row, i) => ( + <div key={i} className="mb-1.5 flex items-center gap-2"> + <input + value={row.key} + onChange={(e) => patch({ labels: f.labels.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)) })} + placeholder="kars.azure.com/team" + className="w-1/2 rounded-lg border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" + /> + <input + value={row.value} + onChange={(e) => patch({ labels: f.labels.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)) })} + placeholder="value" + className="w-1/2 rounded-lg border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" + /> + <button type="button" onClick={() => patch({ labels: f.labels.filter((_, j) => j !== i) })} className="shrink-0 text-foreground-muted hover:text-danger"> + <Icon name="cross" size={13} /> + </button> + </div> + ))} + <button + type="button" + onClick={() => patch({ labels: [...f.labels, { key: "", value: "" }] })} + className="text-xs text-signal hover:underline" + > + + Add label + </button> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="lock" size={13} /> Authentication</legend> + <label className="flex items-center gap-2 text-xs text-foreground-muted"> + <input type="checkbox" checked={f.productionMode} onChange={(e) => patch({ productionMode: e.target.checked })} /> + Production mode — require OAuth 2.1 bearer auth (dev-only if unchecked) + </label> + {f.productionMode && ( + <div className="mt-2 grid gap-3 sm:grid-cols-3"> + <Field label="OAuth issuer (required)"> + <input + value={f.issuer} + onChange={(e) => patch({ issuer: e.target.value })} + placeholder="https://issuer.example.com" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + <Field label="Audience (optional)"> + <input + value={f.audience} + onChange={(e) => patch({ audience: e.target.value })} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + <Field label="Resource indicator (optional)"> + <input + value={f.resource} + onChange={(e) => patch({ resource: e.target.value })} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + </div> + )} + <div className="mt-2 grid gap-3 sm:grid-cols-2"> + <Field label="OAuth scopes (comma-separated, optional)"> + <input + value={f.scopes} + onChange={(e) => patch({ scopes: e.target.value })} + placeholder="read:docs" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + <Field label="Outbound bearer from env var (optional)"> + <input + value={f.bearerFromEnv} + onChange={(e) => patch({ bearerFromEnv: e.target.value })} + placeholder="COPILOT_GITHUB_TOKEN" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </Field> + </div> + <p className="mt-1.5 text-[11px] text-foreground-muted"> + Reuses a pre-existing sandbox env var as an <span className="font-mono">Authorization</span> bearer + header on every outbound call to this server — no new credential mount. Unset/empty is skipped, non-fatal. + </p> + </fieldset> + </div> + )} + + <p className="text-[11px] text-foreground-muted"> + Applied with <span className="font-mono">kubectl apply</span> semantics (field manager <span className="font-mono">kars-bridge</span>). The cluster validates it — invalid specs are rejected with the API server's own message. + </p> + <label className="flex items-center gap-2 text-[11px] text-foreground-muted"> + <input type="checkbox" name="force" /> Force — take ownership of fields another manager owns (only on a conflict) + </label> + <div className="flex items-center gap-3"> + <button type="submit" disabled={pending || productionBlocked} className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Applying…" : editing ? "Save changes" : "Create MCP server"} + </button> + {productionBlocked && <p className="text-xs text-warning">Production mode needs an OAuth issuer.</p>} + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </div> + </form> + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <label className="block text-xs text-foreground-muted"> + {label} + <div className="mt-1">{children}</div> + </label> + ); +} diff --git a/bridge/web/src/app/console/operator-github-status.tsx b/bridge/web/src/app/console/operator-github-status.tsx new file mode 100644 index 000000000..488d3f3ae --- /dev/null +++ b/bridge/web/src/app/console/operator-github-status.tsx @@ -0,0 +1,111 @@ +"use client"; + +// kars Bridge Operator Console — GitHub App self-service setup. Operators +// configure the SHARED kars GitHub App once (the platform identity); the +// credentials are verified against the real GitHub API before being stored, +// and individual users then connect their own repos from their Workspace. +// This used to be a status-only display telling the operator to run +// `kubectl create secret` by hand — now it's a real form. + +import { useActionState, useState } from "react"; +import { + putGithubAppAction, + disconnectGithubAppAction, + type GithubAppState, +} from "./configuration/github-app-actions"; + +const init: GithubAppState = { error: null, ok: null }; + +export function OperatorGithubStatus({ + configured, + slug, +}: { + configured: boolean; + slug: string | null; +}) { + const [setupState, setupAction, settingUp] = useActionState(putGithubAppAction, init); + const [disconnectState, disconnectAction, disconnecting] = useActionState(disconnectGithubAppAction, init); + const [confirmingDisconnect, setConfirmingDisconnect] = useState(false); + + // Once either action reports success, the page revalidates server-side — + // show a brief confirmation until the fresh `configured` prop lands. + if (setupState.ok || disconnectState.ok) { + return <p className="text-xs font-medium text-ok">{setupState.ok ?? disconnectState.ok} Refreshing…</p>; + } + + if (configured) { + return ( + <div className="space-y-2"> + <p className="text-xs font-medium text-ok"> + ✓ Shared GitHub App configured{slug ? " — " : ""} + {slug && <span className="font-mono">{slug}</span>} + </p> + <p className="max-w-2xl text-xs text-foreground-muted"> + The platform identity is set. Users don’t configure repos here — each user connects + their own repositories from their <strong className="text-foreground">Workspace → Connections</strong>. + The App’s private key stays in the <code className="font-mono">kars-github-app</code> secret + and is mounted only to the inference router, never to an agent. + </p> + {confirmingDisconnect ? ( + <form action={disconnectAction} className="flex items-center gap-2"> + <span className="text-[11px] text-foreground-muted">Remove the App credential? Workspaces lose the ability to connect repos.</span> + <button type="submit" disabled={disconnecting} className="rounded-md border border-danger/40 px-2 py-1 text-[11px] font-medium text-danger hover:bg-danger/10 disabled:opacity-50"> + {disconnecting ? "Disconnecting…" : "Confirm disconnect"} + </button> + <button type="button" onClick={() => setConfirmingDisconnect(false)} className="text-[11px] text-foreground-muted hover:text-foreground"> + Cancel + </button> + </form> + ) : ( + <button + type="button" + onClick={() => setConfirmingDisconnect(true)} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:border-danger/40 hover:text-danger" + > + Disconnect + </button> + )} + {disconnectState.error && <p className="text-xs text-danger">{disconnectState.error}</p>} + </div> + ); + } + + return ( + <div className="space-y-3"> + <p className="text-xs font-medium text-warning">GitHub App not configured</p> + <p className="max-w-2xl text-xs text-foreground-muted"> + Register one shared kars GitHub App on GitHub (Settings → Developer settings → GitHub Apps → New + GitHub App, permissions: Contents + Pull requests: read/write; Metadata + Checks + Commit + statuses + Dependabot alerts + Code scanning alerts + Secret scanning alerts: read, no + webhook needed), then paste its + App ID and the private key <code className="font-mono">.pem</code> file it generated below. + Verified against GitHub before saving; once set, users self-serve from their Workspace → Connections. + </p> + <form action={setupAction} className="max-w-2xl space-y-2"> + <input + name="app_id" + placeholder="App ID (numeric, e.g. 123456)" + required + inputMode="numeric" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-signal" + /> + <textarea + name="private_key" + placeholder={"-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"} + required + rows={6} + spellCheck={false} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed outline-none focus:border-signal" + /> + <button + type="submit" + disabled={settingUp} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50" + > + {settingUp ? "Verifying with GitHub…" : "Verify & connect"} + </button> + {setupState.error && <p className="text-xs text-danger">{setupState.error}</p>} + </form> + </div> + ); +} diff --git a/bridge/web/src/app/console/page.tsx b/bridge/web/src/app/console/page.tsx new file mode 100644 index 000000000..3db7fb205 --- /dev/null +++ b/bridge/web/src/app/console/page.tsx @@ -0,0 +1,224 @@ +// kars Bridge Operator Console — Fleet Health (shift-triage home). +// Sandbox phase counts, the degraded list, and substrate scope. Operator +// truth: zeros are legitimate and useful here (operators count resources). + +import Link from "next/link"; +import { HonestState } from "@/components/honest-state"; +import { Icon } from "@/components/icon"; +import { listSandboxes, getInsights, listEgress, getOrchestrator, listSreActions } from "@/lib/bff"; +import { headlampUrl } from "@/lib/config"; +import type { Sandbox, Insights, EgressApproval, Orchestrator, SreAction } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function phaseClass(phase: string | null): string { + switch (phase) { + case "Running": + case "Ready": + return "text-ok"; + case "Degraded": + case "Failed": + return "text-danger"; + case "Pending": + case "Launching": + return "text-warning"; + default: + return "text-foreground-muted"; + } +} + +export default async function FleetHealth() { + let sandboxes: Sandbox[] = []; + let insights: Insights | null = null; + let egress: EgressApproval[] = []; + let orchestrator: Orchestrator | null = null; + let error = false; + try { + [sandboxes, insights, egress] = await Promise.all([ + listSandboxes(), + getInsights().catch(() => null), + listEgress().catch(() => [] as EgressApproval[]), + ]); + } catch { + error = true; + } + orchestrator = await getOrchestrator().catch(() => null); + const sreActions: SreAction[] = await listSreActions().catch(() => [] as SreAction[]); + + const counts = sandboxes.reduce<Record<string, number>>((acc, s) => { + const p = s.phase ?? "Unknown"; + acc[p] = (acc[p] ?? 0) + 1; + return acc; + }, {}); + const degraded = sandboxes.filter((s) => s.phase === "Degraded" || s.phase === "Failed"); + const egressPending = egress.filter((e) => e.phase === "Pending").length; + const headlamp = headlampUrl(); + + return ( + <div className="space-y-6"> + <div> + <h1 className="text-xl font-semibold tracking-tight">Fleet Health</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Substrate triage — sandbox phases, degraded resources, and governance throughput. + </p> + </div> + + {error ? ( + <HonestState variant="not_wired" title="Cluster unreachable" detail="The Bridge backend can't reach the Kubernetes API right now." /> + ) : ( + <> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> + <Metric label="Sandboxes" value={sandboxes.length} mono /> + <Metric label="Running" value={(counts["Running"] ?? 0) + (counts["Ready"] ?? 0)} mono ok /> + <Metric label="Degraded" value={degraded.length} mono danger={degraded.length > 0} href="/console/fleet" /> + <Metric label="Egress grants" value={egress.length} mono warn={egressPending > 0} href="/console/approvals" hint={egressPending > 0 ? `${egressPending} pending` : undefined} /> + <Metric label="Amp. blocks" value={insights?.amplification_rejections ?? 0} mono warn={(insights?.amplification_rejections ?? 0) > 0} /> + <Metric label="Receipts issued" value={insights?.receipts_issued ?? 0} mono href="/console/audit" /> + </div> + + <section className="rounded-lg border border-border bg-surface"> + <div className="flex items-center justify-between border-b border-border px-5 py-3"> + <h2 className="text-sm font-semibold">Degraded / failed</h2> + <Link href="/console/fleet" className="text-xs text-signal hover:underline">All sandboxes →</Link> + </div> + {degraded.length === 0 ? ( + <p className="px-5 py-6 text-sm text-foreground-muted"> + Cluster is healthy — no degraded or failed sandboxes. + </p> + ) : ( + <ul className="divide-y divide-border"> + {degraded.map((s) => ( + <li key={`${s.namespace}/${s.name}`} className="px-5 py-3"> + <div className="flex items-center justify-between gap-3"> + <span className="font-mono text-sm">{s.namespace}/{s.name}</span> + <span className={`text-xs font-medium ${phaseClass(s.phase)}`}>{s.phase}</span> + </div> + {s.message && <p className="mt-0.5 text-xs text-foreground-muted">{s.message}</p>} + </li> + ))} + </ul> + )} + </section> + + <section className="rounded-lg border border-border bg-surface px-5 py-4"> + <h2 className="text-sm font-semibold">Phase breakdown</h2> + <div className="mt-3 flex flex-wrap gap-x-6 gap-y-2"> + {Object.entries(counts).length === 0 ? ( + <span className="text-sm text-foreground-muted">No sandboxes running — substrate idle.</span> + ) : ( + Object.entries(counts).map(([phase, n]) => ( + <span key={phase} className="inline-flex items-baseline gap-1.5 text-sm"> + <span className={`font-semibold tabular-nums ${phaseClass(phase)}`}>{n}</span> + <span className="text-foreground-muted">{phase}</span> + </span> + )) + )} + </div> + </section> + + {/* Orchestrator health — the compose engine + which inference path it + runs on, with the failover recommendation under load. */} + {orchestrator && ( + <section className={`rounded-lg border p-5 ${orchestrator.recommend_direct ? "border-amber-500/30 bg-amber-500/[0.04]" : "border-border bg-surface"}`}> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="flex items-center gap-2 text-sm font-semibold"> + <Icon name="compass" size={14} /> Orchestrator (compose engine) + </h2> + <p className="mt-1 max-w-2xl text-xs text-foreground-muted">{orchestrator.note}</p> + </div> + <span className={`shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${ + orchestrator.mode === "direct" ? "bg-emerald-500/15 text-emerald-600" + : orchestrator.mode === "sandbox" ? "bg-signal/15 text-signal" + : "bg-rose-500/15 text-rose-600"}`}> + {orchestrator.mode === "direct" ? "Direct endpoint" : orchestrator.mode === "sandbox" ? "Sandbox router" : "No path"} + </span> + </div> + <dl className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4 text-xs"> + <div><dt className="text-foreground-muted">Direct endpoint</dt><dd className="font-medium">{orchestrator.direct_configured ? "configured" : "not set"}</dd></div> + <div><dt className="text-foreground-muted">Orchestrator sandbox</dt><dd className="font-medium">{orchestrator.sandbox_present ? (orchestrator.sandbox_phase ?? "present") : "absent"}</dd></div> + <div><dt className="text-foreground-muted">Pod</dt><dd className="font-medium">{orchestrator.sandbox_ready ?? "—"}{orchestrator.sandbox_waiting_reason ? ` · ${orchestrator.sandbox_waiting_reason}` : ""}{orchestrator.sandbox_restarts ? ` · ${orchestrator.sandbox_restarts}↻` : ""}</dd></div> + <div title="Running sandbox inference-routers the compose orchestrator can route through when no direct endpoint is set — each is a fallback path, so more than one means redundancy."><dt className="text-foreground-muted">Router fallbacks</dt><dd className="font-medium">{orchestrator.router_candidates}{orchestrator.router_candidates === 1 ? " path" : " paths"}</dd></div> + </dl> + </section> + )} + + {/* Cluster tools — deep-links to the K8s-native surfaces an operator + reaches for, without leaving the console guessing what exists. */} + <section className="rounded-lg border border-border bg-surface px-5 py-4"> + <h2 className="text-sm font-semibold">Cluster tools</h2> + <div className="mt-3 grid gap-3 sm:grid-cols-3"> + <Link href="/console/troubleshooting" className="rounded-lg border border-border bg-surface p-3 transition hover:border-signal/40"> + <p className="flex items-center gap-1.5 text-sm font-medium"><Icon name="stethoscope" size={14} /> Diagnostics</p> + <p className="mt-0.5 text-xs text-foreground-muted">Live “what’s failing right now” scan — image pulls, crash loops, degraded sandboxes.</p> + </Link> + {headlamp ? ( + <a href={headlamp} target="_blank" rel="noreferrer" className="rounded-lg border border-border bg-surface p-3 transition hover:border-signal/40"> + <p className="flex items-center gap-1.5 text-sm font-medium"><Icon name="eye" size={14} /> Headlamp ↗</p> + <p className="mt-0.5 text-xs text-foreground-muted">Open the Kubernetes dashboard for deep pod/node/event inspection.</p> + </a> + ) : ( + <div className="rounded-lg border border-dashed border-border bg-surface-muted/30 p-3"> + <p className="flex items-center gap-1.5 text-sm font-medium text-foreground-muted"><Icon name="eye" size={14} /> Headlamp — not linked</p> + <p className="mt-0.5 text-xs text-foreground-muted" title="Operators: set the BRIDGE_HEADLAMP_URL environment variable to deep-link the Kubernetes dashboard here.">The Kubernetes dashboard isn’t linked in this environment.</p> + </div> + )} + <Link href="/console/sre-actions" className="rounded-lg border border-border bg-surface p-3 transition hover:border-signal/40"> + <p className="flex items-center gap-1.5 text-sm font-medium"> + <Icon name="wrench" className="h-3.5 w-3.5" /> kars-SRE + {sreActions.filter((a) => a.actionable).length > 0 && ( + <span className="ml-auto rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] font-semibold text-warning"> + {sreActions.filter((a) => a.actionable).length} pending + </span> + )} + </p> + <p className="mt-0.5 text-xs text-foreground-muted"> + {sreActions.length > 0 + ? `${sreActions.length} remediation proposal${sreActions.length === 1 ? "" : "s"} from the SRE agent — review & decide.` + : "No remediation proposals yet. If the kars-sre agent is installed, its proposals appear here for approval."} + </p> + </Link> + </div> + </section> + </> + )} + </div> + ); +} + +function Metric({ + label, + value, + mono, + ok, + danger, + warn, + href, + hint, +}: { + label: string; + value: number; + mono?: boolean; + ok?: boolean; + danger?: boolean; + warn?: boolean; + href?: string; + hint?: string; +}) { + const body = ( + <div className="rounded-lg border border-border bg-surface p-4 transition hover:border-signal/40"> + <p + className={[ + "text-2xl font-semibold", + mono ? "font-mono" : "", + danger ? "text-danger" : warn ? "text-warning" : ok ? "text-ok" : "text-foreground", + ].join(" ")} + > + {value} + </p> + <p className="mt-0.5 text-xs text-foreground-muted">{label}</p> + {hint && <p className="mt-0.5 text-[11px] font-medium text-warning">{hint}</p>} + </div> + ); + return href ? <Link href={href} className="block">{body}</Link> : body; +} diff --git a/bridge/web/src/app/console/policies/page.tsx b/bridge/web/src/app/console/policies/page.tsx new file mode 100644 index 000000000..36d9c702c --- /dev/null +++ b/bridge/web/src/app/console/policies/page.tsx @@ -0,0 +1,240 @@ +// kars Bridge Operator Console — Policies. Inventory of the governance config: +// connected MCP servers, tool policies, inference policies, and temporary +// egress approvals. All real reads via the operator API. + +import { PageHeader, Section, Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { Icon } from "@/components/icon"; +import { AuthorResource } from "../author-resource"; +import { InferencePolicyEditor } from "../inference-policy-editor"; +import { DeleteResource } from "../delete-resource"; +import { McpCatalog } from "../mcp-catalog"; +import { PolicyBuilder } from "../policy-builder"; +import { InferenceBudgets, InferenceBudgetEdit } from "@/components/inference-budgets"; +import { RetentionPolicyPanel } from "@/components/retention-policy"; +import { canAdminister } from "@/lib/session"; +import { + listMcpServers, + listToolPolicies, + listInferencePolicies, + listEgress, + getOptions, +} from "@/lib/bff"; +import type { + McpServer, + ToolPolicy, + InferencePolicy, + EgressApproval, + ModelOption, +} from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** Settle a list fetch into data + an error flag, so a backend/RBAC/cluster + * failure renders as an explicit "couldn't load" state rather than being + * silently indistinguishable from a legitimately empty list. */ +async function settle<T>(p: Promise<T[]>): Promise<{ data: T[]; error: boolean }> { + try { + return { data: await p, error: false }; + } catch { + return { data: [], error: true }; + } +} + +function countBadge(n: number) { + return <Badge tone="muted">{n}</Badge>; +} + +export default async function PoliciesPage() { + const isAdmin = await canAdminister(); + const [mcpR, toolsR, inferenceR, egressR, options] = await Promise.all([ + settle<McpServer>(listMcpServers()), + settle<ToolPolicy>(listToolPolicies()), + settle<InferencePolicy>(listInferencePolicies()), + settle<EgressApproval>(listEgress()), + getOptions().catch(() => null), + ]); + const mcp = mcpR.data; + const tools = toolsR.data; + const inference = inferenceR.data; + const egress = egressR.data; + const models: ModelOption[] = options?.models ?? []; + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="Policies" + lead="The governance configuration agents run under — the services they may reach, the tools they may call, their inference limits, and any temporary egress widenings." + /> + + <Section + title="Connected services (MCP)" + action={<div className="flex items-center gap-2">{countBadge(mcp.length)}<AuthorResource kind="McpServer" /></div>} + > + <div className="mb-3"><McpCatalog /></div> + {mcpR.error ? ( + <HonestState variant="not_wired" compact title="Couldn't load MCP servers" detail="The operator API for MCP servers is unreachable — this isn't the same as none being registered." /> + ) : mcp.length === 0 ? ( + <HonestState variant="empty" compact title="No MCP servers registered" detail="Pick one from the catalog above, or register a custom MCP server." /> + ) : ( + <ul className="space-y-2"> + {mcp.map((m) => ( + <li key={`${m.namespace}/${m.name}`} className="rounded-lg border border-border bg-surface px-4 py-3"> + <div className="flex items-center justify-between gap-3"> + <span className="flex items-center gap-1.5"> + <span aria-hidden><Icon name="plug" size={14} /></span> + <span className="font-mono text-sm font-medium">{m.name}</span> + </span> + <div className="flex items-center gap-2"> + {m.production != null && <Badge tone={m.production ? "info" : "muted"}>{m.production ? "production" : "dev"}</Badge>} + {m.phase && <span className="text-xs text-foreground-muted">{m.phase}</span>} + </div> + </div> + {m.url && <p className="mt-1 font-mono text-xs text-foreground-muted">{m.url}</p>} + {m.allowed_tools.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1"> + {m.allowed_tools.map((t) => ( + <span key={t} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground-muted">{t}</span> + ))} + </div> + )} + <div className="mt-2 flex items-center gap-3"> + <AuthorResource kind="McpServer" initialName={m.name} initialSpec={JSON.stringify(m.spec, null, 2)} /> + <DeleteResource kind="McpServer" name={m.name} label="MCP server" /> + </div> + </li> + ))} + </ul> + )} + </Section> + + <Section + title="Tool policies" + action={<div className="flex items-center gap-2">{countBadge(tools.length)}<AuthorResource kind="ToolPolicy" /></div>} + > + <div className="mb-3"><PolicyBuilder /></div> + {toolsR.error ? ( + <HonestState variant="not_wired" compact title="Couldn't load tool policies" detail="The operator API for tool policies is unreachable — not the same as none existing." /> + ) : tools.length === 0 ? ( + <HonestState variant="empty" compact title="No tool policies" detail="Tool policies bound which tools an agent may call." /> + ) : ( + <ul className="space-y-2"> + {tools.map((t) => ( + <li key={`${t.namespace}/${t.name}`} className="flex items-start justify-between gap-3 rounded-lg border border-border bg-surface px-4 py-3"> + <div className="min-w-0"> + <span className="font-mono text-sm font-medium">{t.name}</span> + <span className="ml-2 font-mono text-xs text-foreground-muted">{t.namespace}</span> + <div className="mt-1.5 flex flex-wrap items-center gap-1.5"> + {t.applies_to && <Badge tone="muted">{t.applies_to}</Badge>} + {t.has_governance_profile && <Badge tone="info">AGT governance profile</Badge>} + {t.allowed.map((a) => ( + <span key={a} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground-muted">{a}</span> + ))} + </div> + <div className="mt-2 flex items-center gap-3"> + <AuthorResource kind="ToolPolicy" initialName={t.name} initialSpec={JSON.stringify(t.spec, null, 2)} /> + <DeleteResource kind="ToolPolicy" name={t.name} label="tool policy" /> + </div> + </div> + {t.phase && <span className="shrink-0 text-xs text-foreground-muted">{t.phase}</span>} + </li> + ))} + </ul> + )} + </Section> + + <Section title="Inference budgets" subtitle="A hierarchy over inference token spend — cluster and per-workspace caps (editable), plus the per-sandbox policies. Passive alerts; buffer allows headroom then blocks; strict blocks at the limit." action={countBadge(inference.length)}> + <InferenceBudgets isAdmin={isAdmin} /> + <div className="mt-5"> + <div className="flex items-center justify-between"> + <h4 className="text-xs font-semibold text-foreground-muted">Per-sandbox policies</h4> + <InferencePolicyEditor models={models} /> + </div> + <p className="mt-1 text-[11px] text-foreground-muted"> + Most are controller-generated from each mission’s budget. You can also author a + standalone policy (a selector + token budget + content-safety floor), edit a policy’s + daily budget in place, or remove an authored one. + </p> + {inferenceR.error ? ( + <HonestState variant="not_wired" compact title="Couldn't load inference policies" detail="The operator API for inference policies is unreachable." /> + ) : inference.length === 0 ? ( + <p className="mt-2 text-[11px] text-foreground-muted">No per-sandbox policies yet.</p> + ) : ( + <div className="mt-2 overflow-hidden rounded-lg border border-border"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b border-border bg-surface-muted/40 text-left text-xs text-foreground-muted"> + <th className="px-4 py-2 font-medium">Name</th> + <th className="px-3 py-2 font-medium">Sandbox</th> + <th className="px-3 py-2 font-medium">Daily token budget</th> + <th className="px-3 py-2 font-medium">Content safety</th> + <th className="px-4 py-2 font-medium">Phase</th> + <th className="px-4 py-2 font-medium"></th> + </tr> + </thead> + <tbody> + {inference.map((p) => ( + <tr key={`${p.namespace}/${p.name}`} className="border-b border-border last:border-0"> + <td className="px-4 py-2 font-mono text-xs">{p.name}</td> + <td className="px-3 py-2 font-mono text-xs text-foreground-muted">{p.sandbox ?? "—"}</td> + <td className="px-3 py-2 tabular-nums"> + <InferenceBudgetEdit name={p.name} current={p.daily_token_budget ?? null} /> + </td> + <td className="px-3 py-2">{p.content_safety ? <Badge tone="ok">on</Badge> : <span className="text-foreground-muted">—</span>}</td> + <td className="px-4 py-2 text-xs text-foreground-muted">{p.phase ?? "—"}</td> + <td className="px-4 py-2 text-right"> + <div className="flex items-center justify-end gap-2"> + <InferencePolicyEditor initialName={p.name} initialSpec={p.spec} models={models} /> + <DeleteResource kind="InferencePolicy" name={p.name} label="inference policy" /> + </div> + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + </Section> + + <Section title="Retention" subtitle="Auto-cleanup for delivered missions and team-run records — Kars keeps them by design for audit until this TTL elapses."> + <RetentionPolicyPanel isAdmin={isAdmin} /> + </Section> + + <Section title="Temporary egress grants" action={countBadge(egress.length)}> + {egressR.error ? ( + <HonestState variant="not_wired" compact title="Couldn't load egress grants" detail="The operator API for egress approvals is unreachable." /> + ) : egress.length === 0 ? ( + <HonestState + variant="empty" + compact + title="No temporary egress grants" + detail="Sandboxes run on their signed baseline allowlist. Temporary widenings appear here." + /> + ) : ( + <ul className="space-y-2"> + {egress.map((e) => ( + <li key={`${e.namespace}/${e.name}`} className="rounded-lg border border-border bg-surface px-4 py-3"> + <div className="flex items-center justify-between gap-3"> + <span className="font-mono text-sm font-medium">{e.sandbox ?? e.name}</span> + <span className="text-xs text-foreground-muted">{e.phase ?? "—"}</span> + </div> + {e.hosts.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1"> + {e.hosts.map((h) => ( + <span key={h} className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground-muted">{h}</span> + ))} + </div> + )} + {e.reason && <p className="mt-1.5 text-xs text-foreground-muted">{e.reason}</p>} + {e.expires_at && <p className="mt-0.5 text-xs text-foreground-muted">expires {new Date(e.expires_at).toLocaleString()}</p>} + <div className="mt-2"><DeleteResource kind="EgressApproval" name={e.name} label="egress grant" verb="Revoke" /></div> + </li> + ))} + </ul> + )} + </Section> + </div> + ); +} diff --git a/bridge/web/src/app/console/policy-builder-data.ts b/bridge/web/src/app/console/policy-builder-data.ts new file mode 100644 index 000000000..bcd70b6ef --- /dev/null +++ b/bridge/web/src/app/console/policy-builder-data.ts @@ -0,0 +1,178 @@ +// kars Bridge — structured AGT tool-policy builder. Instead of hand-writing the +// agentmesh PolicyEngine YAML (the "embarrassing" part), the operator toggles +// capability presets and adds optional custom allow/deny rules; this module +// generates a valid ToolPolicy spec (appliesTo + agtProfile.inline YAML). +// +// The AGT DSL: `policies:` is a priority-ordered list of rules; each rule has a +// type (capability), an allow (`allowed_actions`) or deny (`denied_actions`) +// list of glob action patterns, and a priority (higher = evaluated first, first +// match wins). Action format examples: shell:<cmd>, inference:*, tool:<name>, +// foundry:<tool>:*, spawn:create:*, mesh:send:*. + +export interface PolicyPreset { + id: string; + label: string; + description: string; + effect: "allow" | "deny"; + actions: string[]; + priority: number; + /** On by default in a new policy (safe, common baseline). */ + defaultOn: boolean; +} + +export const POLICY_PRESETS: PolicyPreset[] = [ + { + id: "deny-dangerous-shell", + label: "Block dangerous shell", + description: "Deny destructive/networking shell commands (rm, mkfs, dd, nmap, nc, shutdown…).", + effect: "deny", + priority: 100, + defaultOn: true, + actions: [ + "shell:rm", "shell:mkfs", "shell:shutdown", "shell:reboot", "shell:dd", + "shell:nmap", "shell:nc", "shell:netcat", "shell:socat", "shell:nsenter", + "shell:unshare", "shell:chroot", + ], + }, + { + id: "safe-shell", + label: "Safe shell commands", + description: "Allow common read/dev shell tools (ls, cat, grep, git, python, node, npm, curl, jq…).", + effect: "allow", + priority: 90, + defaultOn: true, + actions: [ + "shell:ls", "shell:cat", "shell:grep", "shell:find", "shell:echo", "shell:head", + "shell:tail", "shell:wc", "shell:sort", "shell:uniq", "shell:diff", "shell:python", + "shell:python3", "shell:pip", "shell:node", "shell:npm", "shell:git", "shell:curl", + "shell:jq", "shell:sed", "shell:awk", + ], + }, + { + id: "inference", + label: "Inference & image generation", + description: "Allow the agent to call models and image generation.", + effect: "allow", + priority: 80, + defaultOn: true, + actions: ["inference:*", "image_generation:*"], + }, + { + id: "foundry-tools", + label: "Foundry tools", + description: "Allow Azure AI Foundry tools: web search, code execute, file search, memory, evaluations…", + effect: "allow", + priority: 80, + defaultOn: true, + actions: [ + "foundry:web_search:*", "foundry:code_execute:*", "foundry:file_search:*", + "foundry:memory:*", "foundry:image_generation:*", "foundry:conversations:*", + "foundry:evaluations:*", "foundry:agents:*", + ], + }, + { + id: "mcp-tools", + label: "MCP / connected-service tools", + description: "Allow tool calls to connected MCP servers (scoped further by each server's allow-list).", + effect: "allow", + priority: 70, + defaultOn: true, + actions: ["tool:*"], + }, + { + id: "spawn", + label: "Spawn sub-agents", + description: "Allow the agent to spawn governed sub-agents (each attenuated below the parent).", + effect: "allow", + priority: 70, + defaultOn: false, + actions: ["spawn:create:*"], + }, + { + id: "mesh", + label: "Mesh messaging", + description: "Allow encrypted inter-agent messages over the mesh (send + receive).", + effect: "allow", + priority: 70, + defaultOn: false, + actions: ["mesh:send:*", "mesh:receive"], + }, +]; + +export interface CustomRule { + id: number; + name: string; + effect: "allow" | "deny"; + actions: string; // comma/space separated + priority: number; +} + +function yamlList(actions: string[]): string { + return actions.map((a) => ` - "${a}"`).join("\n"); +} + +/** Generate the agentmesh PolicyEngine YAML from selected presets + custom rules. */ +export function generateAgtProfile( + agent: string, + presetIds: Set<string>, + custom: CustomRule[], +): string { + const rules: string[] = []; + for (const p of POLICY_PRESETS) { + if (!presetIds.has(p.id)) continue; + const key = p.effect === "deny" ? "denied_actions" : "allowed_actions"; + rules.push( + ` - name: ${p.id}\n type: capability\n ${key}:\n${yamlList(p.actions)}\n priority: ${p.priority}`, + ); + } + for (const c of custom) { + const actions = c.actions.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean); + if (!c.name.trim() || actions.length === 0) continue; + const key = c.effect === "deny" ? "denied_actions" : "allowed_actions"; + const safeName = c.name.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-"); + rules.push( + ` - name: ${safeName}\n type: capability\n ${key}:\n${yamlList(actions)}\n priority: ${c.priority}`, + ); + } + return [ + "# Generated by the kars Bridge policy builder — agentmesh PolicyEngine format.", + "# Rules are evaluated by priority (higher first); first match wins.", + 'version: "1.0"', + `agent: ${agent || "custom-policy"}`, + "policies:", + rules.join("\n"), + "", + ].join("\n"); +} + +/** Build the full ToolPolicy spec object from the builder inputs. */ +export function buildToolPolicySpec(opts: { + agent: string; + sandboxLabelKey: string; + sandboxLabelValue: string; + toolGlob: string; + presetIds: Set<string>; + custom: CustomRule[]; + approvalMode: "none" | "always" | "aboveThreshold"; + approvalThreshold: string; +}): Record<string, unknown> { + const appliesTo: Record<string, unknown> = {}; + if (opts.sandboxLabelKey.trim()) { + appliesTo.sandboxMatchLabels = { [opts.sandboxLabelKey.trim()]: opts.sandboxLabelValue.trim() || "true" }; + } + if (opts.toolGlob.trim() && opts.toolGlob.trim() !== "*") { + appliesTo.tool = opts.toolGlob.trim(); + } + const spec: Record<string, unknown> = { + appliesTo, + agtProfile: { inline: generateAgtProfile(opts.agent, opts.presetIds, opts.custom) }, + }; + if (opts.approvalMode !== "none") { + const approval: Record<string, unknown> = { mode: opts.approvalMode }; + if (opts.approvalMode === "aboveThreshold" && opts.approvalThreshold.trim()) { + approval.threshold = opts.approvalThreshold.trim(); + } + spec.approval = approval; + } + return spec; +} diff --git a/bridge/web/src/app/console/policy-builder.tsx b/bridge/web/src/app/console/policy-builder.tsx new file mode 100644 index 000000000..c3375982e --- /dev/null +++ b/bridge/web/src/app/console/policy-builder.tsx @@ -0,0 +1,218 @@ +"use client"; + +// kars Bridge — structured AGT tool-policy builder. Toggle capability presets, +// add custom allow/deny rules, set approval — and it generates a valid ToolPolicy +// (no hand-written YAML). A collapsible live preview shows the generated policy +// for the curious; submit goes through the same SSA path as manual authoring. + +import { useActionState, useMemo, useState } from "react"; +import { Icon } from "@/components/icon"; +import { applyGovernanceAction, type GovState } from "./governance-actions"; +import { + POLICY_PRESETS, + buildToolPolicySpec, + generateAgtProfile, + type CustomRule, +} from "./policy-builder-data"; + +const init: GovState = { error: null, ok: null }; +let RID = 1; + +export function PolicyBuilder() { + const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState(applyGovernanceAction, init); + + const [name, setName] = useState(""); + const [labelKey, setLabelKey] = useState("kars.azure.com/system-default"); + const [labelValue, setLabelValue] = useState("true"); + const [toolGlob, setToolGlob] = useState("*"); + const [presets, setPresets] = useState<Set<string>>( + () => new Set(POLICY_PRESETS.filter((p) => p.defaultOn).map((p) => p.id)), + ); + const [custom, setCustom] = useState<CustomRule[]>([]); + const [approvalMode, setApprovalMode] = useState<"none" | "always" | "aboveThreshold">("none"); + const [approvalThreshold, setApprovalThreshold] = useState("USD 25.00"); + const [showYaml, setShowYaml] = useState(false); + + const spec = useMemo( + () => + JSON.stringify( + buildToolPolicySpec({ + agent: name, + sandboxLabelKey: labelKey, + sandboxLabelValue: labelValue, + toolGlob, + presetIds: presets, + custom, + approvalMode, + approvalThreshold, + }), + null, + 2, + ), + [name, labelKey, labelValue, toolGlob, presets, custom, approvalMode, approvalThreshold], + ); + const yaml = useMemo(() => generateAgtProfile(name, presets, custom), [name, presets, custom]); + + const togglePreset = (id: string) => + setPresets((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-signal/40 bg-signal/10 px-3 py-1.5 text-xs font-medium text-signal hover:bg-signal/15" + > + <Icon name="wrench" size={14} className="inline mr-1" /> Build a policy + </button> + ); + } + + if (state.ok) { + return ( + <div className="mt-2 rounded-xl border border-ok/30 bg-ok/5 p-4 text-sm"> + <p className="font-medium text-ok">Policy created.</p> + <p className="mt-0.5 text-xs text-foreground-muted">{state.ok}</p> + <button type="button" onClick={() => setOpen(false)} className="mt-2 rounded-md border border-border px-2 py-1 text-xs hover:bg-surface-muted">Done</button> + </div> + ); + } + + return ( + <form action={action} className="mt-2 space-y-4 rounded-xl border border-border bg-surface p-4"> + <input type="hidden" name="kind" value="ToolPolicy" /> + <input type="hidden" name="name" value={name} /> + <input type="hidden" name="spec" value={spec} /> + + <div className="flex items-center justify-between"> + <div> + <p className="text-sm font-semibold">Build a tool policy</p> + <p className="text-xs text-foreground-muted">Pick what agents may do — no YAML. The cluster compiles and enforces it.</p> + </div> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground">Close</button> + </div> + + {/* Identity + scope */} + <div className="grid gap-3 sm:grid-cols-2"> + <label className="text-xs text-foreground-muted"> + Policy name + <input value={name} onChange={(e) => setName(e.target.value)} required placeholder="repo-agents" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm" /> + </label> + <label className="text-xs text-foreground-muted"> + Applies to tool (glob · <span className="font-mono">*</span> = all tools) + <input value={toolGlob} onChange={(e) => setToolGlob(e.target.value)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm" /> + </label> + <label className="text-xs text-foreground-muted"> + Sandbox selector — label key + <input value={labelKey} onChange={(e) => setLabelKey(e.target.value)} placeholder="kars.azure.com/team" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm" /> + </label> + <label className="text-xs text-foreground-muted"> + Label value + <input value={labelValue} onChange={(e) => setLabelValue(e.target.value)} placeholder="true" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm" /> + </label> + </div> + + {/* Capability presets */} + <div> + <p className="text-xs font-semibold">Capabilities</p> + <p className="text-[11px] text-foreground-muted">Toggle what this policy allows or blocks. These become priority-ordered rules.</p> + <ul className="mt-2 grid gap-2 sm:grid-cols-2"> + {POLICY_PRESETS.map((p) => { + const on = presets.has(p.id); + return ( + <li key={p.id}> + <button + type="button" + onClick={() => togglePreset(p.id)} + className={`flex w-full items-start gap-2 rounded-lg border p-2.5 text-left ${on ? "border-signal/40 bg-signal/5" : "border-border hover:bg-surface-muted/50"}`} + > + <span className={`mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded border text-[10px] ${on ? "border-signal bg-signal text-signal-fg" : "border-border"}`}>{on ? "✓" : ""}</span> + <span className="min-w-0"> + <span className="flex items-center gap-1.5 text-xs font-medium"> + {p.label} + <span className={`rounded-full px-1.5 text-[10px] ${p.effect === "deny" ? "bg-danger/10 text-danger" : "bg-ok/10 text-ok"}`}>{p.effect}</span> + </span> + <span className="mt-0.5 block text-[11px] text-foreground-muted">{p.description}</span> + </span> + </button> + </li> + ); + })} + </ul> + </div> + + {/* Custom rules */} + <div> + <div className="flex items-center justify-between"> + <p className="text-xs font-semibold">Custom rules (advanced)</p> + <button type="button" onClick={() => setCustom((c) => [...c, { id: RID++, name: "", effect: "allow", actions: "", priority: 60 }])} + className="rounded-md border border-border px-2 py-1 text-[11px] hover:bg-surface-muted">+ Add rule</button> + </div> + {custom.length > 0 && ( + <ul className="mt-2 space-y-2"> + {custom.map((c) => ( + <li key={c.id} className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-muted/40 p-2"> + <input value={c.name} onChange={(e) => setCustom((cs) => cs.map((x) => x.id === c.id ? { ...x, name: e.target.value } : x))} placeholder="rule name" + className="w-32 rounded border border-border bg-surface px-2 py-1 text-xs" /> + <select value={c.effect} onChange={(e) => setCustom((cs) => cs.map((x) => x.id === c.id ? { ...x, effect: e.target.value as "allow" | "deny" } : x))} + className="rounded border border-border bg-surface px-2 py-1 text-xs"> + <option value="allow">allow</option><option value="deny">deny</option> + </select> + <input value={c.actions} onChange={(e) => setCustom((cs) => cs.map((x) => x.id === c.id ? { ...x, actions: e.target.value } : x))} placeholder="action patterns, e.g. tool:github_* shell:make" + className="min-w-40 flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-xs" /> + <input type="number" value={c.priority} onChange={(e) => setCustom((cs) => cs.map((x) => x.id === c.id ? { ...x, priority: Number(e.target.value) } : x))} + className="w-16 rounded border border-border bg-surface px-2 py-1 text-xs" title="priority (higher first)" /> + <button type="button" onClick={() => setCustom((cs) => cs.filter((x) => x.id !== c.id))} className="text-xs text-foreground-muted hover:text-danger">✕</button> + </li> + ))} + </ul> + )} + </div> + + {/* Approval gate */} + <div className="flex flex-wrap items-center gap-3"> + <label className="text-xs text-foreground-muted"> + Human approval + <select value={approvalMode} onChange={(e) => setApprovalMode(e.target.value as typeof approvalMode)} + className="ml-2 rounded border border-border bg-surface px-2 py-1 text-xs"> + <option value="none">not required</option> + <option value="always">always</option> + <option value="aboveThreshold">above a spend threshold</option> + </select> + </label> + {approvalMode === "aboveThreshold" && ( + <input value={approvalThreshold} onChange={(e) => setApprovalThreshold(e.target.value)} placeholder="USD 25.00" + className="w-32 rounded border border-border bg-surface px-2 py-1 font-mono text-xs" /> + )} + </div> + + {/* Live preview */} + <div> + <button type="button" onClick={() => setShowYaml((v) => !v)} className="inline-flex items-center gap-1 text-[11px] font-medium text-foreground-muted hover:text-foreground"> + <span aria-hidden className={`inline-block transition-transform ${showYaml ? "rotate-180" : ""}`}>⌄</span> + {showYaml ? "Hide" : "Show"} generated policy + </button> + {showYaml && ( + <pre className="mt-2 max-h-64 overflow-auto rounded-lg border border-border bg-surface-muted/40 p-3 font-mono text-[10px] leading-relaxed">{yaml}</pre> + )} + </div> + + <div className="flex items-center gap-3"> + <button type="submit" disabled={pending || !name.trim()} className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Creating…" : "Create policy"} + </button> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + </div> + </form> + ); +} diff --git a/bridge/web/src/app/console/profile-editor.tsx b/bridge/web/src/app/console/profile-editor.tsx new file mode 100644 index 000000000..8d866af25 --- /dev/null +++ b/bridge/web/src/app/console/profile-editor.tsx @@ -0,0 +1,292 @@ +"use client"; + +// kars Bridge Operator Console — visual KarsProfile (team profile) editor. +// Replaces the raw-JSON textarea (AuthorResource) for this one resource kind +// with real fields matching the actual CRD schema +// (controller/src/kars_profile.rs): displayName, domain, charterTemplate, a +// roles[] roster (each with systemPrompt + skills), defaultEnvelope +// (tier/authorityCeiling/delegationDepth/toolPolicyRef), the team's default +// toolPolicy, and knowledgeCommons. Submits through the SAME +// applyGovernanceAction the textarea used (name + a JSON "spec" string), so +// the backend contract is unchanged; only the authoring experience is +// visual. An "Edit as JSON" escape hatch stays available, mirroring +// InferencePolicyEditor. + +import { useActionState, useState } from "react"; +import { applyGovernanceAction, type GovState } from "./governance-actions"; +import { Icon } from "@/components/icon"; +import type { RefOption } from "@/lib/types"; + +const init: GovState = { error: null, ok: null }; + +const TIERS = [1, 2, 3, 4, 5] as const; + +type Role = { name: string; systemPrompt: string; skills: string[] }; + +interface FormShape { + displayName: string; + domain: string; + charterTemplate: string; + tier: number; + authorityCeiling: number; + delegationDepth: number; + envelopeToolPolicy: string; + toolPolicy: string; + knowledgeCommons: string; + roles: Role[]; +} + +function parseSpec(spec: Record<string, unknown> | undefined): FormShape { + const envelope = (spec?.defaultEnvelope as Record<string, unknown>) ?? {}; + const toolPolicyRef = (envelope.toolPolicyRef as Record<string, unknown>) ?? {}; + const roles = (spec?.roles as Record<string, unknown>[] | undefined) ?? []; + return { + displayName: (spec?.displayName as string) ?? "", + domain: (spec?.domain as string) ?? "", + charterTemplate: (spec?.charterTemplate as string) ?? "", + tier: (envelope.tier as number) ?? 3, + authorityCeiling: (envelope.authorityCeiling as number) ?? 2, + delegationDepth: (envelope.delegationDepth as number) ?? 1, + envelopeToolPolicy: (toolPolicyRef.name as string) ?? "", + toolPolicy: (spec?.toolPolicy as string) ?? "", + knowledgeCommons: (spec?.knowledgeCommons as string) ?? "", + roles: roles.map((r) => ({ + name: (r.name as string) ?? "", + systemPrompt: (r.systemPrompt as string) ?? "", + skills: (r.skills as string[] | undefined) ?? [], + })), + }; +} + +function buildSpec(f: FormShape): Record<string, unknown> { + const spec: Record<string, unknown> = { + domain: f.domain.trim(), + charterTemplate: f.charterTemplate.trim(), + }; + if (f.displayName.trim()) spec.displayName = f.displayName.trim(); + if (f.toolPolicy.trim()) spec.toolPolicy = f.toolPolicy.trim(); + if (f.knowledgeCommons.trim()) spec.knowledgeCommons = f.knowledgeCommons.trim(); + + const envelope: Record<string, unknown> = { + tier: f.tier, + authorityCeiling: f.authorityCeiling, + delegationDepth: f.delegationDepth, + }; + if (f.envelopeToolPolicy.trim()) envelope.toolPolicyRef = { name: f.envelopeToolPolicy.trim() }; + spec.defaultEnvelope = envelope; + + spec.roles = f.roles + .filter((r) => r.name.trim()) + .map((r) => ({ + name: r.name.trim(), + ...(r.systemPrompt.trim() ? { systemPrompt: r.systemPrompt.trim() } : {}), + ...(r.skills.length > 0 ? { skills: r.skills } : {}), + })); + + return spec; +} + +export function ProfileEditor({ + initialName, + initialSpec, + toolPolicies, + skills, +}: { + initialName?: string; + initialSpec?: Record<string, unknown>; + toolPolicies: RefOption[]; + skills: RefOption[]; +}) { + const [open, setOpen] = useState(false); + const [state, action, pending] = useActionState(applyGovernanceAction, init); + const editing = Boolean(initialName); + const [advanced, setAdvanced] = useState(false); + const [f, setF] = useState<FormShape>(() => parseSpec(initialSpec)); + const [rawSpec, setRawSpec] = useState(() => JSON.stringify(initialSpec ?? {}, null, 2)); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + {editing ? `Edit ${initialName}` : "+ Add team profile"} + </button> + ); + } + + const specJson = advanced ? rawSpec : JSON.stringify(buildSpec(f)); + const patch = (p: Partial<FormShape>) => setF((prev) => ({ ...prev, ...p })); + const patchRole = (i: number, p: Partial<Role>) => setF((prev) => ({ ...prev, roles: prev.roles.map((r, j) => (j === i ? { ...r, ...p } : r)) })); + + return ( + <form action={action} className="mt-2 space-y-4 rounded-lg border border-border bg-surface-muted p-4"> + <input type="hidden" name="kind" value="KarsProfile" /> + <input type="hidden" name="spec" value={specJson} /> + <div className="flex items-center justify-between"> + <p className="text-xs font-medium">{editing ? "Edit team profile" : "New team profile"}</p> + <div className="flex items-center gap-3"> + <button type="button" onClick={() => setAdvanced((v) => !v)} className="text-xs text-foreground-muted hover:text-foreground"> + {advanced ? "Use visual form" : "Edit as JSON"} + </button> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground">Cancel</button> + </div> + </div> + + <input + name="name" + defaultValue={initialName} + readOnly={editing} + placeholder="name (lowercase-with-hyphens)" + required + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm read-only:opacity-70" + /> + + {advanced ? ( + <textarea + value={rawSpec} + onChange={(e) => setRawSpec(e.target.value)} + rows={16} + spellCheck={false} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed" + /> + ) : ( + <div className="space-y-4"> + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="person" size={13} /> Identity</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <Field label="Display name (optional)"> + <input value={f.displayName} onChange={(e) => patch({ displayName: e.target.value })} placeholder="e.g. Engineering maintainer" className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /> + </Field> + <Field label="Domain"> + <input value={f.domain} onChange={(e) => patch({ domain: e.target.value })} placeholder="e.g. eng, finance, docs, soc, legal" className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /> + </Field> + </div> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="note" size={13} /> Charter</legend> + <textarea + value={f.charterTemplate} + onChange={(e) => patch({ charterTemplate: e.target.value })} + rows={3} + placeholder="The standing mandate a team instantiated from this profile adopts." + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="shield" size={13} /> Default envelope</legend> + <div className="grid gap-3 sm:grid-cols-3"> + <Field label="Tier"> + <select value={f.tier} onChange={(e) => patch({ tier: Number(e.target.value) })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + {TIERS.map((t) => <option key={t} value={t}>{t}</option>)} + </select> + </Field> + <Field label="Authority ceiling"> + <select value={f.authorityCeiling} onChange={(e) => patch({ authorityCeiling: Number(e.target.value) })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + {TIERS.map((t) => <option key={t} value={t}>{t}</option>)} + </select> + </Field> + <Field label="Delegation depth"> + <input type="number" min={0} value={f.delegationDepth} onChange={(e) => patch({ delegationDepth: Number(e.target.value) })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm tabular-nums" /> + </Field> + </div> + <div className="mt-3"> + <Field label="Envelope tool policy (optional)"> + <select value={f.envelopeToolPolicy} onChange={(e) => patch({ envelopeToolPolicy: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">none</option> + {toolPolicies.map((tp) => <option key={tp.name} value={tp.name}>{tp.name}{tp.summary ? ` — ${tp.summary}` : ""}</option>)} + </select> + </Field> + </div> + <p className="mt-1.5 text-[11px] text-foreground-muted">Authority ceiling must be ≤ tier — the cluster validates this on save.</p> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="gear" size={13} /> Team defaults</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <Field label="Members' bounding tool policy (optional)"> + <select value={f.toolPolicy} onChange={(e) => patch({ toolPolicy: e.target.value })} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">cluster default (kars-default)</option> + {toolPolicies.map((tp) => <option key={tp.name} value={tp.name}>{tp.name}{tp.summary ? ` — ${tp.summary}` : ""}</option>)} + </select> + </Field> + <Field label="Knowledge commons (optional)"> + <input value={f.knowledgeCommons} onChange={(e) => patch({ knowledgeCommons: e.target.value })} placeholder="team's own commons (default)" className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /> + </Field> + </div> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="handshake" size={13} /> Roles</legend> + <div className="space-y-2"> + {f.roles.map((r, i) => ( + <div key={i} className="rounded-lg border border-border bg-surface p-2.5"> + <div className="flex items-center gap-2"> + <input value={r.name} onChange={(e) => patchRole(i, { name: e.target.value })} placeholder="role name (e.g. triager)" className="flex-1 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm font-medium" /> + <button type="button" onClick={() => patch({ roles: f.roles.filter((_, j) => j !== i) })} className="shrink-0 text-foreground-muted hover:text-danger"> + <Icon name="cross" size={13} /> + </button> + </div> + <textarea + value={r.systemPrompt} + onChange={(e) => patchRole(i, { systemPrompt: e.target.value })} + rows={2} + placeholder="standing instructions for this role…" + className="mt-2 w-full resize-y rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" + /> + {skills.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1.5"> + {skills.map((sk) => { + const on = r.skills.includes(sk.name); + return ( + <button + key={sk.name} + type="button" + title={sk.summary ?? undefined} + onClick={() => patchRole(i, { skills: on ? r.skills.filter((s) => s !== sk.name) : [...r.skills, sk.name] })} + className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${on ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground"}`} + > + {sk.name} + </button> + ); + })} + </div> + )} + </div> + ))} + <button type="button" onClick={() => patch({ roles: [...f.roles, { name: "", systemPrompt: "", skills: [] }] })} className="text-xs text-signal hover:underline"> + + Add role + </button> + </div> + </fieldset> + </div> + )} + + <p className="text-[11px] text-foreground-muted"> + Applied with <span className="font-mono">kubectl apply</span> semantics (field manager <span className="font-mono">kars-bridge</span>). The cluster validates it — invalid specs are rejected with the API server's own message. + </p> + <label className="flex items-center gap-2 text-[11px] text-foreground-muted"> + <input type="checkbox" name="force" /> Force — take ownership of fields another manager owns (only on a conflict) + </label> + <div className="flex items-center gap-3"> + <button type="submit" disabled={pending} className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Applying…" : editing ? "Save changes" : "Create team profile"} + </button> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </div> + </form> + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <label className="block text-xs text-foreground-muted"> + {label} + <div className="mt-1">{children}</div> + </label> + ); +} diff --git a/bridge/web/src/app/console/skill-approval.tsx b/bridge/web/src/app/console/skill-approval.tsx new file mode 100644 index 000000000..d96559b09 --- /dev/null +++ b/bridge/web/src/app/console/skill-approval.tsx @@ -0,0 +1,98 @@ +"use client"; + +// Operator skill-admission control. The trust gate the user described: +// a user-uploaded skill is scanned by the controller (attestation), then an +// operator reviews and APPROVES it, which LOCKS the approval to the exact +// version digest — only then may users assign it. Any later change breaks the +// lock and returns it to review. Approve is disabled until the scan verifies. + +import { useActionState } from "react"; +import { Icon } from "@/components/icon"; +import { reviewSkillAction, type GovState } from "./governance-actions"; +import type { SkillSummary } from "@/lib/types"; + +const init: GovState = { error: null, ok: null }; + +export function SkillApproval({ skill }: { skill: SkillSummary }) { + const [state, action, pending] = useActionState(reviewSkillAction, init); + const scanned = skill.version_digest != null; + // Attestation is OPTIONAL: the controller marks a skill "validated and grantable" + // even with "no attestation declared" (attestation_verified === null). Approval is + // only blocked when the scan EXPLICITLY failed (attestation_verified === false) — + // matching the BFF gate. A null attestation must not deadlock approval. + const attestationFailed = skill.attestation_verified === false; + const enabled = scanned && !attestationFailed; + // "Approved" annotation with no lock recorded is a half-approved state (e.g. set + // out-of-band), NOT genuine drift. Only a lock that no longer matches the live + // digest is real "changed since approval". + const drifted = + skill.review === "approved" && skill.locked_digest != null && !skill.usable; + const approvedUnlocked = + skill.review === "approved" && skill.locked_digest == null && !skill.usable; + + if (state.ok) { + return <span className="text-[11px] text-signal">{state.ok} refreshing…</span>; + } + + return ( + <div className="flex flex-wrap items-center gap-2"> + {skill.usable ? ( + <> + <span className="inline-flex items-center gap-1 rounded-md bg-signal/10 px-2 py-0.5 text-[11px] font-medium text-signal"> + <Icon name="check" size={11} /> locked + </span> + <form action={action}> + <input type="hidden" name="name" value={skill.name} /> + <input type="hidden" name="action" value="revoke" /> + <button type="submit" disabled={pending} className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:border-danger/40 hover:text-danger disabled:opacity-50"> + {pending ? "Revoking…" : "Revoke"} + </button> + </form> + </> + ) : drifted ? ( + // Approved earlier but the skill changed since — lock is broken. + <> + <span className="inline-flex items-center gap-1 rounded-md bg-warning/10 px-2 py-0.5 text-[11px] font-medium text-warning"> + <Icon name="warning" size={12} className="inline mr-0.5" /> changed since approval — re-review + </span> + <ApproveButton name={skill.name} action={action} pending={pending} enabled={enabled} label="Re-approve & lock" /> + </> + ) : approvedUnlocked ? ( + // Approved out-of-band without a recorded lock — lock it to make it usable. + <> + <span className="inline-flex items-center gap-1 rounded-md bg-warning/10 px-2 py-0.5 text-[11px] font-medium text-warning"> + ● approved — not locked + </span> + <ApproveButton name={skill.name} action={action} pending={pending} enabled={enabled} /> + </> + ) : ( + <> + <span className="inline-flex items-center gap-1 rounded-md bg-surface-muted px-2 py-0.5 text-[11px] font-medium text-foreground-muted"> + ● pending review + </span> + <ApproveButton name={skill.name} action={action} pending={pending} enabled={enabled} /> + </> + )} + {!scanned && <span className="text-[11px] text-foreground-muted">awaiting scan…</span>} + {attestationFailed && <span className="text-[11px] text-warning">attestation failed — can’t approve</span>} + {state.error && <span className="text-[11px] text-danger">{state.error}</span>} + </div> + ); +} + +function ApproveButton({ name, action, pending, enabled, label }: { name: string; action: (fd: FormData) => void; pending: boolean; enabled: boolean; label?: string }) { + return ( + <form action={action}> + <input type="hidden" name="name" value={name} /> + <input type="hidden" name="action" value="approve" /> + <button + type="submit" + disabled={pending || !enabled} + title={enabled ? "Approve and lock to this version" : "The controller must scan this skill (and any declared attestation must pass) before it can be approved"} + className="rounded-md border border-signal/40 bg-signal/10 px-2.5 py-1 text-[11px] font-semibold text-signal disabled:opacity-40" + > + {pending ? "Approving…" : (label ?? "Approve & lock")} + </button> + </form> + ); +} diff --git a/bridge/web/src/app/console/skill-submit-action.ts b/bridge/web/src/app/console/skill-submit-action.ts new file mode 100644 index 000000000..2d360a7ed --- /dev/null +++ b/bridge/web/src/app/console/skill-submit-action.ts @@ -0,0 +1,25 @@ +"use server"; + +// kars Bridge Operator Console — skill submission (server action). Same +// validated create path the Workspace user-submit uses (submitSkill / POST +// /api/skills): the operator gets the identical guided form + backend +// validation instead of a bare-file-picker + raw-governance-apply path. +// Lands PENDING like any submission — the operator's own scan+approve gate +// (SkillApproval) still governs before it becomes usable. + +import { revalidatePath } from "next/cache"; +import { submitSkill, BffError } from "@/lib/bff"; +import { operatorIdentity } from "@/lib/config"; +import type { SkillComposerInput, SkillComposerResult } from "@/components/skill-composer"; + +export async function submitSkillConsoleAction(input: SkillComposerInput): Promise<SkillComposerResult> { + try { + await submitSkill({ ...input, uploaded_by: operatorIdentity() }); + revalidatePath("/console/configuration"); + revalidatePath("/console/capabilities"); + return { ok: true }; + } catch (e) { + const msg = e instanceof BffError ? e.message || e.code : e instanceof Error ? e.message : "Couldn't submit the skill."; + return { ok: false, error: msg }; + } +} diff --git a/bridge/web/src/app/console/sre-action-decision.tsx b/bridge/web/src/app/console/sre-action-decision.tsx new file mode 100644 index 000000000..9ecd98ec0 --- /dev/null +++ b/bridge/web/src/app/console/sre-action-decision.tsx @@ -0,0 +1,54 @@ +"use client"; + +// kars-SRE remediation-proposal approve/reject control. Mirrors SkillApproval: +// a client component driving the decideSreActionAction server action so the +// approve/reject buttons show pending state and inline errors. + +import { useActionState } from "react"; +import { decideSreActionAction } from "./governance-actions"; +import type { GovState } from "./governance-actions"; +import type { SreAction } from "@/lib/types"; + +const init: GovState = { error: null, ok: null }; + +export function SreActionDecision({ action: sreAction }: { action: SreAction }) { + const [state, formAction, pending] = useActionState(decideSreActionAction, init); + + if (state.ok) { + return <span className="text-[11px] text-signal">{state.ok} refreshing…</span>; + } + + if (!sreAction.actionable) { + return null; + } + + return ( + <div className="flex flex-wrap items-center gap-2"> + <form action={formAction}> + <input type="hidden" name="ns" value={sreAction.namespace} /> + <input type="hidden" name="name" value={sreAction.name} /> + <input type="hidden" name="action" value="approve" /> + <button + type="submit" + disabled={pending} + className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-signal/90 disabled:opacity-50" + > + {pending ? "Deciding…" : "Approve"} + </button> + </form> + <form action={formAction}> + <input type="hidden" name="ns" value={sreAction.namespace} /> + <input type="hidden" name="name" value={sreAction.name} /> + <input type="hidden" name="action" value="reject" /> + <button + type="submit" + disabled={pending} + className="rounded-md border border-border px-2.5 py-1 text-[11px] font-medium text-foreground-muted hover:border-danger/40 hover:text-danger disabled:opacity-50" + > + {pending ? "Deciding…" : "Reject"} + </button> + </form> + {state.error && <span className="text-[11px] text-danger">{state.error}</span>} + </div> + ); +} diff --git a/bridge/web/src/app/console/sre-actions/page.tsx b/bridge/web/src/app/console/sre-actions/page.tsx new file mode 100644 index 000000000..e9fc957c7 --- /dev/null +++ b/bridge/web/src/app/console/sre-actions/page.tsx @@ -0,0 +1,117 @@ +// kars Bridge Operator Console — SRE Actions. The kars-sre agent's +// self-remediation proposal surface: it diagnoses a workload incident and +// proposes ONE typed fix (KarsSREAction, closed action set); an operator +// approves or rejects here. On approval the controller mints a narrowly- +// scoped one-shot token, executes, tears the binding down, and records the +// real outcome (Applied/Recovered/Failed) — the Bridge never executes the +// remediation itself, only records the human decision. + +import { PageHeader, Section, Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { Icon } from "@/components/icon"; +import { listSreActions } from "@/lib/bff"; +import { SreActionDecision } from "../sre-action-decision"; +import type { SreAction } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +type Tone = "ok" | "warn" | "danger" | "info" | "muted" | "accent"; + +function phaseTone(phase: string): Tone { + switch (phase) { + case "Applied": + case "Recovered": + return "ok"; + case "Failed": + case "Degraded": + return "danger"; + case "Approved": + return "info"; + case "Rejected": + case "Expired": + return "muted"; + default: + return "warn"; // Proposed — awaiting operator decision + } +} + +export default async function SreActionsPage() { + let actions: SreAction[] = []; + let error = false; + try { + actions = await listSreActions(); + } catch { + error = true; + } + + const pending = actions.filter((a) => a.actionable).length; + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Operator Console" + title="SRE Actions" + lead="Self-remediation proposals from the kars-sre agent. Each is ONE typed, scoped fix (delete a stuck ResourceQuota, roll back an image, scale/restart a workload) with a rationale — you approve or reject. On approval the controller mints a one-shot, narrowly-scoped token, executes, tears it down, and records the real outcome." + /> + + {error ? ( + <HonestState variant="not_wired" title="Cluster unreachable" detail="The Bridge backend can't reach the cluster right now." /> + ) : actions.length === 0 ? ( + <HonestState + variant="empty" + title="No SRE proposals" + detail="If the kars-sre agent (runtimes/hermes plugin) is deployed and diagnosing the cluster, its remediation proposals will appear here as KarsSREAction objects, Pending, until you approve or reject. None have been raised yet — this is a legitimate idle state, not a missing integration." + /> + ) : ( + <Section + title="Remediation proposals" + action={ + <Badge tone={pending > 0 ? "warn" : "muted"} dot={pending > 0}> + {pending > 0 ? `${pending} pending` : `${actions.length} total`} + </Badge> + } + > + <div className="space-y-3"> + {actions.map((a) => ( + <div key={`${a.namespace}/${a.name}`} className="rounded-lg border border-border bg-surface p-4"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <p className="flex items-center gap-1.5 text-sm font-semibold"> + <Icon name="wrench" className="h-3.5 w-3.5 text-foreground-muted" /> {a.action_type} + {a.target_namespace && ( + <span className="font-normal text-foreground-muted"> +  → {a.target_namespace}{a.target_name ? `/${a.target_name}` : ""} + </span> + )} + </p> + {a.diagnosis && <p className="mt-1 text-xs text-foreground-muted">{a.diagnosis}</p>} + </div> + <div className="flex shrink-0 items-center gap-2"> + <Badge tone={phaseTone(a.approval_state === "Pending" ? "Proposed" : a.phase)}> + {a.approval_state === "Pending" ? "Awaiting decision" : a.phase} + </Badge> + </div> + </div> + {a.rationale && ( + <p className="mt-2 rounded-md bg-surface-muted/40 p-2 text-xs text-foreground-muted">{a.rationale}</p> + )} + <dl className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4"> + <div><dt className="text-foreground-muted">Approval</dt><dd className="font-medium">{a.approval_state}</dd></div> + <div><dt className="text-foreground-muted">TTL</dt><dd className="font-medium">{a.ttl_minutes ?? 15}m</dd></div> + <div><dt className="text-foreground-muted">Created</dt><dd className="font-medium">{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}</dd></div> + <div><dt className="text-foreground-muted">Applied</dt><dd className="font-medium">{a.applied_at ? new Date(a.applied_at).toLocaleString() : "—"}</dd></div> + </dl> + {a.approval_note && ( + <p className="mt-2 text-xs italic text-foreground-muted">“{a.approval_note}”</p> + )} + <div className="mt-3"> + <SreActionDecision action={a} /> + </div> + </div> + ))} + </div> + </Section> + )} + </div> + ); +} diff --git a/bridge/web/src/app/console/troubleshooting/page.tsx b/bridge/web/src/app/console/troubleshooting/page.tsx new file mode 100644 index 000000000..3b8c1a471 --- /dev/null +++ b/bridge/web/src/app/console/troubleshooting/page.tsx @@ -0,0 +1,193 @@ +// kars Bridge — System / wiring view. Delivery Constraint #5: the product +// never hides an un-wired gap behind a finished screen. This page shows the +// true, cluster-read status of every stage of the governed-agent pipeline. + +import { PageHeader, Section, Stat } from "@/components/ui"; +import { WiringBadge } from "@/components/wiring-badge"; +import { Icon } from "@/components/icon"; +import { authWired, operatorIdentity } from "@/lib/config"; +import { BffError, getSystem, getDiagnostics } from "@/lib/bff"; +import type { WiringStatus, Diagnostics } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function connectorTone(status: WiringStatus): string { + return status === "live" ? "bg-ok/40" : "bg-border"; +} + +const LEAD = + "Real problems first — what's failing right now and how to fix it — then the honest end-to-end wiring map. Every status here is read from the live cluster, nothing implied to work that doesn't."; + +export default async function SystemPage() { + let system; + try { + system = await getSystem(); + } catch (err) { + const code = err instanceof BffError ? err.code : "unknown"; + return ( + <div className="space-y-6"> + <PageHeader eyebrow="Operator Console" title="Troubleshooting" lead={LEAD} /> + <div className="kb-card p-8 text-center text-sm text-foreground-muted"> + {code === "cluster_unavailable" + ? "Not connected to a cluster — system status is unavailable." + : `Could not load system status (${code}).`} + </div> + </div> + ); + } + + let diagnostics: Diagnostics | null = null; + try { + diagnostics = await getDiagnostics(); + } catch { + diagnostics = null; + } + + const liveCount = system.pipeline.filter((s) => s.status === "live").length; + const crdsInstalled = system.crds.filter((c) => c.installed).length; + const critical = diagnostics?.issues.filter((i) => i.severity === "critical").length ?? 0; + + return ( + <div className="space-y-6"> + <PageHeader eyebrow="Operator Console" title="Troubleshooting" lead={LEAD} /> + + {!authWired() && ( + <div className="rounded-xl border border-warn/40 bg-warn/10 p-4"> + <p className="inline-flex items-center gap-2 text-sm font-semibold text-foreground"> + <Icon name="shield" className="h-4 w-4 text-warn" aria-hidden /> + Identity disclosure — decisions are attributed to a configured operator + </p> + <p className="mt-1 text-xs text-foreground-muted"> + This Bridge does not yet have per-user sign-in wired. Every approval, denial, + and launch made through the UI is recorded as <span className="font-mono">{operatorIdentity()}</span>, + not a logged-in individual. Real per-user authentication (binding the decider + to an authenticated session) is a named next step — surfaced here, and next to + every decision control, so nothing is implied that can’t be proven. + </p> + </div> + )} + + {/* Active issues — the real diagnostics: what's broken, and the fix. */} + {diagnostics && ( + <Section + title="Active issues" + subtitle={ + diagnostics.healthy + ? `All clear — scanned ${diagnostics.scanned_pods} pod${diagnostics.scanned_pods === 1 ? "" : "s"} and ${diagnostics.scanned_sandboxes} sandbox${diagnostics.scanned_sandboxes === 1 ? "" : "es"}, nothing failing.` + : `${diagnostics.issues.length} issue${diagnostics.issues.length === 1 ? "" : "s"}${critical > 0 ? ` · ${critical} critical` : ""} across ${diagnostics.scanned_pods} pods.` + } + > + {diagnostics.healthy ? ( + <div className="flex items-center gap-3 rounded-xl border border-ok/30 bg-ok/[0.05] p-4"> + <span aria-hidden className="grid h-9 w-9 place-items-center rounded-lg bg-surface text-lg"><Icon name="check" size={20} /></span> + <p className="text-sm text-foreground">No failing pods, crash loops, image-pull errors, or degraded sandboxes right now.</p> + </div> + ) : ( + <ul className="space-y-2"> + {diagnostics.issues.map((issue, i) => { + const crit = issue.severity === "critical"; + return ( + <li + key={`${issue.subject}-${issue.kind}-${i}`} + className={`rounded-xl border p-4 ${crit ? "border-rose-500/30 bg-rose-500/[0.04]" : "border-amber-500/30 bg-amber-500/[0.04]"}`} + > + <div className="flex flex-wrap items-center gap-2"> + <span + className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${crit ? "bg-rose-500/15 text-rose-600" : "bg-amber-500/15 text-amber-600"}`} + > + {crit ? "Critical" : "Warning"} + </span> + <code className="font-mono text-xs font-semibold text-foreground">{issue.kind}</code> + <span className="text-xs text-foreground-muted">·</span> + <code className="font-mono text-[11px] text-foreground-muted">{issue.subject}</code> + <span className="ml-auto text-[11px] text-foreground-muted">{issue.reason}</span> + </div> + {issue.detail && ( + <p className="mt-1.5 font-mono text-[11px] leading-relaxed text-foreground-muted">{issue.detail}</p> + )} + <p className="mt-1.5 text-xs text-foreground"> + <span className="font-medium">Fix:</span> {issue.remedy} + </p> + <div className="mt-2 flex flex-wrap gap-3 text-[11px]"> + <a + href={`/console/fleet?q=${encodeURIComponent(issue.subject.split("/").pop() ?? "")}`} + className="font-medium text-signal hover:underline" + > + Inspect sandbox in fleet → + </a> + </div> + </li> + ); + })} + </ul> + )} + </Section> + )} + + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> + <Stat label="Pipeline stages live" value={`${liveCount}/${system.pipeline.length}`} accent={liveCount === system.pipeline.length} /> + <Stat label="CRDs installed" value={`${crdsInstalled}/${system.crds.length}`} /> + <div className="rounded-xl border border-border bg-surface p-4"> + <p className="inline-flex items-center gap-2 text-sm font-semibold"> + Controller + <WiringBadge status={system.controller_reachable ? "live" : "not_wired"} /> + </p> + <p className="mt-1 text-xs text-foreground-muted">The reconciler that materializes every sandbox.</p> + </div> + </div> + + <Section + title="Pipeline wiring" + subtitle={`${liveCount} of ${system.pipeline.length} stages are live end-to-end. The rest are named here so nothing is hidden.`} + > + <ol className="space-y-0"> + {system.pipeline.map((stage, i) => ( + <li key={stage.id} className="relative flex gap-4"> + <div className="flex flex-col items-center"> + <span + className={[ + "grid h-7 w-7 shrink-0 place-items-center rounded-full border text-xs font-semibold", + stage.status === "live" + ? "border-ok/40 bg-ok/15 text-ok" + : stage.status === "partial" + ? "border-warning/40 bg-warning/15 text-warning" + : "border-border bg-surface-muted text-foreground-muted", + ].join(" ")} + > + {i + 1} + </span> + {i < system.pipeline.length - 1 && ( + <span className={`w-px flex-1 ${connectorTone(system.pipeline[i + 1].status)}`} style={{ minHeight: "1.5rem" }} /> + )} + </div> + <div className="flex-1 pb-6"> + <div className="flex flex-wrap items-center gap-2"> + <h3 className="text-sm font-medium">{stage.name}</h3> + <WiringBadge status={stage.status} /> + </div> + <p className="mt-0.5 text-sm text-foreground-muted">{stage.description}</p> + <p className={`mt-1.5 text-xs ${stage.status === "not_wired" ? "text-foreground-muted" : "text-foreground"}`}> + {stage.detail} + </p> + </div> + </li> + ))} + </ol> + </Section> + + <Section + title="Substrate CRDs" + subtitle={`Custom resources the pipeline depends on, as installed in ${system.namespace}.`} + > + <ul className="grid gap-2 sm:grid-cols-2"> + {system.crds.map((crd) => ( + <li key={crd.name} className="flex items-center justify-between rounded-lg border border-border bg-surface px-3 py-2"> + <code className="font-mono text-xs">{crd.name}</code> + <WiringBadge status={crd.installed ? "live" : "not_wired"} /> + </li> + ))} + </ul> + </Section> + </div> + ); +} diff --git a/bridge/web/src/app/dex/[...path]/route.ts b/bridge/web/src/app/dex/[...path]/route.ts new file mode 100644 index 000000000..dece74874 --- /dev/null +++ b/bridge/web/src/app/dex/[...path]/route.ts @@ -0,0 +1,110 @@ +// kars Bridge — same-origin OIDC IdP proxy (/dex/* → in-cluster Dex). +// +// Why: colleagues reach the Bridge over a single `kubectl port-forward +// svc/kars-bridge-web 3000:3000` — no public ingress, no LoadBalancer. The OIDC +// login flow redirects the *browser* to the IdP's authorization endpoint, so the +// IdP must be reachable at an origin the browser can resolve WITHOUT editing +// /etc/hosts or running a second port-forward for Dex. +// +// By setting Dex's issuer to `http://localhost:3000/dex` and proxying /dex/* +// from this pod to the in-cluster Dex Service, BOTH the browser (authorize + +// login form) AND the server-side token/JWKS/discovery calls flow through the +// one localhost:3000 origin. One port-forward, zero hosts-file hacks. +// +// redirect:"manual" so Dex's 3xx (connector select → login → approval → back to +// /auth/callback) pass straight to the browser. set-cookie is forwarded per +// header (getSetCookie) because Dex carries CSRF/session state in cookies during +// the flow and fetch() otherwise collapses multiples into one comma-joined value. + +import { type NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +function dexBase(): string { + return ( + process.env.DEX_UPSTREAM_URL ?? "http://dex.kars-system.svc.cluster.local:5556" + ).replace(/\/$/, ""); +} + +// Hop-by-hop headers must not be forwarded verbatim. +const STRIP = new Set([ + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-length", +]); + +async function forward(req: NextRequest): Promise<Response> { + const { pathname, search } = req.nextUrl; + // pathname already includes the leading /dex segment; Dex is mounted at /dex + // upstream, so forward it verbatim. + const target = `${dexBase()}${pathname}${search}`; + + const headers = new Headers(); + req.headers.forEach((value, key) => { + if (!STRIP.has(key.toLowerCase())) headers.set(key, value); + }); + + const method = req.method.toUpperCase(); + const hasBody = method !== "GET" && method !== "HEAD"; + + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + redirect: "manual", + }; + if (hasBody) { + init.body = req.body; + init.duplex = "half"; + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + return new Response( + JSON.stringify({ + error: { + code: "bad_gateway", + message: "OIDC IdP (Dex) unreachable", + detail: String(err), + target, + }, + }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } + + const respHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + const k = key.toLowerCase(); + if (k === "set-cookie") return; // handled below, per-cookie + if (!STRIP.has(k)) respHeaders.set(key, value); + }); + + // Preserve each Set-Cookie header individually — Dex relies on cookies for the + // login/approval flow, and a comma-joined collapse would corrupt them. + const setCookies = upstream.headers.getSetCookie?.() ?? []; + for (const c of setCookies) respHeaders.append("set-cookie", c); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: respHeaders, + }); +} + +export const GET = forward; +export const POST = forward; +export const PUT = forward; +export const PATCH = forward; +export const DELETE = forward; +export const HEAD = forward; +export const OPTIONS = forward; diff --git a/bridge/web/src/app/favicon.ico b/bridge/web/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO<?sK2}EE5RAKnxHU7lft+ zNRAPL3?T?25I&drAjl1ssi=G|D?(7bFsgtO(2o>{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UX<xm7|19n6Hxvd5m6xx<*9a4%RmR{en}E&p$X-wy5A}T zU0^dwXVA>IbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%<G) zWdETe=&R39RaKR)udn|#TOgZ!e!yM=<=+`Uz{l^5UtkZ2fHDQ;UwMB}v%l$A-`~F- z{Qr^x^CSUf63Sry{6y#+`<sMA?dPFvg)$lC_RkFRKnCi7&P<a6>hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M<!8cv(gkb9@A>>36U4Us zfgYWSiHZL3;lpWT=<n~R&zm>zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6<!ZvGbtU{7FdY&`9DeD(=q|M30$GCs(E?S0J1$e@G0#Z=wz zl)*a>Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B<UyBc9U%rn&@xFZ-e{%i>@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<<x-(q{Yn-pG zKTz?fwGmh&&2-F3f57**)?Xk#p#S9h^DhK{VVKE&0KR^-_MMD9nf@pDACnmVll!kp z3?Tha?LWW70P;AL{}cP~sW|?W|MbA09{7Kt2f!i(y>fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?<jWWPHxu*D53Uq)j1!ZtH3Vi&#Nd^rV zj`B>MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7<Kk?_r;;``Uc^3+u}-v3@Q8<@$Nr`<F?K z-%F>?r!zQTPPSv}{so2e>Fjs1{<qUF=hGRSFDG$<z3x<+@%{Vd%a`e+qodRP&D<om zAEn>gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*<R_VaVlPH<<CgYr!E->>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w<boVrLOyLG9R$m+7N>6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P<HJ;%@cvfCkvm6xcMjdY zed_u6xK)F%|1Hy`)`e~K(f*MqTJ?92I+4lga{A5`-U@Cab35G6unNk<*dpB|Rtkp; z?32o^yBlJsuA-^abQ~7;%<oa^k<DbKc{lOW2!yM#nEALvv)IhY7b|Wfg(UhtiurTM zY-B6L26$JQo&Kt3nh3JTJ)garEgw^{uEM3__%b$U5{~+aMO*k)6R#grkER2`U6KS- z=j1=QhCkuy%iiHWrqH8CeGNw*C?epTpl2Bo@ugUPKRFeiVHOpL7PHu-SAgX@qmTGH z_%ePz1`io8XDfwLmip;Rn;1yo+3>3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@<gIi}tCXee1<sGV$i z4r_`X#mEQbiDh!Efji0GjM9z-0bF}p0(*s(OzMJ|;K&OJBar<ARLp}T>a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1<ZO0#U-k07ifx!> zrO6RSXHH}D<I*>Mc$&|?D004<Y&c6)m74d`LOLU@ruR+Um4>DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*A<g|TlOeriuPP`vK2IntATvs?Iv|J14j&;NFSFo zyJ+sca?G+8C%!b{Sq=6cJJqS>y{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDT<?u;)RfLQwg>N}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4Ul<x{xc_m~`mWBP0<g-{#wm}Vv~Ef3pKWC&N_<~88zSbEk;;+{DnJ9-u&Zc74s zJ6TCQyl_^|5cY;wmDdrU@LTL-3v0H#Ui?8ICQV{imof1MHuM$`e*ux>IWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyT<MDk{HKbd#ckg5-pS_?QUVhZv?&Q-ioBS}$nvBd)nE7YO0deN~G(#zCJAbY$E z!)g3Ytl=_NDUV%pykcE+Q<{EoZ_4FR@&#d<hqs%N>DrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5E<MCr+anDo)-{XRlCJ;D#M( zT=3WgR02;Nm!54biUb^FtzPh8iGrf412epnki-k+G4mdkzC|lJqaRMbb0~Jjp-{}I z5Do5afZi>ajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7gi<U zTpbX&UCeYeNu>LVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z<cK@1=jX>?J<BS8bpdt^R+}%A_DEhF^%o}8e!!lc`Y!qU>;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1e<Q<iIG*|o$r?OTFp`s)@_nHs4LeWbGvg7^}NK)>dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91<J5P5=Ly{?(NNY{6`O~L5r@sJe3rNZn06%SLk); z9?hvE^Hr{!*G$<_doyzGn#*z*#}?)8dH=eYTgvc)T~}Jw!kCv68<+KL5{5?EXtDAZ zWeNqp8%KIuBi&icn5s815Vho<+99VW1~m@L8l0=$c`t-L{q))~<!p*~vCdUcBcPz` zyUi}!-k_`G{>P8|av8hQoCmQXkd?7wIJw<dY^{|7OQJUHKB~nksN_|Xy;DL?xjxU^ zbMa`WdfTBnr<wTd$mY&SgJ4U|X``k`#`gN@M+0x2W{YgC3kbLk<uYFJWglkx_)2#b ztRiuA!EK9o)f`I2k)l;Of%E`ff91WlZh8yfRi6#N-mC`Ma(yr~U82SyAhc9B+ur!f zP-3igg*KeYs9mGOAw@OaXYy9DnGjn0<m`JH&Q^h}^!h+uS9Ct*o-oEy(?iT6Yco>b z_^v8bbg`<ZOL)a;i=IdfK0Zvw4nXsoC?eTOMpY)_ptiORm%J(1CD3dE0Z%Vy<2iHp zcp>SAn{I*4bH$u(RZ6*x<DqKJ+5;a6Jq~=Y8V&c?Vsyq88!2nD?H?Eww58Mqt$7R8 z5BMjmKx>UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq3<?y%xNvu0N78_R?~<RDFQx0ynlRG(E|j zvEGN3bF<E_9p-I!UwQXFqcSGV#e^98tgFqLp+z9eP}y!jNA{)r*a+%M-_20xg?94< zzmM{}syi0cd&P)zywMdS&Y_9k5JDtOM!L)b^2WP!+fHYGv>6!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p<K1~3>C^<jVp}L(pzgMB_Vs-O?{Z?y$8M;) zi@7zwpzV9#m72%En~(9@E)GWV^(~J*@^*K*TE0mynAnGJ5YSLCEnC42H-`tr4L=oW zI}N{xQ$HT8Q6CVHf%RY&xw7!Zj(0xmg(K#UQ4u!ej95z7V4phlcTJ2&AR}$)zV-s! zO7bqY6(=?1t+JCOW_z%HRE>S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk(<gsVPionpJ-imI56$j4P0!br@ny3=!{x2TY^ zCD=)8_PgmN)E!^nczcDGc9Wm7oo5O3@fh=k=kh8J?_3KqEp7JHdv8z_iZ5#KmbiPt z2Bt8Ro^p$7pS!xL3mtj<iN3f}#r6_&$Es0PnJTE?c;0#$%cGdu`T%~`gW;c^VD-S= zrAatMf^%Lzr*wQ4kHSOb?WOUuEsJQ3xr{Imf1t{~iNmRwb_SP9!?FFN=b-E){!8P2 ztWCT~262O8`%?3<W4Wg+ovWY<re)?^kZ|Yi>$?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU<o zeu8G~Z>^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh<G@KZw z+<GL!lpeahq2+nO{>CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c<SELWpDAg~83oY-J_WoDiI6d7>70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*<wp?Ryt$UFh41$qd}LyNJ7Oao(Aw2g|wy zH_nZ+R#~EUME^#j4$@^5&>_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a<qXXnUI&{l`dM&{4Gw)jZn; zlj{VxW@#OcVE1Y%J*u^Z@H+XSqL6SwA|^jv2RU_+d;O!mk)dw7-m9B4{6*G1zRdR6 zQ}6v&Xt7R2h3Xp}EQk4nF2TULG{Ri=D|JC<a+K7dldN1}CY_f!vK#u}K3`g#TpO&W z;!;64`0$d9raD!VbYP`kuFUasaMh!;&81y}LHS(SuGRxwEn4LZb4DS1j9iAq$MXd@ z(Ebka7_Gc(ljGaJqtI-OzmA@c@sYB$)Vg!RP4~``vaVyRq$rJXRjIPwtepN;(B%wy zmU>H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L<c0d<h!DNBIa<xax8W3(Ru8L0cVXQ18|Y^|*S%)R96z zBT$(=zQ}2vmt6LzN~Oyf_Y92%P@QOx{7~}5!UIqCdfu?VwC0Nb!2@iiit8-5zUWFG z*G&+GLIU#J;}hvowNJWnglvb^<2q~lS#?ixVtYT@(O3{TC|4kFJYLB*jni-4YZi0> zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I<Cd*bZlOJ9YmRUK2<qXkpRR3nr6r~%Jz z*(8tA&DYO)etdgVmoonqD{*<5Fog4ClIs-~_uhjuZOI}#Wy+ce${%#oyHloXelqfz z8)?D3Y_>cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU<MM~gB&J0gc}IH}?|B4WRK zWPL0FhctFGdMucOFdhrVunIe5)4K^H9IjB#eA)p5w?c#v7kp8jx^~bxxJB{;hPFL9 zkR9Dbpj+T5ZMgHQg|oj*DS;x&jK}1rn&}Shp9sgOI*7puQD-w?3H*cg72;5H(_zW* zApJBIM-p2~F;qWDj!n|Kd=5|T8OPkQ_G;ujgvKybr5@~eci2{8WAz+%NUSp-&eoG! zOGLNLJewWl&1*NT467W3god~fYgX?!f0?NCFnjD$qE-fyQ)|Q_DLc*{olmXSVl$g_ z$vj}o?RatMy(o*j8?q1Mgw{OUOgVR6_qvS<Co*&!cR`ROi|*I`ajyG5s@L8agnX2J zF=DLkMG`z{RP&996y0yAtvJcb<cba?TV#j4VYFPC>&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=<xUfo0v~z=RA=cFWKXgcMECd}xHp7iqkBanH}TZ0h0rA= zqxUZ>A=<k-RjTtwbJkkep{8z*173wY^e%-U0{Ue!n@wbg^2q)Vx5c(_RfvuR4}XXn z+JE>yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v<oS3Xw7 zu51m`3~hoyxErcHymdFTZd#AO59{EkuFTcpAR33(3xc{zRnn1~1Ei(i*^HdCvM~;; za&}Uip|u>#ix45EVrcEhr>!NMhprl<CqZuKa#zuI&@zymVzIicetS0bq#u?m(r_@S zJ79bl%4EyHCQ3fK@en+A1@)e}HWLP|gr_zuoA{}Z<(-*53Zu@k+=^%~5F(z$EFLI; z-TQTS8$W|GRbZq93Ha1?lu+`O;rn>$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~<Ao%ZuW})CJ)6^(aRV(gGxR z89#(FDW;GZEAf;rI$+PU)rEV|rASrwP0_mr^Ldv)IuUf1M>&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<<q5KGu)u(OEfEJJw2aEi(;x-i=Y=j3ram9H2n-Fuqv0dVlXJ z&WgG5X({!vJFDrEbm+CWDca^zIe2@s1@a;;Y3!U9Q)&P0UXFmCP51_!wvTfAIyR^M z7^R*O@yz1b-s4VC>4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C<kr{U&JG{9FhoZ<aTve_lLz39> zI@}sc<h3gsW}hp-`WUywKA>Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+<Td{{5RWR}u2f(q<b(D$9JsF0OOzJ*+z0P5kc1t}CXlYgua%x*2lSgp|*WS3H-# zdYr7?GQOL18zUS<2|;+vi4|4sQBM2Gs&WVS!D`q5Lz;XR@5rEfa{uG-!q?R8Ncz%( z5K6~LQ@d2wp#)5q4u<ENlFbS)U4o1t9{-d>9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2<VfJZemI(PFAD{6Sm|uE%BTbkl zROsg*MOh20YgGs3H7?@pmQ>`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M<xTd?60J5qsr1Cg7F~~U2N!(@lC<>=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(<ov z$YXcI9;^grAyiJ4dWTv3b}K~Ww09(;mLY4+kj|$A?IMr}`7q?mIS1>O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/bridge/web/src/app/globals.css b/bridge/web/src/app/globals.css new file mode 100644 index 000000000..2420ad9b3 --- /dev/null +++ b/bridge/web/src/app/globals.css @@ -0,0 +1,286 @@ +@import "tailwindcss"; + +/* + * kars Bridge design tokens — refreshed. + * A premium, restrained enterprise palette: cool slate neutrals + a single + * teal "signal" accent reserved for trust/verification surfaces. Elevation, + * radius, and ring tokens give a consistent, modern depth. Token NAMES are + * stable — every existing component inherits the refresh for free. + */ +:root { + --background: #f6f8fb; + --surface: #ffffff; + --surface-muted: #eef2f7; + --border: #e3e9f1; + --foreground: #0b1424; + --foreground-muted: #475268; + --signal: #0e8f86; + --signal-fg: #ffffff; + --accent: #6366f1; + --accent-fg: #ffffff; + --danger: #dc2626; + --warning: #c9700a; + --ok: #15a34a; + --radius: 0.875rem; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05), 0 1px 3px rgba(15, 23, 42, 0.06); + --shadow-md: 0 10px 28px -10px rgba(15, 23, 42, 0.16), 0 2px 6px -2px rgba(15, 23, 42, 0.08); + --shadow-lg: 0 24px 56px -18px rgba(15, 23, 42, 0.26), 0 6px 14px -6px rgba(15, 23, 42, 0.10); +} + +/* Dark tokens. Applied when the operator explicitly chooses dark + * (`html.dark`) OR when the system prefers dark and no explicit light choice + * was made (`html:not(.light)`), so an in-app toggle can override the system. */ +:root.dark { + --background: #070b14; + --surface: #0e1626; + --surface-muted: #18233a; + --border: #1d2a40; + --foreground: #e6edf7; + --foreground-muted: #93a1ba; + --signal: #2dd4bf; + --signal-fg: #042f2e; + --accent: #818cf8; + --accent-fg: #0b1020; + --danger: #f87171; + --warning: #fbbf24; + --ok: #4ade80; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 10px 30px -10px rgba(0, 0, 0, 0.55); +} + +@media (prefers-color-scheme: dark) { + :root:not(.light):not(.dark) { + --background: #070b14; + --surface: #0e1626; + --surface-muted: #18233a; + --border: #1d2a40; + --foreground: #e6edf7; + --foreground-muted: #93a1ba; + --signal: #2dd4bf; + --signal-fg: #042f2e; + --accent: #818cf8; + --accent-fg: #0b1020; + --danger: #f87171; + --warning: #fbbf24; + --ok: #4ade80; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 10px 30px -10px rgba(0, 0, 0, 0.55); + } +} + +@theme inline { + --color-background: var(--background); + --color-surface: var(--surface); + --color-surface-muted: var(--surface-muted); + --color-border: var(--border); + --color-foreground: var(--foreground); + --color-foreground-muted: var(--foreground-muted); + --color-signal: var(--signal); + --color-signal-fg: var(--signal-fg); + --color-accent: var(--accent); + --color-accent-fg: var(--accent-fg); + --color-danger: var(--danger); + --color-warning: var(--warning); + --color-ok: var(--ok); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +body { + background: + radial-gradient(1100px 620px at 78% -8%, color-mix(in srgb, var(--signal) 12%, transparent), transparent 60%), + radial-gradient(900px 560px at -6% 8%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 55%), + var(--background); + background-attachment: fixed; + color: var(--foreground); + font-feature-settings: "cv02", "cv03", "cv04", "ss01"; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Premium scrollbars + smooth, consistent focus across the app. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +/* B34 — visible keyboard focus (WCAG 2.4.7/2.4.11). Keyboard users get a + clear ring; mouse users are unaffected because we key off :focus-visible. */ +:focus-visible { + outline: 2px solid var(--signal, #14b8a6); + outline-offset: 2px; + border-radius: 4px; +} +/* B6 — buttons must advertise their affordance. The browser/Tailwind default + for <button> is cursor: default, making every CTA read as unclickable, and + disabled buttons give no distinct signal. Restore the expected cursors. */ +button:not(:disabled), +[role="button"]:not([aria-disabled="true"]), +summary { + cursor: pointer; +} +button:disabled, +button[aria-disabled="true"], +[role="button"][aria-disabled="true"] { + cursor: not-allowed; +} + +/* ─── Motion + surface utilities (design system) ─────────────────────────── */ +@keyframes kb-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes kb-pop { 0% { opacity: 0; transform: scale(.96); } 60% { transform: scale(1.01); } 100% { opacity: 1; transform: scale(1); } } +@keyframes kb-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } +@keyframes kb-pulse-ring { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--signal) 45%, transparent); } 70% { box-shadow: 0 0 0 7px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } } +@keyframes kb-draw { from { stroke-dashoffset: var(--len, 100); } to { stroke-dashoffset: 0; } } +/* Orchestration cube — a slowly tumbling governed core. Pure presentation; the + data tiles around it carry the REAL orchestration facts. */ +@keyframes kb-cube-spin { + 0% { transform: rotateX(-24deg) rotateY(0deg); } + 100% { transform: rotateX(-24deg) rotateY(360deg); } +} +@keyframes kb-cube-settle { + 0% { transform: rotateX(-24deg) rotateY(var(--from, 300deg)); } + 100% { transform: rotateX(-18deg) rotateY(360deg); } +} +@keyframes kb-edge-flow { + 0%,100% { opacity: .25; } + 50% { opacity: 1; } +} +/* Packet flow along an active graph edge (dash marches from hub → node). */ +@keyframes kb-flow { + to { stroke-dashoffset: -17; } +} +@keyframes kb-agent-breathe { + 0%, 100% { + box-shadow: + 0 8px 24px color-mix(in srgb, var(--signal) 8%, transparent), + 0 0 0 0 color-mix(in srgb, var(--signal) 24%, transparent); + } + 50% { + box-shadow: + 0 12px 32px color-mix(in srgb, var(--signal) 16%, transparent), + 0 0 0 5px transparent; + } +} +@keyframes kb-graph-orbit { + 0% { transform: scale(.88); opacity: .75; } + 70%, 100% { transform: scale(1.25); opacity: 0; } +} +.kb-agent-node-active { animation: kb-agent-breathe 2.6s ease-in-out infinite; } +.kb-graph-edge-active { animation: kb-flow .9s linear infinite; } +.kb-graph-orbit { animation: kb-graph-orbit 2s ease-out infinite; } +.kb-cube-scene { perspective: 640px; } +.kb-cube { + position: relative; + transform-style: preserve-3d; + animation: kb-cube-spin 9s linear infinite; +} +.kb-cube.kb-cube-done { animation: kb-cube-settle 1.1s cubic-bezier(.22,1,.36,1) both; } +.kb-cube-face { + position: absolute; inset: 0; + border: 1px solid color-mix(in srgb, var(--signal) 55%, var(--border)); + background: color-mix(in srgb, var(--signal) 10%, var(--surface)); + border-radius: 10px; +} +@media (prefers-reduced-motion: reduce) { + .kb-cube { animation: none; transform: rotateX(-20deg) rotateY(-32deg); } +} + +/* ── Real Rubik's cube — 6 faces × 3×3 stickers, premium 3D tumble. ───────── */ +@keyframes kb-rubik-tumble { + 0% { transform: rotateX(-28deg) rotateY(-42deg); } + 50% { transform: rotateX(-20deg) rotateY(-6deg); } + 100% { transform: rotateX(-28deg) rotateY(-42deg); } +} +.kb-rubik-scene { + perspective: 760px; + perspective-origin: 50% 42%; + filter: drop-shadow(0 18px 26px color-mix(in srgb, var(--foreground) 22%, transparent)); +} +.kb-rubik { + position: relative; + transform-style: preserve-3d; + transform: rotateX(-26deg) rotateY(-34deg); + animation: kb-rubik-tumble 7s ease-in-out infinite; +} +.kb-rubik.kb-rubik-settle { animation-play-state: paused; transform: rotateX(-22deg) rotateY(-34deg); transition: transform .9s cubic-bezier(.22,1,.36,1); } +.kb-rubik-face { + position: absolute; inset: 0; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 6%; + padding: 6%; + border-radius: 12px; + background: #0b0f16; /* the cube's plastic body */ + box-shadow: inset 0 0 0 1px rgba(255,255,255,.04); + backface-visibility: hidden; +} +.kb-rubik-sticker { + border-radius: 16%; + box-shadow: inset 0 2px 4px rgba(255,255,255,.35), inset 0 -3px 6px rgba(0,0,0,.35), 0 1px 1px rgba(0,0,0,.25); +} +/* Self-assembly: while the package is being computed, stickers cascade in from + nothing (scale + fade, staggered per position) so the cube visibly builds + itself, then keeps a faint "recompute" flicker to read as live calculation. */ +@keyframes kb-sticker-assemble { + 0% { opacity: 0; transform: scale(.15) rotate(-25deg); } + 70% { opacity: 1; transform: scale(1.06); } + 100% { opacity: 1; transform: scale(1) rotate(0); } +} +.kb-rubik-assembling .kb-rubik-sticker { + animation: kb-sticker-assemble .55s cubic-bezier(.22,1,.36,1) both; +} +@media (prefers-reduced-motion: reduce) { + .kb-rubik { animation: none; transform: rotateX(-22deg) rotateY(-34deg); } + .kb-rubik-assembling .kb-rubik-sticker { animation: none; } +} + +.kb-rise { animation: kb-rise .4s cubic-bezier(.22,1,.36,1) both; } +.kb-pop { animation: kb-pop .45s cubic-bezier(.22,1,.36,1) both; } +.kb-stagger > * { animation: kb-rise .45s cubic-bezier(.22,1,.36,1) both; } +.kb-stagger > *:nth-child(1){animation-delay:.04s} +.kb-stagger > *:nth-child(2){animation-delay:.10s} +.kb-stagger > *:nth-child(3){animation-delay:.16s} +.kb-stagger > *:nth-child(4){animation-delay:.22s} +.kb-stagger > *:nth-child(5){animation-delay:.28s} +.kb-stagger > *:nth-child(6){animation-delay:.34s} +.kb-stagger > *:nth-child(7){animation-delay:.40s} +.kb-stagger > *:nth-child(8){animation-delay:.46s} + +.kb-pulse { animation: kb-pulse-ring 1.8s ease-out infinite; } + +.kb-skeleton { + background: linear-gradient(90deg, var(--surface-muted) 25%, color-mix(in srgb, var(--surface-muted) 60%, var(--surface)) 37%, var(--surface-muted) 63%); + background-size: 200% 100%; + animation: kb-shimmer 1.4s ease-in-out infinite; + border-radius: .5rem; +} + +/* Card surface with hover lift — the new default container. */ +.kb-card { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } +/* A faint top highlight gives cards a lit, elevated feel (premium depth). */ +.kb-card::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 1px; border-radius: var(--radius) var(--radius) 0 0; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--foreground) 8%, transparent), transparent); pointer-events: none; } +.kb-card-hover { transition: box-shadow .22s cubic-bezier(.22,1,.36,1), border-color .22s, transform .22s cubic-bezier(.22,1,.36,1); } +.kb-card-hover:hover { box-shadow: var(--shadow-lg); border-color: color-mix(in srgb, var(--signal) 42%, var(--border)); transform: translateY(-2px); } + +/* Subtle dotted/grid texture for hero + composer canvases. */ +.kb-canvas { background-image: radial-gradient(color-mix(in srgb, var(--foreground) 6%, transparent) 1px, transparent 1px); background-size: 22px 22px; } + +/* Org chart — a real reporting-line tree drawn with pure-CSS connectors so the + edges never break on wrap (the row scrolls horizontally instead). */ +.kb-orgtree ul { display: flex; justify-content: center; padding-top: 20px; position: relative; margin: 0; list-style: none; } +.kb-orgtree > ul { padding-top: 0; } +.kb-orgtree li { display: flex; flex-direction: column; align-items: center; position: relative; padding: 20px 12px 0 12px; } +.kb-orgtree li::before, .kb-orgtree li::after { content: ""; position: absolute; top: 0; right: 50%; width: 50%; height: 20px; border-top: 1.5px solid var(--border); } +.kb-orgtree li::after { right: auto; left: 50%; border-left: 1.5px solid var(--border); } +.kb-orgtree li:only-child::before, .kb-orgtree li:only-child::after { display: none; } +.kb-orgtree li:only-child { padding-top: 20px; } +.kb-orgtree li:first-child::before, .kb-orgtree li:last-child::after { border: 0 none; } +.kb-orgtree li:last-child::before { border-right: 1.5px solid var(--border); border-radius: 0 6px 0 0; } +.kb-orgtree li:first-child::after { border-radius: 6px 0 0 0; } +.kb-orgtree ul ul::before { content: ""; position: absolute; top: 0; left: 50%; width: 0; height: 20px; border-left: 1.5px solid var(--border); } +.kb-orgtree-node { display: inline-flex; } + +@media (prefers-reduced-motion: reduce) { + .kb-rise, .kb-pop, .kb-stagger > *, .kb-pulse, .kb-skeleton, + .kb-agent-node-active, .kb-graph-edge-active, .kb-graph-orbit { animation: none; } +} diff --git a/bridge/web/src/app/inbox/approval-actions.ts b/bridge/web/src/app/inbox/approval-actions.ts new file mode 100644 index 000000000..0171f02dc --- /dev/null +++ b/bridge/web/src/app/inbox/approval-actions.ts @@ -0,0 +1,26 @@ +// kars Bridge — approval decision server action (the steering primitive). +"use server"; + +import { defaultNamespace } from "@/lib/config"; +import { decideApproval } from "@/lib/bff"; + +export async function decide( + name: string, + verdict: "approve" | "deny", + resourceVersion: string, + boundEnvelopeDigest: string | null, + reason?: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + await decideApproval(ns, name, { + verdict, + reason: reason || undefined, + resource_version: resourceVersion, + bound_envelope_digest: boundEnvelopeDigest, + }); + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + return { error: null }; +} diff --git a/bridge/web/src/app/layout.tsx b/bridge/web/src/app/layout.tsx new file mode 100644 index 000000000..65ec0571e --- /dev/null +++ b/bridge/web/src/app/layout.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: { + default: "kars Bridge", + template: "%s · kars Bridge", + }, + description: + "Harness-neutral agent mission control on the kars secure-agent substrate.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <html + lang="en" + suppressHydrationWarning + className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} + > + <head> + {/* Apply the saved theme choice before first paint so there's no flash + of the wrong palette. No stored choice = follow the OS preference. */} + <script + dangerouslySetInnerHTML={{ + __html: + "try{var t=localStorage.getItem('kb-theme');if(t==='dark')document.documentElement.classList.add('dark');else if(t==='light')document.documentElement.classList.add('light');}catch(e){}", + }} + /> + </head> + <body className="min-h-full font-sans"> + {/* B35 — skip-navigation link for keyboard users (first focusable + element; visually hidden until focused). Section layouts mark their + main region with id="main-content". */} + <a + href="#main-content" + className="sr-only left-2 top-2 z-50 rounded-lg bg-signal px-3 py-2 text-sm font-medium text-signal-fg focus:not-sr-only focus:absolute" + > + Skip to main content + </a> + {children} + </body> + </html> + ); +} diff --git a/bridge/web/src/app/page.tsx b/bridge/web/src/app/page.tsx new file mode 100644 index 000000000..17982a6c2 --- /dev/null +++ b/bridge/web/src/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +// The product opens on the Workspace (the employee surface). Operators switch +// to the Console from the header. Entitlement-based default routing lands with +// the auth slice. +export default function RootPage() { + redirect("/workspace"); +} diff --git a/bridge/web/src/app/role-actions.ts b/bridge/web/src/app/role-actions.ts new file mode 100644 index 000000000..ddbe834ad --- /dev/null +++ b/bridge/web/src/app/role-actions.ts @@ -0,0 +1,38 @@ +"use server"; + +// kars Bridge — set the DEV role-simulation cookie. No SSO yet, so this lets one +// developer view the Bridge as each role and verify the gates hold. When an auth +// proxy is added it sets this (or a signed header) from verified group claims. + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { ALL_ROLES, type Role } from "@/lib/config"; +import { ssoConfigured } from "@/lib/oidc-config"; + +export async function switchRole(role: string): Promise<void> { + // Hard-disable the dev role switch once a real IdP is configured — otherwise + // any user could set `bridge-role` to `admin` and self-escalate. Under SSO the + // only source of roles is the signed session (see lib/session.ts). + if (ssoConfigured()) { + redirect("/workspace"); + } + const jar = await cookies(); + if (role === "reset" || !(ALL_ROLES as string[]).includes(role)) { + jar.delete("bridge-role"); + } else { + jar.set("bridge-role", role as Role, { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }); + } + // Land on the home each role should see, so switching feels like signing in. + const home = + role === "operator" || role === "admin" + ? "/console" + : role === "auditor" + ? "/audit" + : "/workspace"; + redirect(home); +} diff --git a/bridge/web/src/app/tasks/[name]/execution-panel.tsx b/bridge/web/src/app/tasks/[name]/execution-panel.tsx new file mode 100644 index 000000000..f18956f0f --- /dev/null +++ b/bridge/web/src/app/tasks/[name]/execution-panel.tsx @@ -0,0 +1,303 @@ +"use client"; + +// kars Bridge — execution panel. The §20 launch control + honest live +// execution status. Launching asks the controller to materialize a governed +// sandbox; the panel reflects the real execution phase, including the honest +// "needs a real Foundry endpoint" caveat on a local cluster. + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { StatusBadge } from "@/components/status-badge"; +import { missionStatus } from "@/components/mission-status"; +import type { TaskDetail, ValidationResult } from "@/lib/types"; +import { runMissionClient } from "@/lib/run-mission-client"; +import { setLaunch, validateTask } from "./launch-actions"; + +type Tone = "ok" | "warning" | "danger" | "muted"; + +// One projected status → one label + tone, shared with the page badge and the +// deploy timeline. The panel never invents a state the rest of the page denies. +function statusView(task: TaskDetail): { label: string; tone: Tone } { + const blocked = task.result?.blocked ?? null; + const assignmentMatchesCurrentRun = + task.current_run_nonce == null + || task.assignment?.task_id === task.current_run_nonce; + const assignmentFailed = + assignmentMatchesCurrentRun + && task.assignment?.completed_at != null + && task.assignment.state === "Failed"; + const failed = + assignmentFailed || (task.result?.status === "error" && blocked == null); + const delivered = + !assignmentFailed + && task.result != null + && task.result.status !== "error" + && blocked == null; + const s = missionStatus(task.phase, task.execution_phase, { + delivered, + launched: task.launched, + failed, + }); + switch (s) { + case "running": + return { label: "Running", tone: "ok" }; + case "deploying": + return { label: "Deploying", tone: "warning" }; + case "done": + return { label: "Delivered", tone: "ok" }; + case "blocked": + return { label: "Blocked", tone: "danger" }; + case "failed": + return { label: "Run failed", tone: "danger" }; + default: + return { label: task.launched ? "Starting" : "Ready to launch", tone: "muted" }; + } +} + +export function ExecutionPanel({ task }: { task: TaskDetail }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + const [validation, setValidation] = useState<ValidationResult | null>(null); + const [launchAccepted, setLaunchAccepted] = useState<boolean | null>(null); + const [rerunBaseline, setRerunBaseline] = useState<string | null | undefined>(undefined); + const awaitingAssignment = Boolean( + task.current_run_nonce + && task.assignment?.task_id !== task.current_run_nonce, + ); + const assignmentInFlight = + awaitingAssignment + || ( + task.assignment?.completed_at == null + && (task.assignment?.state === "Assigned" || task.assignment?.state === "Running") + ); + const rerunAccepted = + assignmentInFlight + || (rerunBaseline !== undefined && (task.result?.finished_at ?? null) === rerunBaseline); + const launched = launchAccepted ?? task.launched; + const effectiveTask = { + ...task, + launched, + result: rerunAccepted ? null : task.result, + }; + const ready = task.phase === "Ready"; + + useEffect(() => { + if (launchAccepted === null || launchAccepted === task.launched) return; + const timer = window.setInterval(() => router.refresh(), 2_000); + return () => window.clearInterval(timer); + }, [launchAccepted, router, task.launched]); + + useEffect(() => { + if (!rerunAccepted) return; + const timer = window.setInterval(() => router.refresh(), 2_000); + return () => window.clearInterval(timer); + }, [rerunAccepted, router]); + + function toggle(launch: boolean) { + setError(null); + startTransition(async () => { + // Launching a draft runs the §20 pre-flight gate first — the same checks + // the new-mission flow runs — so a draft can't be launched past a failing + // package. Stopping needs no validation. + if (launch) { + const v = await validateTask(task.name); + setValidation(v); + if (!v.ok) return; + } else { + setValidation(null); + } + const res = await setLaunch(task.name, launch); + if (res.error) { + setError(res.error); + } else { + setLaunchAccepted(launch); + window.setTimeout(() => router.refresh(), 0); + } + }); + } + + function run() { + setError(null); + startTransition(async () => { + const res = await runMissionClient(task.namespace, task.name); + if (res.error) { + setError(res.error); + } else { + setRerunBaseline(task.result?.finished_at ?? null); + window.setTimeout(() => router.refresh(), 0); + } + }); + } + + const sandboxRunning = task.execution_phase === "Running"; + const running = sandboxRunning && effectiveTask.result == null; + const view = statusView(effectiveTask); + // Fail loud: if the mission is launched but the sandbox never reached Running + // and nothing has been delivered, the run is stalled — say so with a reason, + // never a silent "Idle". A common cause is a chat-gateway harness (Hermes) + // that waits for messages instead of executing an autonomous loop. + const stalled = + launched && + !running && + task.result == null && + task.phase !== "Degraded" && + (task.activity?.length ?? 0) === 0; + + return ( + <section + aria-labelledby="exec-heading" + className="rounded-xl border border-border bg-surface p-6" + > + <div className="flex items-center justify-between gap-4"> + <div> + <h2 id="exec-heading" className="text-sm font-semibold"> + Execution + </h2> + <p className="mt-0.5 text-sm text-foreground-muted"> + {rerunAccepted + ? "A corrected rerun is in progress. This panel updates until a new terminal result arrives." + : task.result?.status === "error" + ? "The latest run failed. Its sandbox remains available for a corrected re-run." + : launched + ? "This task is launched — the controller materializes a governed sandbox." + : "Review the trust envelope above, then launch to run a governed agent."} + </p> + </div> + <StatusBadge tone={view.tone} label={view.label} /> + </div> + + {task.sandbox && ( + <dl className="mt-4 flex items-center justify-between border-t border-border pt-3"> + <dt className="text-sm text-foreground-muted">Sandbox</dt> + <dd className="font-mono text-xs">{task.sandbox}</dd> + </dl> + )} + + {stalled && ( + <div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2.5 text-xs text-warning"> + <p className="font-semibold">This run hasn’t started producing work.</p> + <p className="mt-1 leading-relaxed"> + The sandbox is up but the agent hasn’t reached <span className="font-medium">Running</span> or + emitted any activity yet. If this persists, the chosen harness may not execute an autonomous + mission (for example, a chat-gateway harness like <span className="font-mono">Hermes</span> waits + for inbound messages). Check the deploy timeline below, or re-compose with the{" "} + <span className="font-mono">OpenClaw</span> harness for one-shot missions. + </p> + </div> + )} + + {task.execution_detail && ( + <p className="mt-3 rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-foreground-muted"> + {task.execution_detail} + </p> + )} + + {error && ( + <p className="mt-3 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger"> + {error} + </p> + )} + + {validation && ( + <div className="mt-3 rounded-lg border border-border bg-surface-muted/40 px-3 py-2.5"> + <p className="text-xs font-medium text-foreground-muted">Pre-flight check</p> + <ul className="mt-1.5 space-y-1"> + {validation.checks.map((c) => ( + <li key={c.id} className="flex items-start gap-2 text-xs"> + <span + className={ + c.status === "pass" + ? "text-ok" + : c.status === "warn" + ? "text-warning" + : "text-danger" + } + aria-hidden + > + {c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✕"} + </span> + <span> + <span className="font-medium">{c.label}</span>{" "} + <span className="text-foreground-muted">{c.detail}</span> + </span> + </li> + ))} + </ul> + {!validation.ok && ( + <p className="mt-2 text-xs font-medium text-danger"> + This mission can't launch until the failing checks are resolved (fix the connected + services / tool policy in the Operator Console). + </p> + )} + </div> + )} + + <div className="mt-4 flex flex-wrap items-center gap-3"> + {!launched ? ( + <> + <button + type="button" + disabled={!ready || pending} + onClick={() => toggle(true)} + className="rounded-lg bg-signal px-4 py-2 text-sm font-medium text-signal-fg hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {pending ? "Launching…" : "Launch"} + </button> + {!ready && ( + <span className="text-xs text-foreground-muted"> + {task.phase === "Pending" + ? "The controller is admitting this package. Launch enables automatically when it is ready." + : "This package is not launchable; review its status and validation details."} + </span> + )} + {ready && ( + <span className="text-xs text-foreground-muted"> + Materializes the governed sandbox and starts the mission automatically. + </span> + )} + </> + ) : effectiveTask.result ? ( + // Delivered (or a stop condition) — a re-run is a deliberate choice. + <> + <button + type="button" + disabled={pending || !sandboxRunning} + onClick={run} + title={sandboxRunning ? "Run this mission again" : "The sandbox must be Running to re-run."} + className="rounded-lg bg-signal px-4 py-2 text-sm font-medium text-signal-fg hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {pending ? "Running…" : "Run again"} + </button> + <button + type="button" + disabled={pending} + onClick={() => toggle(false)} + title="Tear down the running agent sandbox and free its resources. Keeps the mission and its deliverable." + className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-surface-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {pending ? "Stopping…" : "Stop sandbox"} + </button> + </> + ) : ( + // Launched, first run in flight — it starts automatically, so there's no + // manual "run" button to second-guess; just an honest status + a way out. + <> + <span className="inline-flex items-center gap-2 rounded-lg bg-surface-muted px-3 py-2 text-sm font-medium text-foreground-muted"> + {rerunAccepted || running ? "Running the mission…" : view.label === "Deploying" ? "Coming online…" : "Starting…"} + </span> + <button + type="button" + disabled={pending} + onClick={() => toggle(false)} + title="Tear down the agent sandbox. Keeps the mission; you can launch it again later." + className="text-xs text-foreground-muted underline underline-offset-2 hover:text-foreground disabled:opacity-50" + > + {pending ? "Stopping…" : "Stop"} + </button> + </> + )} + </div> + </section> + ); +} diff --git a/bridge/web/src/app/tasks/[name]/launch-actions.ts b/bridge/web/src/app/tasks/[name]/launch-actions.ts new file mode 100644 index 000000000..18407a9ce --- /dev/null +++ b/bridge/web/src/app/tasks/[name]/launch-actions.ts @@ -0,0 +1,53 @@ +// kars Bridge — launch / un-launch server action (the §20 gate). +"use server"; + +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; +import type { ValidationResult } from "@/lib/types"; + +/** Validate a draft task's own stored blueprint (the §20 gate at launch). */ +export async function validateTask(name: string): Promise<ValidationResult> { + const ns = defaultNamespace(); + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(name)}/validate`, + { method: "POST", cache: "no-store", headers: { accept: "application/json" } }, + ); + if (!res.ok) { + return { + ok: false, + checks: [ + { + id: "validate_error", + label: "Validation could not run", + status: "fail", + detail: `The pre-flight check failed to run (${res.status}).`, + }, + ], + }; + } + return (await res.json()) as ValidationResult; +} + +export async function setLaunch( + name: string, + launch: boolean, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(name)}/launch`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ launch }), + }, + ); + if (!res.ok) { + return { error: `Launch request failed (${res.status}).` }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + return { error: null }; +} diff --git a/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx b/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx new file mode 100644 index 000000000..a06030b5c --- /dev/null +++ b/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx @@ -0,0 +1,87 @@ +// kars Bridge — task-scoped approvals panel. Shows the human decisions gating +// this task (the steering surface, in the task's own context), with inline +// approve/deny for any still pending. + +import { ApprovalDecision } from "@/components/approval-decision"; +import { ApprovalPhaseBadge, actionLabel } from "@/components/approval-phase-badge"; +import { TIER_LABELS, type Approval } from "@/lib/types"; + +export function TaskApprovalsPanel({ + approvals, + decider, + authWired, +}: { + approvals: Approval[]; + decider: string; + authWired: boolean; +}) { + if (approvals.length === 0) return null; + const pendingCount = approvals.filter((a) => a.actionable).length; + + return ( + <section + aria-labelledby="approvals-heading" + className="overflow-hidden rounded-xl border border-border bg-surface" + > + <div className="border-b border-border px-6 py-4"> + <h2 id="approvals-heading" className="text-sm font-semibold"> + Approvals + {pendingCount > 0 && ( + <span className="ml-2 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning"> + {pendingCount} awaiting + </span> + )} + </h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Human decisions gating this task. Each is bound to the envelope it was + requested under and recorded in the Governance Receipt. + </p> + </div> + <ul className="divide-y divide-border"> + {approvals.map((a) => ( + <li key={a.name} className="px-6 py-4"> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5 text-xs font-medium text-foreground-muted"> + {actionLabel(a.action_kind)} + </span> + {a.requested_tier != null && ( + <span className="text-xs text-foreground-muted"> + → Tier {a.requested_tier} ·{" "} + {TIER_LABELS[a.requested_tier] ?? "?"} + </span> + )} + </div> + <p className="mt-1.5 text-sm font-medium">{a.summary}</p> + {a.detail && ( + <p className="mt-0.5 text-xs text-foreground-muted">{a.detail}</p> + )} + </div> + <ApprovalPhaseBadge phase={a.phase} /> + </div> + {a.actionable ? ( + <div className="mt-3"> + <ApprovalDecision + name={a.name} + decider={decider} + authWired={authWired} + resourceVersion={a.resource_version} + boundEnvelopeDigest={a.bound_envelope_digest} + compact + requireReason={a.action_kind === "clarification"} + /> + </div> + ) : ( + <p className="mt-2 text-xs text-foreground-muted"> + {a.decider + ? `${a.phase} by ${a.decider}${a.decided_at ? ` · ${a.decided_at}` : ""}` + : a.phase} + </p> + )} + </li> + ))} + </ul> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/agents/page.tsx b/bridge/web/src/app/workspace/agents/page.tsx new file mode 100644 index 000000000..51fecf932 --- /dev/null +++ b/bridge/web/src/app/workspace/agents/page.tsx @@ -0,0 +1,322 @@ +// kars Bridge Workspace — Active agents. The plain answer to "what is working +// right now, and what just finished?" Sourced from real run telemetry (not idle +// pods): live runs pulse with what they're doing this second; recent runs show +// their outcome, rounds, tools, and token cost. Honest empty when nothing ran. + +import Link from "next/link"; +import { HonestState } from "@/components/honest-state"; +import { LivePulse, LiveRefresh } from "@/components/live-refresh"; +import { FleetLive } from "@/components/fleet-live"; +import { listAgents, getFleetTelemetry } from "@/lib/bff"; +import { TIER_LABELS, type AgentLifecycle, type FleetTelemetry } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function ago(iso: string | null): string { + if (!iso) return ""; + const t = Date.parse(iso); + if (Number.isNaN(t)) return ""; + const s = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +function statusTone(status: string | null): string { + if (status === "ok") return "border-ok/40 bg-ok/10 text-ok"; + if (status) return "border-danger/40 bg-danger/10 text-danger"; + return "border-border bg-surface-muted text-foreground-muted"; +} + +function runMoment(task: string | null): string | null { + if (!task) return null; + const m = task.match(/-run-(\d{10})(\d{0,3})$/); + if (!m) return null; + const ms = Number(m[1]) * 1000 + (m[2] ? Number(m[2].padEnd(3, "0")) : 0); + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? null : d.toLocaleString(); +} + +function agentHref(agent: AgentLifecycle): string { + if (agent.team && agent.task) { + return `/workspace/teams/${encodeURIComponent(agent.team)}/runs/${encodeURIComponent(agent.task)}`; + } + return `/workspace/missions/${encodeURIComponent(agent.task ?? agent.sandbox)}`; +} + +function AgentCard({ a, live }: { a: AgentLifecycle; live: boolean }) { + const moment = runMoment(a.task); + // A team-owned scheduled run is NOT a spawned sub-agent — only runtime-spawned + // children (a parent, with no owning team) are sub-agents. + const isSubAgent = !!a.parent && !a.team; + const hasTrace = (a.rounds ?? 0) > 0 || (a.tool_calls ?? 0) > 0; + return ( + <li className="rounded-xl border border-border bg-surface p-5"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <Link href={agentHref(a)} className="truncate font-medium text-signal hover:underline"> + {a.display_name ?? a.task ?? a.sandbox} + </Link> + {a.team && ( + <Link href={`/workspace/teams/${a.team}`} className="shrink-0 rounded-full bg-surface-muted px-2 py-0.5 text-[11px] text-foreground-muted hover:text-foreground"> + {a.team} + </Link> + )} + {isSubAgent && <span className="shrink-0 rounded-full bg-surface-muted px-2 py-0.5 text-[11px]">sub-agent</span>} + </div> + {moment && <p className="mt-0.5 font-mono text-[11px] text-foreground-muted">{moment}</p>} + {a.objective && <p className="mt-1 line-clamp-2 text-sm text-foreground-muted">{a.objective}</p>} + </div> + {live ? ( + <LivePulse label="Working" /> + ) : ( + <span className={`shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-medium ${statusTone(a.status)}`}> + {a.phase ?? "Idle"} + </span> + )} + </div> + <dl className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-xs text-foreground-muted"> + {a.tier != null && <span>Tier {a.tier} · {TIER_LABELS[a.tier] ?? "?"}</span>} + {live && !hasTrace ? ( + <span className="italic">warming up — waiting for first model round…</span> + ) : ( + <> + <span>{a.rounds} round{a.rounds === 1 ? "" : "s"}</span> + <span>{a.tool_calls} tool call{a.tool_calls === 1 ? "" : "s"}</span> + {a.tokens != null && <span>{a.tokens.toLocaleString()} tokens</span>} + </> + )} + {live && a.last_action && ( + <span>now: <span className="font-mono text-foreground">{a.last_action}</span></span> + )} + {!live && a.finished_at && <span>{ago(a.finished_at)}</span>} + </dl> + {live && a.health && <HealthRow h={a.health} />} + </li> + ); +} + +function HealthRow({ h }: { h: import("@/lib/types").PodHealth }) { + const ready = h.total_containers > 0 && h.ready_containers === h.total_containers; + const unhealthy = !!h.waiting_reason || (h.total_containers > 0 && h.ready_containers < h.total_containers); + return ( + <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border pt-3 text-[11px]"> + <span className="font-medium uppercase tracking-wide text-foreground-muted/80">Health</span> + <HealthChip + tone={unhealthy ? "danger" : ready ? "ok" : "warn"} + label={`${h.ready_containers}/${h.total_containers} ready`} + dot + /> + <HealthChip + tone={h.restarts > 0 ? "warn" : "muted"} + label={`${h.restarts} restart${h.restarts === 1 ? "" : "s"}`} + /> + {h.uptime_seconds != null && <HealthChip tone="muted" label={`up ${fmtUptime(h.uptime_seconds)}`} />} + {h.node && <HealthChip tone="muted" label={h.node} mono />} + {h.waiting_reason && <HealthChip tone="danger" label={h.waiting_reason} />} + </div> + ); +} + +function HealthChip({ + tone, + label, + dot, + mono, +}: { + tone: "ok" | "warn" | "danger" | "muted"; + label: string; + dot?: boolean; + mono?: boolean; +}) { + const cls = { + ok: "border-ok/30 bg-ok/10 text-ok", + warn: "border-warning/30 bg-warning/10 text-warning", + danger: "border-danger/30 bg-danger/10 text-danger", + muted: "border-border bg-surface-muted text-foreground-muted", + }[tone]; + return ( + <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${cls} ${mono ? "font-mono" : ""}`}> + {dot && <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden />} + {label} + </span> + ); +} + +function fmtUptime(s: number): string { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d`; +} + +function fmtTok(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`; + return `${n}`; +} + +/** Spend-against-budget gauge for a single run — spent tokens over the envelope + * ceiling, with a bar and an over-budget flag. Pure surfacing of data the + * substrate already records (mission-output tokens + envelope budget). */ +function SpendGauge({ spent, budget }: { spent: number | null; budget: number | null }) { + if (spent == null && budget == null) return null; + if (budget == null) { + return <span className="tabular-nums text-xs text-foreground-muted">{fmtTok(spent ?? 0)} tok</span>; + } + const pct = spent != null ? Math.min(100, Math.round((spent / Math.max(budget, 1)) * 100)) : 0; + const over = spent != null && spent > budget; + const near = pct >= 80 && !over; + const tone = over ? "bg-danger" : near ? "bg-warning" : "bg-ok"; + return ( + <span className="flex items-center gap-2" title={`${(spent ?? 0).toLocaleString()} of ${budget.toLocaleString()} token budget (${pct}%)`}> + <span className="hidden h-1.5 w-16 overflow-hidden rounded-full bg-surface-muted sm:block"> + <span className={`block h-full ${tone}`} style={{ width: `${Math.max(pct, 2)}%` }} /> + </span> + <span className={`tabular-nums text-xs ${over ? "text-danger" : "text-foreground-muted"}`}> + {fmtTok(spent ?? 0)}/{fmtTok(budget)} + </span> + </span> + ); +} + +function RecentRunRow({ a }: { a: AgentLifecycle }) { + const moment = runMoment(a.task); + const label = a.display_name && a.display_name !== `${a.team ?? ""} — standing run` ? a.display_name : null; + return ( + <li> + <Link + href={agentHref(a)} + className="flex items-center gap-3 px-4 py-2.5 text-sm transition hover:bg-surface-muted/50" + > + <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${a.status === "ok" ? "bg-ok" : a.status ? "bg-danger" : "bg-foreground-muted"}`} aria-hidden /> + <span className="min-w-0 flex-1 truncate"> + <span className="font-mono text-xs text-foreground-muted">{moment ?? a.task ?? a.sandbox}</span> + {label && <span className="ml-2 text-foreground">{label}</span>} + {a.team && ( + <span className="ml-2 rounded-full bg-surface-muted px-1.5 py-0.5 text-[10px] text-foreground-muted">{a.team}</span> + )} + </span> + <span className="hidden shrink-0 gap-3 text-xs text-foreground-muted sm:flex"> + <span>{a.rounds} rd</span> + <span>{a.tool_calls} tools</span> + <SpendGauge spent={a.tokens} budget={a.budget_tokens} /> + </span> + <span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] font-medium ${statusTone(a.status)}`}> + {a.status === "ok" ? "Delivered" : a.status ? "Errored" : a.phase ?? "Idle"} + </span> + {a.finished_at && <span className="w-14 shrink-0 text-right text-[11px] text-foreground-muted">{ago(a.finished_at)}</span>} + </Link> + </li> + ); +} + +export default async function AgentsPage() { + let agents: AgentLifecycle[] = []; + let fleet: FleetTelemetry | null = null; + let error = false; + try { + [agents, fleet] = await Promise.all([listAgents(), getFleetTelemetry().catch(() => null)]); + } catch { + error = true; + } + + const live = agents.filter((a) => a.live); + const recent = agents.filter((a) => !a.live); + + return ( + <div className="space-y-6"> + <LiveRefresh active intervalMs={4000} /> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Active agents</h1> + <p className="mt-1 text-sm text-foreground-muted"> + What's working right now, and what just finished — real run telemetry, not idle pods. + </p> + </div> + + {/* Fleet-wide live telemetry — the at-scale view: aggregate live metrics + + a single streaming feed of what every working agent is doing now. */} + {!error && <FleetLive initial={fleet} />} + + {/* Cost cockpit — total token spend across recent runs, and how it sits + against the budgets those runs carried. Surfaces data the substrate + already records (mission-output tokens + envelope budgets); "costs + opaque" is the #6 enterprise complaint, so this is a headline gauge. */} + {!error && agents.some((a) => a.tokens != null) && (() => { + const spend = agents.reduce((s, a) => s + (a.tokens ?? 0), 0); + const budgeted = agents.filter((a) => a.budget_tokens != null); + const budgetSum = budgeted.reduce((s, a) => s + (a.budget_tokens ?? 0), 0); + const overCount = agents.filter((a) => a.tokens != null && a.budget_tokens != null && a.tokens > a.budget_tokens).length; + const pct = budgetSum > 0 ? Math.min(100, Math.round((budgeted.reduce((s, a) => s + (a.tokens ?? 0), 0) / budgetSum) * 100)) : null; + return ( + <section className="rounded-2xl border border-border bg-surface p-5"> + <div className="flex items-baseline justify-between"> + <h2 className="text-sm font-semibold">Spend & budget</h2> + <span className="text-[11px] text-foreground-muted">across {agents.length} recent run{agents.length === 1 ? "" : "s"}</span> + </div> + <div className="mt-3 grid grid-cols-2 gap-4 sm:grid-cols-4"> + <div> + <p className="text-2xl font-semibold tabular-nums">{spend.toLocaleString()}</p> + <p className="text-xs text-foreground-muted">tokens spent</p> + </div> + <div> + <p className="text-2xl font-semibold tabular-nums">{budgetSum > 0 ? budgetSum.toLocaleString() : "—"}</p> + <p className="text-xs text-foreground-muted">budgeted ({budgeted.length}/{agents.length} capped)</p> + </div> + <div> + <p className={`text-2xl font-semibold tabular-nums ${pct != null && pct >= 80 ? "text-warning" : ""}`}>{pct != null ? `${pct}%` : "—"}</p> + <p className="text-xs text-foreground-muted">of budget used</p> + </div> + <div> + <p className={`text-2xl font-semibold tabular-nums ${overCount > 0 ? "text-danger" : "text-ok"}`}>{overCount}</p> + <p className="text-xs text-foreground-muted">over budget</p> + </div> + </div> + {pct != null && ( + <div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-surface-muted"> + <div className={`h-full ${pct >= 100 ? "bg-danger" : pct >= 80 ? "bg-warning" : "bg-ok"}`} style={{ width: `${Math.max(pct, 2)}%` }} /> + </div> + )} + </section> + ); + })()} + + {error ? ( + <HonestState variant="not_wired" title="Run environment unreachable" detail="Agents will appear once it reconnects." /> + ) : agents.length === 0 ? ( + <HonestState variant="empty" title="No agent runs yet" detail="Launch a mission or a standing team and its agents — live and recent — show here." /> + ) : ( + <div className="space-y-6"> + <section> + <div className="mb-2 flex items-center gap-2"> + <h2 className="text-sm font-semibold">Live agent runs</h2> + <span className="rounded-full bg-surface-muted px-2 py-0.5 text-[11px] text-foreground-muted">{live.length}</span> + <span className="text-[11px] text-foreground-muted" title="The headline 'Working now' above counts every running sandbox including spawned sub-agents; this lists the top-level runs.">top-level runs</span> + </div> + {live.length === 0 ? ( + <p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-foreground-muted"> + No agent is working this moment. Standing teams mint a run on their cadence; recent results are below. + </p> + ) : ( + <ul className="space-y-3">{live.map((a) => <AgentCard key={a.sandbox} a={a} live />)}</ul> + )} + </section> + + {recent.length > 0 && ( + <section> + <div className="mb-2 flex items-center gap-2"> + <h2 className="text-sm font-semibold">Recent runs</h2> + <span className="rounded-full bg-surface-muted px-2 py-0.5 text-[11px] text-foreground-muted">{recent.length}</span> + </div> + <ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface"> + {recent.map((a) => <RecentRunRow key={a.sandbox} a={a} />)} + </ul> + </section> + )} + </div> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/connections/page.tsx b/bridge/web/src/app/workspace/connections/page.tsx new file mode 100644 index 000000000..3ab39aa4b --- /dev/null +++ b/bridge/web/src/app/workspace/connections/page.tsx @@ -0,0 +1,41 @@ +// kars Bridge Workspace — Connections. Where a USER connects their own GitHub +// repos so their agents can open pull requests (keyless). Operator-level App +// setup lives in the Console; each user's GitHub connection is isolated. + +import { PageHeader, Section } from "@/components/ui"; +import { ConnectGithub } from "@/components/connect-github"; +import { ConnectChannels } from "@/components/connect-channels"; +import { ConnectTeams } from "@/components/connect-teams"; +import { defaultNamespace } from "@/lib/config"; + +export const dynamic = "force-dynamic"; + +export default function ConnectionsPage() { + const ns = defaultNamespace(); + return ( + <div className="space-y-6"> + <PageHeader + title="Connections" + lead="Connect the tools and channels your agents work with. GitHub is private to your signed-in user; messaging channels are workspace-wide. No agent ever handles a raw credential." + /> + <Section + title="GitHub" + subtitle="Install the kars app on the repositories you want your agents to work on, then Connect. When a mission needs to push, the router mints a short-lived, repo-scoped token and injects it — your agents never see a token, and you can Disconnect to remove your grant." + > + <ConnectGithub ns={ns} /> + </Section> + <Section + title="Channels" + subtitle="Wire Telegram, Slack, Discord, or WhatsApp once for the whole workspace. Any mission or team can then report its progress and deliverables over them — agent-agnostic, harness-neutral. Tokens are stored encrypted-at-rest as a Kubernetes secret and never shown again." + > + <ConnectChannels ns={ns} /> + </Section> + <Section + title="Microsoft Teams" + subtitle="Connect a Microsoft Teams channel for HITL approval cards and proactive updates. Requires an Entra App Registration with Bot enabled and admin consent. Credentials are write-only." + > + <ConnectTeams ns={ns} /> + </Section> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/inbox/loading.tsx b/bridge/web/src/app/workspace/inbox/loading.tsx new file mode 100644 index 000000000..2a4a12d65 --- /dev/null +++ b/bridge/web/src/app/workspace/inbox/loading.tsx @@ -0,0 +1,5 @@ +import { ListSkeleton } from "@/components/list-skeleton"; + +export default function Loading() { + return <ListSkeleton />; +} diff --git a/bridge/web/src/app/workspace/inbox/page.tsx b/bridge/web/src/app/workspace/inbox/page.tsx new file mode 100644 index 000000000..f17575d1d --- /dev/null +++ b/bridge/web/src/app/workspace/inbox/page.tsx @@ -0,0 +1,329 @@ +// kars Bridge Workspace — Inbox. The fleet-wide decision queue. Every card +// answers what/by-which-role/why/impact before the buttons (no rubber-stamping). + +import Link from "next/link"; +import { ApprovalDecision } from "@/components/approval-decision"; +import { ClarificationAnswer } from "@/components/clarification-answer"; +import { actionLabel } from "@/components/approval-phase-badge"; +import { HonestState } from "@/components/honest-state"; +import { Icon } from "@/components/icon"; +import { BffError, getDigests, listApprovals, listTeams } from "@/lib/bff"; +import { authWired, defaultNamespace, operatorIdentity } from "@/lib/config"; +import { currentPrincipal } from "@/lib/session"; +import { type Approval, type Digest, type TeamSummary } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function digestTone(health: string): string { + switch (health) { + case "Healthy": + return "border-emerald-500/30"; + case "Stalled": + return "border-rose-500/30"; + case "Unproductive": + return "border-amber-500/30"; + default: + return "border-border"; + } +} + +function DigestCard({ d, priorCount = 0 }: { d: Digest; priorCount?: number }) { + return ( + <li className={`rounded-xl border ${digestTone(d.health)} bg-surface p-5`}> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <Link + href={`/workspace/teams/${encodeURIComponent(d.team)}`} + className="text-sm font-semibold text-signal hover:underline" + > + {d.team} + </Link> + <p className="mt-1 text-sm">{d.summary}</p> + </div> + <span className="shrink-0 rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium text-foreground-muted"> + {d.health} + </span> + </div> + <div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-foreground-muted"> + <span>{d.runs_generated} runs</span> + <span>{d.runs_delivered} delivered</span> + <span>{d.tokens_spent.toLocaleString()} tokens</span> + <span>{d.knowledge_entries} knowledge entries</span> + <span>{new Date(d.at).toLocaleString()}</span> + {priorCount > 0 && ( + <Link href={`/workspace/teams/${encodeURIComponent(d.team)}`} className="hover:underline" title="Earlier digests from this team"> + +{priorCount} earlier + </Link> + )} + {d.channel && ( + <span className="inline-flex items-center gap-1 rounded-full bg-surface-muted px-2 py-0.5 font-mono text-[11px]" title="Verified reporting channel — reports travel only this declared line"> + {d.gated ? <Icon name="lock" size={10} className="inline mr-0.5" /> : null}{d.channel} + </span> + )} + </div> + </li> + ); +} + +function isTeamMilestoneReview(a: Approval): boolean { + return a.action_kind === "checkpoint" && a.team != null && a.milestone != null; +} + +function impactLine(a: Approval): string { + switch (a.action_kind) { + case "egress": + return "Reaches an external system · review the destination"; + case "tierRaise": + return a.requested_tier != null + ? `Raises this role's authority to Tier ${a.requested_tier} · grants more independence` + : "Raises this role's authority"; + case "budgetRaise": + return "Raises the governed token ceiling · tools and authority stay unchanged"; + case "clarification": + return "The agent is asking you a question · your answer resumes its active run"; + case "irreversible": + return "Cannot be undone · review carefully"; + case "toolCall": + return "Calls an external tool · may cost money"; + case "checkpoint": + return isTeamMilestoneReview(a) + ? "Review a Team outcome · approve it or return exact changes to the same work item" + : "Checkpoint sign-off · confirms progress"; + default: + return "Needs your decision"; + } +} + +function DecisionCard({ + a, + decider, + auth, + team, +}: { + a: Approval; + decider: string; + auth: boolean; + team: TeamSummary | null; +}) { + const decided = !a.actionable; + const teamMilestoneReview = isTeamMilestoneReview(a); + return ( + <li className={`rounded-xl border p-5 ${decided ? "border-border bg-surface" : "border-warning/30 bg-warning/5"}`}> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-2"> + <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5 text-xs font-medium text-foreground-muted"> + {actionLabel(a.action_kind)} + </span> + <Link + href={ + team + ? `/workspace/teams/${encodeURIComponent(team.name)}/runs/${encodeURIComponent(a.task)}` + : `/workspace/missions/${encodeURIComponent(a.task)}` + } + className="text-xs text-signal hover:underline" + > + {team ? "Team run" : "Mission"} {a.task} + </Link> + </div> + <p className="mt-2 text-sm font-medium">{a.summary}</p> + {a.detail && <p className="mt-0.5 text-xs text-foreground-muted">{a.detail}</p>} + <p className="mt-2 inline-flex items-center gap-1.5 text-xs text-foreground-muted"> + <svg viewBox="0 0 16 16" className="h-3.5 w-3.5 text-warning" fill="currentColor" aria-hidden> + <path d="M8 1.5 1 14h14L8 1.5Zm0 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 6.5ZM8 11.5a.9.9 0 1 0 0 1.8.9.9 0 0 0 0-1.8Z" /> + </svg> + {impactLine(a)} + </p> + </div> + {decided && ( + <span className="shrink-0 text-right text-xs text-foreground-muted"> + <span className="block">{a.phase}{a.decider ? ` by ${a.decider}` : ""}</span> + {a.decided_at && <span className="block">{new Date(a.decided_at).toLocaleString()}</span>} + </span> + )} + </div> + {a.actionable && ( + <div className="mt-4 border-t border-warning/20 pt-3"> + {a.action_kind === "clarification" ? ( + <ClarificationAnswer + name={a.name} + decider={decider} + authWired={auth} + resourceVersion={a.resource_version} + boundEnvelopeDigest={a.bound_envelope_digest} + /> + ) : ( + <ApprovalDecision + name={a.name} + decider={decider} + authWired={auth} + resourceVersion={a.resource_version} + boundEnvelopeDigest={a.bound_envelope_digest} + approveLabel={teamMilestoneReview ? "Approve outcome" : "Approve"} + denyLabel={teamMilestoneReview ? "Request changes" : "Deny"} + requireDenyReason={teamMilestoneReview} + reasonPlaceholder={ + teamMilestoneReview + ? "Describe exactly what the Team must change before you review this work again." + : undefined + } + /> + )} + </div> + )} + </li> + ); +} + +export default async function WorkspaceInbox({ + searchParams, +}: { + searchParams: Promise<{ history?: string }>; +}) { + const { history } = await searchParams; + const ns = defaultNamespace(); + const principal = await currentPrincipal(); + const decider = principal.name || operatorIdentity(); + const auth = authWired(); + + let approvals: Approval[] = []; + let teams: TeamSummary[] = []; + let error: string | null = null; + try { + [approvals, teams] = await Promise.all([listApprovals(ns), listTeams(ns).catch(() => [])]); + } catch (err) { + error = err instanceof BffError ? err.code : "unknown"; + } + + let digests: Digest[] = []; + try { + digests = await getDigests(); + } catch { + digests = []; + } + + const pending = approvals.filter((a) => a.actionable); + const decided = approvals.filter((a) => !a.actionable); + const visibleDecided = history === "all" ? decided : decided.slice(0, 10); + const teamFor = (approval: Approval) => + teams.find( + (team) => + team.name === approval.team + || approval.task === `${team.name}-principal` + || approval.task.startsWith(`${team.name}-run-`), + ) ?? null; + // One card per team — the latest digest — with a count of how many it stands + // for, so a chatty/stalled team doesn't flood the inbox with identical cards. + const digestGroups = Array.from( + digests + .reduce((acc, d) => { + const g = acc.get(d.team); + if (!g) acc.set(d.team, { latest: d, count: 1 }); + else { + g.count += 1; + if ((d.at ?? "") > (g.latest.at ?? "")) g.latest = d; + } + return acc; + }, new Map<string, { latest: Digest; count: number }>()) + .values(), + ).sort((a, b) => (b.latest.at ?? "").localeCompare(a.latest.at ?? "")); + + return ( + <div className="space-y-6"> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Inbox</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Decisions your missions and standing teams are waiting on. Approve, deny, or adjust — + every choice is recorded in the work's Governance Receipt. + </p> + </div> + + {error ? ( + <HonestState variant="not_wired" title="Inbox unavailable" detail="The run environment isn't reachable right now." /> + ) : ( + <> + <section> + <h2 className="mb-3 text-sm font-semibold"> + Waiting on you + {pending.length > 0 && ( + <span + aria-label={`${pending.length} items waiting`} + className="ml-2 rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning" + > + {pending.length} + </span> + )} + </h2> + {pending.length === 0 ? ( + <HonestState variant="empty" compact title="No approvals pending" detail="No mission or team run is waiting on a human decision right now." /> + ) : ( + <ul className="mt-4 space-y-3"> + {pending.map((a) => ( + <DecisionCard + key={a.name} + a={a} + decider={decider} + auth={auth} + team={teamFor(a)} + /> + ))} + </ul> + )} + </section> + + {decided.length > 0 && ( + <details className="rounded-xl border border-border bg-surface p-5"> + <summary className="cursor-pointer list-none text-sm font-semibold"> + Decision history + <span className="ml-2 rounded-full bg-surface-muted px-2 py-0.5 text-xs font-medium text-foreground-muted"> + {decided.length} + </span> + <span className="ml-2 text-xs font-normal text-foreground-muted"> + resolved items are collapsed by default + </span> + </summary> + <ul className="space-y-3"> + {visibleDecided.map((a) => ( + <DecisionCard + key={a.name} + a={a} + decider={decider} + auth={auth} + team={teamFor(a)} + /> + ))} + </ul> + {decided.length > visibleDecided.length && ( + <Link + href="/workspace/inbox?history=all" + className="mt-3 inline-block text-xs font-medium text-signal hover:underline" + > + Show all {decided.length} resolved decisions → + </Link> + )} + </details> + )} + + {digestGroups.length > 0 && ( + <section> + <h2 className="mb-3 text-sm font-semibold"> + Team digests + <span className="ml-2 rounded-full bg-surface-muted px-2 py-0.5 text-xs font-medium text-foreground-muted"> + {digestGroups.length} + </span> + </h2> + <p className="mb-3 text-xs text-foreground-muted"> + Your standing teams reporting in — the latest from each. No action needed. + </p> + <ul className="space-y-3"> + {digestGroups.map((g) => ( + <DigestCard key={g.latest.team} d={g.latest} priorCount={g.count - 1} /> + ))} + </ul> + </section> + )} + </> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/layout.tsx b/bridge/web/src/app/workspace/layout.tsx new file mode 100644 index 000000000..0c213c2bf --- /dev/null +++ b/bridge/web/src/app/workspace/layout.tsx @@ -0,0 +1,97 @@ +// kars Bridge Workspace — the employee shell. +// +// Consumer-grade chrome: warm, generous spacing, the product identity, the +// current operator identity (honest: no auth session yet), and the surface +// switcher. NO Kubernetes concept ever appears here — no namespace, no pod, no +// CRD. This is the surface that must be flawless for a non-technical task-giver. + +import Link from "next/link"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { WorkspaceNav } from "@/components/workspace-nav"; +import { SurfaceSwitcher } from "@/components/surface-switcher"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { listApprovals } from "@/lib/bff"; +import { operatorIdentity, authWired, defaultNamespace } from "@/lib/config"; +import { currentPrincipal } from "@/lib/session"; +import { loginPath, safeReturnTo } from "@/lib/auth-return"; +import { ssoConfigured } from "@/lib/oidc-config"; +import { RoleSwitcher } from "@/components/role-switcher"; + +import type { Metadata as _Metadata } from "next"; +export const metadata: _Metadata = { title: "Workspace" }; +export default async function WorkspaceLayout({ + children, +}: { + children: React.ReactNode; +}) { + const auth = authWired(); + const principal = await currentPrincipal(); + const who = principal.name || operatorIdentity(); + const requestPath = safeReturnTo( + (await headers()).get("x-bridge-return-to"), + "/workspace", + ); + if (ssoConfigured() && principal.roles.length === 0) { + redirect(loginPath(requestPath)); + } + const canUseWorkspace = principal.roles.some((role) => + ["user", "operator", "admin"].includes(role), + ); + if (ssoConfigured() && !canUseWorkspace) { + redirect(principal.roles.includes("auditor") ? "/audit" : "/auth/no-roles"); + } + const canOperate = principal.roles.includes("operator"); + let pendingAsks = 0; + try { + const approvals = await listApprovals(defaultNamespace()); + pendingAsks = approvals.filter((a) => a.actionable).length; + } catch { + pendingAsks = 0; + } + return ( + <div className="flex min-h-full flex-col"> + <header className="sticky top-0 z-10 border-b border-border bg-surface/80 backdrop-blur"> + <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6"> + <Link + href="/workspace" + prefetch={false} + className="flex items-center gap-2.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <span className="grid h-7 w-7 place-items-center rounded-md bg-signal text-signal-fg text-sm font-bold"> + kb + </span> + <span className="font-semibold tracking-tight">kars Bridge</span> + </Link> + <div className="flex items-center gap-3"> + <span + className="hidden items-center gap-1.5 text-xs text-foreground-muted sm:inline-flex" + title={auth ? undefined : "No authenticated session yet — identity is the configured operator."} + > + <span className="grid h-5 w-5 place-items-center rounded-full bg-surface-muted text-[10px] font-semibold text-foreground-muted"> + {who.slice(0, 1).toUpperCase()} + </span> + {who} + {!auth && <span className="ml-1 rounded bg-surface-muted px-1.5 py-0.5 text-[10px] font-medium text-foreground-muted/70">dev</span>} + </span> + <SurfaceSwitcher canOperate={canOperate} canAudit={principal.roles.includes("auditor")} /> + <RoleSwitcher + principal={principal.name} + primary={principal.primary} + roles={principal.roles} + simulated={principal.simulated} + ssoSignedIn={principal.ssoSignedIn} + ssoAvailable={ssoConfigured()} + /> + <ThemeToggle /> + </div> + </div> + </header> + + <div className="mx-auto flex w-full max-w-6xl flex-1 gap-8 px-6 py-8"> + <WorkspaceNav pendingAsks={pendingAsks} /> + <main id="main-content" className="min-w-0 flex-1">{children}</main> + </div> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx b/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx new file mode 100644 index 000000000..94f98ab8b --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { runMissionClient } from "@/lib/run-mission-client"; + +function suggestedBudget(current: number | null, spent: number | null) { + const baseline = current ?? 200_000; + const target = Math.max(baseline * 2, (spent ?? baseline) + baseline); + return Math.ceil(target / 50_000) * 50_000; +} + +export function BudgetRecovery({ + namespace, + name, + current, + spent, + stoppedLimit, + approvalPending, +}: { + namespace: string; + name: string; + current: number | null; + spent: number | null; + stoppedLimit: number | null; + approvalPending: boolean; +}) { + const router = useRouter(); + const [budget, setBudget] = useState(suggestedBudget(current, spent)); + const [pending, startTransition] = useTransition(); + const [requested, setRequested] = useState(approvalPending); + const [error, setError] = useState<string | null>(null); + const [message, setMessage] = useState<string | null>(null); + + function requestIncrease() { + setError(null); + setMessage(null); + startTransition(async () => { + try { + const response = await fetch( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/budget`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ daily_tokens: budget }), + }, + ); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.requested) { + setError(payload?.error ?? `Budget request failed (${response.status}).`); + return; + } + setRequested(true); + setMessage( + `Requested a ${budget.toLocaleString()} token ceiling. Approve the typed budget request below or in Inbox.`, + ); + router.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Budget request failed."); + } + }); + } + + function continueMission() { + setError(null); + setMessage(null); + startTransition(async () => { + try { + const run = await runMissionClient(namespace, name); + if (run.error) { + setError(run.error); + return; + } + setMessage( + `The next governed run has started with the ${current?.toLocaleString()} token ceiling.`, + ); + router.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Mission restart failed."); + } + }); + } + + if (stoppedLimit != null && current != null && current > stoppedLimit) { + return ( + <div className="mt-3 rounded-lg border border-ok/30 bg-ok/[0.05] px-3 py-3"> + <p className="text-xs font-medium"> + Budget increase approved — the enforced ceiling is now {current.toLocaleString()} tokens. + </p> + <button + type="button" + disabled={pending} + onClick={continueMission} + className="mt-2 rounded-md bg-signal px-3 py-2 text-xs font-semibold text-signal-fg disabled:opacity-50" + > + {pending ? "Starting…" : "Continue mission"} + </button> + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + {message && <p className="mt-2 text-xs text-ok">{message}</p>} + </div> + ); + } + + if (requested) { + return ( + <div className="mt-3 rounded-lg border border-warning/30 bg-surface px-3 py-3"> + <p className="text-xs font-medium">Budget increase awaiting human approval.</p> + <p className="mt-1 text-[11px] text-foreground-muted"> + The controller will widen the envelope only after the typed approval is accepted. + </p> + <a href="/workspace/inbox" className="mt-2 inline-block text-xs font-medium text-signal hover:underline"> + Open Inbox approval → + </a> + {message && <p className="mt-2 text-xs text-ok">{message}</p>} + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + </div> + ); + } + + return ( + <div className="mt-3 rounded-lg border border-warning/30 bg-surface px-3 py-3"> + <div className="flex flex-wrap items-end gap-2"> + <label className="text-xs font-medium"> + Requested daily token budget + <input + type="number" + min={(current ?? 0) + 1} + step={50_000} + value={budget} + onChange={(event) => setBudget(Number(event.target.value))} + className="mt-1 block w-44 rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm tabular-nums" + /> + </label> + <button + type="button" + disabled={pending || budget <= (current ?? 0)} + onClick={requestIncrease} + className="rounded-md bg-signal px-3 py-2 text-xs font-semibold text-signal-fg disabled:opacity-50" + > + {pending ? "Requesting…" : "Request budget increase"} + </button> + </div> + <p className="mt-2 text-[11px] text-foreground-muted"> + Opens a typed approval. The controller—not Bridge—widens the trust envelope after approval. + </p> + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts b/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts new file mode 100644 index 000000000..8b7734faf --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts @@ -0,0 +1,34 @@ +// kars Bridge — "Delete mission" server action. Permanently removes a mission +// via the BFF (which deletes the KarsTask and sweeps its deliverable, files, +// trace, and review record). Destructive and irreversible; the control gates it +// behind an explicit confirm. +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function deleteMission(name: string): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(name)}`, + { method: "DELETE", cache: "no-store" }, + ); + if (!res.ok) { + let message = `Delete failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // keep status message + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath("/workspace/missions"); + redirect("/workspace/missions"); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx b/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx new file mode 100644 index 000000000..b3deaf596 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx @@ -0,0 +1,62 @@ +"use client"; + +// kars Bridge — "Delete mission" control. Deleting a mission is destructive: it +// removes the KarsTask and sweeps its deliverable, files, trace, and review +// record. Gated behind an explicit two-step confirm before the server action. + +import { useState, useTransition } from "react"; +import { deleteMission } from "./delete-actions"; + +export function DeleteMissionControl({ name }: { name: string }) { + const [pending, startTransition] = useTransition(); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState<string | null>(null); + + function submit() { + setError(null); + startTransition(async () => { + const res = await deleteMission(name); + // On success the action redirects; only an error returns here. + if (res?.error) { + setError(res.error); + setConfirming(false); + } + }); + } + + if (!confirming) { + return ( + <button + type="button" + onClick={() => setConfirming(true)} + className="cursor-pointer rounded-lg border border-rose-500/60 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:border-rose-600 hover:bg-rose-600 hover:text-white" + title="Permanently delete this mission and its deliverable, files, trace, and review." + > + Delete mission + </button> + ); + } + + return ( + <div className="flex items-center gap-2"> + <span className="text-xs text-foreground-muted">Delete this mission and its records?</span> + <button + type="button" + disabled={pending} + onClick={submit} + className="cursor-pointer rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" + > + {pending ? "Deleting…" : "Yes, delete"} + </button> + <button + type="button" + disabled={pending} + onClick={() => setConfirming(false)} + className="cursor-pointer rounded-lg border border-border px-3 py-1.5 text-xs text-foreground-muted transition hover:bg-surface-muted" + > + Cancel + </button> + {error && <span className="text-xs text-rose-600">{error}</span>} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx b/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx new file mode 100644 index 000000000..8fc0872c2 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx @@ -0,0 +1,123 @@ +// kars Bridge Workspace — the live Deploy timeline (design note FL/REQ15: +// "dynamically watch agents deploy"). The journey rail shows the high-level +// beat; this fills the Build→Run gap with the granular, real provisioning +// steps so launch is a visible event, not a status flip. Every step is derived +// from real task state (no fabrication): launch approved → sandbox provisioning +// → inference router + access verified → agent online on the mesh → first +// activity → running. Updates with the page's live refresh. + +import type { TaskDetail } from "@/lib/types"; + +type StepState = "done" | "active" | "pending" | "failed"; + +function dotClass(s: StepState): string { + return s === "done" + ? "bg-emerald-500" + : s === "failed" + ? "bg-danger" + : s === "active" + ? "bg-signal kb-pulse" + : "bg-surface-muted"; +} + +export function DeployTimeline({ task }: { task: TaskDetail }) { + const phase = task.execution_phase; + const running = phase === "Running"; + const launching = phase === "Launching" || phase === "Pending"; + const hasSandbox = !!task.sandbox; + const accessVerified = running || phase === "Succeeded"; + const agentOnline = !!task.agent_identity?.last_seen; + const firstActivity = (task.activity?.length ?? 0) > 0; + const failed = task.result?.status === "error"; + const succeeded = !failed && (phase === "Succeeded" || !!task.result); + + // Derive each step's state from real signals. A step is "done" once a later + // signal proves it completed; "active" when it's the current frontier. + const steps: { label: string; detail: string; state: StepState }[] = [ + { + label: "Launch approved", + detail: "You authorized the start — the controller began materializing a sandbox.", + state: task.launched ? "done" : "pending", + }, + { + label: "Sandbox provisioning", + detail: hasSandbox + ? `Namespaced sandbox ${task.sandbox} — image pull, seccomp, default-deny egress.` + : "Creating the isolated namespace, network policy, and seccomp profile.", + state: hasSandbox && (accessVerified || agentOnline) ? "done" : launching || hasSandbox ? "active" : "pending", + }, + { + label: "Inference router + access verified", + detail: "The governed model path (router sidecar) is up; the composed accesses are enforced at the boundary.", + state: accessVerified ? "done" : hasSandbox ? "active" : "pending", + }, + { + label: "Agent online on the mesh", + detail: agentOnline + ? "The agent registered its encrypted mesh identity and is reachable." + : "Waiting for the agent to register on the encrypted agent mesh.", + state: agentOnline ? "done" : accessVerified ? "active" : "pending", + }, + { + label: "Working", + detail: failed + ? "The agent started, but the run terminated before producing a deliverable. Open Run failed for the exact reason." + : firstActivity + ? "The agent's loop is live — tool calls and model rounds are streaming into Activity." + : "Waiting for the first model round / tool call.", + state: failed ? "failed" : succeeded ? "done" : firstActivity || running ? "active" : "pending", + }, + ]; + + const doneCount = steps.filter((s) => s.state === "done").length; + const allDone = !failed && (running || succeeded); + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">{failed ? "Run failed" : allDone ? "Running" : "Deploying"}</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {failed + ? "Provisioning completed, but the agent run ended before delivery." + : allDone + ? "The agent is deployed and working — every step below is verified." + : "Watch the agent come online — each step is a real, verified provisioning event."} + </p> + </div> + <span + className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${ + failed + ? "border-danger/30 bg-danger/10 text-danger" + : allDone + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + : "border-signal/30 bg-signal/10 text-signal" + }`} + > + {failed ? "Failed" : allDone ? "Live" : `${doneCount}/${steps.length}`} + </span> + </div> + <ol className="mt-4 space-y-0"> + {steps.map((s, i) => ( + <li key={s.label} className="flex gap-3"> + <div className="flex flex-col items-center"> + <span className={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${dotClass(s.state)}`} aria-hidden /> + {i < steps.length - 1 && ( + <span className={`my-0.5 w-px flex-1 ${s.state === "done" ? "bg-emerald-500/40" : "bg-border"}`} aria-hidden /> + )} + </div> + <div className={`pb-4 ${s.state === "pending" ? "opacity-60" : ""}`}> + <p className="text-sm font-medium leading-tight"> + {s.label} + {s.state === "active" && ( + <span className="ml-2 align-middle text-[10px] font-normal text-signal">in progress</span> + )} + </p> + <p className="mt-0.5 text-xs text-foreground-muted">{s.detail}</p> + </div> + </li> + ))} + </ol> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts b/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts new file mode 100644 index 000000000..2c11570a5 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts @@ -0,0 +1,37 @@ +// kars Bridge Workspace — request temporary website access for a mission. +// +// Files an EgressApproval the controller reconciles through human approval; the +// BFF/UI never widens the sandbox allowlist directly. This is the §20 "agent +// asks to reach an extra site" path — scoped host, reason, and a TTL. + +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, requestEgress } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; + +export interface EgressState { + error: string | null; + ok: string | null; +} + +export async function requestEgressAction( + _prev: EgressState, + form: FormData, +): Promise<EgressState> { + const mission = String(form.get("mission") ?? ""); + const host = String(form.get("host") ?? "").trim(); + const reason = String(form.get("reason") ?? "").trim(); + const portRaw = String(form.get("port") ?? "443").trim(); + const ttl = String(form.get("ttl") ?? "2h").trim(); + if (!host) return { error: "Enter a website host.", ok: null }; + if (reason.length < 3) return { error: "Give a short reason.", ok: null }; + const port = portRaw ? Number(portRaw) : 443; + try { + const r = await requestEgress(defaultNamespace(), mission, { host, port, reason, ttl }); + revalidatePath(`/workspace/missions/${mission}`); + return { error: null, ok: r.note }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "request failed", ok: null }; + } +} diff --git a/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx b/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx new file mode 100644 index 000000000..aebbb34b1 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx @@ -0,0 +1,43 @@ +"use client"; + +// Mission "request website access" form. The agent's egress is default-deny; +// this is how a human grants a scoped, time-boxed exception — surfaced plainly +// so the elevation ask is visible, not buried. + +import { useActionState } from "react"; +import { requestEgressAction, type EgressState } from "./egress-actions"; + +const init: EgressState = { error: null, ok: null }; + +export function EgressRequest({ mission }: { mission: string }) { + const [state, action, pending] = useActionState(requestEgressAction, init); + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Website access</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The agent can reach only the model path by default. Grant a scoped, time-boxed exception — + it opens only after approval and expires automatically. + </p> + <form action={action} className="mt-4 space-y-3"> + <input type="hidden" name="mission" value={mission} /> + <div className="flex flex-wrap gap-2"> + <input name="host" placeholder="host, e.g. api.github.com" required + className="flex-1 min-w-48 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + <input name="port" defaultValue="443" inputMode="numeric" + className="w-20 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + <select name="ttl" aria-label="Grant duration" defaultValue="2h" className="rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="1h">1h</option><option value="2h">2h</option><option value="8h">8h</option><option value="24h">24h</option> + </select> + </div> + <input name="reason" placeholder="why does this mission need it?" required + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + <button type="submit" disabled={pending} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {pending ? "Requesting…" : "Request access"} + </button> + {state.error && <p className="text-xs text-danger">{state.error}</p>} + {state.ok && <p className="text-xs text-ok">{state.ok}</p>} + </form> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx b/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx new file mode 100644 index 000000000..02e291d97 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx @@ -0,0 +1,90 @@ +"use client"; + +// kars Bridge — governed emergency-stop ("red button"). One click halts a +// running mission: the BFF un-launches it (the controller tears down the +// sandbox, removing the agent from the mesh so it can no longer receive or +// answer delegated work) and records the halt as a governed decision. The +// mission record, deliverable, and audit trail are retained — this is a STOP, +// not a delete. No major agent platform ships a governed kill. + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export function HaltButton({ ns, task }: { ns: string; task: string }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + async function halt() { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/namespaces/${ns}/tasks/${task}/halt`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reason: reason.trim() || null }), + }); + if (!res.ok) { + const b = await res.json().catch(() => null); + throw new Error(b?.error?.message ?? `Halt failed (${res.status})`); + } + setOpen(false); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Halt failed"); + } finally { + setBusy(false); + } + } + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="inline-flex items-center gap-1.5 rounded-lg border border-danger/40 bg-danger/[0.06] px-3 py-1.5 text-xs font-semibold text-danger transition hover:bg-danger/10" + title="Governed emergency-stop — halt this running agent and record the decision" + > + <span aria-hidden>⏹</span> Halt agent + </button> + ); + } + + return ( + <div className="rounded-xl border border-danger/40 bg-danger/[0.05] p-4"> + <p className="text-sm font-semibold text-danger">Halt this mission?</p> + <p className="mt-1 text-xs text-foreground-muted"> + The agent's sandbox is torn down immediately — it leaves the mesh and stops all work. + The deliverable, trace, and receipt are kept. The halt is recorded as a governed decision. + </p> + <input + type="text" + value={reason} + onChange={(e) => setReason(e.target.value)} + placeholder="Reason (recorded on the decision) — e.g. runaway cost, wrong scope" + className="mt-3 w-full rounded-lg border border-border bg-surface px-3 py-2 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger" + /> + <div className="mt-3 flex items-center gap-2"> + <button + type="button" + onClick={halt} + disabled={busy} + className="rounded-lg bg-danger px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:opacity-50" + > + {busy ? "Halting…" : "Confirm halt"} + </button> + <button + type="button" + onClick={() => setOpen(false)} + disabled={busy} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground" + > + Cancel + </button> + {error && <span className="text-xs text-danger">{error}</span>} + </div> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx new file mode 100644 index 000000000..14259e2e1 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx @@ -0,0 +1,89 @@ +"use client"; + +// kars Bridge Workspace — first-run auto-kickoff. +// +// The §20 gate is two beats: Launch materialises the governed sandbox, then a +// run delivers the objective into it. An operator who launched a one-shot +// mission expects it to just start — they shouldn't have to click "Run" a +// second time once the sandbox is up. This tiny component drives that first run +// exactly once, the instant a launched mission's sandbox reaches Running with no +// prior run (no deliverable, no activity yet). Re-runs stay a deliberate "Run +// again" click in the Execution panel. +// +// It lives here (rendered unconditionally on the mission page) rather than in the +// Execution panel because the panel sits on the lazily-mounted Overview tab — a +// launched+running mission opens on the Activity tab, so the panel isn't mounted +// and its effect would never fire. The run endpoint's in-flight nonce guard makes +// a stray double-trigger a no-op, so being always-mounted is safe. + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { runMissionClient } from "@/lib/run-mission-client"; + +export function MissionAutoRun({ + namespace, + name, + active, +}: { + namespace: string; + name: string; + active: boolean; +}) { + const router = useRouter(); + const fired = useRef(false); + const inFlight = useRef(false); + const [attempt, setAttempt] = useState(0); + const [error, setError] = useState<string | null>(null); + const maxAttempts = 5; + + useEffect(() => { + if (!active || fired.current || inFlight.current || attempt >= maxAttempts) return; + let cancelled = false; + let retry: ReturnType<typeof setTimeout> | null = null; + inFlight.current = true; + void (async () => { + const result = await runMissionClient(namespace, name); + inFlight.current = false; + if (cancelled) return; + if (result.ok) { + fired.current = true; + setError(null); + router.refresh(); + return; + } + setError(result.error ?? "The automatic mission start failed."); + router.refresh(); + retry = setTimeout( + () => setAttempt((current) => current + 1), + Math.min(2_000 * 2 ** attempt, 10_000), + ); + })(); + return () => { + cancelled = true; + if (retry) clearTimeout(retry); + }; + }, [active, attempt, maxAttempts, name, namespace, router]); + + if (!error) return null; + const exhausted = attempt >= maxAttempts - 1; + return ( + <div role="alert" className="rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-sm"> + <p className="font-medium"> + {exhausted ? "The mission could not start automatically." : "The mission start is retrying."} + </p> + <p className="mt-1 text-xs text-foreground-muted">{error}</p> + {exhausted && ( + <button + type="button" + onClick={() => { + setError(null); + setAttempt(0); + }} + className="mt-2 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium hover:bg-surface-muted" + > + Retry start + </button> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx new file mode 100644 index 000000000..8e2f796a8 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx @@ -0,0 +1,273 @@ +"use client"; + +// kars Bridge — Mission blockers ("what the agent got stuck on"). Built from the +// REAL execution trace (per-tool events with their result previews), not from +// the agent's natural-language summary — which can confabulate ("please grant +// access") when a tool simply isn't available. Each failed tool call is +// classified honestly so the operator knows the actual cause and the actual +// remedy: a capability that isn't available on this cluster's provider, a real +// egress denial (with a one-click Grant), or a policy block. + +import { useMemo } from "react"; +import type { ActivityEvent, Approval } from "@/lib/types"; +import { Icon, type IconName } from "@/components/icon"; +import { ApprovalDecision } from "@/components/approval-decision"; + +type Kind = "capability" | "egress" | "policy" | "error"; + +type Blocker = { + tool: string; + kind: Kind; + detail: string; + host?: string; // for egress-class, the destination to grant + count: number; +}; + +function classify(name: string, result: string): { kind: Kind; detail: string; host?: string } | null { + const r = result.toLowerCase(); + // NB: the caller has already established this tool call FAILED (ok === false). + // We only classify WHY, so we never gate on the presence of the word "error" + // here — a real failure may not contain it, and a success that happens to + // mention it never reaches this function. + // Capability not available on this cluster's provider (e.g. Foundry-only + // tools on a GitHub Models / Copilot cluster). + if (r.includes("does not support") || r.includes("requires azure ai foundry") || r.includes("requires azure")) { + const cap = name.replace(/^foundry[_.]?/i, "").replace(/_/g, " "); + return { + kind: "capability", + detail: `“${cap || name}” isn't available on this cluster's model provider — it needs Azure AI Foundry. The agent has no real fallback, so it may improvise an explanation. Switch the cluster to Foundry, or compose missions without this capability.`, + }; + } + // Real egress denial by the kars boundary. + if (r.includes("not on allowlist") || r.includes("egress policy") || r.includes("signed allowlist") || r.includes("kars egress")) { + const host = extractHost(result); + return { kind: "egress", detail: `The agent tried to reach ${host ?? "an external host"} and was denied by the network boundary.`, host }; + } + // Tool/MCP policy block — the sandbox is purposefully limited. Widening a tool + // policy is a BROAD-access change, so it's an operator action (not a one-click + // user grant like egress): surface exactly what's needed and who can grant it. + if (r.includes("blocked by policy")) { + const m = result.match(/policy '([^']+)'/i); + const tool = name.replace(/_/g, " "); + return { + kind: "policy", + detail: `The agent tried to use “${tool}” but the tool policy${m ? ` “${m[1]}”` : ""} doesn't allow it — the sandbox is deliberately limited to what was granted. This is a broad-access change, so an operator needs to add this tool to the policy (or approve a policy that includes it). Alternatively, re-compose the mission without this tool.`, + }; + } + // Missing/unapproved skill surfaced in a tool result (rare at runtime — usually + // caught at pre-flight). The trust gate refuses to mount an unapproved skill. + if (r.includes("skill") && (r.includes("not found") || r.includes("not approved") || r.includes("not mounted"))) { + return { + kind: "policy", + detail: `The agent needed a skill that isn't available to this sandbox. Skills must be uploaded and approved before the trust gate will mount them — ask an operator to approve the required skill, then re-run.`, + }; + } + // Generic upstream error (e.g. an HTTP 403 from the destination itself — + // reached, but the server refused). Not a kars block. + if (r.includes("403") || r.includes("forbidden")) { + const host = extractHost(result); + return { kind: "error", detail: `Reached ${host ?? "the destination"}, but it returned 403 Forbidden — this is the remote service refusing the request (often a missing header/credential), not a kars block.` }; + } + if (r.includes("404") || r.includes("not found")) { + const host = extractHost(result); + return { + kind: "error", + detail: `Reached ${host ?? "the destination"}, but the requested path returned 404 Not Found. Network access worked; approving egress cannot fix this. The agent should use a valid URL or another authoritative source.`, + }; + } + return { kind: "error", detail: result.slice(0, 200) }; +} + +function extractHost(s: string): string | undefined { + const url = s.match(/https?:\/\/([^/"\s]+)/i); + if (url) return url[1]; + const host = s.match(/'([a-z0-9.-]+\.[a-z]{2,})'/i) || s.match(/([a-z0-9.-]+\.[a-z]{2,})(:\d+)?/i); + return host?.[1]; +} + +const KIND_META: Record<Kind, { label: string; tone: string; glyph: IconName }> = { + capability: { label: "Capability unavailable", tone: "border-amber-500/30 bg-amber-500/5 text-amber-600", glyph: "gear" }, + egress: { label: "Network denied", tone: "border-rose-500/30 bg-rose-500/5 text-rose-600", glyph: "globe" }, + policy: { label: "Policy block", tone: "border-sky-500/30 bg-sky-500/5 text-sky-600", glyph: "shield" }, + error: { label: "Remote error", tone: "border-border bg-surface-muted text-foreground-muted", glyph: "warning" }, +}; + +export function MissionBlockers({ + approvals, + activity, + running, + decider, + authWired, +}: { + ns: string; + task: string; + approvals: Approval[]; + activity: ActivityEvent[]; + running: boolean; + decider: string; + authWired: boolean; +}) { + const blockers = useMemo<Blocker[]>(() => { + const byKey = new Map<string, Blocker>(); + for (const e of activity ?? []) { + const ev = e as unknown as { kind?: string; name?: string; result_preview?: string; ok?: boolean }; + if (ev.kind !== "tool" || !ev.name || !ev.result_preview) continue; + // ONLY a tool call that actually FAILED is a blocker. The trace carries a + // real success flag (ok) per call; gate on it instead of string-matching + // "error" in the result — a successful call whose output merely contains + // the word "error" (e.g. a status line "ERROR: none") is NOT a failure and + // must never surface as "what the agent got stuck on" on a delivered run. + if (ev.ok !== false) continue; + const c = classify(ev.name, ev.result_preview); + if (!c) continue; + const key = `${ev.name}|${c.kind}|${c.host ?? ""}`; + const existing = byKey.get(key); + if (existing) existing.count += 1; + else byKey.set(key, { tool: ev.name, kind: c.kind, detail: c.detail, host: c.host, count: 1 }); + } + // Order: egress (actionable) → capability → policy → error. + const order: Record<Kind, number> = { egress: 0, capability: 1, policy: 2, error: 3 }; + return [...byKey.values()].sort((a, b) => order[a.kind] - order[b.kind]); + }, [activity]); + + // The single source of truth for an egress denial is the KarsApproval the + // controller opens for it — the SAME object the Approvals panel (below) and + // the fleet inbox act on. We reflect its live state here instead of offering + // a second, competing "grant" button that races the approval. + function egressApprovalFor(host?: string): Approval | undefined { + if (!host) return undefined; + const h = host.toLowerCase(); + return approvals + .filter( + (a) => + a.action_kind === "egress" && + ((a.summary ?? "").toLowerCase().includes(h) || (a.detail ?? "").toLowerCase().includes(h)), + ) + .sort((a, b) => { + const rank = (approval: Approval) => + approval.actionable ? 3 : approval.phase === "Approved" ? 2 : approval.phase === "Denied" ? 1 : 0; + return rank(b) - rank(a) || Number(b.resource_version) - Number(a.resource_version); + })[0]; + } + const hasEgressBlock = blockers.some((blocker) => blocker.kind === "egress"); + const hasOnlyRemoteErrors = blockers.every((blocker) => blocker.kind === "error"); + + if (blockers.length === 0) return null; + + return ( + <section className="rounded-xl border border-warning/30 bg-warning/[0.04] p-6"> + <div className="flex items-start gap-2"> + <Icon name="warning" size={16} /> + <div> + <h2 className="text-sm font-semibold">What the agent got stuck on</h2> + <p className="mt-0.5 max-w-xl text-xs text-foreground-muted"> + Read from the real execution trace — not the agent’s own summary, which can + misattribute a missing capability as an “access request.” Here’s the + actual cause and what you can do. + </p> + </div> + </div> + <ul className="mt-4 space-y-2.5"> + {blockers.map((b) => { + const m = KIND_META[b.kind]; + const appr = b.kind === "egress" ? egressApprovalFor(b.host) : undefined; + return ( + <li key={`${b.tool}-${b.kind}-${b.host ?? ""}`} className={`rounded-lg border p-3 ${m.tone.replace(/text-[a-z0-9/-]+/, "")}`}> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0"> + <p className="flex items-center gap-2 text-sm font-medium"> + <Icon name={m.glyph} size={13} /> + <span className="font-mono text-xs">{b.tool}</span> + <span className={`rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${m.tone}`}>{m.label}</span> + {b.count > 1 && <span className="text-[10px] text-foreground-muted">×{b.count}</span>} + </p> + <p className="mt-1 text-xs text-foreground-muted">{b.detail}</p> + </div> + {b.kind === "egress" && b.host && ( + <EgressState appr={appr} running={running} decider={decider} authWired={authWired} /> + )} + </div> + </li> + ); + })} + </ul> + {hasEgressBlock ? ( + <p className="mt-3 text-[11px] text-foreground-muted"> + Only items marked <strong className="text-foreground">Network denied</strong> require an approval. + Decide those in Approvals below (or your inbox); remote HTTP errors need a corrected URL or source instead. + </p> + ) : hasOnlyRemoteErrors ? ( + <p className="mt-3 text-[11px] text-foreground-muted"> + No approval is required for these calls: the network path worked and the remote service returned an error. + Re-run with a corrected URL or another authoritative source. + </p> + ) : ( + <p className="mt-3 text-[11px] text-foreground-muted"> + Resolve the capability or policy item described above; remote HTTP errors cannot be fixed by approving egress. + </p> + )} + </section> + ); +} + +/** Reflects the live state of the egress KarsApproval — never a second grant + * button. The one action lives in the Approvals panel / inbox. */ +function EgressState({ + appr, + running, + decider, + authWired, +}: { + appr?: Approval; + running: boolean; + decider: string; + authWired: boolean; +}) { + if (!appr) { + return ( + <span className="shrink-0 rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs font-medium text-foreground-muted"> + {running ? "Surfacing to your inbox…" : "No active approval — rerun if still needed"} + </span> + ); + } + if (appr.phase === "Approved") { + return ( + <span className="shrink-0 rounded-lg border border-ok/40 bg-ok/10 px-3 py-1.5 text-xs font-medium text-ok"> + ✓ Approved — the agent will retry + </span> + ); + } + if (appr.phase === "Denied") { + return ( + <span className="shrink-0 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600"> + Denied + </span> + ); + } + if (appr.phase === "Expired" || appr.phase === "Stale") { + return ( + <span className="shrink-0 rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs font-medium text-foreground-muted"> + {appr.decider ? `Previously ${appr.phase.toLowerCase()} after decision` : appr.phase} + </span> + ); + } + if (appr.actionable) { + return ( + <div className="shrink-0"> + <ApprovalDecision + name={appr.name} + decider={decider} + authWired={authWired} + resourceVersion={appr.resource_version} + boundEnvelopeDigest={appr.bound_envelope_digest} + compact + /> + </div> + ); + } + return ( + <span className="shrink-0 rounded-lg border border-signal/40 bg-signal/10 px-3 py-1.5 text-xs font-medium text-signal"> + Awaiting your approval ↓ + </span> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx new file mode 100644 index 000000000..bf1d0c43f --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx @@ -0,0 +1,119 @@ +// kars Bridge Workspace — the live mission map (design note §4). A single +// at-a-glance view of a governed run: the delegation tree (principal + reports +// + sub-agents) with each node's authority tier, the live token burn against +// budget, and the orchestration shape (rounds + tool calls). Purely a +// projection of data already on the mission — no new fetch, updates with the +// page's live refresh. + +import { TIER_LABELS, type TaskDetail } from "@/lib/types"; + +function TierPip({ tier }: { tier: number }) { + return ( + <span className="inline-flex items-center gap-1 rounded-full bg-surface-muted px-2 py-0.5 text-[10px] font-medium text-foreground-muted"> + T{tier} · {TIER_LABELS[tier] ?? "?"} + </span> + ); +} + +export function MissionMap({ task }: { task: TaskDetail }) { + const total = task.result?.total_tokens ?? null; + const budget = task.envelope.budget?.tokens ?? null; + const burnPct = total != null && budget != null && budget > 0 + ? Math.min(100, Math.round((total / budget) * 100)) + : null; + const rounds = task.telemetry?.rounds ?? null; + const toolCalls = task.telemetry?.tool_calls ?? null; + // Fall back to counts derived from the real activity trace when the run's + // output ConfigMap didn't roll up loop-shape telemetry (some harnesses report + // the trace but not the totals) — mirrors ActivityStream so the map and the + // activity feed always agree instead of the map claiming "Not run yet". + const roundEvents = task.activity.filter((e) => e.kind === "round").length; + const toolEvents = task.activity.filter((e) => e.kind === "tool").length; + const roundsShown = rounds ?? (roundEvents > 0 ? roundEvents : null); + const toolCallsShown = toolCalls ?? (toolEvents > 0 ? toolEvents : null); + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Mission map</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The whole governed run at a glance — who's working, their authority, and the live spend. + </p> + + {/* Token burn + orchestration shape */} + <div className="mt-4 grid gap-4 sm:grid-cols-3"> + <div className="rounded-lg border border-border bg-surface-muted/40 p-3"> + <p className="text-[11px] text-foreground-muted">Token burn</p> + {total == null ? ( + <p className="mt-1 text-sm text-foreground-muted">Not run yet</p> + ) : ( + <> + <p className="mt-1 text-lg font-semibold tabular-nums">{total.toLocaleString()}</p> + {budget != null ? ( + <div className="mt-1.5"> + <div className="h-1.5 w-full overflow-hidden rounded-full bg-surface"> + <div + className={`h-full ${burnPct! >= 90 ? "bg-rose-500" : burnPct! >= 60 ? "bg-amber-500" : "bg-emerald-500"}`} + style={{ width: `${burnPct}%` }} + /> + </div> + <p className="mt-1 text-[11px] text-foreground-muted"> + {burnPct}% of {budget.toLocaleString()} budget + </p> + </div> + ) : ( + <p className="mt-1 text-[11px] text-foreground-muted">No cap</p> + )} + </> + )} + </div> + <div className="rounded-lg border border-border bg-surface-muted/40 p-3"> + <p className="text-[11px] text-foreground-muted">Model rounds</p> + <p className="mt-1 text-lg font-semibold tabular-nums">{roundsShown ?? "—"}</p> + </div> + <div className="rounded-lg border border-border bg-surface-muted/40 p-3"> + <p className="text-[11px] text-foreground-muted">Tool calls</p> + <p className="mt-1 text-lg font-semibold tabular-nums">{toolCallsShown ?? "—"}</p> + </div> + </div> + + {/* Delegation tree */} + <div className="mt-5"> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Delegation tree</p> + <div className="mt-2 space-y-1.5"> + <div className="flex items-center gap-2 rounded-lg border border-signal/40 bg-signal/5 px-3 py-2"> + <span className="text-sm font-medium">{task.display_name ?? "Lead"}</span> + <TierPip tier={task.envelope.tier} /> + <span className="ml-auto text-[11px] text-foreground-muted"> + grants up to T{task.envelope.authority_ceiling} · depth {task.envelope.delegation_depth} + </span> + </div> + {task.children.map((c) => ( + <div + key={c.name} + className="ml-5 flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2" + > + <span className="text-sm">{c.display_name ?? c.objective}</span> + <TierPip tier={c.tier} /> + </div> + ))} + {task.sub_agents.map((s) => ( + <div + key={s.name} + className="ml-5 flex items-center gap-2 rounded-lg border border-dashed border-border bg-surface px-3 py-2" + > + <span className="text-sm">{s.name}</span> + <span className="rounded-full bg-surface-muted px-2 py-0.5 text-[10px] text-foreground-muted"> + sub-agent + </span> + </div> + ))} + {task.children.length === 0 && task.sub_agents.length === 0 && ( + <p className="ml-5 text-xs text-foreground-muted"> + Working solo — no delegated reports. + </p> + )} + </div> + </div> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx b/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx new file mode 100644 index 000000000..764f446ae --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx @@ -0,0 +1,187 @@ +"use client"; + +// kars Bridge — Network mode (learning → enforced). Surfaces the sandbox's REAL +// egress enforcement mode (KarsSandbox.networkPolicy.egressMode) and lets the +// operator promote it: start in Learning (the agent reaches anything, every +// domain recorded by the router), review what it actually reached, then flip to +// Enforced (only the approved allowlist passes). The flip drives the real lever +// — it pins the mission's egress allowlist, which the controller compiles into +// Strict mode on the next reconcile. Nothing here is cosmetic. + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Icon } from "@/components/icon"; + +type LearnedResp = { + available: boolean; + mode?: string; + domains?: string[]; + enforced?: string[]; + reason?: string; +}; + +export function NetworkMode({ + ns, + task, + mode, +}: { + ns: string; + task: string; + mode: string | null; +}) { + const enforced = mode === "Strict"; + const [data, setData] = useState<LearnedResp | null>(null); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState<Record<string, boolean>>({}); + const [manual, setManual] = useState(""); + const [busy, setBusy] = useState(false); + const router = useRouter(); + + useEffect(() => { + let on = true; + fetch(`/api/namespaces/${ns}/tasks/${task}/egress/learned`) + .then((r) => r.json()) + .then((d: LearnedResp) => { + if (!on) return; + setData(d); + // Pre-select all learned domains for convenience. + const pre: Record<string, boolean> = {}; + (d.domains ?? []).forEach((h) => (pre[h] = true)); + setSelected(pre); + }) + .catch(() => on && setData({ available: false })) + .finally(() => on && setLoading(false)); + return () => { + on = false; + }; + }, [ns, task]); + + async function flip(toEnforced: boolean) { + setBusy(true); + try { + const allow = toEnforced + ? [ + ...Object.entries(selected).filter(([, v]) => v).map(([h]) => h), + ...manual.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean), + ] + : []; + await fetch(`/api/namespaces/${ns}/tasks/${task}/egress-mode`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: toEnforced ? "enforced" : "learning", allow }), + }); + router.refresh(); + } finally { + setBusy(false); + } + } + + const learned = data?.domains ?? []; + const enforcedList = data?.enforced ?? []; + const toggle = (h: string) => setSelected((s) => ({ ...s, [h]: !s[h] })); + const selectedCount = Object.values(selected).filter(Boolean).length + manual.split(/[\s,]+/).filter((s) => s.trim()).length; + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Network enforcement</h2> + <p className="mt-0.5 max-w-xl text-xs text-foreground-muted"> + {enforced + ? "Enforced — the sandbox denies any destination outside the approved allowlist at the boundary." + : "Learning — the agent can reach the network while the router records every domain it touches. Review what it actually reaches, then enforce when you're confident."} + </p> + </div> + <span + className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${ + enforced + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + : "border-sky-500/30 bg-sky-500/10 text-sky-600" + }`} + > + {enforced ? <span className="inline-flex items-center gap-1"><Icon name="lock" size={12} /> Enforced</span> : <span className="inline-flex items-center gap-1"><Icon name="eye" size={12} /> Learning</span>} + </span> + </div> + + {enforced ? ( + // ── Enforced: show the allowlist, offer back-to-learning. ────────── + <div className="mt-4"> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Allowed destinations</p> + {enforcedList.length === 0 ? ( + <p className="mt-1 text-sm text-foreground-muted">Model path only — all other egress denied.</p> + ) : ( + <ul className="mt-2 flex flex-wrap gap-1.5"> + {enforcedList.map((h) => ( + <li key={h} className="inline-flex items-center gap-1.5 rounded-full bg-surface-muted px-2.5 py-1 font-mono text-xs"> + <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden /> + {h} + </li> + ))} + </ul> + )} + <button + type="button" + onClick={() => flip(false)} + disabled={busy} + className="mt-4 rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted disabled:opacity-50" + > + {busy ? "Applying…" : "← Back to learning"} + </button> + </div> + ) : ( + // ── Learning: show learned domains, build allowlist, enforce. ────── + <div className="mt-4"> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Domains this agent has reached</p> + {loading ? ( + <p className="mt-1 text-sm text-foreground-muted">Reading the router’s observation buffer…</p> + ) : learned.length > 0 ? ( + <ul className="mt-2 space-y-1.5"> + {learned.map((h) => ( + <li key={h} className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={!!selected[h]} + onChange={() => toggle(h)} + className="h-3.5 w-3.5 rounded border-border" + /> + <span className="font-mono text-xs">{h}</span> + </li> + ))} + </ul> + ) : ( + <p className="mt-1 text-sm text-foreground-muted"> + {data?.available === false + ? "No observed domains surfaced yet — the agent hasn't reached out, or the router's observation buffer isn't readable from here. You can still enforce an allowlist below." + : "Nothing reached the network yet."} + </p> + )} + + <div className="mt-4"> + <label className="text-[11px] font-medium text-foreground-muted">Add destinations (host or host:port, comma/space separated)</label> + <input + value={manual} + onChange={(e) => setManual(e.target.value)} + placeholder="e.g. api.github.com:443, registry.npmjs.org" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" + /> + </div> + + <div className="mt-4 flex items-center gap-3"> + <button + type="button" + onClick={() => flip(true)} + disabled={busy || selectedCount === 0} + className="rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90 disabled:opacity-50" + title={selectedCount === 0 ? "Select or add at least one destination to enforce" : undefined} + > + {busy ? "Enforcing…" : <span className="inline-flex items-center gap-1"><Icon name="lock" size={12} /> Enforce {selectedCount} destination{selectedCount === 1 ? "" : "s"}</span>} + </button> + <p className="text-[11px] text-foreground-muted"> + Enforcing pins this allowlist; the sandbox then denies everything else at the boundary. + </p> + </div> + </div> + )} + </section> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx b/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx new file mode 100644 index 000000000..1f826a162 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx @@ -0,0 +1,383 @@ +"use client"; + +// kars Bridge Workspace — the org chart: principal + delegated roles, and an +// "add a role" composer constrained to the principal's authority (§12). + +import { useState } from "react"; +import Link from "next/link"; +import { MissionStatusBadge, missionStatus } from "@/components/mission-status"; +import { addRole, type AddRoleInput } from "./role-actions"; +import { TIER_LABELS, type BlueprintEgress, type TaskDetail, type TaskSummary } from "@/lib/types"; + +function parseEgress(items: string[]): BlueprintEgress[] { + return items.map((s) => { + const [host, port] = s.split(":"); + const p = port ? Number(port) : null; + return { host, port: Number.isFinite(p as number) ? (p as number) : null }; + }); +} + +export function OrgChart({ task }: { task: TaskDetail }) { + const [adding, setAdding] = useState(false); + const principalEgress = parseEgress(task.composition?.egress ?? []); + const ceiling = task.envelope.authority_ceiling; + const canDelegate = task.envelope.delegation_depth > 0; + + // Roles share a long `<team>-<role>` naming prefix (e.g. + // `kars-repo-health-ci-reporter`); strip it so nodes read as `ci-reporter` + // instead of a wall of truncated "Kars r…". Derived from the principal name. + const teamPrefix = task.name.replace(/-principal$/, ""); + const shorten = (n: string): string => { + const s = n.startsWith(`${teamPrefix}-`) ? n.slice(teamPrefix.length + 1) : n; + return s.length > 0 ? s : n; + }; + + // Auto-fold: a legible org chart shows a bounded set of roles; the rest + // collapse into a "+N more" node rather than an endless grid. + const MAX_ROLES = 6; + const roles = task.children; + const shownRoles = roles.slice(0, MAX_ROLES); + const foldedRoles = Math.max(0, roles.length - shownRoles.length); + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Org chart</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The team working on this mission. Each role's authority is a verified subset of the + mission's — the reporting line is the trust boundary. + </p> + </div> + {canDelegate && !adding && ( + <button + type="button" + onClick={() => setAdding(true)} + className="shrink-0 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted" + > + + Add a role + </button> + )} + </div> + + {/* Visual org tree: principal on top, a connector spine, then the + delegated roles as a connected row of detail nodes. */} + <div className="mt-5 flex flex-col items-center"> + <PrincipalNode + name={task.display_name ?? "Lead"} + tier={task.envelope.tier} + model={task.composition?.model ?? null} + harness={task.composition?.runtime ?? null} + phase={task.phase} + ceiling={ceiling} + canDelegate={canDelegate} + /> + + {(roles.length > 0 || task.sub_agents.length > 0) && ( + <div className="h-5 w-px bg-border" aria-hidden /> + )} + + {roles.length > 0 && ( + <div className="relative w-full"> + {/* horizontal bus connecting the children */} + {shownRoles.length + (foldedRoles > 0 ? 1 : 0) > 1 && ( + <div className="mx-auto mb-0 h-px bg-border" style={{ width: "80%" }} aria-hidden /> + )} + <ul className="flex flex-wrap items-stretch justify-center gap-4"> + {shownRoles.map((c) => ( + <li key={c.name} className="flex flex-col items-center"> + <div className="h-4 w-px bg-border" aria-hidden /> + <RoleNode role={c} label={shorten(c.display_name ?? c.name)} /> + </li> + ))} + {foldedRoles > 0 && ( + <li className="flex flex-col items-center"> + <div className="h-4 w-px bg-border" aria-hidden /> + <div className="grid w-52 place-items-center rounded-xl border border-dashed border-border bg-surface-muted/40 px-3 py-2.5 text-xs text-foreground-muted"> + +{foldedRoles} more role{foldedRoles === 1 ? "" : "s"} + </div> + </li> + )} + </ul> + </div> + )} + + {roles.length === 0 && task.sub_agents.length === 0 && ( + <p className="mt-2 px-1 text-center text-xs text-foreground-muted"> + {canDelegate + ? "No reports yet. Add a role to delegate part of this mission — its authority will be a verified subset of the mission's." + : "This mission can't delegate further (no delegation budget left)."} + </p> + )} + + {/* Runtime agents/sub-agents — the agent actually running + any it + spawned over the mesh (distinct from the governed roles above). */} + {task.sub_agents.length > 0 && ( + <div className="mt-5 w-full border-t border-border pt-4"> + <p className="text-center text-xs font-medium text-foreground-muted">Running agents</p> + <p className="text-center text-[11px] text-foreground-muted"> + The agent in flight and the sub-agents it spawned to help. + </p> + <ul className="mt-3 flex flex-wrap items-center justify-center gap-3"> + {task.sub_agents.map((a) => ( + <li + key={`${a.namespace}/${a.name}`} + className="flex items-center gap-2 rounded-lg border border-signal/30 bg-signal/[0.04] px-3 py-1.5" + > + <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${a.phase === "Running" ? "bg-ok kb-pulse" : "bg-foreground-muted"}`} aria-hidden /> + <span className="truncate text-sm font-medium">{a.name}</span> + <span className="text-xs text-foreground-muted"> + {a.runtime ?? "agent"} · {a.phase ?? "—"} + </span> + </li> + ))} + </ul> + </div> + )} + </div> + + {adding && ( + <AddRoleForm + principal={task.name} + principalTier={task.envelope.tier} + principalCeiling={ceiling} + principalDelegationDepth={task.envelope.delegation_depth} + toolPolicy={task.composition?.tool_policy ?? null} + principalEgress={principalEgress} + onClose={() => setAdding(false)} + /> + )} + </section> + ); +} + +function PrincipalNode({ + name, + tier, + model, + harness, + phase, + ceiling, + canDelegate, +}: { + name: string; + tier: number; + model: string | null; + harness: string | null; + phase: string; + ceiling: number; + canDelegate: boolean; +}) { + return ( + <div className="w-full max-w-sm rounded-xl border border-signal/40 bg-signal/[0.06] px-4 py-3 shadow-sm"> + <div className="flex items-center justify-between gap-2"> + <p className="truncate text-sm font-semibold">{name}</p> + <span className="rounded-full bg-signal/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-signal"> + Principal + </span> + </div> + <div className="mt-2 flex flex-wrap gap-1.5"> + <NodeChip label={`Tier ${tier} · ${TIER_LABELS[tier] ?? "?"}`} tone="signal" /> + {harness && <NodeChip label={harness} tone="accent" />} + {model && <NodeChip label={model} />} + <NodeChip label={canDelegate ? `grants up to Tier ${ceiling}` : "operates alone"} /> + </div> + {phase === "Degraded" && ( + <p className="mt-2 text-[11px] font-medium text-danger">authority rejected — degraded</p> + )} + </div> + ); +} + +function RoleNode({ role, label }: { role: TaskSummary; label: string }) { + const degraded = role.phase === "Degraded"; + return ( + <Link + href={`/workspace/missions/${encodeURIComponent(role.name)}`} + title={role.display_name ?? role.name} + className="block w-52 rounded-xl border border-border bg-surface px-3 py-2.5 shadow-sm transition hover:-translate-y-0.5 hover:border-signal/40 hover:shadow-md" + > + <div className="flex items-start justify-between gap-2"> + <p className="min-w-0 truncate text-sm font-medium">{label}</p> + <MissionStatusBadge status={missionStatus(role.phase)} /> + </div> + <div className="mt-1.5 flex flex-wrap gap-1.5"> + <NodeChip label={`Tier ${role.tier} · ${TIER_LABELS[role.tier] ?? "?"}`} /> + {degraded && <NodeChip label="authority rejected" tone="danger" />} + </div> + </Link> + ); +} + +function NodeChip({ label, tone = "muted" }: { label: string; tone?: "muted" | "signal" | "accent" | "danger" }) { + const cls = { + muted: "border-border bg-surface-muted text-foreground-muted", + signal: "border-signal/30 bg-signal/10 text-signal", + accent: "border-accent/30 bg-accent/10 text-accent", + danger: "border-danger/30 bg-danger/10 text-danger", + }[tone]; + return ( + <span className={`rounded border px-1.5 py-0.5 text-[10px] font-medium ${cls}`}>{label}</span> + ); +} + +function AddRoleForm({ + principal, + principalTier, + principalCeiling, + principalDelegationDepth, + toolPolicy, + principalEgress, + onClose, +}: { + principal: string; + principalTier: number; + principalCeiling: number; + principalDelegationDepth: number; + toolPolicy: string | null; + principalEgress: BlueprintEgress[]; + onClose: () => void; +}) { + const [roleName, setRoleName] = useState(""); + const [objective, setObjective] = useState(""); + const [tier, setTier] = useState(Math.min(principalCeiling, 2)); + const [instructions, setInstructions] = useState(""); + const [egress, setEgress] = useState<string[]>([]); + const [pending, setPending] = useState(false); + const [error, setError] = useState<string | null>(null); + + const egressKey = (e: BlueprintEgress) => `${e.host}${e.port ? `:${e.port}` : ""}`; + + async function submit() { + setPending(true); + setError(null); + const input: AddRoleInput = { + principal, + principalTier, + principalCeiling, + principalDelegationDepth, + toolPolicy, + roleName, + objective, + tier, + instructions, + egress: principalEgress.filter((e) => egress.includes(egressKey(e))), + }; + const res = await addRole(input); + setPending(false); + if (res.error) { + setError(res.error); + return; + } + onClose(); + } + + return ( + <div className="mt-4 space-y-3 rounded-xl border border-border bg-surface-muted/40 p-4"> + <div className="flex items-center justify-between"> + <h3 className="text-sm font-semibold">Add a role</h3> + <button type="button" onClick={onClose} className="text-xs text-foreground-muted hover:text-foreground"> + Cancel + </button> + </div> + <p className="text-xs text-foreground-muted"> + This role reports to the mission. Its authority is bounded by the mission: at most Tier{" "} + {principalCeiling} + {toolPolicy ? `, the same tool policy (${toolPolicy})` : ""}, and only the network + destinations the mission already holds. + </p> + + <div className="grid gap-3 sm:grid-cols-2"> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Role name</span> + <input + value={roleName} + onChange={(e) => setRoleName(e.target.value)} + placeholder="e.g. Test-coverage engineer" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </label> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Autonomy (≤ Tier {principalCeiling})</span> + <select + value={tier} + onChange={(e) => setTier(Number(e.target.value))} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {Array.from({ length: principalCeiling }, (_, i) => i + 1).map((t) => ( + <option key={t} value={t}> + Tier {t} · {TIER_LABELS[t] ?? "?"} + </option> + ))} + </select> + </label> + </div> + + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">What this role does</span> + <textarea + value={objective} + onChange={(e) => setObjective(e.target.value)} + rows={2} + placeholder="e.g. Raise test coverage in the CLI package and open PRs." + className="mt-1 w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </label> + + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Instructions (optional)</span> + <textarea + value={instructions} + onChange={(e) => setInstructions(e.target.value)} + rows={2} + className="mt-1 w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </label> + + {principalEgress.length > 0 && ( + <div> + <span className="text-xs font-medium text-foreground-muted"> + Network access (subset of the mission's) + </span> + <ul className="mt-1 space-y-1"> + {principalEgress.map((e) => { + const k = egressKey(e); + return ( + <li key={k}> + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={egress.includes(k)} + onChange={(ev) => + setEgress((cur) => (ev.target.checked ? [...cur, k] : cur.filter((x) => x !== k))) + } + className="h-4 w-4 accent-[var(--signal)]" + /> + <span className="font-mono text-xs">{k}</span> + </label> + </li> + ); + })} + </ul> + </div> + )} + + {error && ( + <p role="alert" className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-xs text-danger"> + {error} + </p> + )} + + <div className="flex items-center justify-end gap-2"> + <button + type="button" + disabled={pending} + onClick={submit} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Adding…" : "Add role"} + </button> + </div> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/page.tsx b/bridge/web/src/app/workspace/missions/[name]/page.tsx new file mode 100644 index 000000000..e2a5ce9fe --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/page.tsx @@ -0,0 +1,1253 @@ +// kars Bridge Workspace — Mission detail (the live mission canvas). +// +// The user projection of a governed task: the objective, an always-visible +// governance envelope strip, the role tree (delegated children), the live +// activity stream, the decisions waiting on the user, the efficiency scorecard, +// and the Governance Receipt. No Kubernetes vocabulary surfaces. + +import Link from "next/link"; +import { DeliverableView, DeliverableBody } from "@/components/deliverable-view"; +import { notFound, redirect } from "next/navigation"; +import { ExecutionExplorer } from "@/components/execution-explorer"; +import { LiveRefresh, LivePulse } from "@/components/live-refresh"; +import { MissionAutoRun } from "./mission-autorun"; +import { OrgChart } from "./org-chart"; +import { MissionMap } from "./mission-map"; +import { ReviewPanel } from "./review-panel"; +import { ReadinessPanel } from "./readiness-panel"; +import { DeployTimeline } from "./deploy-timeline"; +import { NetworkMode } from "./network-mode"; +import { MissionBlockers } from "./mission-blockers"; +import { MissionScorecard } from "@/components/mission-scorecard"; +import { MissionStatusBadge, missionStatus } from "@/components/mission-status"; +import { JourneyRail, missionBeat } from "@/components/journey-rail"; +import { ReceiptPanel } from "@/components/receipt-panel"; +import { CompliancePackView } from "@/components/compliance-pack"; +import { ReceiptVerifyButton } from "@/components/receipt-verify"; +import { ProvenanceOverlay } from "@/components/provenance-overlay"; +import { AuditReportDownload } from "@/components/audit-report"; +import { ReliabilityRunner } from "./reliability-runner"; +import { BudgetRecovery } from "./budget-recovery"; +import { PromoteMission } from "./promote-mission"; +import { ProvenanceStory } from "@/components/provenance-story"; +import { EgressRequest } from "./egress-request"; +import { DeleteMissionControl } from "./delete-control"; +import { HonestState } from "@/components/honest-state"; +import { PageHeader } from "@/components/ui"; + +import { TaskApprovalsPanel } from "@/app/tasks/[name]/task-approvals-panel"; +import { ExecutionPanel } from "@/app/tasks/[name]/execution-panel"; +import { + BffError, + getReceipt, + getReview, + getScorecard, + getTroubleshoot, + getTask, + getCompliancePack, + listTaskApprovals, +} from "@/lib/bff"; +import { authWired, defaultNamespace, operatorIdentity } from "@/lib/config"; +import { currentPrincipal } from "@/lib/session"; +import type { ReactNode } from "react"; +import { egressScope, humanizeMcp } from "@/lib/format"; +import { Icon } from "@/components/icon"; +import { HaltButton } from "./halt-button"; +import { TIER_LABELS, type Composition, type MissionResult, type MissionArtifact, type AgentIdentity, type TaskDetail } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +/** What each autonomy tier means for how much interaction flies into the + * operator's inbox — the legible link between autonomy and steering (REQ17). */ +const AUTONOMY_INTERACTION: Record<number, string> = { + 1: "Manual — it proposes every step and acts on nothing on its own; you perform each action.", + 2: "Shared — it acts only on low-risk steps; everything else arrives in your Inbox for approval.", + 3: "Conditional — it acts on its own but pauses for your approval before anything priced, external, or irreversible.", + 4: "Supervised — it runs autonomously with periodic checkpoints you sign off on in your Inbox.", + 5: "Full — it runs to completion within budget; you review the result. Only hard-stops would ask you.", +}; + +/** Infer the review kind from the produced artifact set, for typed routing + * (§16): code → review as a change, docs → prose, data → values. */ +/** True when an artifact is prose (markdown/plain text) that should render as a + * formatted document rather than a raw monospace dump. Code/data files + * (json/csv/yaml/source) stay verbatim in <pre>. Extensionless files are + * treated as prose — agents commonly write briefings with no extension. */ +function isProseArtifact(name: string): boolean { + const dot = name.lastIndexOf("."); + if (dot < 0) return true; // no extension → prose + const ext = name.slice(dot + 1).toLowerCase(); + return ["md", "mdx", "markdown", "txt", "text", "rst", "adoc"].includes(ext); +} + +function reviewKind(artifacts: MissionArtifact[] | undefined): string { + if (!artifacts || artifacts.length === 0) return "output"; + const exts = artifacts.map((a) => a.name.split(".").pop()?.toLowerCase() ?? ""); + const code = ["ts", "tsx", "js", "jsx", "rs", "py", "go", "java", "rb", "c", "cpp", "h", "sh", "yaml", "yml", "toml", "json"]; + const docs = ["md", "mdx", "txt", "rst", "adoc", "html"]; + const data = ["csv", "tsv", "parquet", "xlsx"]; + if (exts.some((e) => code.includes(e))) return "code"; + if (exts.some((e) => data.includes(e))) return "data"; + if (exts.some((e) => docs.includes(e))) return "docs"; + return "output"; +} + +export default async function MissionDetail({ + params, + searchParams, +}: { + params: Promise<{ name: string }>; + searchParams: Promise<{ tab?: string }>; +}) { + const principal = await currentPrincipal(); + const { name } = await params; + const { tab } = await searchParams; + const ns = defaultNamespace(); + + let task; + try { + task = await getTask(ns, name); + } catch (err) { + if (err instanceof BffError && err.code === "not_found") notFound(); + // Any other failure (BFF unreachable, upstream error) renders an honest + // error state — never a raw crash overlay at the user. + return ( + <div className="space-y-6"> + <PageHeader eyebrow="Workspace" title="Mission" /> + <HonestState + variant="not_wired" + title="This mission is unavailable" + detail="The run environment isn't reachable right now, so this mission's details couldn't be loaded. Try again shortly." + action={ + <Link + href="/workspace/missions" + className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:border-signal/40 hover:text-signal" + > + ← Back to missions + </Link> + } + /> + </div> + ); + } + + const [receipt, scorecard, allApprovals, review, compliance] = await Promise.all([ + getReceipt(ns, name).catch(() => null), + getScorecard(ns, name).catch(() => null), + listTaskApprovals(ns, name).catch(() => []), + getReview(ns, name).catch(() => null), + getCompliancePack(ns, name).catch(() => null), + ]); + const currentRunNonce = task.current_run_nonce; + const approvals = allApprovals.filter( + (approval) => + approval.run_nonce == null + || currentRunNonce == null + || approval.run_nonce === currentRunNonce, + ); + + const needsYou = approvals.some((a) => a.actionable); + const awaitingAssignment = Boolean( + task.current_run_nonce + && task.assignment?.task_id !== task.current_run_nonce, + ); + const assignmentInFlight = + awaitingAssignment + || ( + task.assignment?.completed_at == null + && (task.assignment?.state === "Assigned" || task.assignment?.state === "Running") + ); + const resultMatchesCurrentRun = + task.result == null + || task.current_run_nonce == null + || task.result.assignment_nonce == null + || task.result.assignment_nonce === task.current_run_nonce; + const currentResult = assignmentInFlight || !resultMatchesCurrentRun ? null : task.result; + const currentReceipt = assignmentInFlight ? null : receipt; + const displayTask = assignmentInFlight ? { ...task, result: null } : task; + // A run result with status "error" is a FAILURE, not a deliverable — it must + // never read as "Delivered"/"Running", and it must not present a receipt as + // attesting real work (audit f8/f9/f13). + const blocked = currentResult?.blocked ?? null; + const assignmentFailed = + !awaitingAssignment + && task.assignment?.completed_at != null + && task.assignment.state === "Failed"; + const failed = + assignmentFailed || (currentResult?.status === "error" && blocked == null); + // A run whose output is a capability/limit STOP is not a deliverable — surface + // it as an actionable state, never the answer. + // For a failed run, pull the real cluster troubleshooting evidence (pod + + // container status + the agent's own log tail + an evidence-derived cause). + const troubleshoot = failed ? await getTroubleshoot(ns, name).catch(() => null) : null; + const realDelivery = + !assignmentFailed + && currentResult != null + && currentResult.status !== "error" + && blocked == null; + const status = missionStatus(task.phase, task.execution_phase, { + delivered: realDelivery, + needsYou, + launched: task.launched, + failed, + }); + // Poll through the WHOLE live lifecycle — deploying, running, or awaiting a + // human decision — not just "Running". Exhibit C: a mission stuck at + // "Deploying 1/5" must update itself as the sandbox comes up, never require F5. + const live = status === "deploying" || status === "running" || status === "needs_you"; + const running = status === "running"; + // A newly-created draft can land here before the controller has stamped its + // Ready condition. Keep polling admission so Launch enables automatically + // instead of requiring the user to click around or refresh. + const admissionPending = !task.launched && task.phase === "Pending"; + // Keep the live canvas polling not just while the agent runs, but through the + // whole review window — a delivered mission whose review isn't yet approved may + // still re-run (request-changes) or land an approval, and those must appear + // without an F5. Stops once the deliverable is approved (or the mission ends). + // Tail while execution is changing. A delivered-but-unreviewed mission is + // stable until the human acts; background refreshes there reset the selected + // tab and make Activity/Artifacts feel unclickable. + const refreshActive = live || admissionPending; + // Drive the first run automatically once a launched mission's sandbox is up + // and nothing has run yet — so launching a one-shot mission just starts it. + // Gate on "no run ever requested" (not "no activity"): the agent emits startup + // telemetry (MCP init / tool list) before any mission run, which would falsely + // suppress the kickoff and leave the mission silently idle. + const firstRunPending = + task.launched && running && currentResult == null && !task.run_requested; + const budget = task.envelope.budget; + // Which tab opens by default. A delivered/failed run opens on its outcome; a + // freshly LAUNCHED mission drops the operator straight into the live Activity + // view (the "watch it work" moment) instead of the static Overview; an + // un-launched draft opens on Overview to review the plan. + const initialTab = currentResult + ? "deliverable" + : task.launched && (running || status === "deploying") + ? "activity" + : "overview"; + + if (task.team) { + redirect( + `/workspace/teams/${encodeURIComponent(task.team)}/runs/${encodeURIComponent(name)}`, + ); + } + + return ( + <div className="space-y-6"> + {/* Live canvas: while the mission is running, re-fetch on an interval so + the activity trace, telemetry, and deliverable land without a reload. */} + <LiveRefresh active={refreshActive} /> + <MissionAutoRun key={name} namespace={ns} name={name} active={firstRunPending} /> + {/* Header + envelope strip */} + <div> + <nav aria-label="Breadcrumb" className="flex flex-wrap items-center text-sm text-foreground-muted"> + {task.team ? ( + <> + <Link href="/workspace/teams" className="rounded hover:text-foreground"> + Teams + </Link> + <span className="px-1.5" aria-hidden>/</span> + <Link + href={`/workspace/teams/${encodeURIComponent(task.team)}`} + className="rounded font-medium text-signal hover:underline" + > + {task.team} + </Link> + </> + ) : ( + <Link href="/workspace/missions" className="rounded hover:text-foreground"> + Missions + </Link> + )} + {task.lineage.map((ancestor) => ( + <span key={ancestor} className="flex items-center"> + <span className="px-1.5" aria-hidden>/</span> + <Link + href={`/workspace/missions/${encodeURIComponent(ancestor)}`} + className="rounded hover:text-foreground" + > + {ancestor} + </Link> + </span> + ))} + <span className="px-1.5" aria-hidden>/</span> + <span className="text-foreground">{task.team ? "Run" : (task.display_name ?? name)}</span> + </nav> + <div className="mt-2 flex items-start justify-between gap-4"> + <div className="min-w-0"> + <h1 className="text-2xl font-semibold tracking-tight"> + {task.display_name ?? "Mission"} + </h1> + <ObjectiveBlock objective={task.objective} /> + </div> + <div className="flex shrink-0 items-center gap-2"> + {(live) && <LivePulse label={status === "deploying" ? "Deploying" : status === "needs_you" ? "Waiting on you" : "Live"} />} + {live && !task.halted && <HaltButton ns={ns} task={name} />} + {/* While live, the animated pulse already names the phase (Deploying/ + Live/Waiting) — a second static status badge beside it just + duplicates the word. Show the badge only when NOT live (delivered, + blocked, or a draft), so there's exactly one status label. */} + {!live && <MissionStatusBadge status={status} />} + {/* Delete is always reachable from the header (parity with a team) — + not buried at the bottom of a tab. The fuller explanatory block + stays in Overview. */} + <DeleteMissionControl name={name} /> + </div> + </div> + {task.parent && ( + <p className="mt-2 text-sm text-foreground-muted"> + A role within{" "} + <Link href={`/workspace/missions/${encodeURIComponent(task.parent)}`} className="text-signal hover:underline"> + {task.parent} + </Link> + </p> + )} + </div> + + {/* Journey spine — the same seven beats the compose flow showed, now + tracking the live mission so the story reads continuously. */} + <JourneyRail + current={missionBeat({ + launched: task.launched, + executionPhase: task.execution_phase, + hasResult: realDelivery, + blocked: status === "blocked", + })} + blocked={status === "blocked" || status === "failed"} + /> + + {/* Governance envelope strip — always visible. */} + <div className="rounded-xl border border-border bg-surface-muted/50 px-5 py-3 text-sm"> + <div className="flex flex-wrap items-center gap-x-6 gap-y-2"> + <EnvelopeFact label="Autonomy" value={`Tier ${task.envelope.tier} · ${TIER_LABELS[task.envelope.tier] ?? "?"}`} /> + <EnvelopeFact + label="Budget" + value={budget?.tokens != null ? `${budget.tokens.toLocaleString()} tokens` : "No cap"} + /> + <EnvelopeFact + label="External reach" + value={(() => { + const ext = (task.composition?.egress ?? []).filter((e) => egressScope(e) === "external"); + if (ext.length === 0) return "Local only — no external services"; + const gated = task.envelope.tier <= 3; + return `${ext.length} external ${ext.length === 1 ? "service" : "services"} — ${gated ? "gated, asks you first" : "autonomous"}`; + })()} + /> + {task.phase === "Degraded" && task.status_message && ( + <span className="text-danger">{task.status_message}</span> + )} + </div> + {/* What this autonomy level actually means for how much flies into your + inbox — makes the autonomy → interaction link legible (REQ17) — and + frames the envelope as adjustable guardrails, not a fixed cage: every + boundary here can be widened in-flow (raise the autonomy tier, add a + network host, or lift the budget in Overview), each as a governed, + receipted decision. */} + <p className="mt-2 border-t border-border/60 pt-2 text-xs text-foreground-muted"> + {AUTONOMY_INTERACTION[task.envelope.tier] ?? "Acts within its envelope; you review outcomes."} + {task.launched && ( + <> + {" "} + <span className="text-foreground-muted/80"> + These are guardrails, not a cage — raise the tier, add a network host, or lift the budget + anytime in Overview; each change is a governed, receipted decision. + </span> + </> + )} + </p> + </div> + + {/* Next step — ONE primary, state-driven call to action, so the mission + reads as "here's what to do now" instead of ~18 competing buttons + (audit f11). The detailed controls remain grouped in the tabs below. */} + {status !== "blocked" && ( + <NextStep status={status} /> + )} + + {/* Blocked banner */} + {status === "blocked" && task.status_message && ( + <div role="alert" className="flex items-start gap-3 rounded-xl border border-danger/30 bg-danger/10 px-4 py-3"> + <div> + <p className="text-sm font-medium text-danger">This mission is blocked</p> + <p className="mt-0.5 text-sm text-foreground-muted">{task.status_message}</p> + </div> + </div> + )} + + {task.halted && ( + <div className="flex items-start gap-2 rounded-xl border border-danger/40 bg-danger/[0.06] px-4 py-3"> + <span aria-hidden className="text-danger">⏹</span> + <div> + <p className="text-sm font-medium">Mission halted</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + {task.halted}. The agent was torn down and removed from the mesh; the deliverable, + trace, and receipt are retained. This halt is recorded as a governed decision. + </p> + </div> + </div> + )} + {task.harness_corrected && ( + <div className="flex items-start gap-2 rounded-xl border border-accent/40 bg-accent/[0.06] px-4 py-3"> + <Icon name="compass" size={16} className="shrink-0 text-accent" /> + <div> + <p className="text-sm font-medium">Harness capability-corrected</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + {task.harness_corrected}. Recorded as a governed decision so the mission runs on a + harness that can actually execute it — never silently idling. + </p> + </div> + </div> + )} + {realDelivery && ( + <div className="flex items-center justify-between gap-3 rounded-xl border border-ok/40 bg-ok/[0.06] px-4 py-3"> + <div className="flex items-center gap-2"> + <span aria-hidden className="text-ok">✓</span> + <p className="text-sm font-medium">Deliverable ready — review it in the Deliverable tab below.</p> + </div> + <MissionStatusBadge status="done" /> + </div> + )} + {blocked?.reason === "budget" && ( + <div className="rounded-xl border border-warning/50 bg-warning/[0.07] px-4 py-3"> + <div className="flex items-start gap-2"> + <span aria-hidden className="text-warning">⏸</span> + <div className="min-w-0"> + <p className="text-sm font-medium"> + Run stopped — daily token budget reached + {blocked.spent != null && blocked.limit != null + ? ` (${blocked.spent.toLocaleString()} / ${blocked.limit.toLocaleString()} tokens)` + : ""} + . + </p> + <p className="mt-0.5 text-xs text-foreground-muted"> + This isn't the mission's answer — the agent hit its budget mid-run. Increase the + governed budget below to continue in the existing sandbox, narrow the objective, or wait + for the daily reset. + </p> + <BudgetRecovery + namespace={ns} + name={name} + current={task.envelope.budget?.tokens ?? null} + spent={blocked.spent} + stoppedLimit={blocked.limit} + approvalPending={approvals.some( + (approval) => + approval.task === name && + approval.action_kind === "budgetRaise" && + approval.phase === "Pending", + )} + /> + </div> + </div> + </div> + )} + {/* No-op run: the run completed ok but produced no real deliverable and it + isn't a budget stop or a failure — an honest, non-confusing state (the + agent decided there was nothing new to add) with a clear next step. */} + {currentResult != null && !realDelivery && blocked == null && !failed && ( + <div className="rounded-xl border border-border bg-surface-muted/50 px-4 py-3"> + <div className="flex items-start gap-2"> + <span aria-hidden className="text-foreground-muted">◦</span> + <div className="min-w-0"> + <p className="text-sm font-medium">This run produced no new deliverable.</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + The agent completed but reported nothing material to add for this objective. If you + expected output, sharpen the objective or widen its tools/network reach in the launch + package, then run again. + </p> + </div> + </div> + </div> + )} + + {/* Body — organised into tabs so the mission is legible, not a 14-panel scroll. */} + <MissionServerTabs + active={tab ?? initialTab} + basePath={`/workspace/missions/${encodeURIComponent(name)}`} + tabs={[ + { + id: "overview", + label: "Overview", + node: ( + <div className="space-y-5"> + {task.launched && <DeployTimeline task={displayTask} />} + {task.composition && <CompositionPanel composition={task.composition} launched={task.launched} />} + {task.composition && ( + <ReadinessPanel + composition={task.composition} + launched={task.launched} + executionPhase={task.execution_phase} + degraded={task.phase === "Degraded"} + delivered={realDelivery} + failed={failed} + /> + )} + {task.sandbox && ( + <NetworkMode ns={ns} task={name} mode={task.egress_mode} /> + )} + {currentResult != null && <ReliabilityRunner ns={ns} name={name} />} + {task.launched && <PromoteMission ns={ns} name={name} currentTier={task.envelope.tier} />} + {task.agent_identity && <AgentIdentityCard identity={task.agent_identity} />} + <MissionMap task={task} /> + {/* Org chart only for a real multi-agent structure — a team run + or a mission that delegated/spawned. A single-run task shows + no org chart (there's no org). */} + {(task.children.length > 0 || task.sub_agents.length > 0) && <OrgChart task={task} />} + <TaskApprovalsPanel approvals={approvals} decider={principal.name || operatorIdentity()} authWired={authWired()} /> + <ExecutionPanel task={task} /> + {running && <EgressRequest mission={name} />} + </div> + ), + }, + { + id: "activity", + label: "Activity", + live: running, + badge: task.activity?.filter((event) => event.kind === "tool").length ?? null, + node: ( + <div className="space-y-4"> + {task.launched && currentResult == null && <DeployTimeline task={displayTask} />} + <ExecutionExplorer + running={running} + activity={task.activity} + telemetry={task.telemetry} + assignmentEvents={task.assignment_events} + approvals={approvals} + ns={ns} + name={name} + agentLabel={task.display_name ?? name} + agentPhase={task.assignment?.state ?? task.execution_phase ?? task.phase} + agentRuntime={task.composition?.runtime} + agentModel={task.composition?.model} + subAgents={task.sub_agents} + identity={task.agent_identity ?? null} + envelopeDigest={task.envelope_digest ?? null} + receipt={currentReceipt} + /> + <MissionBlockers + ns={ns} + task={name} + approvals={approvals} + activity={task.activity} + running={running} + decider={principal.name || operatorIdentity()} + authWired={authWired()} + /> + <TaskApprovalsPanel + approvals={approvals} + decider={principal.name || operatorIdentity()} + authWired={authWired()} + /> + {running && <EgressRequest mission={name} />} + </div> + ), + }, + { + id: "deliverable", + label: blocked ? "Run stopped" : currentResult?.status === "error" ? "Run failed" : "Deliverable", + live: false, + badge: currentResult != null && currentResult.status !== "error" && blocked == null ? "Ready" : null, + node: currentResult ? ( + <div className="space-y-5"> + {currentResult.status === "error" ? ( + <FailureDiagnostic task={displayTask} troubleshoot={troubleshoot} /> + ) : blocked ? ( + <section className="rounded-xl border border-warning/50 bg-warning/[0.06] p-6"> + <p className="text-sm font-medium"> + {blocked.reason === "budget" ? "Run stopped — daily token budget reached" : "Run stopped"} + {blocked.spent != null && blocked.limit != null + ? ` (${blocked.spent.toLocaleString()} / ${blocked.limit.toLocaleString()} tokens)` + : ""} + </p> + <p className="mt-1 text-sm text-foreground-muted">{blocked.detail}</p> + <p className="mt-3 text-xs text-foreground-muted"> + This is a stop condition, not the mission's answer — so there's no deliverable + to review. Raise the budget in the launch package (enforced by the sandbox's + inference policy), narrow the objective, or resume after the daily reset, then run again. + </p> + <details className="mt-3"> + <summary className="cursor-pointer text-xs text-foreground-muted hover:text-foreground"> + Show the raw stop message + </summary> + <pre className="mt-2 overflow-x-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/50 p-3 text-[11px] text-foreground-muted"> + {currentResult.output} + </pre> + </details> + </section> + ) : ( + <ResultPanel result={currentResult} /> + )} + {currentResult.status !== "error" && blocked == null && currentResult.output && <ReviewPanel task={name} assignmentNonce={currentResult.assignment_nonce ?? task.current_run_nonce ?? name} kind={reviewKind(task.artifacts)} initial={review} sandboxLive={task.launched && task.execution_phase === "Running"} />} + </div> + ) : null, + }, + { + id: "artifacts", + label: "Artifacts", + badge: ((task.artifacts?.length ?? 0) + (task.pull_requests?.length ?? 0)) || null, + node: (task.artifacts && task.artifacts.length > 0) || (task.pull_requests && task.pull_requests.length > 0) ? ( + <ArtifactsPanel ns={ns} task={name} artifacts={task.artifacts} pullRequests={task.pull_requests ?? []} activity={task.activity} egress={task.composition?.egress ?? []} tokens={currentResult?.total_tokens ?? null} /> + ) : realDelivery ? ( + <HonestState + variant="empty" + compact + title="No separate files" + detail="This mission produced a text deliverable — read it in the Deliverable tab. No discrete file artifacts were captured for this run." + /> + ) : null, + }, + { + id: "receipt", + label: "Receipt", + node: ( + <div className="space-y-3"> + {scorecard && <MissionScorecard scorecard={scorecard} />} + {currentReceipt && realDelivery ? ( + <> + <div className="flex items-center justify-end gap-2"> + <AuditReportDownload task={name} receipt={currentReceipt} activity={task.activity} egress={task.composition?.egress ?? []} /> + <ProvenanceOverlay receipt={currentReceipt} deliverableDid={null} activity={task.activity} egress={task.composition?.egress ?? []} /> + </div> + <ReceiptVerifyButton ns={ns} task={name} /> + <ReceiptPanel receipt={currentReceipt} /> + {compliance && <CompliancePackView pack={compliance} />} + </> + ) : ( + <section className="rounded-xl border border-dashed border-border bg-surface-muted/40 p-6 text-center"> + <p className="text-sm font-medium">No Governance Receipt yet</p> + <p className="mt-1 text-xs text-foreground-muted"> + {status === "failed" + ? "This run did not complete successfully — there's no delivered work to attest, so no receipt is presented." + : status === "blocked" + ? "Blocked missions don't produce a receipt — there's no validated work to attest." + : status === "done" + ? "Finalising the signed receipt for this mission's delivered work — refresh in a moment. If it remains unavailable, check receipt signing in the Operator Console." + : "A signed, verifiable receipt is issued once this mission delivers work — it attests the captured deliverable, token cost, and the policies enforced."} + </p> + </section> + )} + </div> + ), + }, + ]} + /> + </div> + ); +} + +type MissionServerTab = { + id: string; + label: string; + badge?: number | string | null; + node: ReactNode; + live?: boolean; +}; + +function MissionServerTabs({ + tabs, + active, + basePath, +}: { + tabs: MissionServerTab[]; + active?: string; + basePath: string; +}) { + const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; + return ( + <div> + <div + role="tablist" + aria-label="Mission sections" + className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" + > + {tabs.map((tab) => { + const selected = tab.id === current.id; + return ( + <Link + key={tab.id} + href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} + role="tab" + aria-selected={selected} + className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ + selected + ? "bg-signal/10 text-foreground" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" + }`} + > + {tab.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} + {tab.label} + {tab.badge != null && tab.badge !== 0 && ( + <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ + selected + ? "bg-signal/20 text-signal" + : "bg-surface-muted text-foreground-muted" + }`}> + {tab.badge} + </span> + )} + </Link> + ); + })} + </div> + <div role="tabpanel" className="kb-rise space-y-5"> + {current.node} + </div> + </div> + ); +} + +function EnvelopeFact({ label, value }: { label: string; value: string }) { + return ( + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">{label}</span> + <span className="font-medium">{value}</span> + </span> + ); +} + +/** The mission objective, rendered so a multi-step, command-laden brief is + * readable instead of collapsing into one wall of text. The header shows a + * clamped one/two-line summary (the first meaningful line); the full brief is + * behind a native disclosure that preserves line breaks. */ +function objectiveSummary(objective: string): string { + const firstLine = objective + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0); + return firstLine ?? objective.trim(); +} + +function ObjectiveBlock({ objective }: { objective: string }) { + const trimmed = (objective ?? "").trim(); + if (!trimmed) { + return <p className="mt-1 text-sm text-foreground-muted">No objective set.</p>; + } + const summary = objectiveSummary(trimmed); + const hasMore = summary.length < trimmed.length; + return ( + <div className="mt-1"> + <p className="line-clamp-2 text-sm text-foreground-muted">{summary}</p> + {hasMore && ( + <details className="group mt-1.5"> + <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs font-medium text-signal hover:underline [&::-webkit-details-marker]:hidden"> + <span className="transition-transform group-open:rotate-90" aria-hidden>›</span> + <span className="group-open:hidden">Show full brief</span> + <span className="hidden group-open:inline">Hide brief</span> + </summary> + <pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/50 px-4 py-3 font-mono text-xs leading-relaxed text-foreground-muted"> + {trimmed} + </pre> + </details> + )} + </div> + ); +} + +/** ONE primary, state-driven next step for the mission (audit f11). It tells the + * user what to do now in plain language and links to the single relevant place, + * rather than presenting every control at once. The full controls live in the + * tabs below; this is the signpost, not a duplicate action surface. */ +function NextStep({ + status, +}: { + status: import("@/components/mission-status").MissionStatus; +}) { + const map: Record<string, { tone: string; title: string; body: string; cta?: { href: string; label: string } }> = { + drafting: { + tone: "border-signal/30 bg-signal/[0.05]", + title: "Ready to launch", + body: "Review the composed plan below — model, tools, network, autonomy, budget — then launch it in the Execution panel when you're happy.", + }, + deploying: { + tone: "border-signal/30 bg-signal/[0.05]", + title: "Deploying — the agent is coming online", + body: "Each provisioning step below is a real, verified event. This page updates itself live; no need to refresh.", + }, + running: { + tone: "border-signal/30 bg-signal/[0.05]", + title: "Running", + body: "Watch the agent work in the Activity tab. If it needs a decision it will ask you here and in your Inbox.", + }, + needs_you: { + tone: "border-warning/40 bg-warning/10", + title: "This mission needs your decision", + body: "It paused for your approval before a priced, external, or irreversible step.", + cta: { href: "/workspace/inbox", label: "Open the inbox →" }, + }, + done: { + tone: "border-ok/40 bg-ok/10", + title: "Delivered", + body: "The deliverable is ready. Review it and accept or request changes in the Deliverable tab; the signed receipt is in the Receipt tab.", + }, + failed: { + tone: "border-danger/40 bg-danger/10", + title: "This run didn't complete", + body: "Open the Run failed tab below for a full diagnosis — the likely cause, how far it got, the runtime's exact reason, and one-click ways to re-compose or re-run.", + }, + }; + const m = map[status] ?? map.drafting; + return ( + <div className={`flex flex-wrap items-center justify-between gap-3 rounded-xl border px-5 py-3.5 ${m.tone}`}> + <div className="min-w-0"> + <p className="text-sm font-semibold">{m.title}</p> + <p className="mt-0.5 text-xs text-foreground-muted">{m.body}</p> + </div> + {m.cta && ( + <Link href={m.cta.href} className="shrink-0 rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90"> + {m.cta.label} + </Link> + )} + </div> + ); +} + +function AgentIdentityCard({ identity }: { identity: AgentIdentity }) { + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Agent mesh identity</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The running agent's real, harness-neutral identity on the encrypted agent mesh — + discovered live from the registry. This is how work is delivered and verified across any + runtime. + </p> + <dl className="mt-3 space-y-2 text-sm"> + <div className="flex flex-wrap items-baseline gap-x-2"> + <dt className="text-xs text-foreground-muted">DID</dt> + <dd className="font-mono text-xs break-all">{identity.did}</dd> + </div> + {identity.capabilities.length > 0 && ( + <div> + <dt className="text-xs text-foreground-muted">Advertised capabilities</dt> + <dd className="mt-1 flex flex-wrap gap-1.5"> + {identity.capabilities.map((c) => ( + <span key={c} className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs"> + {c} + </span> + ))} + </dd> + </div> + )} + {identity.last_seen && ( + <div className="flex flex-wrap items-baseline gap-x-2"> + <dt className="text-xs text-foreground-muted">Last seen on the mesh</dt> + <dd className="text-xs font-medium">{new Date(identity.last_seen).toLocaleString()}</dd> + </div> + )} + </dl> + </section> + ); +} + +function ArtifactsPanel({ ns, task, artifacts, pullRequests, activity, egress, tokens }: { ns: string; task: string; artifacts: MissionArtifact[]; pullRequests: import("@/lib/types").PullRequestRef[]; activity: import("@/lib/types").ActivityEvent[]; egress: string[]; tokens: number | null }) { + const fmtSize = (n: number | null) => + n == null ? "" : n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`; + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Artifacts</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The complete set of files the agent produced through its native loop over the mesh — + captured by the controller into a durable, cluster-native record. + </p> + </div> + <span className="shrink-0 rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium"> + {artifacts.length} file{artifacts.length === 1 ? "" : "s"} + </span> + </div> + {/* Pull requests are a first-class delivery type — a PR the agent opened is + an artifact, shown here as a chip (not only in the deliverable prose). */} + {pullRequests.length > 0 && ( + <div className="mt-4 rounded-lg border border-signal/20 bg-signal/[0.03] p-4"> + <h3 className="text-xs font-semibold">Pull requests opened</h3> + <ul className="mt-2 flex flex-wrap gap-2"> + {pullRequests.map((pr) => ( + <li key={pr.url}> + <a + href={pr.url} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-1.5 hover:bg-signal/10" + title={`Pull request on ${pr.repo}`} + > + <Icon name="branch" size={13} className="shrink-0 text-signal" /> + <span className="text-xs font-medium text-signal">PR #{pr.number}</span> + <span className="font-mono text-[11px] text-foreground-muted">{pr.repo}</span> + <span aria-hidden className="text-[11px] text-foreground-muted">↗</span> + </a> + </li> + ))} + </ul> + </div> + )} + {/* How this was made — the plain-language provenance story over the real trace. */} + <div className="mt-4 rounded-lg border border-border bg-background/40 p-4"> + <h3 className="text-xs font-semibold">How this was made</h3> + <div className="mt-2"><ProvenanceStory activity={activity} egress={egress} tokens={tokens} /></div> + </div> + <ul className="mt-4 divide-y divide-border rounded-lg border border-border"> + {artifacts.map((a, i) => ( + <li key={a.name}> + <details open={i === 0} className="group"> + <summary className="flex cursor-pointer items-center justify-between gap-3 px-4 py-2.5 hover:bg-surface-muted/50"> + <span className="flex items-center gap-2 font-mono text-xs"> + <span aria-hidden className="text-foreground-muted transition-transform group-open:rotate-180">⌄</span> + {a.name} + </span> + <span className="flex shrink-0 items-center gap-3 text-xs text-foreground-muted"> + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + target="_blank" + rel="noopener noreferrer" + className="text-signal hover:underline" + > + {a.content_truncated ? "Open full" : "Open"} + </a> + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + download={a.name} + className="inline-flex items-center gap-1 font-medium text-signal hover:underline" + > + <Icon name="download" size={12} /> + Download + </a> + <span> + {a.content == null ? "binary · " : ""} + {fmtSize(a.size_bytes)} + </span> + </span> + </summary> + {a.content_truncated ? ( + <div className="space-y-3 border-t border-border bg-surface-muted/20 px-4 py-3"> + <p className="text-xs text-foreground-muted"> + Showing a bounded preview of {(a.content_bytes ?? a.size_bytes ?? 0).toLocaleString()} bytes. + </p> + {a.content ? ( + <pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/30 p-3 font-mono text-xs leading-relaxed"> + {a.content} + </pre> + ) : null} + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + target="_blank" + rel="noopener noreferrer" + className="inline-flex text-xs font-medium text-signal hover:underline" + > + Open full artifact ↗ + </a> + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + download={a.name} + className="inline-flex items-center gap-1 text-xs font-medium text-signal hover:underline" + > + <Icon name="download" size={12} /> + Download artifact + </a> + </div> + ) : a.content != null ? ( + isProseArtifact(a.name) ? ( + <div className="max-h-96 overflow-auto border-t border-border bg-surface-muted/20 px-4 py-3"> + <DeliverableBody output={a.content} /> + </div> + ) : ( + <pre className="max-h-96 overflow-auto whitespace-pre-wrap border-t border-border bg-surface-muted/30 px-4 py-3 font-mono text-xs leading-relaxed"> + {a.content} + </pre> + ) + ) : ( + <p className="border-t border-border bg-surface-muted/30 px-4 py-3 text-xs text-foreground-muted"> + Binary artifact — use{" "} + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + download={a.name} + className="text-signal hover:underline" + > + Download + </a>{" "} + to fetch the full file. + </p> + )} + </details> + </li> + ))} + </ul> + </section> + ); +} + +function ResultPanel({ result }: { result: MissionResult }) { + return ( + <div className="space-y-3"> + {result.source === "single_turn" && ( + <div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2 text-xs text-foreground-muted"> + <span aria-hidden className="mt-0.5 text-amber-600">ℹ</span> + <span> + <span className="font-medium text-foreground">Single-turn completion.</span> The full + agent loop (tools + sub-agents) was unavailable on this run, so this is one model turn — + the Activity tab will show no tool calls. Re-run to try the full loop again. + </span> + </div> + )} + <DeliverableView + output={result.output} + model={result.model} + totalTokens={result.total_tokens} + finishedAt={result.finished_at} + /> + </div> + ); +} + +/** Analyse a run failure reason into a plain-language cause + a specific remedy, + * and (when relevant) flag that the harness itself is the problem. */ +function analyzeFailure(reason: string, harness: string | null): { cause: string; remedy: string; harnessIssue: boolean } { + const r = (reason || "").toLowerCase(); + const chatGateway = !!harness && /hermes|gateway|channel/.test(harness.toLowerCase()); + if (r.includes("did not come online") || r.includes("not yet discoverable") || r.includes("mesh registry") || r.includes("not discoverable")) { + return { + cause: chatGateway + ? `The agent never registered on the encrypted mesh. The “${harness}” harness is a chat-gateway — it waits for inbound channel messages and does not execute a one-shot mission on its own, so it never came online to do autonomous work.` + : "The agent sandbox didn't register on the encrypted mesh within the startup window. This is usually a slow container image pull or node pressure delaying the pod — occasionally a crashed agent container.", + remedy: chatGateway + ? "Re-compose this mission on the OpenClaw harness (built for autonomous missions), or drive this one through its channel." + : "Re-run it — a fresh sandbox often comes up cleanly. If it repeats, an operator can inspect the sandbox for image-pull or crash errors.", + harnessIssue: chatGateway, + }; + } + if (r.includes("no progress heartbeat") || r.includes("timed out") || r.includes("timeout")) { + return { + cause: "The agent started but stopped making progress, so the controller timed the run out after a period with no heartbeat.", + remedy: "Re-run it. If it stalls repeatedly, narrow the objective or raise the token/time budget in the envelope.", + harnessIssue: false, + }; + } + if (r.includes("content safety") || r.includes("jailbreak") || r.includes("blocked by")) { + return { + cause: "A content-safety policy blocked the run before it could deliver.", + remedy: "Adjust the objective to avoid the flagged content, or ask an operator about the content-safety floor.", + harnessIssue: false, + }; + } + if (r.includes("budget") || r.includes("token cap") || r.includes("out of tokens")) { + return { + cause: "The run hit its token budget before producing a deliverable.", + remedy: "Re-run with a higher token budget in the envelope.", + harnessIssue: false, + }; + } + return { + cause: "The run ended with an error before producing a deliverable.", + remedy: "Re-run it, or re-compose with a different harness or model.", + harnessIssue: false, + }; +} + +/** Real, actionable troubleshooting for a failed run: what happened, how far the + * provisioning got (which stage it stopped at), and what to do next. When live + * cluster evidence is available (pod/container status + the agent's own log + * tail), it uses the evidence-derived diagnosis and SHOWS the proof; otherwise + * it falls back to analysing the recorded reason. */ +function FailureDiagnostic({ + task, + troubleshoot, +}: { + task: TaskDetail; + troubleshoot: import("@/lib/types").Troubleshoot | null; +}) { + const reason = task.result?.output ?? task.execution_detail ?? "The run ended with an error."; + const harness = task.composition?.runtime ?? null; + // Prefer the live, evidence-derived diagnosis from the cluster; fall back to + // the local reason analysis when the troubleshoot endpoint is unavailable. + const local = analyzeFailure(reason, harness); + const cause = troubleshoot?.cause ?? local.cause; + const remedy = troubleshoot?.remedy ?? local.remedy; + const harnessIssue = troubleshoot?.harness_issue ?? local.harnessIssue; + const meshAcknowledged = task.assignment_events.some( + (event) => event.event_type === "acknowledged" || event.state === "Running", + ); + + // How far provisioning got — the same stages the deploy timeline tracks. The + // first un-reached stage is where it stopped. + const stages: { label: string; reached: boolean }[] = [ + { label: "Launch approved", reached: task.launched }, + { label: "Sandbox provisioned", reached: !!task.sandbox }, + { label: "Agent online on the mesh", reached: meshAcknowledged || !!task.agent_identity?.last_seen }, + { label: "First activity (model round / tool call)", reached: (task.activity?.length ?? 0) > 0 }, + ]; + const stoppedAt = stages.findIndex((s) => !s.reached); + + return ( + <section className="space-y-4 rounded-xl border border-amber-500/40 bg-amber-500/5 p-6"> + <div className="flex items-start gap-3"> + <span className="mt-0.5 text-warning" aria-hidden> + <Icon name="target" size={18} /> + </span> + <div> + <h2 className="text-sm font-semibold">Run did not complete</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {troubleshoot + ? "Diagnosed from the sandbox's live pod status and the agent's own logs." + : "Here's what happened and how to fix it."} + </p> + </div> + </div> + + {/* Likely cause + remedy. */} + <div className="rounded-lg border border-amber-500/30 bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Likely cause</p> + <p className="mt-1 text-sm">{cause}</p> + <p className="mt-3 text-xs font-semibold uppercase tracking-wide text-foreground-muted">What to do</p> + <p className="mt-1 text-sm">{remedy}</p> + </div> + + {/* The real smoking-gun evidence pulled from the agent's logs. */} + {troubleshoot && troubleshoot.evidence.length > 0 && ( + <div className="rounded-lg border border-danger/30 bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Evidence — from the agent's own logs</p> + <ul className="mt-2 space-y-1"> + {troubleshoot.evidence.map((e, i) => ( + <li key={i} className="rounded bg-danger/5 px-2 py-1 font-mono text-[11px] leading-relaxed text-danger">{e}</li> + ))} + </ul> + </div> + )} + + {/* Live container status. */} + {troubleshoot && troubleshoot.containers.length > 0 && ( + <div className="rounded-lg border border-border bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted"> + Sandbox pod {troubleshoot.pod_summary ? `(${troubleshoot.pod_summary} ready)` : ""} + </p> + <ul className="mt-2 grid gap-1.5 sm:grid-cols-2"> + {troubleshoot.containers.map((c) => ( + <li key={c.name} className="flex items-center gap-2 text-xs"> + <span aria-hidden className={c.ready ? "text-ok" : "text-danger"}>{c.ready ? "✓" : "✗"}</span> + <span className="font-mono">{c.name}</span> + <span className="text-foreground-muted"> + {c.state}{c.reason ? ` · ${c.reason}` : ""}{c.restarts > 0 ? ` · ${c.restarts}↻` : ""} + </span> + </li> + ))} + </ul> + </div> + )} + + {/* How far it got. */} + <div className="rounded-lg border border-border bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">How far it got</p> + <ol className="mt-2 space-y-1.5"> + {stages.map((s, i) => { + const isStop = i === stoppedAt; + return ( + <li key={s.label} className="flex items-center gap-2 text-sm"> + <span aria-hidden className={s.reached ? "text-ok" : isStop ? "text-danger" : "text-foreground-muted"}> + {s.reached ? "✓" : isStop ? "✗" : "•"} + </span> + <span className={s.reached ? "" : isStop ? "font-medium text-danger" : "text-foreground-muted"}> + {s.label} + {isStop && <span className="ml-1.5 text-xs font-normal text-danger">— stopped here</span>} + </span> + </li> + ); + })} + </ol> + </div> + + {/* The raw agent log tail — the exact evidence, for the record. */} + <details className="rounded-lg border border-border bg-surface"> + <summary className="cursor-pointer px-4 py-2.5 text-xs font-semibold"> + {troubleshoot && troubleshoot.agent_log_tail.length > 0 ? "Agent log tail (live)" : "Runtime's exact reason"} + </summary> + {troubleshoot && troubleshoot.agent_log_tail.length > 0 ? ( + <pre className="max-h-72 overflow-auto border-t border-border px-4 py-3 font-mono text-[10px] leading-relaxed text-foreground-muted">{troubleshoot.agent_log_tail.join("\n")}</pre> + ) : ( + <p className="border-t border-border px-4 py-3 font-mono text-xs leading-relaxed text-foreground-muted">{reason}</p> + )} + </details> + + {/* Actions. */} + <div className="flex flex-wrap gap-2"> + <Link + href={`/workspace/new?intent=${encodeURIComponent(task.objective)}`} + className="rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90" + > + {harnessIssue ? "Re-compose on OpenClaw →" : "Re-compose from this intent →"} + </Link> + </div> + {task.result?.finished_at && ( + <p className="text-xs text-foreground-muted">Failed {new Date(task.result.finished_at).toLocaleString()}</p> + )} + </section> + ); +} + +function CompositionPanel({ composition, launched }: { composition: Composition; launched: boolean }) { + const c = composition; + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">How this mission runs</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {launched + ? "The effective configuration the running sandbox is using — read from the materialized policy and sandbox (including any defaults the controller applied)." + : "The planned configuration you composed — what this mission will run with once launched."} + </p> + <dl className="mt-4 grid gap-x-8 gap-y-4 sm:grid-cols-2"> + <Fact label="Model" value={c.model} /> + <Fact label="Harness" value={c.runtime} /> + <Fact label="Tool policy" value={c.tool_policy ?? "None — model only"} /> + <Fact label="Isolation" value={c.isolation} /> + <Fact + label="Connected services" + value={c.mcp_servers.length ? c.mcp_servers.map(humanizeMcp).join(", ") : "None"} + /> + <Fact label="Shared memory" value={c.memory ?? "None"} /> + </dl> + <div className="mt-4 border-t border-border pt-4"> + <p className="text-xs font-medium text-foreground-muted">Network egress</p> + {c.egress.length === 0 ? ( + <p className="mt-1 text-sm">Model path only — all other egress denied at the boundary.</p> + ) : ( + <> + <ul className="mt-1.5 flex flex-wrap gap-1.5"> + {c.egress.map((e) => { + const scope = egressScope(e); + return ( + <li key={e} className="inline-flex items-center gap-1.5 rounded-full bg-surface-muted px-2.5 py-1 font-mono text-xs"> + <span + className={`h-1.5 w-1.5 rounded-full ${scope === "internal" ? "bg-sky-500" : "bg-amber-500"}`} + title={scope === "internal" ? "Internal — in-cluster / private" : "External — public internet"} + aria-hidden + /> + {e} + </li> + ); + })} + </ul> + <p className="mt-1.5 text-[11px] text-foreground-muted"> + <span className="inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-sky-500" aria-hidden /> internal</span> + <span className="ml-3 inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden /> external</span> + <span className="ml-2">— same boundary, labelled for clarity.</span> + </p> + </> + )} + </div> + {c.instructions && ( + <div className="mt-4 border-t border-border pt-4"> + <p className="text-xs font-medium text-foreground-muted">Instructions</p> + <p className="mt-1.5 whitespace-pre-wrap rounded-lg bg-surface-muted px-3 py-2 text-sm leading-relaxed"> + {c.instructions} + </p> + </div> + )} + </section> + ); +} + +function Fact({ label, value }: { label: string; value: string | null }) { + return ( + <div> + <dt className="text-xs text-foreground-muted">{label}</dt> + <dd className="mt-0.5 text-sm font-medium">{value ?? "—"}</dd> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx b/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx new file mode 100644 index 000000000..8d1c57ba5 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx @@ -0,0 +1,42 @@ +"use client"; + +// Per-mission autonomy promotion (§12). Requests a higher tier; the controller +// opens a human approval and only widens the envelope once approved — this +// never grants authority directly. Mirrors the standing-team promote control. + +import { useActionState } from "react"; +import { promoteMissionAction, type ReplicateState } from "./run-actions"; + +const init: ReplicateState = { error: null, ok: null }; +const TIERS: Record<number, string> = { 1: "Manual", 2: "Shared", 3: "Conditional", 4: "Supervised", 5: "Full" }; + +export function PromoteMission({ ns, name, currentTier }: { ns: string; name: string; currentTier: number }) { + const [state, action, pending] = useActionState(promoteMissionAction, init); + const next = Math.min(currentTier + 1, 5); + if (currentTier >= 5 && !state.ok) return null; + if (state.ok) { + return <div className="rounded-lg border border-signal/30 bg-signal/5 px-3 py-2 text-xs">{state.ok}</div>; + } + return ( + <form action={action} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-surface-muted/30 px-3 py-2.5"> + <input type="hidden" name="ns" value={ns} /> + <input type="hidden" name="name" value={name} /> + <div> + <p className="text-xs font-semibold">Request more autonomy</p> + <p className="mt-0.5 text-[11px] text-foreground-muted">Currently Tier {currentTier} ({TIERS[currentTier] ?? "?"}). A promotion opens a human approval — it never widens authority directly.</p> + </div> + <div className="flex items-center gap-2"> + <label className="text-[11px] text-foreground-muted"> + → + <select name="tier" aria-label="Target autonomy tier" defaultValue={next} className="ml-1 rounded border border-border bg-surface px-1.5 py-1 text-[11px]"> + {[2, 3, 4, 5].filter((t) => t > currentTier).map((t) => <option key={t} value={t}>Tier {t} ({TIERS[t]})</option>)} + </select> + </label> + <button type="submit" disabled={pending} className="rounded-md border border-accent/40 bg-accent/10 px-2.5 py-1 text-[11px] font-semibold text-accent disabled:opacity-50"> + {pending ? "Requesting…" : "Request promotion"} + </button> + </div> + {state.error && <span className="w-full text-[11px] text-danger">{state.error}</span>} + </form> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx b/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx new file mode 100644 index 000000000..22d5240cf --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx @@ -0,0 +1,140 @@ +// kars Bridge Workspace — Pre-flight access readiness (design note FL5). +// +// Before (and during) a run, the operator must be able to see — at a glance — +// that the agent has every access it needs, and nothing it doesn't. This panel +// turns the composed envelope into an explicit checklist: model path, tool +// policy, connected services, network egress, isolation, shared memory. Honest +// states: pre-launch each line reads "verified at launch" (it's a plan); +// once the controller has materialised the sandbox the same lines read +// "verified" (the boundary is live). A degraded sandbox surfaces as attention. + +import type { Composition } from "@/lib/types"; +import { egressScope, humanizeMcp } from "@/lib/format"; + +type Readiness = "verified" | "pending" | "attention"; + +function StatusDot({ state }: { state: Readiness }) { + const map: Record<Readiness, { cls: string; glyph: string; label: string }> = { + verified: { cls: "text-emerald-600", glyph: "✓", label: "Verified" }, + pending: { cls: "text-foreground-muted", glyph: "○", label: "At launch" }, + attention: { cls: "text-amber-600", glyph: "!", label: "Attention" }, + }; + const m = map[state]; + return ( + <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${m.cls}`}> + <span + aria-hidden + className={`grid h-4 w-4 place-items-center rounded-full text-[10px] ${state === "verified" ? "bg-emerald-500/10" : state === "attention" ? "bg-amber-500/10" : "bg-surface-muted"}`} + > + {m.glyph} + </span> + {m.label} + </span> + ); +} + +function CheckRow({ + label, + value, + state, +}: { + label: string; + value: string; + state: Readiness; +}) { + return ( + <li className="flex items-center justify-between gap-4 px-4 py-2.5"> + <div className="min-w-0"> + <p className="text-sm font-medium">{label}</p> + <p className="truncate text-xs text-foreground-muted">{value}</p> + </div> + <StatusDot state={state} /> + </li> + ); +} + +export function ReadinessPanel({ + composition, + launched, + executionPhase, + degraded, + delivered, + failed, +}: { + composition: Composition; + launched: boolean; + executionPhase: string | null; + degraded: boolean; + delivered?: boolean; + failed?: boolean; +}) { + // A delivered mission ran to completion behind the enforced boundary, so its + // accesses were verified even though the sandbox has since been torn down and + // the execution phase has returned to idle. + const live = + delivered || + (launched && (executionPhase === "Running" || executionPhase === "Ready" || executionPhase === "Succeeded")); + const base: Readiness = degraded ? "attention" : live ? "verified" : "pending"; + + const rows: { label: string; value: string }[] = [ + { label: "Model path", value: composition.model ?? "controller default" }, + { label: "Isolation boundary", value: composition.isolation ?? "standard" }, + ]; + if (composition.tool_policy) rows.push({ label: "Tool policy", value: composition.tool_policy }); + if (composition.mcp_servers.length) + rows.push({ label: "Connected services", value: composition.mcp_servers.map(humanizeMcp).join(", ") }); + rows.push({ + label: "Network egress", + value: composition.egress.length + ? (() => { + const ext = composition.egress.filter((e) => egressScope(e) === "external"); + const int = composition.egress.filter((e) => egressScope(e) === "internal"); + const parts: string[] = []; + if (ext.length) parts.push(`${ext.length} external (${ext.join(", ")})`); + if (int.length) parts.push(`${int.length} internal (${int.join(", ")})`); + return parts.join(" · "); + })() + : "model path only — all else denied", + }); + if (composition.memory) rows.push({ label: "Shared memory", value: composition.memory }); + + const allVerified = base === "verified"; + const headline = degraded + ? "One or more accesses need attention" + : failed + ? "The access boundary was verified; execution failed afterward" + : live + ? "Every access the agent needs is verified and bounded" + : "Access plan — verified the moment this mission launches"; + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Pre-flight access</h2> + <p className="mt-0.5 text-xs text-foreground-muted">{headline}</p> + </div> + <span + className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${ + allVerified + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + : degraded + ? "border-amber-500/30 bg-amber-500/10 text-amber-600" + : "border-border bg-surface-muted text-foreground-muted" + }`} + > + {allVerified ? "Ready" : degraded ? "Needs attention" : "Pending launch"} + </span> + </div> + <ul className="mt-4 divide-y divide-border rounded-lg border border-border"> + {rows.map((r) => ( + <CheckRow key={r.label} label={r.label} value={r.value} state={base} /> + ))} + </ul> + <p className="mt-3 text-[11px] text-foreground-muted"> + Each line is enforced at the sandbox boundary — the agent cannot reach anything not listed + here. Granting more happens through an approval you'll see in your Inbox. + </p> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx b/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx new file mode 100644 index 000000000..0e5d1059c --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx @@ -0,0 +1,59 @@ +"use client"; + +// Reliability runner — the pass^k trigger. Runs a delivered mission's EXACT +// package k more times so the efficiency frontier can measure pass^k +// reliability (fraction of the repeated package accepted on every attempt). +// These are real governed runs, so it's a deliberate two-step action. + +import { useActionState, useState } from "react"; +import { replicateMissionAction, type ReplicateState } from "./run-actions"; + +const init: ReplicateState = { error: null, ok: null }; + +export function ReliabilityRunner({ ns, name }: { ns: string; name: string }) { + const [state, action, pending] = useActionState(replicateMissionAction, init); + const [count, setCount] = useState(2); + const [open, setOpen] = useState(false); + + if (state.ok) { + return ( + <div className="rounded-lg border border-signal/30 bg-signal/5 px-3 py-2 text-xs text-foreground"> + {state.ok} + </div> + ); + } + + return ( + <div className="rounded-lg border border-border bg-surface-muted/30 px-3 py-2.5"> + <div className="flex items-center justify-between gap-3"> + <div> + <p className="text-xs font-semibold">Measure reliability (pass^k)</p> + <p className="mt-0.5 text-[11px] text-foreground-muted"> + Run this exact package again to see how consistently it succeeds — the honest reliability signal. + </p> + </div> + {!open ? ( + <button type="button" onClick={() => setOpen(true)} className="shrink-0 rounded-md border border-border px-2.5 py-1 text-[11px] font-medium hover:bg-surface"> + Run again… + </button> + ) : ( + <form action={action} className="flex shrink-0 items-center gap-2"> + <input type="hidden" name="ns" value={ns} /> + <input type="hidden" name="name" value={name} /> + <label className="text-[11px] text-foreground-muted"> + × + <select name="count" aria-label="Number of repeat runs" value={count} onChange={(e) => setCount(Number(e.target.value))} className="ml-1 rounded border border-border bg-surface px-1.5 py-1 text-[11px]"> + {[2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)} + </select> + </label> + <button type="submit" disabled={pending} className="rounded-md border border-signal/40 bg-signal/10 px-2.5 py-1 text-[11px] font-semibold text-signal disabled:opacity-50"> + {pending ? "Launching…" : `Run ${count}× more`} + </button> + <button type="button" onClick={() => setOpen(false)} className="text-[11px] text-foreground-muted hover:text-foreground">Cancel</button> + </form> + )} + </div> + {state.error && <p className="mt-1.5 text-[11px] text-danger">{state.error}</p>} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/review-actions.ts b/bridge/web/src/app/workspace/missions/[name]/review-actions.ts new file mode 100644 index 000000000..76d89ff7b --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/review-actions.ts @@ -0,0 +1,46 @@ +// kars Bridge — artifact review server action (§16). request_changes re-drives +// the producing task on the reviewer's delta. +"use server"; + +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function submitReview( + task: string, + assignmentNonce: string, + decision: "approve" | "request_changes", + comment?: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/review`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + decision, + comment: comment || undefined, + assignment_nonce: assignmentNonce, + }), + }, + ); + if (!res.ok) { + let message = `Review failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // keep status-derived message + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/missions/${task}`); + revalidatePath("/workspace/artifacts"); + return { error: null }; +} diff --git a/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx b/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx new file mode 100644 index 000000000..c55396389 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx @@ -0,0 +1,262 @@ +"use client"; + +// kars Bridge Workspace — the artifact review loop (§16). A reviewer accepts a +// deliverable or requests changes; request-changes re-drives the producing task +// on the delta and a new revision lands. Typed by artifact kind, with the full +// review lineage and a link into the run's provenance (the execution trace). + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { submitReview } from "./review-actions"; +import { setLaunch } from "@/app/tasks/[name]/launch-actions"; +import type { ReviewState } from "@/lib/types"; + +const KIND_LABEL: Record<string, string> = { + code: "Code — review as a change", + docs: "Document — review the prose", + data: "Data — review the values", + output: "Output — review the result", +}; + +function statusChip(status: string, pending: boolean) { + if (pending) + return { label: "Revising…", cls: "bg-sky-500/10 text-sky-600 border-sky-500/30" }; + switch (status) { + case "approved": + return { label: "Approved", cls: "bg-emerald-500/10 text-emerald-600 border-emerald-500/30" }; + case "changes_requested": + return { label: "Changes requested", cls: "bg-amber-500/10 text-amber-600 border-amber-500/30" }; + default: + return { label: "Awaiting review", cls: "bg-surface-muted text-foreground-muted border-border" }; + } +} + +export function ReviewPanel({ + task, + assignmentNonce, + kind, + initial, + sandboxLive = false, +}: { + task: string; + assignmentNonce: string; + kind: string; + initial: ReviewState | null; + /** True when the producing agent's sandbox is still Running — so approving can + * offer to terminate it (cleanup-on-good). False once it's already torn down. */ + sandboxLive?: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [comment, setComment] = useState(""); + const [error, setError] = useState<string | null>(null); + const [requesting, setRequesting] = useState(false); + // After Approve, we ask whether to terminate the agent (cleanup-on-good). The + // operator can dismiss to leave it running (e.g. to iterate further). + const [keepRunning, setKeepRunning] = useState(false); + + const status = initial?.status ?? "none"; + const redrivePending = initial?.redrive_pending ?? false; + const revision = initial?.revision ?? 0; + const history = initial?.history ?? []; + const chip = statusChip(status, redrivePending); + + function act(decision: "approve" | "request_changes") { + setError(null); + if (decision === "request_changes" && comment.trim().length === 0) { + setError("Describe what needs to change so the agent can revise."); + return; + } + startTransition(async () => { + const res = await submitReview( + task, + assignmentNonce, + decision, + comment.trim() || undefined, + ); + if (res.error) { + setError(res.error); + return; + } + setComment(""); + setRequesting(false); + router.refresh(); + }); + } + + function terminateAgent() { + setError(null); + startTransition(async () => { + const res = await setLaunch(task, false); + if (res.error) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Review</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {KIND_LABEL[kind] ?? KIND_LABEL.output} · accept the deliverable or request changes — + requesting changes re-runs the agent on your feedback. + </p> + </div> + <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${chip.cls}`}> + {chip.label} + {revision > 0 && <span className="ml-1 opacity-70">· rev {revision}</span>} + </span> + </div> + + {redrivePending ? ( + <p className="mt-4 rounded-lg border border-sky-500/30 bg-sky-500/5 px-4 py-3 text-sm text-foreground-muted"> + <span className="font-medium text-sky-700">Changes requested — received.</span>{" "} + The agent is producing a new revision from your feedback. It will land here when ready + (watch it in the Activity tab). + </p> + ) : ( + <div className="mt-4 space-y-3"> + {status === "approved" && !requesting ? ( + <div className="space-y-3"> + <p className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-4 py-3 text-sm text-emerald-700"> + ✓ You approved this deliverable{revision > 0 ? ` (rev ${revision})` : ""}.{" "} + <button + type="button" + onClick={() => setRequesting(true)} + className="underline hover:no-underline" + > + Request changes to iterate + </button> + . + </p> + {/* Cleanup-on-good: once approved, offer to terminate the agent and + free its sandbox. The deliverable + signed receipt are kept — this + only tears down the running agent. Shown only while the sandbox is + still up, and dismissable if the operator wants to keep iterating. */} + {sandboxLive && !keepRunning && ( + <div className="rounded-lg border border-border bg-surface-muted/40 px-4 py-3"> + <p className="text-sm font-medium">Happy with this? You can free the agent now.</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + Terminating tears down the agent’s sandbox and frees its resources. Your + deliverable and its signed receipt are kept — nothing is lost. + </p> + <div className="mt-3 flex flex-wrap gap-2"> + <button + type="button" + disabled={pending} + onClick={terminateAgent} + className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Freeing…" : "Terminate agent & free sandbox"} + </button> + <button + type="button" + disabled={pending} + onClick={() => setKeepRunning(true)} + className="rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium transition hover:bg-surface-muted disabled:opacity-50" + > + Keep it running + </button> + </div> + </div> + )} + {sandboxLive && keepRunning && ( + <p className="text-xs text-foreground-muted"> + The agent stays running — free it any time from the Execution panel + (“Stop sandbox”). + </p> + )} + </div> + ) : !requesting ? ( + <div className="flex flex-wrap gap-2"> + <button + type="button" + disabled={pending} + onClick={() => act("approve")} + className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50" + > + Approve deliverable + </button> + <button + type="button" + disabled={pending} + onClick={() => setRequesting(true)} + className="rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium transition hover:bg-surface-muted disabled:opacity-50" + > + Request changes + </button> + </div> + ) : ( + <div className="space-y-2"> + <textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + rows={3} + placeholder="What should change? Be specific — the agent revises against this." + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <div className="flex flex-wrap gap-2"> + <button + type="button" + disabled={pending} + onClick={() => act("request_changes")} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Sending…" : "Send & re-run"} + </button> + <button + type="button" + disabled={pending} + onClick={() => { + setRequesting(false); + setComment(""); + setError(null); + }} + className="rounded-lg border border-border bg-surface px-4 py-2 text-sm font-medium transition hover:bg-surface-muted disabled:opacity-50" + > + Cancel + </button> + </div> + </div> + )} + {error && <p className="text-xs text-rose-600">{error}</p>} + </div> + )} + + {history.length > 0 && ( + <div className="mt-5 border-t border-border pt-4"> + <p className="text-xs font-medium text-foreground-muted">Review history</p> + <ul className="mt-2 space-y-2"> + {history.map((h, i) => ( + <li key={i} className="text-xs"> + <span + className={ + h.decision === "approve" ? "font-medium text-emerald-600" : "font-medium text-amber-600" + } + > + {h.decision === "approve" ? "Approved" : "Requested changes"} + </span>{" "} + <span className="text-foreground-muted"> + · rev {h.revision} · {new Date(h.decided_at).toLocaleString()} · {h.reviewer} + {h.attested === false && ( + <span + className="ml-1 rounded bg-surface-muted px-1 py-0.5 text-[10px] text-foreground-muted" + title="Self-reported name — not verified against an authenticated identity in this deployment." + > + self-reported + </span> + )} + </span> + {h.comment && <p className="mt-0.5 text-foreground-muted">“{h.comment}”</p>} + </li> + ))} + </ul> + </div> + )} + </section> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/role-actions.ts b/bridge/web/src/app/workspace/missions/[name]/role-actions.ts new file mode 100644 index 000000000..f8065235f --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/role-actions.ts @@ -0,0 +1,93 @@ +// kars Bridge Workspace — add a delegated role to a mission (the §12 org chart). +// +// A "role" is a child KarsTask whose authority is a verified subset of the +// principal's: lower-or-equal tier, the same tool policy, and an egress +// allow-list that is a subset of the principal's. The controller enforces the +// attenuation; this action only composes the child and submits it. Over-reach +// is surfaced honestly (the role lands Degraded with the reason) rather than +// prevented by hiding controls. + +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, createTask } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import type { Blueprint, BlueprintEgress, CreateTaskRequest } from "@/lib/types"; + +export interface AddRoleInput { + principal: string; + principalTier: number; + principalCeiling: number; + principalDelegationDepth: number; + toolPolicy: string | null; + roleName: string; + objective: string; + tier: number; + instructions: string; + egress: BlueprintEgress[]; +} + +export interface AddRoleState { + error: string | null; +} + +function slugify(s: string): string { + const base = s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 28) + .replace(/-+$/g, ""); + const suffix = Math.random().toString(36).slice(2, 6); + return `${base.length ? base : "role"}-${suffix}`; +} + +export async function addRole(input: AddRoleInput): Promise<AddRoleState> { + const ns = defaultNamespace(); + const objective = input.objective.trim(); + const roleName = input.roleName.trim(); + if (roleName.length === 0) return { error: "Give the role a name." }; + if (objective.length === 0) return { error: "Describe what this role does." }; + + // Attenuate: the role never holds more than the principal grants a descendant. + const tier = Math.min(Math.max(1, input.tier), input.principalCeiling); + const delegationDepth = Math.max(0, input.principalDelegationDepth - 1); + + const blueprint: Blueprint = {}; + if (input.toolPolicy) blueprint.tool_policy = input.toolPolicy; + if (input.egress.length) blueprint.egress = input.egress; + if (input.instructions.trim()) blueprint.instructions = input.instructions.trim(); + + const body: CreateTaskRequest = { + name: slugify(roleName), + objective, + display_name: roleName, + parent: input.principal, + envelope: { + tier, + authority_ceiling: tier, + delegation_depth: delegationDepth, + budget: null, + tool_policy: null, + egress_allowlist: null, + }, + blueprint: Object.keys(blueprint).length ? blueprint : null, + launch: false, + }; + + try { + await createTask(ns, body); + } catch (err) { + if (err instanceof BffError) { + if (err.code === "rejected" && err.message) return { error: err.message }; + if (err.code === "cluster_unavailable") { + return { error: "The run environment isn't connected, so the role can't be added yet." }; + } + return { error: "Couldn't add this role. Adjust and try again." }; + } + throw err; + } + + revalidatePath(`/workspace/missions/${input.principal}`); + return { error: null }; +} diff --git a/bridge/web/src/app/workspace/missions/[name]/run-actions.ts b/bridge/web/src/app/workspace/missions/[name]/run-actions.ts new file mode 100644 index 000000000..649f07b71 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/run-actions.ts @@ -0,0 +1,51 @@ +// kars Bridge Workspace — reliability runner (pass^k) server action. +// +// Replicates a delivered mission's EXACT package k times so the efficiency +// frontier can measure pass^k reliability. Each replica is a real governed run. + +"use server"; + +import { revalidatePath } from "next/cache"; +import { BffError, replicateMission } from "@/lib/bff"; + +export interface ReplicateState { + error: string | null; + ok: string | null; +} + +export async function replicateMissionAction( + _prev: ReplicateState, + form: FormData, +): Promise<ReplicateState> { + const ns = String(form.get("ns") ?? "").trim(); + const name = String(form.get("name") ?? "").trim(); + const count = Math.min(5, Math.max(2, Number(form.get("count") ?? 2))); + if (!ns || !name) return { error: "Missing mission reference.", ok: null }; + try { + const r = await replicateMission(ns, name, count); + revalidatePath(`/workspace/missions/${name}`); + return { error: null, ok: r.note }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "replicate failed", ok: null }; + } +} + +/// Request a per-mission autonomy-tier promotion (§12). The controller opens a +/// human approval and widens the envelope only on approval. +export async function promoteMissionAction( + _prev: ReplicateState, + form: FormData, +): Promise<ReplicateState> { + const ns = String(form.get("ns") ?? "").trim(); + const name = String(form.get("name") ?? "").trim(); + const tier = Math.min(5, Math.max(1, Number(form.get("tier") ?? 0))); + if (!ns || !name) return { error: "Missing mission reference.", ok: null }; + try { + const { promoteMission } = await import("@/lib/bff"); + const r = await promoteMission(ns, name, tier); + revalidatePath(`/workspace/missions/${name}`); + return { error: null, ok: r.note }; + } catch (e) { + return { error: e instanceof BffError ? e.message || e.code : "promote failed", ok: null }; + } +} diff --git a/bridge/web/src/app/workspace/missions/loading.tsx b/bridge/web/src/app/workspace/missions/loading.tsx new file mode 100644 index 000000000..2a4a12d65 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/loading.tsx @@ -0,0 +1,5 @@ +import { ListSkeleton } from "@/components/list-skeleton"; + +export default function Loading() { + return <ListSkeleton />; +} diff --git a/bridge/web/src/app/workspace/missions/missions-list.tsx b/bridge/web/src/app/workspace/missions/missions-list.tsx new file mode 100644 index 000000000..0871e3a98 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/missions-list.tsx @@ -0,0 +1,133 @@ +"use client"; + +// kars Bridge Workspace — the missions list as a filterable surface (audit f7): +// search by name/objective and filter by status, so a growing mission fleet +// stays navigable instead of an unbounded scroll of full-width cards. + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { MissionStatusBadge, missionStatus, type MissionStatus } from "@/components/mission-status"; +import { TIER_LABELS, type TaskSummary } from "@/lib/types"; + +type Filter = "all" | "active" | "delivered" | "failed" | "drafts"; +type Sort = "newest" | "oldest" | "name"; + +function statusOf(t: TaskSummary): MissionStatus { + return missionStatus(t.phase, t.execution_phase, { delivered: t.delivered, failed: t.failed, launched: t.launched }); +} + +export function MissionsList({ tasks }: { tasks: TaskSummary[] }) { + const [q, setQ] = useState(""); + const [filter, setFilter] = useState<Filter>("all"); + const [sort, setSort] = useState<Sort>("newest"); + + const counts = useMemo(() => { + const c = { all: tasks.length, active: 0, delivered: 0, failed: 0, drafts: 0 }; + for (const t of tasks) { + const s = statusOf(t); + if (s === "running" || s === "deploying" || s === "needs_you") c.active++; + else if (s === "done") c.delivered++; + else if (s === "failed" || s === "blocked") c.failed++; + else c.drafts++; + } + return c; + }, [tasks]); + + const matches = useMemo(() => { + const needle = q.trim().toLowerCase(); + return tasks.filter((t) => { + const s = statusOf(t); + if (filter === "active" && !(s === "running" || s === "deploying" || s === "needs_you")) return false; + if (filter === "delivered" && s !== "done") return false; + if (filter === "failed" && !(s === "failed" || s === "blocked")) return false; + if (filter === "drafts" && !(s === "drafting")) return false; + if (!needle) return true; + return [t.display_name ?? "", t.objective, t.name].join(" ").toLowerCase().includes(needle); + }).sort((left, right) => { + if (sort === "name") { + return (left.display_name ?? left.name).localeCompare(right.display_name ?? right.name); + } + const leftTime = left.created_at ? new Date(left.created_at).getTime() : 0; + const rightTime = right.created_at ? new Date(right.created_at).getTime() : 0; + return sort === "newest" ? rightTime - leftTime : leftTime - rightTime; + }); + }, [tasks, q, filter, sort]); + + const chips: { id: Filter; label: string; n: number }[] = [ + { id: "all", label: "All", n: counts.all }, + { id: "active", label: "Active", n: counts.active }, + { id: "delivered", label: "Delivered", n: counts.delivered }, + { id: "failed", label: "Failed", n: counts.failed }, + { id: "drafts", label: "Drafts", n: counts.drafts }, + ]; + + return ( + <div className="space-y-4"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-center"> + <label className="relative flex-1"> + <span className="sr-only">Search missions</span> + <svg viewBox="0 0 20 20" aria-hidden className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground-muted" fill="currentColor"> + <path d="M9 3.5a5.5 5.5 0 1 0 3.4 9.83l3.13 3.14a1 1 0 0 0 1.42-1.42l-3.14-3.13A5.5 5.5 0 0 0 9 3.5Zm0 2a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Z" /> + </svg> + <input + type="search" + value={q} + onChange={(e) => setQ(e.target.value)} + placeholder="Search missions by name or objective" + className="w-full rounded-lg border border-border bg-surface py-2 pl-9 pr-3 text-sm outline-none transition focus:border-signal focus:ring-2 focus:ring-signal/30" + /> + </label> + <div className="flex flex-wrap gap-1.5"> + {chips.map((c) => ( + <button + key={c.id} + type="button" + onClick={() => setFilter(c.id)} + className={`rounded-full border px-3 py-1 text-xs font-medium transition ${ + filter === c.id ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground" + }`} + > + {c.label} {c.n} + </button> + ))} + </div> + <select + value={sort} + onChange={(event) => setSort(event.target.value as Sort)} + aria-label="Sort missions" + className="rounded-lg border border-border bg-surface px-3 py-2 text-xs" + > + <option value="newest">Newest first</option> + <option value="oldest">Oldest first</option> + <option value="name">Name A–Z</option> + </select> + </div> + + {matches.length === 0 ? ( + <p className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-foreground-muted"> + No missions match this filter. + </p> + ) : ( + <ul className="overflow-hidden rounded-xl border border-border bg-surface"> + {matches.map((t) => ( + <li key={t.name} className="border-b border-border last:border-0"> + <Link + href={`/workspace/missions/${encodeURIComponent(t.name)}`} + className="flex items-center justify-between gap-4 px-5 py-3.5 transition hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal focus-visible:ring-inset" + > + <div className="min-w-0"> + <p className="truncate text-sm font-medium">{t.display_name ?? t.objective}</p> + <p className="mt-0.5 line-clamp-1 text-xs text-foreground-muted"> + Tier {t.tier} · {TIER_LABELS[t.tier] ?? "?"} + {t.created_at ? ` · Started: ${new Date(t.created_at).toLocaleString()}` : ""} + </p> + </div> + <MissionStatusBadge status={statusOf(t)} /> + </Link> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/page.tsx b/bridge/web/src/app/workspace/missions/page.tsx new file mode 100644 index 000000000..5ef0e9bad --- /dev/null +++ b/bridge/web/src/app/workspace/missions/page.tsx @@ -0,0 +1,57 @@ +// kars Bridge Workspace — Missions list. Plain-language projection of the task +// fleet, filterable by what the user cares about (running / ready / blocked). + +import Link from "next/link"; +import { HonestState } from "@/components/honest-state"; +import { listTasks } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import { type TaskSummary } from "@/lib/types"; +import { MissionsList } from "./missions-list"; + +export const dynamic = "force-dynamic"; + +export default async function MissionsPage() { + const ns = defaultNamespace(); + let tasks: TaskSummary[] = []; + let error = false; + try { + tasks = (await listTasks(ns)).filter((t) => !t.team); + } catch { + error = true; + } + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Missions</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Everything you've started. Open one to watch it, steer it, or review its work. + </p> + </div> + <Link + href="/workspace/new" + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg hover:opacity-90" + > + Start a mission + </Link> + </div> + + {error ? ( + <HonestState + variant="not_wired" + title="Missions are unavailable" + detail="The run environment isn't reachable right now. Try again shortly." + /> + ) : tasks.length === 0 ? ( + <HonestState + variant="empty" + title="No missions yet" + detail="Use “Start a mission” above, or just describe an outcome from Home — Bridge composes the plan for you to review." + /> + ) : ( + <MissionsList tasks={tasks} /> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/new/actions.ts b/bridge/web/src/app/workspace/new/actions.ts new file mode 100644 index 000000000..8aac66302 --- /dev/null +++ b/bridge/web/src/app/workspace/new/actions.ts @@ -0,0 +1,180 @@ +// kars Bridge Workspace — mission intake server action. +// +// Creates a governed mission from the reviewed package. The user never sees a +// Kubernetes name — we derive a stable slug from the objective. By default a +// mission is created *governed but not launched* (the §20 review-then-launch +// gate); the user opts into launching. Admission (CEL) enforces the envelope +// invariants; its rejection is surfaced verbatim. + +"use server"; + +import { redirect } from "next/navigation"; +import { BffError, createTask } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import type { CreateTaskRequest } from "@/lib/types"; + +export interface IntakeState { + error: string | null; +} + +/** Validate the composed package against the live cluster (the §20 gate). */ +export async function validateMissionAction( + blueprint: unknown, + envelope?: { tier?: number; budget_tokens?: number | null }, +): Promise<import("@/lib/types").ValidationResult> { + const { validatePackage } = await import("@/lib/bff"); + return validatePackage(defaultNamespace(), blueprint, envelope); +} + +/** Ask the orchestrator to compose a launch package from a plain objective. */ +export async function composeMissionAction( + objective: string, +): Promise<import("@/lib/types").ComposeResponse> { + try { + const { composePackage } = await import("@/lib/bff"); + return await composePackage(defaultNamespace(), objective); + } catch (error) { + const timedOut = error instanceof Error && error.name === "TimeoutError"; + return { + available: false, + reason: timedOut + ? "The orchestrator did not respond within two minutes — compose the package manually below." + : "The orchestrator is unavailable — compose the package manually below.", + proposal: null, + rationale: null, + source: null, + }; + } +} + +/** Ask the orchestrator to PROPOSE a loop for an intent, for review. */ +export async function proposeLoopAction( + intent: string, + surface: "mission" | "team", +): Promise<import("@/lib/types").LoopProposal | null> { + try { + const { proposeLoop } = await import("@/lib/bff"); + return await proposeLoop(defaultNamespace(), intent, surface); + } catch { + return null; + } +} + +function slugify(objective: string): string { + const base = objective + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) + .replace(/-+$/g, ""); + const suffix = Math.random().toString(36).slice(2, 7); + const stem = base.length > 0 ? base : "mission"; + return `${stem}-${suffix}`; +} + +/** A human title for the mission, derived from the objective when the user + * didn't name it — so a mission never falls back to the generic "Mission". + * Takes the first sentence/line, trims trailing punctuation, caps length. */ +function deriveDisplayName(objective: string): string { + const firstLine = objective.split(/\n/)[0]?.trim() ?? ""; + const firstSentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine; + const t = firstSentence.replace(/[.,;:\s]+$/, "").slice(0, 80).trim(); + return t.length > 0 ? t : "Mission"; +} + +export async function createMissionAction( + _prev: IntakeState, + formData: FormData, +): Promise<IntakeState> { + const objective = String(formData.get("objective") ?? "").trim(); + const displayName = String(formData.get("display_name") ?? "").trim(); + const tier = Number(formData.get("tier")) || 3; + const tokens = ((): number | null => { + const v = formData.get("budget_tokens"); + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? Math.trunc(n) : null; + })(); + const launch = formData.get("launch") === "on"; + // The authority ceiling for delegated sub-roles defaults to one tier below + // the mission (a mission never grants a child more than it holds). + // Sub-roles default to one tier BELOW the mission (a mission never grants a + // child more than it holds). Tier 1 is the floor — there is no tier 0 — so a + // tier-1 mission's sub-roles necessarily inherit tier 1. (The previous + // `tier - 1 || 1` silently coerced tier-1 to 1 via JS falsy-zero, but read as + // if it were computing "one below"; this is explicit.) + const authorityCeiling = tier <= 1 ? 1 : tier - 1; + let delegation: import("@/lib/types").MissionDelegation | null = null; + const delegationRaw = formData.get("delegation_json"); + if (typeof delegationRaw === "string" && delegationRaw.trim() !== "") { + try { + delegation = JSON.parse(delegationRaw) as import("@/lib/types").MissionDelegation; + } catch { + delegation = null; + } + } + const delegationDepth = delegation?.mode === "principal-specialists" ? 1 : 0; + + // The editable composition (model/harness/instructions/tools/MCP/egress/ + // isolation/memory) — serialized by the package UI. Parsed defensively; an + // empty/invalid payload simply omits the blueprint and the controller uses + // its defaults (honest, never a hard failure on a malformed optional field). + let blueprint: CreateTaskRequest["blueprint"] = null; + const bpRaw = formData.get("blueprint_json"); + if (typeof bpRaw === "string" && bpRaw.trim() !== "") { + try { + const parsed = JSON.parse(bpRaw) as NonNullable<CreateTaskRequest["blueprint"]>; + if (parsed && typeof parsed === "object") blueprint = parsed; + } catch { + blueprint = null; + } + } + + if (objective.length === 0 || objective.length > 4096) { + return { error: "Describe what you want done (1–4096 characters)." }; + } + + const name = slugify(displayName || objective); + const gitWriteRepos = String(formData.get("git_write_repos") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const { currentPrincipal } = await import("@/lib/session"); + const principal = await currentPrincipal(); + const body: CreateTaskRequest = { + name, + objective, + display_name: displayName === "" ? deriveDisplayName(objective) : displayName, + envelope: { + tier, + authority_ceiling: authorityCeiling, + delegation_depth: delegationDepth, + budget: tokens == null ? null : { tokens, usd_micros: null }, + tool_policy: null, + egress_allowlist: null, + }, + blueprint, + delegation, + launch, + git_write_repos: gitWriteRepos.length ? gitWriteRepos : null, + created_by: principal.name, + }; + + try { + await createTask(defaultNamespace(), body); + } catch (err) { + if (err instanceof BffError) { + if (err.code === "cluster_unavailable") { + return { + error: + "The run environment isn't connected, so this mission can't be created yet. Nothing was charged.", + }; + } + if (err.code === "rejected" && err.message) return { error: err.message }; + return { error: "We couldn't create this mission. Please adjust and try again." }; + } + throw err; + } + + redirect(`/workspace/missions/${encodeURIComponent(name)}`); +} diff --git a/bridge/web/src/app/workspace/new/envelope-reveal.tsx b/bridge/web/src/app/workspace/new/envelope-reveal.tsx new file mode 100644 index 000000000..f5136b2e3 --- /dev/null +++ b/bridge/web/src/app/workspace/new/envelope-reveal.tsx @@ -0,0 +1,124 @@ +"use client"; + +// kars Bridge — Composer reveal. The moment that makes kars legible: after an +// objective is given, the trust envelope is shown being ASSEMBLED facet by facet +// — model, harness, instructions, tools, connected services, egress, isolation, +// memory, autonomy, budget — each with a one-line rationale and (where relevant) +// an efficiency hint drawn from the learned frontier. Staggered entrance so the +// user literally watches the governed package compose itself. + +import type { Blueprint, MissionDelegation } from "@/lib/types"; +import { Icon } from "@/components/icon"; +import { humanizeMcp } from "@/lib/format"; + +type Facet = { icon: import("@/components/icon").IconName; label: string; value: string; why?: string; tone?: "accent" | "signal" | "muted" }; + +const TIER_WORD: Record<number, string> = { 1: "Manual", 2: "Shared", 3: "Conditional", 4: "Supervised", 5: "Full" }; + +export function EnvelopeReveal({ + blueprint, + tier, + budgetTokens, + rationale, + source, + recommended, + modelBasis, + delegation, +}: { + blueprint: Blueprint; + tier: number; + budgetTokens?: string; + rationale?: string | null; + source?: string | null; + recommended?: string | null; + modelBasis?: string | null; + delegation: MissionDelegation; +}) { + const model = blueprint.model ? `${blueprint.model.deployment}` : "controller default"; + // Prefer the real, data-grounded basis from the orchestrator (efficiency + // frontier / objective fit); fall back to a heuristic only when absent. + const modelWhy = modelBasis + ? modelBasis + : recommended && blueprint.model?.deployment === recommended + ? "Top of the efficiency frontier — cheapest per accepted outcome." + : "Fits the objective's reasoning load."; + const executionPlan = blueprint.execution_plan; + const executionWhy = executionPlan + ? executionPlan.roles + .map((role) => { + const phases = role.phases + .map((phase) => { + const capabilities = phase.capabilities.length + ? phase.capabilities.join("+") + : "reasoning-only"; + return `${phase.name}:${capabilities}/${phase.max_tool_calls}`; + }) + .join(", "); + return `${role.name} [${phases}]`; + }) + .join(" · ") + : undefined; + + const facets: Facet[] = [ + { icon: "brain", label: "Model", value: model, why: modelWhy, tone: "accent" }, + { icon: "gear", label: "Harness", value: blueprint.runtime ?? "OpenClaw", why: "Verified runtime on this cluster." }, + { icon: "target", label: "Autonomy", value: `Tier ${tier} · ${TIER_WORD[tier] ?? "?"}`, why: tier <= 3 ? "Pauses before anything costly or irreversible." : "Acts autonomously within the envelope.", tone: "signal" }, + { + icon: "branch", + label: "Execution strategy", + value: + executionPlan + ? `Principal + ${executionPlan.roles.length} planned workers` + : delegation.mode === "principal-specialists" + ? `Principal + ${delegation.roles.length} legacy leaf specialists` + : "Single agent", + why: + executionPlan + ? `${executionWhy} · up to ${executionPlan.max_parallel} in parallel.` + : delegation.mode === "principal-specialists" + ? `${delegation.roles.map((role) => role.name).join(", ")} · up to ${delegation.max_parallel} in parallel.` + : "Best for small, tightly coupled work where delegation would add overhead.", + }, + { icon: "wrench", label: "Tool policy", value: blueprint.tool_policy ?? "none — model only", why: blueprint.tool_policy ? "Bounds every tool the agent may call." : "No tools — pure reasoning." }, + { icon: "plug", label: "Connected services", value: blueprint.mcp_servers?.length ? blueprint.mcp_servers.map(humanizeMcp).join(", ") : "none", why: blueprint.mcp_servers?.length ? "MCP servers the agent may reach, bounded by the tool policy." : undefined }, + { icon: "globe", label: "Network egress", value: blueprint.egress?.length ? blueprint.egress.map((e) => e.host + (e.port ? `:${e.port}` : "")).join(", ") : "model path only", why: blueprint.egress?.length ? "Exact host:port destinations allowed at the network boundary; everything else is denied." : "Default-deny — only the model path is reachable." }, + { icon: "shield", label: "Isolation", value: blueprint.isolation ?? "standard", why: "Sandbox hardening level." }, + { icon: "database", label: "Shared memory", value: blueprint.memory ?? "none", why: blueprint.memory ? "Knowledge commons the mission reads + writes." : undefined }, + { icon: "coin", label: "Budget", value: budgetTokens ? `${Number(budgetTokens).toLocaleString()} tokens` : "no cap", why: "Hard ceiling on spend." }, + ]; + + return ( + <div className="kb-card overflow-hidden"> + <div className="flex items-center justify-between gap-3 border-b border-border bg-surface-muted/40 px-5 py-3"> + <div className="flex items-center gap-2"> + <span className="grid h-6 w-6 place-items-center rounded-md bg-accent/15 text-[11px] font-semibold text-accent">kb</span> + <h2 className="text-sm font-semibold">Trust envelope composed</h2> + </div> + {source && <span className="rounded-full bg-surface px-2 py-0.5 font-mono text-[11px] text-foreground-muted">{source}</span>} + </div> + + {rationale && ( + <p className="kb-rise border-b border-border px-5 py-3 text-sm text-foreground-muted">{rationale}</p> + )} + + <ul className="kb-stagger divide-y divide-border"> + {facets.map((f) => ( + <li key={f.label} className="flex items-start gap-3 px-5 py-3"> + <span className="mt-0.5 text-foreground-muted" aria-hidden><Icon name={f.icon} /></span> + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-baseline gap-x-2"> + <span className="text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">{f.label}</span> + <span className={`text-sm font-medium ${f.tone === "accent" ? "text-accent" : f.tone === "signal" ? "text-signal" : ""}`}>{f.value}</span> + </div> + {f.why && <p className="mt-0.5 text-xs text-foreground-muted">{f.why}</p>} + </div> + </li> + ))} + </ul> + + <p className="border-t border-border bg-surface-muted/30 px-5 py-3 text-xs text-foreground-muted"> + This is a proposal. Review and edit every field below — the pre-flight validation gate and the explicit Launch step still govern what runs. Nothing has started. + </p> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/new/intake-flow.tsx b/bridge/web/src/app/workspace/new/intake-flow.tsx new file mode 100644 index 000000000..cbbf54e00 --- /dev/null +++ b/bridge/web/src/app/workspace/new/intake-flow.tsx @@ -0,0 +1,1336 @@ +"use client"; + +// kars Bridge Workspace — mission intake → editable launch package → launch. +// +// The design note's §20 flow in plain language: describe what you want, review +// a complete package composed from REAL cluster facts (the models this cluster +// serves, the harnesses it can run, the tool policies / connected services / +// shared memory that exist), edit any of it, then deliberately Launch. Honesty: +// the package is a deterministic sensible-defaults starting point you review — +// we do not claim an AI composed it (the intake orchestrator is itself a +// governed run, surfaced when the run environment is connected). Every control +// maps to a real field the controller compiles into the InferencePolicy + +// KarsSandbox. + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useFormStatus } from "react-dom"; +import { useActionState } from "react"; +import { SegmentedTier } from "@/components/segmented-tier"; +import { OrchestrationCube } from "@/components/orchestration-cube"; +import { Icon } from "@/components/icon"; +import { JourneyRail } from "@/components/journey-rail"; +import { humanizeMcp } from "@/lib/format"; +import { EnvelopeReveal } from "./envelope-reveal"; +import { LoopDesigner } from "@/components/loop-designer"; +import { RepoAccess } from "@/components/repo-access"; +import { + createMissionAction, + validateMissionAction, + composeMissionAction, + proposeLoopAction, + type IntakeState, +} from "./actions"; +import type { + Blueprint, + BlueprintEgress, + ComposeProposal, + Efficiency, + Options, + ValidationResult, +} from "@/lib/types"; + +const TIER_CONSEQUENCE: Record<number, string> = { + 1: "Manual — the mission proposes every step and does nothing on its own. You perform each action.", + 2: "Shared — the mission acts only on low-risk steps; everything else waits for your approval.", + 3: "Conditional — the mission acts on its own but pauses for your approval before anything that costs money, touches external systems, or can't be undone.", + 4: "Supervised — the mission runs autonomously with periodic checkpoints you sign off on.", + 5: "Full — the mission runs autonomously within its budget and time limit; you review the result.", +}; + +const EXAMPLES = [ + "Audit our README for outdated install steps and propose fixes.", + "Draft a competitive teardown of the top 3 agent platforms.", + "Summarize this contract's risk and obligations.", +]; + +function modelKey(provider: string, deployment: string) { + return `${provider}::${deployment}`; +} + +function moveFallback(routes: string[], index: number, delta: number): string[] { + const next = index + delta; + if (next < 0 || next >= routes.length) return routes; + const copy = [...routes]; + [copy[index], copy[next]] = [copy[next], copy[index]]; + return copy; +} + +export function IntakeFlow({ options, efficiency, initialObjective }: { options: Options; efficiency?: Efficiency | null; initialObjective?: string }) { + const [state, formAction] = useActionState<IntakeState, FormData>( + createMissionAction, + { error: null }, + ); + const [objective, setObjective] = useState(initialObjective ?? ""); + const [proposed, setProposed] = useState(false); + // Loop-review step: when arriving with an intent, the orchestrator proposes a + // loop the user reviews here BEFORE we compose the package. + const [loopProposal, setLoopProposal] = useState<import("@/lib/types").LoopProposal | null>(null); + const [loopLoading, setLoopLoading] = useState(false); + const [loopReviewed, setLoopReviewed] = useState(false); + + // The learned efficiency frontier drives the recommendation. The recommended + // route (e.g. `azure-openai/openai/gpt-4o`) carries the deployment; match it + // back to a real model option and surface its stats at the point of choice. + const recommendedRouteRaw = efficiency?.recommended ?? null; + const recommendedModel = recommendedRouteRaw + ? options.models.find((m) => recommendedRouteRaw.includes(m.deployment)) ?? null + : null; + const recommendedStats = recommendedRouteRaw + ? efficiency?.routes.find((r) => r.route === recommendedRouteRaw) ?? null + : null; + const actionableRecommendedModel = + efficiency?.recommended_low_confidence ? null : recommendedModel; + + // ── Composition state (the editable blueprint) ────────────────────────── + const defaultModel = + actionableRecommendedModel ?? options.models.find((m) => m.is_default) ?? options.models[0] ?? null; + const [model, setModel] = useState<string>( + defaultModel ? modelKey(defaultModel.provider, defaultModel.deployment) : "", + ); + const [modelFallbacks, setModelFallbacks] = useState<string[]>([]); + const [runtime, setRuntime] = useState<string>( + options.runtimes[0]?.kind ?? "OpenClaw", + ); + const [instructions, setInstructions] = useState(""); + // The loop scaffold (LOOP:/GOAL:/CYCLE/SUB-AGENT INHERITANCE …) the harness + // runs. It is the agent's OPERATING CONTRACT — never the mission's identity. + // It is carried into the blueprint's instructions, so it reaches the harness + // and sub-agents, but it NEVER becomes the objective, display name, or slug. + const [loopDirective, setLoopDirective] = useState(""); + const [toolPolicy, setToolPolicy] = useState<string>(""); + const [mcp, setMcp] = useState<string[]>([]); + const [skills, setSkills] = useState<string[]>([]); + const [egress, setEgress] = useState<BlueprintEgress[]>([]); + // Egress enforcement mode (§ learning|strict). "strict" enforces the proposed + // allowlist from the first run; "learning" starts in Learn mode (observe the + // hosts the agent actually reaches, enforce nothing), so you can enforce the + // learned set after review. Maps to the controller's Strict-vs-Learn network + // policy: a strict launch carries the allowlist, a learning launch carries none. + const [egressMode, setEgressMode] = useState<"strict" | "learning">("strict"); + const [isolation, setIsolation] = useState<string>( + options.isolation[0]?.value ?? "standard", + ); + const [memory, setMemory] = useState<string>(""); + const [delegation, setDelegation] = useState<import("@/lib/types").MissionDelegation>({ + mode: "single-agent", + roles: [], + max_parallel: 1, + }); + const [executionPlan, setExecutionPlan] = useState<import("@/lib/types").ExecutionPlan | null>( + null, + ); + const [executionPlanDraft, setExecutionPlanDraft] = useState(""); + const [executionPlanError, setExecutionPlanError] = useState<string | null>(null); + + // ── Governance state ──────────────────────────────────────────────────── + const [tier, setTier] = useState(3); + const [budgetTokens, setBudgetTokens] = useState<string>(""); + const [launch, setLaunch] = useState(false); + + function changeRuntime(nextRuntime: string) { + setRuntime(nextRuntime); + if (nextRuntime === "BYO") { + setDelegation({ mode: "single-agent", roles: [], max_parallel: 1 }); + setExecutionPlan(null); + setExecutionPlanDraft(""); + } + if (nextRuntime === "OpenClaw") return; + setSkills([]); + if (toolPolicy === "kars-team-member") setToolPolicy("kars-default"); + const repositorySecurity = + nextRuntime === "Hermes" && + mcp.some((server) => server.toLowerCase() === "github") && + egress.some((endpoint) => endpoint.host.toLowerCase() === "api.osv.dev"); + if ( + repositorySecurity && + (budgetTokens.trim() === "" || Number(budgetTokens) < 600_000) + ) { + setBudgetTokens("600000"); + } + } + + // ── Orchestrator (intent → composed package) ──────────────────────────── + const [composing, setComposing] = useState(false); + const [rationale, setRationale] = useState<string | null>(null); + const [composeSource, setComposeSource] = useState<string | null>(null); + const [modelBasis, setModelBasis] = useState<string | null>(null); + const [composeNote, setComposeNote] = useState<string | null>(null); + + function applyProposal(p: ComposeProposal) { + if (p.model) setModel(modelKey(p.model.provider, p.model.deployment)); + setModelFallbacks( + (p.model_fallbacks ?? []).map((route) => modelKey(route.provider, route.deployment)), + ); + if (p.runtime) setRuntime(p.runtime); + setInstructions(p.instructions ?? ""); + setToolPolicy(p.tool_policy ?? ""); + setMcp(p.mcp_servers ?? []); + setSkills(p.skills ?? []); + setEgress(p.egress ?? []); + if (p.isolation) setIsolation(p.isolation); + setMemory(p.memory ?? ""); + setDelegation( + p.delegation ?? { mode: "single-agent", roles: [], max_parallel: 1 }, + ); + setExecutionPlan(p.execution_plan ?? null); + setExecutionPlanDraft( + p.execution_plan ? JSON.stringify(p.execution_plan, null, 2) : "", + ); + setExecutionPlanError(null); + if (p.tier) setTier(Math.min(5, Math.max(1, p.tier))); + setBudgetTokens(p.budget_tokens != null ? String(p.budget_tokens) : ""); + } + + async function composeAndReview(objectiveOverride?: string) { + const obj = objectiveOverride ?? objective; + setComposing(true); + setComposeNote(null); + setRationale(null); + setComposeSource(null); + try { + const res = await composeMissionAction(obj); + if (res.available && res.proposal) { + applyProposal(res.proposal); + setRationale(res.rationale); + setComposeSource(res.source); + setModelBasis(res.proposal.model_basis ?? null); + setProposed(true); + } else { + setComposeNote( + res.reason ?? + "The AI orchestrator isn't available. Retry composition before reviewing or validating a package.", + ); + } + } catch { + setComposeNote( + "Couldn't reach the orchestrator. Retry composition before reviewing or validating a package.", + ); + } finally { + setComposing(false); + } + } + + // Unified intake: when arriving with a prefilled intent, first ask the + // ORCHESTRATOR to propose a loop for the user to review (not straight to + // compose). Once reviewed + applied, composeAndReview runs on the loop-shaped + // objective. If the loop step is skipped, we compose the raw intent. + const autoRan = useRef(false); + useEffect(() => { + if (!autoRan.current && initialObjective && initialObjective.trim().length > 0) { + autoRan.current = true; + setLoopLoading(true); + void proposeLoopAction(initialObjective.trim(), "mission") + .then((p) => setLoopProposal(p)) + .finally(() => setLoopLoading(false)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Apply the reviewed loop → the loop text becomes the agent's OPERATING + // CONTRACT (carried in instructions), NOT the objective. The objective stays + // the human's plain intent so the title, URL, and sandbox name stay clean. + // We also fold the user's edits back into loopProposal so returning to the + // loop-review step (via Back / Adjust loop) shows exactly what they applied. + function applyLoopAndCompose( + text: string, + parts?: { goal: string; patternId: string; criteria: string }, + ) { + const humanIntent = (initialObjective ?? objective).trim(); + setLoopDirective(text); + if (parts) { + setLoopProposal((prev) => ({ + pattern: parts.patternId, + goal: parts.goal, + criteria: parts.criteria, + rationale: prev?.rationale ?? "", + source: prev?.source ?? "heuristic", + })); + } + setObjective(humanIntent); + setLoopReviewed(true); + void composeAndReview(humanIntent); + } + function skipLoop() { + setLoopReviewed(true); + if (initialObjective && initialObjective.trim().length > 0) { + void composeAndReview(initialObjective.trim()); + } + } + + // Coherent back-navigation from the review page. The intent-first path came + // through the loop-review step, so Back must return THERE (loop preserved) — + // not dump the user at the intent box ("the beginning"). The manual path has + // no loop-review step, so Back returns to intent capture. + const cameViaLoopReview = !!(initialObjective && initialObjective.trim().length > 0); + function goBackToLoopReview() { + setProposed(false); + setLoopReviewed(false); + } + function goBack() { + setProposed(false); + if (cameViaLoopReview) setLoopReviewed(false); + } + + // ── Pre-flight validation (§20) ───────────────────────────────────────── + const [validation, setValidation] = useState<ValidationResult | null>(null); + const [validatedSig, setValidatedSig] = useState<string | null>(null); + const [validating, setValidating] = useState(false); + const [validationError, setValidationError] = useState<string | null>(null); + + const ceiling = Math.max(1, tier - 1); + const recommendedRoute = actionableRecommendedModel?.deployment ?? null; + + // MCP access requires a tool policy to bound it (the substrate enforces this + // at admission; we surface it here so the user fixes it before launch). + const mcpNeedsPolicy = mcp.length > 0 && toolPolicy === ""; + + const blueprint: Blueprint = useMemo(() => { + const [provider, deployment] = model.split("::"); + const bp: Blueprint = { runtime, isolation }; + if (provider && deployment) bp.model = { provider, deployment }; + bp.model_fallbacks = modelFallbacks.map((route) => { + const [fallbackProvider, fallbackDeployment] = route.split("::"); + return { provider: fallbackProvider, deployment: fallbackDeployment }; + }); + // Instructions + the loop operating contract both feed the harness. The + // loop scaffold is appended here (never to the objective/title) so the + // agent runs the loop and sub-agents inherit it, while identity stays clean. + const mergedInstructions = [instructions.trim(), loopDirective.trim()] + .filter(Boolean) + .join("\n\n"); + if (mergedInstructions) bp.instructions = mergedInstructions; + if (toolPolicy) bp.tool_policy = toolPolicy; + if (mcp.length) bp.mcp_servers = mcp; + if (skills.length) bp.skills = skills; + // Strict enforces the proposed allowlist; learning launches in Learn mode + // (no allowlist → the controller observes egress instead of blocking it). + if (egressMode === "strict" && egress.length) bp.egress = egress; + bp.egress_mode = egressMode; + if (memory) bp.memory = memory; + if (executionPlan) bp.execution_plan = executionPlan; + return bp; + }, [model, modelFallbacks, runtime, instructions, loopDirective, toolPolicy, mcp, skills, egress, egressMode, isolation, memory, executionPlan]); + + // A validation is only "fresh" for the exact package it was run against — any + // edit (composition, tier, or budget) makes the prior result stale, so you + // must re-validate exactly what you'll launch. + const packageSig = useMemo( + () => JSON.stringify({ blueprint, tier, budgetTokens, executionPlanDraft }), + [blueprint, tier, budgetTokens, executionPlanDraft], + ); + const validationFresh = validation != null && validatedSig === packageSig; + + async function runValidation() { + setValidating(true); + setValidationError(null); + try { + const res = await validateMissionAction(blueprint, { + tier, + budget_tokens: budgetTokens.trim() === "" ? null : Number(budgetTokens), + }); + if (!res || !Array.isArray(res.checks)) { + throw new Error("The pre-flight service returned an unexpected response (no checks)."); + } + setValidation(res); + setValidatedSig(packageSig); + } catch (e) { + // Fail loud: never leave the user staring at a button with no feedback. + setValidation(null); + setValidationError( + e instanceof Error ? e.message : "Pre-flight validation failed — the cluster could not be reached.", + ); + } finally { + setValidating(false); + } + } + + // Step 1a — LOOP REVIEW: arrived with an intent → the orchestrator proposed a + // loop; the user reviews/edits it here before we compose. Runs before the + // package compose, and only for the intent-first path. + if (!proposed && !loopReviewed && initialObjective && initialObjective.trim().length > 0) { + return ( + <div className="space-y-5"> + <JourneyRail current="describe" /> + <div className="kb-card kb-canvas p-6"> + <h2 className="text-sm font-semibold">Review the loop</h2> + <p className="mt-1 text-xs text-foreground-muted"> + The orchestrator turned your intent into a feedback loop (2026 loop engineering). Review + the pattern and success criteria — change anything — then continue; the loop becomes what + the harness runs and is inherited by any sub-agents. + </p> + {loopLoading ? ( + <div className="mt-4 flex items-center gap-2 text-sm text-foreground-muted"> + <span className="h-2 w-2 animate-ping rounded-full bg-accent" /> Orchestrator is defining the loop… + </div> + ) : ( + <div className="mt-3"> + <LoopDesigner + surface="mission" + defaultOpen + initialGoal={loopProposal?.goal ?? initialObjective} + initialPatternId={loopProposal?.pattern} + initialCriteria={loopProposal?.criteria ?? ""} + rationale={loopProposal?.rationale} + applyLabel="Continue with this loop →" + onApply={applyLoopAndCompose} + /> + <button + type="button" + onClick={skipLoop} + className="mt-3 text-xs text-foreground-muted underline hover:text-foreground" + > + Skip — compose from my plain intent instead + </button> + </div> + )} + </div> + </div> + ); + } + + // Composing (either path): show ONLY the orchestration animation — never fall + // back to the editable intent form, which reads as "jumped back to the start" + // the instant the user continues from the loop review. + if (!proposed && composing) { + return ( + <div className="space-y-5"> + <JourneyRail current="compose" /> + <OrchestrationCube + title="Orchestrating your package" + done={false} + active={0} + phases={[ + { icon: "layers", label: "Reading the cluster palette", detail: `${options.models.length} model${options.models.length === 1 ? "" : "s"} · ${options.runtimes.filter((r) => r.wired).length} harness${options.runtimes.filter((r) => r.wired).length === 1 ? "" : "es"} · ${options.tool_policies.length} tool ${options.tool_policies.length === 1 ? "policy" : "policies"}` }, + { + icon: "chart", + label: "Consulting the efficiency frontier", + detail: recommendedModel + ? efficiency?.recommended_low_confidence + ? `limited evidence for ${recommendedModel.deployment}; preserving the cluster default` + : `learned recommendation: ${recommendedModel.deployment}` + : "no completed runs yet — composing from safe defaults", + }, + { icon: "layers", label: "Assembling the governed package", detail: `${options.mcp_servers.length} connected service${options.mcp_servers.length === 1 ? "" : "s"} · egress · autonomy tier · budget` }, + { icon: "note", label: loopDirective.trim() ? "Folding in your reviewed loop" : "Preparing the proposal for review", detail: (initialObjective ?? objective).trim().slice(0, 72) || "your objective" }, + ]} + /> + </div> + ); + } + + // Step 1 — intent capture, two-column: prompt + a preview of what composes. + if (!proposed) { + return ( + <div className="space-y-5"> + <JourneyRail current="describe" /> + {composeNote && ( + <div className="rounded-xl border border-danger/30 bg-danger/5 p-4 text-sm text-danger"> + {composeNote} + </div> + )} + <div className="grid gap-5 lg:grid-cols-[1.15fr_0.85fr]"> + <div className="kb-card kb-canvas p-6"> + <label htmlFor="objective" className="text-sm font-semibold"> + What do you want done? + </label> + <p className="mt-1 text-xs text-foreground-muted">Describe an outcome. The orchestrator composes a complete, governed package you can edit before anything runs.</p> + <textarea + id="objective" + value={objective} + onChange={(e) => { + setObjective(e.target.value); + // Auto-grow so long objectives are fully visible instead of + // scrolling inside a fixed 5-row box. + e.target.style.height = "auto"; + e.target.style.height = `${Math.min(e.target.scrollHeight, 480)}px`; + }} + rows={5} + maxLength={4000} + autoFocus + placeholder="e.g. Audit our README for outdated install steps and open a PR with fixes…" + className="mt-3 w-full resize-y overflow-y-auto rounded-xl border border-border bg-surface px-4 py-3 text-sm leading-relaxed placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <div className="mt-1 text-right text-[11px] tabular-nums text-foreground-muted"> + {objective.length.toLocaleString()} / 4,000 + </div> + <div className="mt-3 flex flex-wrap gap-2"> + {EXAMPLES.map((ex) => ( + <button + key={ex} + type="button" + onClick={() => setObjective(ex)} + className="rounded-full border border-border bg-surface-muted px-3 py-1 text-xs text-foreground-muted transition hover:bg-surface hover:text-foreground" + > + {ex} + </button> + ))} + </div> + {/* Loop engineering (2026): design the feedback loop, not a one-shot + prompt. The generated loop becomes the objective the harness runs + and is inherited by any sub-agents. */} + <div className="mt-3"> + <LoopDesigner + surface="mission" + initialGoal={objective} + onApply={(text, parts) => { + // The loop scaffold is the agent's OPERATING CONTRACT — it folds + // into the blueprint instructions (via mergedInstructions), and + // must NEVER become the mission objective/title/slug. Set the + // directive; only seed the objective from the loop's GOAL when + // it's still empty, so Compose enables without the scaffold ever + // becoming the mission identity. + setLoopDirective(text); + setLoopReviewed(true); + if (parts) { + setLoopProposal((prev) => ({ + pattern: parts.patternId, + goal: parts.goal, + criteria: parts.criteria, + rationale: prev?.rationale ?? "", + source: prev?.source ?? "heuristic", + })); + } + if (!objective.trim() && parts?.goal) setObjective(parts.goal); + }} + /> + </div> + <div className="mt-5 flex items-center justify-end"> + <button + type="button" + disabled={objective.trim().length === 0 || composing} + onClick={() => composeAndReview()} + className="inline-flex items-center gap-2 rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal disabled:opacity-50" + > + {composing ? ( + <> + <span className="relative flex h-2 w-2" aria-hidden> + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-signal-fg/70" /> + <span className="relative inline-flex h-2 w-2 rounded-full bg-signal-fg" /> + </span> + Composing… + </> + ) : ( + "Compose the package →" + )} + </button> + </div> + </div> + + <div className="kb-card p-6"> + <h2 className="text-sm font-semibold">What gets assembled</h2> + <p className="mt-0.5 text-xs text-foreground-muted">From your intent, kars composes a full trust envelope — and lets you edit every part.</p> + <ul className={`mt-4 space-y-2.5 ${composing ? "opacity-100" : ""}`}> + {([ + ["brain", "Model & harness", "a best-fit runtime, informed by past efficiency where available"], + ["target", "Autonomy tier", "how much it may do before asking you"], + ["wrench", "Tools & policy", "the bounded set of tools it may call"], + ["plug", "Connected services", "MCP servers / internal systems it may reach"], + ["globe", "Network egress", "exact external hosts allowed — all else denied"], + ["database", "Shared memory", "the knowledge commons it reads + writes"], + ] as const).map(([icon, t, d]) => ( + <li key={t} className="flex items-start gap-2.5"> + <span className={`mt-0.5 text-foreground-muted ${composing ? "kb-pulse rounded-full" : ""}`} aria-hidden><Icon name={icon} /></span> + <div> + <p className="text-sm font-medium">{t}</p> + <p className="text-xs text-foreground-muted">{d}</p> + </div> + </li> + ))} + </ul> + <p className="mt-5 rounded-lg bg-surface-muted/60 px-3 py-2 text-[11px] text-foreground-muted"> + Nothing runs at compose time. You review, edit, validate, then explicitly launch. + </p> + </div> + </div> + </div> + ); + } + + // Step 2 — the editable package + hard launch gate. + return ( + <form action={formAction} className="space-y-5"> + <input type="hidden" name="objective" value={objective} /> + <input type="hidden" name="tier" value={tier} /> + <input type="hidden" name="budget_tokens" value={budgetTokens} /> + <input type="hidden" name="launch" value={launch ? "on" : "off"} /> + <input type="hidden" name="blueprint_json" value={JSON.stringify(blueprint)} /> + <input type="hidden" name="delegation_json" value={JSON.stringify(delegation)} /> + + <JourneyRail current={launch ? "launch" : "review"} /> + + {rationale !== null || proposed ? ( + <EnvelopeReveal + blueprint={blueprint} + tier={tier} + budgetTokens={budgetTokens} + rationale={rationale} + source={composeSource} + recommended={recommendedRoute} + modelBasis={modelBasis} + delegation={delegation} + /> + ) : null} + {composeNote && ( + <div className="rounded-xl border border-border bg-surface-muted/50 p-4 text-sm text-foreground-muted"> + {composeNote} + </div> + )} + + <PackageSection title="Objective" subtitle="Restate it clearly — edit if Bridge misread you."> + <textarea + value={objective} + onChange={(e) => setObjective(e.target.value)} + rows={3} + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </PackageSection> + + <details open className="group rounded-xl border border-border bg-surface-muted/20 [&_summary::-webkit-details-marker]:hidden"> + <summary className="flex cursor-pointer items-center justify-between gap-3 px-5 py-4 text-sm"> + <span className="min-w-0"> + <span className="font-medium">Composed package — review & edit</span> + <span className="ml-2 text-xs text-foreground-muted"> + Model & harness, instructions, tools, network, isolation, memory — composed for you. Every field is editable; collapse if you just want the defaults. + </span> + </span> + <span aria-hidden className="shrink-0 text-foreground-muted transition-transform group-open:rotate-90">▸</span> + </summary> + <div className="space-y-5 border-t border-border p-4"> + + <PackageSection + title="Model & harness" + subtitle="What the mission reasons with, and the agent runtime it runs on." + > + {options.provider && ( + <div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-surface-muted/40 px-3 py-2.5"> + <Icon name="link" size={16} /> + <div className="min-w-0"> + <p className="text-xs font-medium"> + This cluster serves models via{" "} + <span className="text-foreground">{options.provider.label}</span> + <span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-foreground-muted"> + inherited + </span> + </p> + <p className="mt-0.5 text-[11px] text-foreground-muted">{options.provider.note}</p> + </div> + </div> + )} + <div className="grid gap-4 sm:grid-cols-2"> + <div> + <label className="text-xs font-medium text-foreground-muted">Model</label> + {options.models.length === 0 ? ( + <p className="mt-1.5 rounded-lg bg-surface-muted px-3 py-2 text-xs text-foreground-muted"> + No models are listed for this cluster — the mission will use the configured default + {options.default_model ? ` (${options.default_model})` : ""}. + </p> + ) : ( + <select + value={model} + onChange={(event) => { + const route = event.target.value; + setModel(route); + setModelFallbacks((current) => current.filter((fallback) => fallback !== route)); + }} + className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {options.models.map((m) => ( + <option key={modelKey(m.provider, m.deployment)} value={modelKey(m.provider, m.deployment)}> + {m.deployment} + {m.is_default ? " (default)" : ""} + </option> + ))} + </select> + )} + <p className="mt-1 text-[11px] text-foreground-muted"> + A default is pre-selected for the objective; switch to any model + {options.provider ? ` ${options.provider.label}` : " your cluster"} serves. + </p> + <label className="mt-3 block text-xs text-foreground-muted"> + Qualified fallback routes + <select + multiple + value={modelFallbacks} + onChange={(event) => { + const selected = new Set( + Array.from(event.currentTarget.selectedOptions, (option) => option.value), + ); + setModelFallbacks((current) => [ + ...current.filter((route) => selected.has(route)), + ...Array.from(selected).filter((route) => !current.includes(route)), + ].slice(0, 8)); + }} + className="mt-1.5 min-h-24 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + {options.models + .map((option) => modelKey(option.provider, option.deployment)) + .filter((route) => route !== model) + .map((route) => ( + <option key={route} value={route}> + {route} + </option> + ))} + </select> + {modelFallbacks.map((route, index) => ( + <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> + <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> + <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setModelFallbacks((current) => moveFallback(current, index, -1))}>↑</button> + <button type="button" aria-label={`Move ${route} later`} disabled={index === modelFallbacks.length - 1} onClick={() => setModelFallbacks((current) => moveFallback(current, index, 1))}>↓</button> + </span> + ))} + <span className="mt-1 block text-[11px]"> + Preflight rejects any fallback that lacks atomic evidence for this exact package and its selected resources. + </span> + </label> + {recommendedModel && ( + <div className="mt-2 flex items-start gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-2"> + <Icon name="lightbulb" size={14} /> + <div className="min-w-0 text-[11px]"> + <p className="font-medium text-foreground"> + {efficiency?.recommended_low_confidence + ? "Insufficient evidence for automatic recommendation" + : "Recommended by the efficiency frontier"} + </p> + <p className="mt-0.5 text-foreground-muted"> + {recommendedModel.deployment} + {recommendedStats + ? ` — ${Math.round(recommendedStats.acceptance_rate * 100)}% accepted across ${recommendedStats.runs} run${recommendedStats.runs === 1 ? "" : "s"}, ${recommendedStats.tokens_per_outcome.toLocaleString()} tokens/outcome` + : " — learned from completed runs on this cluster"} + {efficiency?.recommended_low_confidence + ? " — not selected automatically" + : ""} + </p> + </div> + </div> + )} + </div> + <div> + <label className="text-xs font-medium text-foreground-muted">Harness</label> + <select + value={runtime} + onChange={(e) => changeRuntime(e.target.value)} + className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {(() => { + const rts = options.runtimes.length + ? options.runtimes + : [{ kind: "OpenClaw", label: "OpenClaw", wired: true, status: "ready" as const, note: "" }]; + const ready = rts.filter((r) => r.status === "ready"); + const needsImage = rts.filter((r) => r.status === "needs_image"); + const unavailable = rts.filter((r) => r.status === "unavailable"); + const opt = (r: (typeof rts)[number]) => ( + <option key={r.kind} value={r.kind} disabled={!r.wired}> + {r.label} + {r.status === "needs_image" ? " — image not configured here" : r.status === "unavailable" ? " — not available" : ""} + </option> + ); + return ( + <> + {ready.length > 0 && <optgroup label="Ready on this cluster">{ready.map(opt)}</optgroup>} + {needsImage.length > 0 && <optgroup label="Supported — needs runtime image">{needsImage.map(opt)}</optgroup>} + {unavailable.length > 0 && <optgroup label="Not available yet">{unavailable.map(opt)}</optgroup>} + </> + ); + })()} + </select> + <p className="mt-1 text-[11px] text-foreground-muted"> + {options.runtimes.filter((r) => r.wired).length} harness{options.runtimes.filter((r) => r.wired).length === 1 ? "" : "es"} can run on this cluster right now. Others are supported by the runtime but need their image configured by an operator. Team members can each use a different ready harness. + </p> + </div> + </div> + </PackageSection> + + <PackageSection + title="Instructions" + subtitle="The mission's system prompt — how it should behave, in addition to the objective." + > + <textarea + value={instructions} + onChange={(e) => setInstructions(e.target.value)} + rows={4} + placeholder="e.g. Be meticulous. Verify every claim against a primary source and cite file paths. Never change code without a passing test." + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm leading-relaxed placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </PackageSection> + + <PackageSection + title="Execution plan" + subtitle="Roles, dependencies, phases, capabilities, tool-call bounds, synthesis, and deliverables. This is typed and runtime-neutral." + > + {executionPlanDraft ? ( + <> + <textarea + value={executionPlanDraft} + onChange={(event) => { + const next = event.target.value; + setExecutionPlanDraft(next); + try { + const parsed = JSON.parse(next) as import("@/lib/types").ExecutionPlan; + setExecutionPlan(parsed); + setExecutionPlanError(null); + } catch { + setExecutionPlanError("The execution plan must be valid JSON before validation or launch."); + } + }} + rows={18} + spellCheck={false} + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + {executionPlanError && ( + <p className="mt-2 text-xs text-danger">{executionPlanError}</p> + )} + </> + ) : ( + <p className="text-xs text-foreground-muted"> + Single-agent execution — no worker plan was proposed. Recompose the mission to request decomposition. + </p> + )} + </PackageSection> + + {loopDirective.trim() && ( + <PackageSection + title="Loop — operating contract" + subtitle="The feedback loop the harness runs and any sub-agents inherit. Composed from the loop you reviewed." + > + <div className="flex items-center justify-between gap-3"> + <p className="text-xs text-foreground-muted"> + This loop is part of the launched package (folded into the agent’s instructions). + </p> + {cameViaLoopReview && ( + <button + type="button" + onClick={goBackToLoopReview} + className="shrink-0 rounded-lg border border-accent/40 bg-accent/[0.06] px-3 py-1.5 text-xs font-medium text-accent transition hover:bg-accent/10" + > + Adjust loop → + </button> + )} + </div> + <pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface p-3 font-mono text-[11px] leading-relaxed text-foreground"> + {loopDirective.trim()} + </pre> + </PackageSection> + )} + + <PackageSection + title="Tools & connected services" + subtitle="The tool policy that bounds what it may call, and the MCP services it may use." + > + <label className="text-xs font-medium text-foreground-muted">Tool policy</label> + <select + value={toolPolicy} + onChange={(e) => setToolPolicy(e.target.value)} + className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <option value="">None — model only (no governed tools)</option> + {options.tool_policies.map((t) => ( + <option key={t.name} value={t.name}> + {t.name} + {t.summary ? ` · ${t.summary}` : ""} + </option> + ))} + </select> + + <div className="mt-4"> + <label className="text-xs font-medium text-foreground-muted">Connected services (MCP)</label> + {options.mcp_profiles.length > 0 && ( + <div className="mt-1.5 flex flex-wrap items-center gap-1.5"> + <span className="text-[11px] text-foreground-muted">Vetted bundles:</span> + {options.mcp_profiles.map((prof) => { + const active = prof.servers.length > 0 && prof.servers.every((s) => mcp.includes(s)); + return ( + <button + key={prof.name} + type="button" + title={prof.summary ?? `${prof.servers.length} server(s): ${prof.servers.join(", ")}`} + onClick={() => + setMcp((cur) => + active + ? cur.filter((x) => !prof.servers.includes(x)) + : [...new Set([...cur, ...prof.servers])], + ) + } + className={`rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${active ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground"}`} + > + {active ? "✓ " : "+ "}{prof.name} + </button> + ); + })} + </div> + )} + {options.mcp_servers.length === 0 ? ( + <p className="mt-1.5 text-xs text-foreground-muted"> + No services are connected. Connect MCP servers in the Operator Console to give the + mission more tools. + </p> + ) : ( + <ul className="mt-1.5 space-y-1.5"> + {options.mcp_servers.map((m) => { + const checked = mcp.includes(m.name); + return ( + <li key={m.name}> + <label className="flex items-center gap-2.5 text-sm"> + <input + type="checkbox" + checked={checked} + onChange={(e) => + setMcp((cur) => + e.target.checked ? [...cur, m.name] : cur.filter((x) => x !== m.name), + ) + } + className="h-4 w-4 accent-[var(--signal)]" + /> + <span className="font-medium">{humanizeMcp(m.name)}</span> + {m.summary && <span className="text-xs text-foreground-muted">{m.summary}</span>} + </label> + </li> + ); + })} + </ul> + )} + {mcpNeedsPolicy && ( + <p role="alert" className="mt-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning"> + Connected services need a tool policy to bound them. Select a tool policy above, or + clear the services. + </p> + )} + </div> + <div className="mt-4"> + <label className="text-xs font-medium text-foreground-muted">Approved skills</label> + {options.skills.length === 0 ? ( + <p className="mt-1.5 text-xs text-foreground-muted">No approved skills are available.</p> + ) : ( + <ul className="mt-1.5 space-y-1.5"> + {options.skills.map((skill) => ( + <li key={skill.name}> + <label className="flex items-center gap-2.5 text-sm"> + <input + type="checkbox" + checked={skills.includes(skill.name)} + onChange={(e) => + setSkills((current) => + e.target.checked + ? [...current, skill.name] + : current.filter((name) => name !== skill.name), + ) + } + className="h-4 w-4 accent-[var(--signal)]" + /> + <span className="font-medium">{skill.name}</span> + {skill.summary && ( + <span className="text-xs text-foreground-muted">{skill.summary}</span> + )} + </label> + </li> + ))} + </ul> + )} + </div> + </PackageSection> + + <PackageSection + title="Network egress" + subtitle="Exactly which external hosts the mission may reach. Empty means no extra egress beyond the model path." + > + <div className="mb-3 inline-flex rounded-lg border border-border bg-surface p-1 text-xs"> + <button + type="button" + onClick={() => setEgressMode("strict")} + className={`rounded-md px-3 py-1.5 font-medium transition ${egressMode === "strict" ? "bg-signal text-signal-fg" : "text-foreground-muted hover:text-foreground"}`} + > + Strict + </button> + <button + type="button" + onClick={() => setEgressMode("learning")} + className={`rounded-md px-3 py-1.5 font-medium transition ${egressMode === "learning" ? "bg-accent text-accent-fg" : "text-foreground-muted hover:text-foreground"}`} + > + Learning + </button> + </div> + <p className="mb-3 text-xs text-foreground-muted"> + {egressMode === "strict" + ? "Only the hosts below are reachable from the first run — everything else is denied. The safe default." + : "The mission starts in Learn mode: it observes which hosts the agent actually reaches (nothing is blocked yet), so you can review and enforce the learned set afterward. Use for exploratory work when the host set isn't known up front."} + </p> + <div className={egressMode === "learning" ? "opacity-50" : ""}> + <EgressEditor egress={egress} onChange={setEgress} /> + </div> + </PackageSection> + + <PackageSection title="Isolation" subtitle="The sandbox hardening the mission runs inside."> + <div className="space-y-1.5"> + {(options.isolation.length + ? options.isolation + : [{ value: "standard", label: "Standard", note: "" }] + ).map((iso) => ( + <label key={iso.value} className="flex items-start gap-2.5 text-sm"> + <input + type="radio" + name="isolation_radio" + checked={isolation === iso.value} + onChange={() => setIsolation(iso.value)} + className="mt-0.5 h-4 w-4 accent-[var(--signal)]" + /> + <span> + <span className="font-medium">{iso.label}</span> + {iso.note && <span className="ml-1.5 text-xs text-foreground-muted">{iso.note}</span>} + </span> + </label> + ))} + </div> + </PackageSection> + + {options.memories.length > 0 && ( + <PackageSection + title="Shared memory" + subtitle="A shared knowledge store the mission reads and writes (optional)." + > + <select + value={memory} + onChange={(e) => setMemory(e.target.value)} + aria-label="Shared memory store" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <option value="">None — this mission keeps its own context</option> + {options.memories.map((m) => ( + <option key={m.name} value={m.name}> + {m.name} + {m.summary ? ` · ${m.summary}` : ""} + </option> + ))} + </select> + </PackageSection> + )} + </div> + </details> + + <PackageSection title="Autonomy" subtitle="How much the mission may do on its own."> + <SegmentedTier name="tier_display" value={tier} onChange={setTier} /> + <p className="mt-3 rounded-lg bg-surface-muted px-3 py-2 text-xs text-foreground-muted"> + {TIER_CONSEQUENCE[tier]} + {" "}Delegated sub-roles may hold at most <span className="font-medium text-foreground">Tier {ceiling}</span> — one below the mission. + </p> + </PackageSection> + + <PackageSection title="Budget" subtitle="An optional token ceiling for the whole mission."> + <div className="flex items-center gap-2"> + <input + type="number" + min={0} + value={budgetTokens} + onChange={(e) => setBudgetTokens(e.target.value)} + placeholder="e.g. 200000" + className="w-48 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <span className="text-xs text-foreground-muted">tokens — leave blank for no cap</span> + </div> + </PackageSection> + + <PackageSection title="Governance envelope" subtitle="The hard limits this mission runs under."> + <ul className="space-y-1 text-sm text-foreground-muted"> + <li>• Acts at <span className="font-medium text-foreground">Tier {tier}</span> autonomy.</li> + <li>• Delegated sub-roles can hold at most <span className="font-medium text-foreground">Tier {ceiling}</span> — never more than the mission.</li> + {tier <= 3 && ( + <li>• Pauses for your approval before any priced, external, or irreversible action.</li> + )} + <li>• Reaches only the {egress.length === 0 ? "model path" : `${egress.length} host${egress.length === 1 ? "" : "s"} you allowed`}; all other egress is denied at the sandbox boundary.</li> + <li>• Every decision and steer is recorded in a signed Governance Receipt.</li> + </ul> + </PackageSection> + + {/* Pre-flight validation (§20) — prove launch-ready before anything runs. */} + <section className="rounded-2xl border border-border bg-surface p-5 shadow-sm"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Pre-flight check</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Validate the package against the live cluster before anything runs — tools, services, + memory, model, and network are checked. + </p> + </div> + <button + type="button" + onClick={runValidation} + disabled={validating || mcpNeedsPolicy || executionPlanError !== null} + className="shrink-0 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted disabled:opacity-50" + > + {validating ? "Checking…" : "Validate package"} + </button> + </div> + {/* Validation animates the same orchestration cube with a live feed of + what's being checked against the live cluster — so validate feels as + alive as compose, and the Execute button only appears once green. */} + {validating && ( + <div className="mt-4"> + <OrchestrationCube + title="Validating against the live cluster" + done={false} + active={0} + phases={[ + { icon: "brain", label: "Resolving the model on the cluster", detail: model ? model.split("::")[1] ?? model : "controller default" }, + { icon: "wrench", label: "Checking tool policy + connected services", detail: `${mcp.length} service${mcp.length === 1 ? "" : "s"}${toolPolicy ? ` · ${toolPolicy}` : ""}` }, + { icon: "globe", label: "Verifying egress reachability", detail: egress.length ? egress.map((e) => e.host).slice(0, 3).join(", ") : "model path only" }, + { icon: "shield", label: "Proving the envelope is launch-ready", detail: `Tier ${tier} · capability + budget checks` }, + ]} + /> + </div> + )} + {/* Loud, honest feedback in every branch — never a dead button. */} + {mcpNeedsPolicy && ( + <p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> + Connected services (MCP) require a tool policy to bound them. Pick a tool policy above, + then validate. + </p> + )} + {validationError && ( + <div className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger"> + <span className="font-semibold">Pre-flight could not complete.</span> {validationError} + </div> + )} + {validation && ( + <> + {!validationFresh && ( + <p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> + You edited the package since this ran — these results are stale. Re-validate to launch. + </p> + )} + <ul className="mt-3 space-y-1.5"> + {validation.checks.map((c) => ( + <li key={c.id} className="flex items-start gap-2 text-sm"> + <CheckMark status={c.status} /> + <span> + <span className="font-medium">{c.label}</span> + <span className="ml-1.5 text-xs text-foreground-muted">{c.detail}</span> + </span> + </li> + ))} + </ul> + {validationFresh && !validation.ok && ( + <p className="mt-2 text-xs font-medium text-danger"> + Fix the failing checks above before launching. + </p> + )} + </> + )} + </section> + + <RepoAccess /> + + <label className="flex items-center gap-2.5 rounded-lg border border-border bg-surface px-4 py-3 text-sm"> + <input + type="checkbox" + checked={launch} + onChange={(e) => setLaunch(e.target.checked)} + className="h-4 w-4 accent-[var(--signal)]" + /> + <span> + Launch immediately after creating.{" "} + <span className="text-foreground-muted"> + Leave unchecked to create a governed draft you launch when ready. + </span> + </span> + </label> + + {state.error && ( + <p role="alert" className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger"> + {state.error} + </p> + )} + + <div className="flex items-center justify-between rounded-xl border border-border bg-surface-muted/50 px-4 py-3"> + <p className="text-xs font-medium text-foreground-muted">Nothing has started yet.</p> + <div className="flex items-center gap-3"> + <button + type="button" + onClick={goBack} + className="rounded-lg px-3 py-2 text-sm text-foreground-muted hover:text-foreground" + > + {cameViaLoopReview ? "← Back to loop" : "← Back"} + </button> + <CreateButton + disabled={ + mcpNeedsPolicy + || executionPlanError !== null + || (launch && !(validationFresh && validation!.ok)) + } + launch={launch} + needsValidation={launch && !(validationFresh && validation?.ok === true)} + /> + </div> + </div> + </form> + ); +} + +function CheckMark({ status }: { status: "pass" | "fail" | "warn" }) { + const map = { + pass: { c: "text-ok", s: "✓" }, + warn: { c: "text-warning", s: "!" }, + fail: { c: "text-danger", s: "✕" }, + } as const; + const m = map[status]; + return ( + <span className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-bold ${m.c}`} aria-hidden> + {m.s} + </span> + ); +} + +function CreateButton({ + disabled, + launch, + needsValidation, +}: { + disabled: boolean; + launch: boolean; + needsValidation: boolean; +}) { + const { pending } = useFormStatus(); + const label = pending + ? "Creating…" + : needsValidation + ? "Validate to launch" + : launch + ? "Create & launch" + : "Create draft"; + return ( + <button + type="submit" + disabled={pending || disabled} + className="rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal disabled:opacity-50" + > + {label} + </button> + ); +} + +function EgressEditor({ + egress, + onChange, +}: { + egress: BlueprintEgress[]; + onChange: (e: BlueprintEgress[]) => void; +}) { + const [host, setHost] = useState(""); + const [port, setPort] = useState("443"); + const [err, setErr] = useState<string | null>(null); + + // A permissive hostname / IPv4 check — rejects schemes, paths, spaces, and + // obvious junk so a bad allowlist entry can't silently reach the controller. + const HOST_RE = /^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^(?:\d{1,3}\.){3}\d{1,3}$|^localhost$/; + + function add() { + setErr(null); + const h = host.trim().toLowerCase(); + if (!h) return; + if (h.includes("/") || h.includes(":") || h.includes(" ")) { + setErr("Enter a bare hostname (no scheme, port, or path) — set the port separately."); + return; + } + if (!HOST_RE.test(h)) { + setErr("That doesn't look like a valid hostname or IP."); + return; + } + let p: number | null = null; + if (port.trim() !== "") { + const n = Number(port); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + setErr("Port must be a whole number between 1 and 65535."); + return; + } + p = n; + } + if (egress.some((e) => e.host === h && e.port === p)) { + setErr("That host:port is already in the allowlist."); + return; + } + onChange([...egress, { host: h, port: p }]); + setHost(""); + setPort("443"); + } + + return ( + <div className="space-y-2"> + {egress.length > 0 && ( + <ul className="space-y-1.5"> + {egress.map((e, i) => ( + <li + key={`${e.host}:${e.port ?? ""}:${i}`} + className="flex items-center justify-between rounded-lg bg-surface-muted px-3 py-1.5 text-sm" + > + <span className="font-mono text-xs"> + {e.host} + {e.port ? `:${e.port}` : ""} + </span> + <button + type="button" + onClick={() => onChange(egress.filter((_, j) => j !== i))} + className="text-xs text-foreground-muted hover:text-danger" + > + Remove + </button> + </li> + ))} + </ul> + )} + <div className="flex items-center gap-2"> + <input + value={host} + onChange={(e) => setHost(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + placeholder="host, e.g. api.github.com" + className="flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <input + value={port} + onChange={(e) => setPort(e.target.value)} + placeholder="443" + inputMode="numeric" + className="w-20 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <button + type="button" + onClick={add} + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition hover:bg-surface-muted" + > + Add + </button> + </div> + {err && <p className="text-xs text-danger">{err}</p>} + </div> + ); +} + +function PackageSection({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: React.ReactNode; +}) { + return ( + <section className="rounded-2xl border border-border bg-surface p-5 shadow-sm"> + <h2 className="text-sm font-semibold">{title}</h2> + <p className="mt-0.5 text-xs text-foreground-muted">{subtitle}</p> + <div className="mt-3">{children}</div> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/new/page.tsx b/bridge/web/src/app/workspace/new/page.tsx new file mode 100644 index 000000000..9d4eeb65e --- /dev/null +++ b/bridge/web/src/app/workspace/new/page.tsx @@ -0,0 +1,62 @@ +// kars Bridge Workspace — New mission (intake → editable package → launch). + +import { IntakeFlow } from "./intake-flow"; +import { getOptions, getEfficiency } from "@/lib/bff"; +import type { Options, Efficiency } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +const EMPTY_OPTIONS: Options = { + models: [], + default_model: null, + provider: null, + runtimes: [], + isolation: [], + tool_policies: [], + mcp_servers: [], + mcp_profiles: [], + memories: [], + skills: [], +}; + +export default async function NewMissionPage({ + searchParams, +}: { + searchParams: Promise<{ intent?: string }>; +}) { + const { intent } = await searchParams; + // The composable palette — real models/runtimes/policies/services/memory read + // from the live cluster. Never fabricated; an unreachable BFF yields the empty + // palette and the package degrades to the controller defaults honestly. + let options: Options = EMPTY_OPTIONS; + try { + options = await getOptions(); + } catch { + // Non-fatal: the package still renders with honest "uses default" states. + } + + // The learned efficiency frontier — what actually performs best per outcome. + // This is the spine of orchestration: the recommendation the composer surfaces + // at the point of choice comes from real completed runs, not a static default. + let efficiency: Efficiency | null = null; + try { + efficiency = await getEfficiency(); + } catch { + efficiency = null; + } + + return ( + <div className="mx-auto max-w-3xl space-y-6"> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Start a mission</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Describe what you want. Bridge composes a complete, editable package — the model and + harness it runs on, its instructions, the tools and services it may use, where it may + reach on the network, how much it can act on its own, and the limits it runs under — for + you to review and adjust before anything starts. + </p> + </div> + <IntakeFlow options={options} efficiency={efficiency} initialObjective={intent} /> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/page.tsx b/bridge/web/src/app/workspace/page.tsx new file mode 100644 index 000000000..625341d66 --- /dev/null +++ b/bridge/web/src/app/workspace/page.tsx @@ -0,0 +1,474 @@ +// kars Bridge Workspace — Home. Action-led, not a stat dashboard: the single +// "Start a mission" entry point + your in-flight missions + anything waiting on +// you. Honest empty state on a fresh cluster (no zeroed cards that imply +// measurement). + +import Link from "next/link"; +import { IntentEntry } from "@/components/intent-entry"; +import { HowItWorksSteps } from "@/components/how-it-works"; +import { Stat } from "@/components/ui"; +import { MissionStatusBadge, missionStatus } from "@/components/mission-status"; +import { + BffError, + getArtifacts, + getDigests, + getTask, + getTeam, + listApprovals, + listTasks, + listTeams, +} from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import { + TIER_LABELS, + type TaskSummary, + type Approval, + type TeamSummary, + type Digest, + type MissionArtifacts, +} from "@/lib/types"; +import { analyzeTeamRun } from "@/lib/team-run-evidence"; + +export const dynamic = "force-dynamic"; + +function teamHealthDot(health: string | null, paused: boolean): string { + const h = paused ? "Hibernating" : (health ?? ""); + if (h === "Healthy") return "bg-emerald-500"; + if (h === "Stalled") return "bg-rose-500"; + if (h === "Unproductive") return "bg-amber-500"; + if (h === "Watching") return "bg-sky-500"; + return "bg-foreground-muted"; +} + +function completedHeadline(item: MissionArtifacts): string { + const pr = item.pull_requests[0]; + if (pr) return `Change proposed · ${pr.repo} PR #${pr.number}`; + return item.excerpt || item.display_name || item.objective || "Completed outcome"; +} + +export default async function WorkspaceHome() { + const ns = defaultNamespace(); + let tasks: TaskSummary[] = []; + let approvals: Approval[] = []; + let teams: TeamSummary[] = []; + let digests: Digest[] = []; + let artifacts: MissionArtifacts[] = []; + let backendError: string | null = null; + try { + [tasks, approvals, teams, digests, artifacts] = await Promise.all([ + listTasks(ns), + listApprovals(ns, { pending: true }).catch(() => []), + listTeams(ns).catch(() => []), + getDigests().catch(() => []), + getArtifacts() + .then((i) => i.missions) + .catch(() => []), + ]); + } catch (err) { + backendError = + err instanceof BffError ? err.code : err instanceof Error ? err.message : "unknown"; + } + + const waiting = approvals.filter((a) => a.actionable); + // Standalone missions only — team machinery (principal/members/standing runs) + // lives on the Team surface, not in the user's mission list. + const standaloneTasks = tasks.filter((t) => !t.team); + // A run minted by a standing team's charter loop (`<team>-run-<epoch>`) is + // autonomous — it must NOT appear in the user's personal review queue. Also + // exclude anything whose task name is owned by a team (any `<team>-…` object), + // so team machinery never inflates the user's "To review" KPI (audit f2). + const isTeamOwned = (task: string) => + teams.some((t) => task === t.name || task.startsWith(`${t.name}-`)); + const isStandingRun = (task: string) => + teams.some((t) => task.startsWith(`${t.name}-run-`)); + // Deliverables from the user's own missions that have landed and still need a + // human decision (§16) — excludes autonomous standing-team output. + const toReview = artifacts.filter( + (m) => + m.summary && + m.status !== "error" && + m.review_status !== "approved" && + !isStandingRun(m.task) && + !isTeamOwned(m.task), + ); + // One card per team — the latest digest — so a stalled team doesn't flood the + // home with repeated identical messages. + const latestDigestByTeam = Array.from( + digests + .reduce((acc, d) => { + const prev = acc.get(d.team); + if (!prev || (d.at ?? "") > (prev.at ?? "")) acc.set(d.team, d); + return acc; + }, new Map<string, Digest>()) + .values(), + ).sort((a, b) => (b.at ?? "").localeCompare(a.at ?? "")); + const completedTeam = (mission: MissionArtifacts) => + mission.team + ? teams.find((team) => team.name === mission.team) + : teams.find((team) => mission.task.startsWith(`${team.name}-run-`)); + const completedCandidates = artifacts + .filter((m) => m.status !== "error" && m.finished_at != null) + .sort( + (a, b) => + (Number(Boolean(completedTeam(b))) * 2 + + Number(b.review_status === "approved")) - + (Number(Boolean(completedTeam(a))) * 2 + + Number(a.review_status === "approved")) || + (b.finished_at ?? "").localeCompare(a.finished_at ?? ""), + ) + .slice(0, 12); + const teamOutcomeByTask = new Map<string, ReturnType<typeof analyzeTeamRun>["outcome"]>(); + const teamDetails = new Map( + await Promise.all( + Array.from( + new Set( + completedCandidates + .map((candidate) => completedTeam(candidate)?.name) + .filter((name): name is string => Boolean(name)), + ), + ).map(async (teamName) => [ + teamName, + await getTeam(ns, teamName).catch(() => null), + ] as const), + ), + ); + await Promise.all( + completedCandidates.flatMap((candidate) => { + const team = completedTeam(candidate); + if (!team) return []; + return [getTask(ns, candidate.task).catch(() => null).then((task) => { + const detail = teamDetails.get(team.name); + if (detail && task) { + teamOutcomeByTask.set(candidate.task, analyzeTeamRun(detail, task).outcome); + } + })]; + }), + ); + const recentCompleted = completedCandidates + .filter((candidate) => { + const team = completedTeam(candidate); + if (!team) return true; + const outcome = teamOutcomeByTask.get(candidate.task); + return outcome === "delivered" || outcome === "delivered_with_issues"; + }) + .slice(0, 6); + const completedHref = (mission: MissionArtifacts) => { + const team = completedTeam(mission); + if (!team) return `/workspace/missions/${encodeURIComponent(mission.task)}`; + return mission.evidence_key && mission.evidence_key !== mission.task + ? `/workspace/teams/${encodeURIComponent(team.name)}?tab=runs` + : `/workspace/teams/${encodeURIComponent(team.name)}/runs/${encodeURIComponent(mission.task)}`; + }; + + return ( + <div className="space-y-8"> + {/* Hero: one intent → the orchestrator routes it. */} + <section className="relative overflow-hidden rounded-3xl border border-border bg-gradient-to-br from-surface via-surface to-surface-muted/50 p-8 shadow-lg sm:p-10 kb-canvas"> + <div aria-hidden className="pointer-events-none absolute -right-20 -top-24 h-72 w-72 rounded-full bg-signal/15 blur-3xl" /> + <div aria-hidden className="pointer-events-none absolute -bottom-24 -left-16 h-64 w-64 rounded-full bg-accent/10 blur-3xl" /> + <div className="relative"> + <span className="inline-flex items-center gap-1.5 rounded-full border border-signal/25 bg-signal/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-signal"> + <span className="kb-pulse inline-block h-1.5 w-1.5 rounded-full bg-signal" /> + AI orchestrator + </span> + <h1 className="mt-3 text-3xl font-semibold tracking-tight sm:text-[2.4rem] sm:leading-[1.1]">What do you want done?</h1> + <p className="mt-2 max-w-xl text-[15px] leading-relaxed text-foreground-muted"> + Describe an outcome. Bridge decides whether it’s a one-off mission or a standing team, + composes a governed agent — or a whole org — you review the plan, then get back verifiable + work with a signed receipt. + </p> + <div className="mt-6 sm:max-w-2xl"> + <IntentEntry /> + </div> + <details className="mt-4 sm:max-w-2xl"> + <summary className="cursor-pointer text-xs text-foreground-muted hover:text-foreground"> + Or start from scratch + </summary> + <div className="mt-3 grid gap-3 sm:grid-cols-2"> + <Link href="/workspace/new" className="kb-card kb-card-hover group flex items-start gap-3 p-4"> + <span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-signal/15 text-signal"> + <svg viewBox="0 0 16 16" className="h-4 w-4" fill="currentColor" aria-hidden><path d="M8 2.5v11M2.5 8h11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg> + </span> + <div> + <p className="text-sm font-semibold">Start a mission</p> + <p className="mt-0.5 text-xs text-foreground-muted">A one-off task — compose, review, launch, done.</p> + </div> + </Link> + <Link href="/workspace/teams/new" className="kb-card kb-card-hover group flex items-start gap-3 p-4"> + <span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-accent/15 text-accent"> + <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden><path d="M7 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm6 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm-6 1.5c-2.5 0-4.5 1.4-4.5 3.2V16h9v-2.3c0-1.8-2-3.2-4.5-3.2Zm6 0c-.6 0-1.2.08-1.7.23 1 .8 1.7 1.9 1.7 3V16h4.5v-2.3c0-1.8-2-3.2-4.5-3.2Z" /></svg> + </span> + <div> + <p className="text-sm font-semibold">Set up a team</p> + <p className="mt-0.5 text-xs text-foreground-muted">A standing org that works continuously under a charter.</p> + </div> + </Link> + </div> + </details> + <details className="mt-2 sm:max-w-2xl"> + <summary className="cursor-pointer text-xs text-foreground-muted hover:text-foreground"> + How it works + </summary> + <HowItWorksSteps /> + </details> + </div> + </section> + + {/* Live stat strip — what's alive right now (only when there's signal). */} + {(standaloneTasks.length > 0 || teams.length > 0 || waiting.length > 0 || toReview.length > 0) && ( + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + <Stat label="Missions" value={standaloneTasks.length} accent={standaloneTasks.length > 0} /> + <Stat label="Standing teams" value={teams.length} /> + <Stat label="Waiting on you" value={waiting.length} accent={waiting.length > 0} /> + <Stat label="To review" value={toReview.length} accent={toReview.length > 0} /> + </div> + )} + + {/* First-run orientation (audit f1): when the workspace is empty, explain + the three-step flow in plain language before the user has any data. */} + {standaloneTasks.length === 0 && teams.length === 0 && waiting.length === 0 && toReview.length === 0 && !backendError && ( + <section className="rounded-2xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">New here? How it works</h2> + <p className="mt-0.5 text-xs text-foreground-muted">Three steps from an idea to verifiable, governed work.</p> + <HowItWorksSteps withExamples /> + </section> + )} + + {backendError && ( + <div role="alert" className="rounded-xl border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger"> + The run environment isn't reachable right now. Your missions will appear here once it + reconnects. Nothing is lost. + </div> + )} + + {/* Waiting on you. */} + {waiting.length > 0 && ( + <section> + <div className="mb-3 flex items-center justify-between"> + <h2 className="text-sm font-semibold">Waiting on you</h2> + <Link href="/workspace/inbox" className="text-xs font-medium text-signal hover:underline"> + Open inbox → + </Link> + </div> + <ul className="space-y-2"> + {waiting.slice(0, 3).map((a) => ( + <li key={a.name}> + <Link + href="/workspace/inbox" + className="flex items-center justify-between gap-3 rounded-xl border border-warning/30 bg-warning/5 px-4 py-3 transition hover:bg-warning/10" + > + <div className="min-w-0"> + <p className="truncate text-sm font-medium">{a.summary}</p> + <p className="text-xs text-foreground-muted">Mission {a.task}</p> + </div> + <span className="shrink-0 rounded-full bg-warning/15 px-2.5 py-0.5 text-xs font-medium text-warning"> + Decide + </span> + </Link> + </li> + ))} + </ul> + </section> + )} + + {recentCompleted.length > 0 && ( + <section> + <div className="mb-3 flex items-center justify-between"> + <div> + <h2 className="text-sm font-semibold">Recently completed</h2> + <span className="text-xs text-foreground-muted"> + Latest delivered outcomes across your missions and team runs + </span> + </div> + <Link href="/workspace/artifacts" className="text-xs font-medium text-signal hover:underline"> + All completed work → + </Link> + </div> + <ul className="grid gap-3 sm:grid-cols-2"> + {recentCompleted.map((m) => ( + <li key={m.evidence_key ?? m.task} className="min-w-0"> + <Link + href={completedHref(m)} + className="kb-card kb-card-hover block p-4" + > + <div className="flex items-start justify-between gap-3"> + <p className="min-w-0 truncate text-sm font-semibold"> + {completedTeam(m)?.display_name ?? + completedTeam(m)?.name ?? + m.display_name ?? + m.objective ?? + m.task} + </p> + <span className="shrink-0 rounded-full border border-ok/30 bg-ok/10 px-2 py-0.5 text-[11px] font-medium text-ok"> + {m.pull_requests.length > 0 + ? "Change proposed" + : completedTeam(m) + ? teamOutcomeByTask.get(m.task) === "delivered_with_issues" + ? "Outcome with issues" + : "Outcome" + : m.review_status === "approved" + ? "Approved" + : "Delivered"} + </span> + </div> + <p className="mt-1.5 line-clamp-2 text-sm font-medium text-foreground"> + {completedHeadline(m)} + </p> + <p className="mt-2 text-[11px] text-foreground-muted"> + {completedTeam(m) ? "Standing team run · " : ""} + {m.model ?? "Model not reported"} + {m.finished_at ? ` · ${new Date(m.finished_at).toLocaleString()}` : ""} + </p> + </Link> + </li> + ))} + </ul> + </section> + )} + + {/* Standing teams — the command center's persistent operations. */} + {teams.length > 0 && ( + <section> + <div className="mb-3 flex items-center justify-between"> + <div> + <h2 className="text-sm font-semibold">Standing teams</h2> + <span className="text-xs text-foreground-muted">Long-running orgs that work continuously</span> + </div> + <Link href="/workspace/teams" className="text-xs font-medium text-signal hover:underline"> + All teams → + </Link> + </div> + <ul className="grid gap-3 sm:grid-cols-2"> + {teams.slice(0, 4).map((t) => ( + <li key={t.name} className="min-w-0"> + <Link + href={`/workspace/teams/${encodeURIComponent(t.name)}`} + className="kb-card kb-card-hover group block p-4" + > + <div className="flex items-center justify-between gap-3"> + <p className="min-w-0 truncate text-sm font-semibold"> + {t.display_name ?? t.name} + </p> + <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-surface-muted/60 px-2 py-0.5 text-[11px] font-medium text-foreground-muted"> + <span className={`h-1.5 w-1.5 rounded-full ${teamHealthDot(t.health, t.paused)}`} /> + {t.paused ? "Hibernating" : (t.health ?? t.phase)} + </span> + </div> + <p className="mt-1.5 line-clamp-1 text-xs text-foreground-muted">{t.charter}</p> + <p className="mt-2 text-xs text-foreground-muted"> + {t.member_count} members · recent {t.retained_delivered} delivered /{" "} + {t.retained_failed} failed · {t.generated_task_count} checks + {t.every_minutes != null ? ` · every ${t.every_minutes}m` : ""} + </p> + </Link> + </li> + ))} + </ul> + </section> + )} + + {/* Latest from your teams — the autonomous digest stream (§20). */} + {latestDigestByTeam.length > 0 && ( + <section> + <div className="mb-3 flex items-center justify-between"> + <h2 className="text-sm font-semibold">Latest from your teams</h2> + <Link href="/workspace/inbox" className="text-xs font-medium text-signal hover:underline"> + All digests → + </Link> + </div> + <ul className="space-y-2"> + {latestDigestByTeam.slice(0, 3).map((d, i) => ( + <li + key={`${d.team}-${d.at}-${i}`} + className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-3" + > + <div className="min-w-0"> + <p className="truncate text-sm"> + <Link href={`/workspace/teams/${encodeURIComponent(d.team)}`} className="font-medium hover:underline">{d.team}</Link>{" "} + <span className="text-foreground-muted">{d.summary}</span> + </p> + </div> + <span className="shrink-0 text-xs text-foreground-muted"> + {new Date(d.at).toLocaleTimeString()} + </span> + </li> + ))} + </ul> + </section> + )} + + {/* Deliverables awaiting review (§16). */} + {toReview.length > 0 && ( + <section> + <div className="mb-3 flex items-center justify-between"> + <h2 className="text-sm font-semibold">Deliverables to review</h2> + <Link href="/workspace/artifacts" className="text-xs font-medium text-signal hover:underline"> + All artifacts → + </Link> + </div> + <ul className="space-y-2"> + {toReview.slice(0, 4).map((m) => ( + <li key={m.evidence_key ?? m.task}> + <Link + href={completedHref(m)} + className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-4 py-3 transition hover:border-signal/40 hover:bg-surface-muted" + > + <div className="min-w-0"> + <p className="truncate text-sm font-medium">{m.display_name ?? m.objective ?? m.task}</p> + {m.excerpt && <p className="truncate text-xs text-foreground-muted">{m.excerpt}</p>} + </div> + <span + className={`shrink-0 rounded-full border px-2.5 py-0.5 text-xs font-medium ${ + m.review_status === "changes_requested" + ? "border-amber-500/30 bg-amber-500/10 text-amber-600" + : "border-signal/30 bg-signal/10 text-signal" + }`} + > + {m.review_status === "changes_requested" ? "Revising" : "Review"} + </span> + </Link> + </li> + ))} + </ul> + </section> + )} + + {/* Your missions — only when there are some; the intent entry above is the + prompt when there are none (no redundant empty block on the home). */} + {standaloneTasks.length > 0 && ( + <section> + <div className="mb-3 flex items-baseline justify-between"> + <h2 className="text-sm font-semibold">Your missions</h2> + <span className="text-xs text-foreground-muted">One-off tasks — start, review, done</span> + </div> + <ul className="grid gap-3 sm:grid-cols-2"> + {standaloneTasks.map((t) => { + const st = missionStatus(t.phase, null, { delivered: t.delivered }); + return ( + <li key={t.name} className="min-w-0"> + <Link + href={`/workspace/missions/${encodeURIComponent(t.name)}`} + className="block rounded-xl border border-border bg-surface p-4 transition hover:border-signal/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <div className="flex items-start justify-between gap-3"> + <p className="min-w-0 truncate text-sm font-medium"> + {t.display_name ?? t.objective} + </p> + <MissionStatusBadge status={st} /> + </div> + <p className="mt-1.5 line-clamp-2 text-xs text-foreground-muted"> + {t.objective} + </p> + <p className="mt-2 text-xs text-foreground-muted"> + Tier {t.tier} · {TIER_LABELS[t.tier] ?? "?"} + </p> + </Link> + </li> + ); + })} + </ul> + </section> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/skills/loading.tsx b/bridge/web/src/app/workspace/skills/loading.tsx new file mode 100644 index 000000000..2a4a12d65 --- /dev/null +++ b/bridge/web/src/app/workspace/skills/loading.tsx @@ -0,0 +1,5 @@ +import { ListSkeleton } from "@/components/list-skeleton"; + +export default function Loading() { + return <ListSkeleton />; +} diff --git a/bridge/web/src/app/workspace/skills/page.tsx b/bridge/web/src/app/workspace/skills/page.tsx new file mode 100644 index 000000000..4ad400a2d --- /dev/null +++ b/bridge/web/src/app/workspace/skills/page.tsx @@ -0,0 +1,110 @@ +// kars Bridge Workspace — Skills. The USER side of the skill trust gate: upload +// a skill package, watch it move through operator review, and see which skills +// are approved + usable to assign to a task or team. Uploading proposes +// capability; an operator scans, reviews, and signs before it's grantable. + +import { PageHeader, Section } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { listUserSkills, getOptions } from "@/lib/bff"; +import type { SkillSummary, Options } from "@/lib/types"; +import { SkillUpload } from "./skill-upload"; + +export const dynamic = "force-dynamic"; + +function reviewBadge(s: SkillSummary) { + if (s.usable) return { label: "Approved · usable", cls: "border-ok/30 bg-ok/10 text-ok" }; + if (s.review === "approved") + return { label: "Approved · re-scan pending", cls: "border-warning/30 bg-warning/10 text-warning" }; + return { label: "Pending operator review", cls: "border-border bg-surface-muted text-foreground-muted" }; +} + +export default async function SkillsPage() { + let skills: SkillSummary[] = []; + let options: Options | null = null; + let error = false; + try { + [skills, options] = await Promise.all([listUserSkills(), getOptions().catch(() => null)]); + } catch { + error = true; + } + + const usable = skills.filter((s) => s.usable); + const pending = skills.filter((s) => !s.usable); + + return ( + <div className="space-y-6"> + <PageHeader + eyebrow="Workspace" + title="Skills" + lead="Capability packages your agents can be granted. Upload a skill and it goes to an operator to scan, review, and sign — once approved and locked to its version, it's usable to assign to a task or team. You propose; the operator vets." + /> + + {error ? ( + <HonestState + variant="not_wired" + title="Skills are unavailable" + detail="The run environment isn't reachable right now. Try again shortly." + /> + ) : ( + <> + <SkillUpload toolPolicies={options?.tool_policies ?? []} /> + + <Section title="Approved skills" subtitle={`${usable.length} usable — signed + locked to a version.`}> + {usable.length === 0 ? ( + <HonestState + variant="empty" + compact + title="No approved skills yet" + detail="Upload a skill above; once an operator signs it, it appears here ready to assign." + /> + ) : ( + <ul className="divide-y divide-border"> + {usable.map((s) => ( + <SkillRow key={s.name} s={s} /> + ))} + </ul> + )} + </Section> + + <Section title="In review" subtitle={`${pending.length} awaiting the operator trust gate.`}> + {pending.length === 0 ? ( + <HonestState variant="empty" compact title="Nothing in review" detail="Uploaded skills waiting on an operator show here." /> + ) : ( + <ul className="divide-y divide-border"> + {pending.map((s) => ( + <SkillRow key={s.name} s={s} /> + ))} + </ul> + )} + </Section> + </> + )} + </div> + ); +} + +function SkillRow({ s }: { s: SkillSummary }) { + const badge = reviewBadge(s); + const displayName = (s.spec?.displayName as string | undefined) ?? s.name; + return ( + <li className="flex items-start justify-between gap-3 py-3"> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <p className="truncate text-sm font-medium">{displayName}</p> + {s.version && <span className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[10px] text-foreground-muted">v{s.version}</span>} + </div> + {s.summary && <p className="mt-0.5 truncate text-xs text-foreground-muted">{s.summary}</p>} + <div className="mt-1 flex flex-wrap gap-1.5 text-[10px] text-foreground-muted"> + {s.bounding_policy && <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5">bounded by {s.bounding_policy}</span>} + {s.version_digest ? ( + <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5 font-mono">scanned {s.version_digest.slice(0, 19)}…</span> + ) : ( + <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5">not scanned yet</span> + )} + {s.approved_by && <span className="rounded border border-border bg-surface-muted px-1.5 py-0.5">signed by {s.approved_by}</span>} + </div> + </div> + <span className={`mt-0.5 shrink-0 whitespace-nowrap rounded-full border px-2.5 py-0.5 text-xs font-medium ${badge.cls}`}>{badge.label}</span> + </li> + ); +} diff --git a/bridge/web/src/app/workspace/skills/skill-actions.ts b/bridge/web/src/app/workspace/skills/skill-actions.ts new file mode 100644 index 000000000..5b9b133ba --- /dev/null +++ b/bridge/web/src/app/workspace/skills/skill-actions.ts @@ -0,0 +1,23 @@ +"use server"; + +// kars Bridge Workspace — user skill submission (server action). A team member +// uploads a skill package; it lands as a PENDING KarsSkill for the operator to +// scan, review, and sign. Never marks a skill approved — that's the operator's +// trust gate. + +import { submitSkill, type SubmitSkillInput } from "@/lib/bff"; +import { operatorIdentity } from "@/lib/config"; +import { revalidatePath } from "next/cache"; + +export type SubmitSkillResult = { ok: true } | { ok: false; error: string }; + +export async function submitSkillAction(input: SubmitSkillInput): Promise<SubmitSkillResult> { + try { + await submitSkill({ ...input, uploaded_by: input.uploaded_by ?? operatorIdentity() }); + revalidatePath("/workspace/skills"); + return { ok: true }; + } catch (e) { + const msg = e instanceof Error ? e.message : "Couldn't submit the skill."; + return { ok: false, error: msg }; + } +} diff --git a/bridge/web/src/app/workspace/skills/skill-upload.tsx b/bridge/web/src/app/workspace/skills/skill-upload.tsx new file mode 100644 index 000000000..063895914 --- /dev/null +++ b/bridge/web/src/app/workspace/skills/skill-upload.tsx @@ -0,0 +1,17 @@ +"use client"; + +// kars Bridge Workspace — skill upload. Thin wrapper around the shared +// SkillComposer: submits via submitSkillAction (lands PENDING operator +// review). See @/components/skill-composer.tsx for the actual form. + +import type { RefOption } from "@/lib/types"; +import { SkillComposer, type SkillComposerInput, type SkillComposerResult } from "@/components/skill-composer"; +import { submitSkillAction } from "./skill-actions"; + +export function SkillUpload({ toolPolicies }: { toolPolicies: RefOption[] }) { + async function submit(input: SkillComposerInput): Promise<SkillComposerResult> { + const res = await submitSkillAction(input); + return res.ok ? { ok: true } : { ok: false, error: res.error }; + } + return <SkillComposer toolPolicies={toolPolicies} submit={submit} />; +} diff --git a/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts b/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts new file mode 100644 index 000000000..9a849c751 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts @@ -0,0 +1,60 @@ +// kars Bridge — team communication-channel server actions. Tokens are sent to +// the BFF (which stores them only in a K8s Secret) and never returned to the +// browser. GET/state reports enablement plus route-qualification status. +"use server"; + +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function setTeamChannel( + team: string, + channel: string, + token: string, + allowFrom?: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/channels`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ channel, token, allow_from: allowFrom ?? null }), + }, + ); + if (!res.ok) { + let message = `Enable channel failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + /* keep status message */ + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; +} + +export async function deleteTeamChannel( + team: string, + channel: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/channels/${encodeURIComponent(channel)}`, + { method: "DELETE", cache: "no-store" }, + ); + if (!res.ok) return { error: `Disable channel failed (${res.status}).` }; + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; +} diff --git a/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts b/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts new file mode 100644 index 000000000..6f07b9cb2 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts @@ -0,0 +1,34 @@ +// kars Bridge — "Delete team" server action. Permanently removes a standing +// team via the BFF (which deletes the KarsTeam and sweeps its runs, member +// sandboxes, shared memory, task backlog, and channel secret). Destructive and +// irreversible; the control gates it behind an explicit confirm. +"use server"; + +import { redirect } from "next/navigation"; +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function deleteTeam(team: string): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}`, + { method: "DELETE", cache: "no-store" }, + ); + if (!res.ok) { + let message = `Delete failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // keep status message + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath("/workspace/teams"); + redirect("/workspace/teams"); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx b/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx new file mode 100644 index 000000000..1997623c4 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx @@ -0,0 +1,63 @@ +"use client"; + +// kars Bridge — "Delete team" control. A standing team is long-lived and owns +// runs, member sandboxes, shared memory and a task backlog, so deletion is +// destructive: this gates it behind an explicit two-step confirm before calling +// the server action (which cascade-removes everything the team owns). + +import { useState, useTransition } from "react"; +import { deleteTeam } from "./delete-actions"; + +export function DeleteTeamControl({ team }: { team: string }) { + const [pending, startTransition] = useTransition(); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState<string | null>(null); + + function submit() { + setError(null); + startTransition(async () => { + const res = await deleteTeam(team); + // On success the action redirects; only an error returns here. + if (res?.error) { + setError(res.error); + setConfirming(false); + } + }); + } + + if (!confirming) { + return ( + <button + type="button" + onClick={() => setConfirming(true)} + className="rounded-lg border border-rose-500/60 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-600 hover:text-white hover:border-rose-600" + title="Permanently delete this team and everything it owns." + > + Delete team + </button> + ); + } + + return ( + <div className="flex items-center gap-2"> + <span className="text-xs text-foreground-muted">Delete team and all its work?</span> + <button + type="button" + disabled={pending} + onClick={submit} + className="rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Deleting…" : "Yes, delete"} + </button> + <button + type="button" + disabled={pending} + onClick={() => setConfirming(false)} + className="rounded-lg border border-border px-3 py-1.5 text-xs text-foreground-muted transition hover:bg-surface-muted" + > + Cancel + </button> + {error && <span className="text-xs text-rose-600">{error}</span>} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts b/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts new file mode 100644 index 000000000..42a690ce9 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts @@ -0,0 +1,94 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { + authenticatedBffFetch, + deleteEngineeringSource, + putEngineeringSource, + syncEngineeringSource, +} from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import type { EngineeringSignal, EngineeringSource } from "@/lib/types"; + +type Result = { source: EngineeringSource | null; error: string | null }; + +function message(error: unknown): string { + return error instanceof Error ? error.message : "Engineering intake request failed."; +} + +export async function configureEngineeringSource( + team: string, + body: { + enabled: boolean; + auto_run: boolean; + repos: string[]; + signals: EngineeringSignal[]; + poll_interval_seconds: number; + }, +): Promise<Result> { + try { + const source = await putEngineeringSource(defaultNamespace(), team, body); + revalidatePath(`/workspace/teams/${team}`); + return { source, error: null }; + } catch (error) { + return { source: null, error: message(error) }; + } +} + +export async function decideEngineeringReview( + team: string, + body: { + decision: "request_changes"; + repo: string; + pr_number: number; + pr_url: string; + head_sha: string; + run: string; + comment?: string; + }, +): Promise<{ error: string | null }> { + try { + const ns = defaultNamespace(); + const response = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/engineering-review`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const payload = await response.json().catch(() => null); + return { + error: + payload?.error?.message ?? + `Engineering review decision failed (${response.status}).`, + }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; + } catch (error) { + return { error: message(error) }; + } +} + +export async function runEngineeringSync(team: string): Promise<Result> { + try { + const source = await syncEngineeringSource(defaultNamespace(), team); + revalidatePath(`/workspace/teams/${team}`); + return { source, error: null }; + } catch (error) { + return { source: null, error: message(error) }; + } +} + +export async function disconnectEngineeringSource(team: string): Promise<Result> { + try { + const source = await deleteEngineeringSource(defaultNamespace(), team); + revalidatePath(`/workspace/teams/${team}`); + return { source, error: null }; + } catch (error) { + return { source: null, error: message(error) }; + } +} diff --git a/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx b/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx new file mode 100644 index 000000000..478bdeec1 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import type { + EngineeringSignal, + EngineeringSource, + GithubConnection, +} from "@/lib/types"; +import { + configureEngineeringSource, + decideEngineeringReview, + disconnectEngineeringSource, + runEngineeringSync, +} from "./engineering-actions"; + +const INTERVALS = [ + [300, "Every 5 minutes"], + [900, "Every 15 minutes"], + [1800, "Every 30 minutes"], + [3600, "Every hour"], + [21600, "Every 6 hours"], +] as const; + +const STATE_META = { + disabled: ["Disabled", "border-border bg-surface-muted text-foreground-muted"], + idle: ["Scheduled", "border-sky-500/30 bg-sky-500/10 text-sky-600"], + syncing: ["Syncing", "border-sky-500/30 bg-sky-500/10 text-sky-600"], + ok: ["Healthy", "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"], + partial: ["Partial", "border-amber-500/30 bg-amber-500/10 text-amber-600"], + error: ["Error", "border-rose-500/30 bg-rose-500/10 text-rose-600"], +} as const; + +const REVIEW_META = { + ready_for_review: ["Ready for review", "border-emerald-500/30 bg-emerald-500/10 text-emerald-600"], + waiting_for_ci: ["Waiting for GitHub CI", "border-sky-500/30 bg-sky-500/10 text-sky-600"], + ci_failed: ["CI failed", "border-rose-500/30 bg-rose-500/10 text-rose-600"], + blocked: ["Blocked", "border-amber-500/30 bg-amber-500/10 text-amber-600"], + unknown: ["Unverified", "border-border bg-surface-muted text-foreground-muted"], +} as const; + +function when(value: string | null): string { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +export function EngineeringIntake({ + team, + initialSource, + connection, +}: { + team: string; + initialSource: EngineeringSource | null; + connection: GithubConnection | null; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [source, setSource] = useState(initialSource); + const [enabled, setEnabled] = useState(initialSource?.enabled ?? false); + const [autoRun, setAutoRun] = useState(initialSource?.auto_run ?? true); + const [repos, setRepos] = useState<Set<string>>( + new Set(initialSource?.repos ?? []), + ); + const [dependabot, setDependabot] = useState( + initialSource?.configured + ? initialSource.signals.includes("dependabot_pr") + : true, + ); + const [dependabotAlerts, setDependabotAlerts] = useState( + initialSource?.signals.includes("dependabot_alert") ?? false, + ); + const [codeScanning, setCodeScanning] = useState( + initialSource?.signals.includes("code_scanning_alert") ?? false, + ); + const [secretScanning, setSecretScanning] = useState( + initialSource?.signals.includes("secret_scanning_alert") ?? false, + ); + const [interval, setInterval] = useState( + initialSource?.poll_interval_seconds ?? 900, + ); + const [error, setError] = useState<string | null>(null); + const [feedbackRun, setFeedbackRun] = useState<string | null>(null); + const [feedback, setFeedback] = useState(""); + + const noLongerAuthorized = connection?.connected + ? source?.repos.filter((repo) => !connection.repos.includes(repo)) ?? [] + : []; + const status = source?.status; + const meta = STATE_META[status?.state ?? "disabled"]; + + function toggleRepo(repo: string) { + setRepos((current) => { + const next = new Set(current); + if (next.has(repo)) next.delete(repo); + else next.add(repo); + return next; + }); + } + + function save() { + setError(null); + startTransition(async () => { + const signals: EngineeringSignal[] = [ + ...(dependabot ? (["dependabot_pr"] as const) : []), + ...(dependabotAlerts ? (["dependabot_alert"] as const) : []), + ...(codeScanning ? (["code_scanning_alert"] as const) : []), + ...(secretScanning ? (["secret_scanning_alert"] as const) : []), + ]; + const result = await configureEngineeringSource(team, { + enabled, + auto_run: autoRun, + repos: [...repos], + signals, + poll_interval_seconds: interval, + }); + if (result.error) { + setError(result.error); + return; + } + setSource(result.source); + router.refresh(); + }); + } + + function sync() { + setError(null); + startTransition(async () => { + const result = await runEngineeringSync(team); + if (result.error) { + setError(result.error); + return; + } + setSource(result.source); + router.refresh(); + }); + } + + function disconnect() { + setError(null); + startTransition(async () => { + const result = await disconnectEngineeringSource(team); + if (result.error) { + setError(result.error); + return; + } + setSource(result.source); + setEnabled(false); + setRepos(new Set()); + router.refresh(); + }); + } + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Engineering intake</h2> + <p className="mt-0.5 max-w-3xl text-xs text-foreground-muted"> + Continuously discover actionable work in repositories from your existing GitHub App + connection and queue it in this team's durable backlog. Agents propose changes; + current checks must still provide CI evidence before anyone claims success. + </p> + </div> + <span className={`rounded-full border px-2.5 py-1 text-xs font-medium ${meta[1]}`}> + {meta[0]} + </span> + </div> + + <div className="mt-4 rounded-lg border border-border bg-surface-muted/20 p-4"> + <p className="text-xs font-semibold">How one engineering item moves through kars</p> + <ol className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-6"> + {[ + ["1", "Intake", "GitHub signal is discovered."], + ["2", "Backlog", "A durable team task is queued."], + ["3", "Run", "The team mints one governed task force."], + ["4", "Agents", "Selected roles work and hand back evidence."], + ["5", "Result", "One principal deliverable plus role artifacts land."], + ["6", "Readiness", "GitHub CI and mergeability are observed."], + ].map(([step, label, detail]) => ( + <li key={step} className="rounded-md border border-border bg-surface px-3 py-2"> + <p className="text-[10px] font-semibold uppercase tracking-wide text-signal"> + {step} · {label} + </p> + <p className="mt-1 text-[11px] leading-relaxed text-foreground-muted">{detail}</p> + </li> + ))} + </ol> + <p className="mt-3 text-[11px] text-foreground-muted"> + Activity is the live timeline inside a run. Artifacts are files produced by individual + agents. The deliverable is the principal's final synthesis. Engineering intake owns + discovery and GitHub readiness; it links to the exact run that performed the work. + </p> + </div> + + {initialSource === null ? ( + <p className="mt-4 rounded-lg border border-warning/30 bg-warning/[0.05] p-3 text-xs text-foreground-muted"> + Engineering intake status is unavailable while the Bridge integration store cannot be + reached. + </p> + ) : ( + <> + {!connection?.connected && ( + <p className="mt-4 rounded-lg border border-border bg-surface-muted/30 p-3 text-xs text-foreground-muted"> + Connect the shared GitHub App for your user to configure or sync repositories. This + team never asks for or exposes a token; you can still inspect status or disconnect + this intake source. + </p> + )} + <div className="mt-4 grid gap-4 lg:grid-cols-[1.4fr_1fr]"> + <div className="rounded-lg border border-border bg-surface-muted/20 p-4"> + <p className="text-xs font-medium">Authorized repositories</p> + {connection?.account && ( + <p className="mt-0.5 text-[11px] text-foreground-muted"> + Connected as <span className="font-mono">{connection.account}</span> + </p> + )} + {!connection?.connected ? ( + <p className="mt-3 text-xs text-foreground-muted"> + No connected repository list is available for this user. + </p> + ) : connection.repos.length === 0 ? ( + <p className="mt-3 text-xs text-foreground-muted"> + Your installation currently grants no repositories. Update it on GitHub, then + re-sync the connection. + </p> + ) : ( + <div className="mt-3 grid gap-2 sm:grid-cols-2"> + {connection.repos.map((repo) => ( + <label key={repo} className="flex cursor-pointer items-center gap-2 text-xs"> + <input + type="checkbox" + checked={repos.has(repo)} + onChange={() => toggleRepo(repo)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + <span className="font-mono">{repo}</span> + </label> + ))} + </div> + )} + {noLongerAuthorized.length > 0 && ( + <p className="mt-3 text-[11px] text-warning"> + No longer authorized: {noLongerAuthorized.join(", ")}. Save a valid selection + before the next sync. + </p> + )} + </div> + + <div className="space-y-3 rounded-lg border border-border bg-surface-muted/20 p-4"> + <label className="flex items-center gap-2 text-xs font-medium"> + <input + type="checkbox" + checked={enabled} + onChange={(event) => setEnabled(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Enable continuous intake + </label> + <label className="flex items-center gap-2 text-xs font-medium"> + <input + type="checkbox" + checked={autoRun} + onChange={(event) => setAutoRun(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Start the team automatically when new work is queued + </label> + <label className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={dependabot} + onChange={(event) => setDependabot(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Dependabot pull requests + </label> + <label className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={dependabotAlerts} + onChange={(event) => setDependabotAlerts(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Dependabot vulnerability alerts + </label> + <label className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={codeScanning} + onChange={(event) => setCodeScanning(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Code scanning / code-quality alerts + </label> + <label className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={secretScanning} + onChange={(event) => setSecretScanning(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Secret scanning alerts + </label> + <p className="text-[11px] leading-relaxed text-foreground-muted"> + Security signals require the GitHub App's corresponding read permission and + the repository feature to be enabled. Missing permissions or unavailable features + show as Partial/Unavailable; they are never reported as zero findings. + </p> + <label className="block text-xs"> + <span className="font-medium">Poll interval</span> + <select + value={interval} + onChange={(event) => setInterval(Number(event.target.value))} + className="mt-1 block w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs" + > + {INTERVALS.map(([seconds, label]) => ( + <option key={seconds} value={seconds}> + {label} + </option> + ))} + </select> + </label> + </div> + </div> + + <dl className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-9"> + <div> + <dt className="text-[11px] text-foreground-muted">Last sync</dt> + <dd className="mt-0.5 text-xs">{when(status?.last_sync_at ?? null)}</dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Last success</dt> + <dd className="mt-0.5 text-xs">{when(status?.last_success_at ?? null)}</dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Discovered</dt> + <dd className="mt-0.5 text-xs tabular-nums"> + {status?.items_discovered ?? 0} + </dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Newly queued</dt> + <dd className="mt-0.5 text-xs tabular-nums">{status?.items_queued ?? 0}</dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Total queued</dt> + <dd className="mt-0.5 text-xs tabular-nums"> + {status?.total_items_queued ?? 0} + </dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Next poll</dt> + <dd className="mt-0.5 text-xs">{when(status?.next_poll_at ?? null)}</dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Ready for review</dt> + <dd className="mt-0.5 text-xs tabular-nums text-emerald-600"> + {status?.ready_for_review ?? 0} + </dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Waiting for CI</dt> + <dd className="mt-0.5 text-xs tabular-nums">{status?.waiting_for_ci ?? 0}</dd> + </div> + <div> + <dt className="text-[11px] text-foreground-muted">Red / blocked</dt> + <dd className="mt-0.5 text-xs tabular-nums text-rose-600"> + {status?.ci_failed ?? 0} + </dd> + </div> + </dl> + + {status?.last_error && ( + <p className="mt-3 rounded-lg border border-rose-500/30 bg-rose-500/[0.05] p-3 text-xs text-rose-600"> + {status.last_error} + </p> + )} + + {(status?.signal_results ?? []).length > 0 && ( + <div className="mt-4"> + <h3 className="text-xs font-semibold">Source coverage</h3> + <p className="mt-0.5 text-[11px] text-foreground-muted"> + One row per repository and selected signal. This is the honest answer to what was + actually scanned during the last sync. + </p> + <ul className="mt-2 grid gap-2 md:grid-cols-2"> + {(status?.signal_results ?? []).map((result) => ( + <li + key={`${result.repo}:${result.signal}`} + className="rounded-md border border-border bg-surface-muted/20 px-3 py-2" + > + <div className="flex items-start justify-between gap-2"> + <div> + <p className="font-mono text-[10px]">{result.repo}</p> + <p className="mt-0.5 text-xs font-medium"> + {result.signal.replaceAll("_", " ")} + </p> + </div> + <span + className={`rounded-full border px-2 py-0.5 text-[10px] ${ + result.state === "ok" + ? "border-emerald-500/30 text-emerald-600" + : result.state === "unavailable" + ? "border-border bg-surface-muted text-foreground-muted" + : result.state === "truncated" + ? "border-amber-500/30 text-amber-600" + : "border-rose-500/30 text-rose-600" + }`} + > + {result.state} + </span> + </div> + <p className="mt-1 text-[11px] text-foreground-muted">{result.detail}</p> + </li> + ))} + </ul> + </div> + )} + + {(status?.review_items ?? []).length > 0 && ( + <div className="mt-4"> + <div> + <h3 className="text-xs font-semibold">Engineering work items</h3> + <p className="mt-0.5 text-[11px] text-foreground-muted"> + Each card joins the intake source to its backlog task, exact run, agent + handbacks, deliverable/artifacts, and GitHub-observed readiness. + </p> + </div> + <ul className="mt-3 space-y-2"> + {(status?.review_items ?? []).map((item) => { + const reviewMeta = REVIEW_META[item.state]; + return ( + <li key={`${item.repo}#${item.pr_number}`} className="rounded-lg border border-border bg-surface-muted/20 p-3"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="min-w-0"> + <a href={item.pr_url} target="_blank" rel="noreferrer" className="text-xs font-semibold text-signal hover:underline"> + {item.repo} #{item.pr_number} · {item.title} ↗ + </a> + <p className="mt-1 text-[11px] text-foreground-muted">{item.detail}</p> + <div className="mt-2 flex flex-wrap gap-1.5 text-[10px]"> + <span className="rounded-full border border-border px-2 py-0.5"> + backlog {item.task_status || "unknown"} + </span> + <span className="rounded-full border border-border px-2 py-0.5"> + run {item.run_state?.toLowerCase() ?? "unknown"} + </span> + <span className="rounded-full border border-border px-2 py-0.5"> + agents {item.delivered_roles.length}/{item.selected_roles.length} delivered + </span> + <span className="rounded-full border border-border px-2 py-0.5"> + {item.artifact_count == null + ? "artifacts unavailable" + : `${item.artifact_count} artifact${item.artifact_count === 1 ? "" : "s"}`} + </span> + </div> + {item.selected_roles.length > 0 && ( + <p className="mt-2 text-[11px] text-foreground-muted"> + Agents: {item.selected_roles.map((role) => ( + <span key={role} className="mr-1.5 inline-flex items-center gap-1"> + <span aria-hidden>{item.delivered_roles.includes(role) ? "✓" : "○"}</span> + <span className="font-mono">{role}</span> + </span> + ))} + </p> + )} + <p className="mt-1 font-mono text-[10px] text-foreground-muted"> + {item.checks_passed}/{item.checks_total} checks · {item.head_sha.slice(0, 12)} · run {item.run} + </p> + <div className="mt-2 flex flex-wrap gap-3 text-[11px] font-medium"> + <Link + href={`/workspace/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(item.run)}?tab=activity`} + className="text-signal hover:underline" + > + Open agent activity → + </Link> + <Link + href={`/workspace/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(item.run)}?tab=deliverables`} + className="text-signal hover:underline" + > + Open deliverable & artifacts → + </Link> + </div> + <div className="mt-3 border-t border-border pt-3"> + {feedbackRun === item.run ? ( + <div className="space-y-2"> + <textarea + value={feedback} + onChange={(event) => setFeedback(event.target.value)} + rows={2} + placeholder="Describe exactly what the principal must change before this PR can be reviewed again." + className="w-full rounded-md border border-border bg-surface px-2.5 py-2 text-xs outline-none focus:border-signal" + /> + <div className="flex gap-2"> + <button + type="button" + disabled={pending || !feedback.trim()} + onClick={() => { + setError(null); + startTransition(async () => { + const result = await decideEngineeringReview(team, { + decision: "request_changes", + repo: item.repo, + pr_number: item.pr_number, + pr_url: item.pr_url, + head_sha: item.head_sha, + run: item.run, + comment: feedback.trim(), + }); + if (result.error) { + setError(result.error); + return; + } + setFeedback(""); + setFeedbackRun(null); + router.refresh(); + }); + }} + className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-signal-fg disabled:opacity-50" + > + Queue changes & run team + </button> + <button + type="button" + onClick={() => { + setFeedback(""); + setFeedbackRun(null); + }} + className="rounded-md border border-border px-2.5 py-1 text-[11px]" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="flex flex-wrap gap-2"> + <button + type="button" + onClick={() => setFeedbackRun(item.run)} + className="rounded-md border border-border px-2.5 py-1 text-[11px] font-medium" + > + Request changes + </button> + <a + href={item.pr_url} + target="_blank" + rel="noreferrer" + className="rounded-md border border-emerald-500/40 px-2.5 py-1 text-[11px] font-medium text-emerald-600" + > + Review / merge in GitHub ↗ + </a> + </div> + )} + <p className="mt-2 text-[10px] text-foreground-muted"> + Bridge can queue review feedback today. Principal merge authorization + remains disabled until it is a typed, single-use grant bound to this + repository, PR, and exact head SHA. + </p> + </div> + </div> + <span className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${reviewMeta[1]}`}> + {reviewMeta[0]} + </span> + </div> + </li> + ); + })} + </ul> + </div> + )} + + <div className="mt-4 flex flex-wrap items-center gap-2"> + <button + type="button" + onClick={save} + disabled={ + pending || + !connection?.connected || + (enabled && + (repos.size === 0 || + (!dependabot && !dependabotAlerts && !codeScanning && !secretScanning))) + } + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg disabled:opacity-50" + > + {pending ? "Working…" : source?.configured ? "Save configuration" : "Configure"} + </button> + <button + type="button" + onClick={sync} + disabled={ + pending || + !connection?.connected || + !source?.configured || + !source.enabled + } + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium disabled:opacity-50" + > + Sync now + </button> + {source?.configured && ( + <button + type="button" + onClick={disconnect} + disabled={pending} + className="rounded-lg border border-rose-500/30 px-3 py-1.5 text-xs font-medium text-rose-600 disabled:opacity-50" + > + Disconnect intake + </button> + )} + </div> + </> + )} + {error && <p className="mt-3 text-xs text-rose-600">{error}</p>} + </section> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/page.tsx new file mode 100644 index 000000000..74ef21029 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/page.tsx @@ -0,0 +1,969 @@ +// kars Bridge Workspace — Team detail. The standing org's command surface: +// its charter, who it watches and on what cadence, its org chart (principal + +// roster, each a verified subset of the team's authority), and the live +// history of task-force runs the charter loop has generated autonomously. + +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { HonestState } from "@/components/honest-state"; +import { TeamEdit } from "./team-edit"; +import { LiveRefresh, LivePulse } from "@/components/live-refresh"; +import { EnvelopeDigest } from "@/components/envelope-digest"; +import { + getEngineeringSource, + getGithubConnection, + getOptions, + getTask, + getTeam, + getTeamChannels, + getTeamCommons, + getTeamLedger, + listTaskApprovals, +} from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import { humanizeMcp } from "@/lib/format"; +import { formatWarmIdle } from "@/lib/format"; +import { + TIER_LABELS, + type CommonsResponse, + type EngineeringSource, + type GithubConnection, + type LedgerEvent, + type TeamChannelsState, + type TeamDetail, +} from "@/lib/types"; +import { WatchingStatus } from "./watching-status"; +import { TeamLedger } from "./team-ledger"; +import { PromoteControl } from "./promote-control"; +import { RunNowControl } from "./run-control"; +import { DeleteTeamControl } from "./delete-control"; +import { TeamTasks } from "./team-tasks"; +import { TeamChannels } from "./team-channels"; +import { DeliverableBody, toPlainPreview } from "@/components/deliverable-view"; +import { TeamRosterEdit } from "./team-roster-edit"; +import { OrgTree } from "@/components/org-tree"; +import { DeployTimeline } from "@/app/workspace/missions/[name]/deploy-timeline"; +import { JourneyRail, teamBeat } from "@/components/journey-rail"; +import { analyzeTeamRun } from "@/lib/team-run-evidence"; +import { TeamRunFlow } from "@/components/team-run-flow"; +import { ExecutionExplorer } from "@/components/execution-explorer"; +import type { ReactNode } from "react"; +import { EngineeringIntake } from "./engineering-intake"; +import { TeamTiming } from "@/components/team-timing"; +import { + RecentTeamOutcomes, + TeamRunHistory, + TeamValueSummary, +} from "./team-outcomes"; + +export const dynamic = "force-dynamic"; + +function healthLabel(health: string): string { + return health === "AwaitingReview" ? "Awaiting review" : health; +} + +function HealthChip({ health }: { health: string }) { + const tone: Record<string, string> = { + Healthy: "bg-emerald-500/10 text-emerald-600 border-emerald-500/30", + Watching: "bg-sky-500/10 text-sky-600 border-sky-500/30", + AwaitingReview: "bg-amber-500/10 text-amber-600 border-amber-500/30", + Unproductive: "bg-amber-500/10 text-amber-600 border-amber-500/30", + Stalled: "bg-rose-500/10 text-rose-600 border-rose-500/30", + Hibernating: "bg-surface-muted text-foreground-muted border-border", + }; + const cls = tone[health ?? ""] ?? "bg-surface-muted text-foreground-muted border-border"; + return ( + <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${cls}`}> + {healthLabel(health)} + </span> + ); +} + +export default async function TeamDetailPage({ + params, + searchParams, +}: { + params: Promise<{ name: string }>; + searchParams: Promise<{ tab?: string }>; +}) { + const { name } = await params; + const { tab } = await searchParams; + const ns = defaultNamespace(); + let team: TeamDetail; + try { + team = await getTeam(ns, name); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("not_found") || msg.includes("404")) notFound(); + return ( + <HonestState + variant="not_wired" + title="This team is unavailable" + detail="The run environment isn't reachable right now. Try again shortly." + /> + ); + } + + const active = !team.paused && team.phase === "Active"; + const runtimeWorking = !team.paused && team.runtime_state === "Working"; + const latestRun = + (team.current_assignment_nonce ? team.principal_task : null) + ?? [...team.generated_tasks].sort().reverse()[0]; + + // The latest task-force run's live detail — so the team page can fold out the + // SAME "watch it work" experience a mission gets: the deploy timeline, the + // auto-folding agent graph, and the live per-round / per-tool activity feed + // for the run executing right now. "In flight" = launched and not yet + // delivered (materializing OR running); "running" once the agent works. + const latestRunTask = latestRun + ? await getTask(ns, latestRun).catch(() => null) + : null; + const allLatestRunApprovals = latestRun + ? await listTaskApprovals(ns, latestRun).catch(() => []) + : []; + const latestRunApprovals = allLatestRunApprovals.filter( + (approval) => + approval.run_nonce == null + || latestRunTask?.current_run_nonce == null + || approval.run_nonce === latestRunTask.current_run_nonce, + ); + const awaitingAssignment = Boolean( + latestRunTask?.current_run_nonce + && latestRunTask.assignment?.task_id !== latestRunTask.current_run_nonce, + ); + const assignmentInFlight = Boolean( + latestRunTask?.assignment?.completed_at == null + && ( + latestRunTask?.assignment?.state === "Assigned" + || latestRunTask?.assignment?.state === "Running" + ), + ); + const runInFlight = Boolean( + latestRunTask + && latestRunTask.launched + && !team.paused + && (awaitingAssignment || assignmentInFlight), + ); + const runRunning = Boolean( + runInFlight && latestRunTask?.execution_phase === "Running", + ); + // Creation redirects here before the team reconciler necessarily stamps + // phase=Active. Poll any unpaused team with no run yet so the kickoff appears + // without a manual refresh even while admission is still converging. + const awaitingFirstRun = !team.paused && team.generated_task_count === 0; + const runActivity = latestRunTask?.activity ?? []; + const hasRunActivity = runActivity.length > 0; + const latestRunEvidence = latestRunTask ? analyzeTeamRun(team, latestRunTask) : null; + + let commons: CommonsResponse | null = null; + try { + commons = await getTeamCommons(ns, name); + } catch { + commons = null; + } + + let ledger: LedgerEvent[] = []; + try { + ledger = await getTeamLedger(ns, name); + } catch { + ledger = []; + } + + let options = null; + try { + options = await getOptions(); + } catch { + options = null; + } + + let engineeringSource: EngineeringSource | null = null; + let githubConnection: GithubConnection | null = null; + let teamChannels: TeamChannelsState | null = null; + const [sourceResult, connectionResult, channelsResult] = await Promise.allSettled([ + getEngineeringSource(ns, name), + getGithubConnection(ns), + getTeamChannels(ns, name), + ]); + if (sourceResult.status === "fulfilled") engineeringSource = sourceResult.value; + if (connectionResult.status === "fulfilled") githubConnection = connectionResult.value; + if (channelsResult.status === "fulfilled") teamChannels = channelsResult.value; + const queuedTasks = team.tasks.filter((task) => task.status === "pending").length; + const activeTasks = team.tasks.filter((task) => task.status === "active").length; + const awaitingReview = team.health === "AwaitingReview"; + + return ( + <div className="space-y-6"> + {/* Refresh aggressively only while a task-force run is changing. Refreshing + an idle standing team every five seconds resets open edit forms and makes + the launch package effectively uneditable. */} + <LiveRefresh active={runInFlight || runtimeWorking || awaitingFirstRun} intervalMs={5000} /> + + <div className="flex items-start justify-between gap-4"> + <div> + <div className="flex items-center gap-3"> + <Link href="/workspace/teams" className="text-xs text-foreground-muted hover:underline"> + ← Teams + </Link> + {active && <LivePulse label={awaitingReview ? "Awaiting review" : "On watch"} />} + </div> + <h1 className="mt-2 text-2xl font-semibold tracking-tight"> + {team.display_name ?? team.name} + </h1> + {team.reporting_to && ( + <p className="mt-1 text-sm text-foreground-muted">Reports to {team.reporting_to}</p> + )} + </div> + <div className="flex shrink-0 items-center gap-2"> + <RunNowControl team={team.name} paused={team.paused} inFlight={runInFlight} /> + <DeleteTeamControl team={team.name} /> + </div> + </div> + + {/* Journey spine — same seven beats as a mission; a standing team lives + mostly in Run, cycling through Build->Run on each cadence tick. */} + <JourneyRail + current={teamBeat({ paused: team.paused, everRan: team.generated_task_count > 0 })} + paused={team.paused && team.generated_task_count > 0} + /> + + {/* "Now" hero — the single answer to "what is this team doing right now". */} + <NowHero + teamName={team.name} + health={team.paused ? "Hibernating" : team.health} + active={active} + runRunning={runRunning} + runInFlight={runInFlight} + everyMinutes={team.every_minutes ?? null} + commonsEntries={team.commons_entry_count} + nextRunAt={team.next_run_at} + lastRunAt={team.last_run_at} + delivered={team.runs_succeeded} + generated={team.generated_task_count} + latestRun={latestRun} + latestOutcome={latestRunEvidence?.outcome ?? null} + lifecycleMode={team.lifecycle_mode} + runtimeState={team.runtime_state} + idleDeadlineAt={team.idle_deadline_at} + /> + <TeamTiming + lastActivityAt={team.last_activity_at} + nextActivityAt={team.paused || awaitingReview ? null : team.next_run_at} + paused={team.paused} + /> + + {/* Budget stop — a standing team whose daily/monthly cap is exhausted mints + no new runs until the cap is raised. Surface it as an actionable state, + not a silent stall the operator has to infer from "no recent runs". */} + {!team.paused && /budget/i.test(team.detail ?? "") && /(exhaust|exceeded|cap)/i.test(team.detail ?? "") && ( + <div className="rounded-xl border border-warning/50 bg-warning/[0.07] px-4 py-3"> + <div className="flex items-start gap-2"> + <span aria-hidden className="text-warning">⏸</span> + <div className="min-w-0"> + <p className="text-sm font-medium">Team paused on budget — no new runs until the cap is raised.</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + {team.detail} Raise the team's token budget in Edit, inspect spend in the ledger below, or + pause the team if this is expected. + </p> + </div> + </div> + </div> + )} + + <TeamServerTabs + active={tab ?? (latestRunTask && hasRunActivity ? "activity" : "overview")} + basePath={`/workspace/teams/${encodeURIComponent(team.name)}`} + tabs={[ + ...(latestRun && latestRunTask && (runInFlight || hasRunActivity) + ? [ + { + id: "activity", + label: "Execution flow", + live: runInFlight, + badge: latestRunEvidence + ? latestRunEvidence.collaboration.length + latestRunEvidence.research.length + : hasRunActivity ? runActivity.length : null, + node: ( + <div className="space-y-4"> + <ExecutionExplorer + running={runInFlight} + activity={latestRunTask.activity} + telemetry={latestRunTask.telemetry} + assignmentEvents={latestRunTask.assignment_events} + approvals={latestRunApprovals} + ns={ns} + name={latestRun} + agentLabel={team.display_name ?? team.name} + agentPhase={ + awaitingAssignment + ? "Launching" + : team.paused + ? "Hibernating" + : latestRunTask.assignment?.state ?? latestRunTask.execution_phase ?? latestRunTask.phase + } + agentRuntime={latestRunTask.composition?.runtime} + agentModel={latestRunTask.composition?.model} + subAgents={latestRunTask.sub_agents} + identity={latestRunTask.agent_identity} + envelopeDigest={latestRunTask.envelope_digest} + /> + <div className="rounded-xl border border-border bg-surface-muted/40 px-4 py-3 text-sm"> + <span className="text-foreground-muted"> + {runInFlight + ? "The team's current task-force run, live — watch it deploy and work, then read the deliverable in Runs." + : "The team's most recent task-force run."} + </span>{" "} + <Link + href={`/workspace/teams/${encodeURIComponent(team.name)}/runs/${encodeURIComponent(latestRun)}`} + className="font-medium text-signal hover:underline" + > + Open the full run → + </Link> + </div> + {runInFlight && <DeployTimeline task={latestRunTask} />} + {latestRunEvidence && ( + <TeamRunFlow task={latestRunTask} evidence={latestRunEvidence} /> + )} + </div> + ), + }, + ] + : []), + { + id: "overview", + label: "Overview", + node: ( + <div className="space-y-6"> + <TeamValueSummary + summary={team.recent_outcome_summary} + generated={team.generated_task_count} + retained={team.recent_outcomes.length} + queued={queuedTasks} + active={activeTasks} + tokens={team.tokens_spent_total} + /> + <RecentTeamOutcomes team={team.name} outcomes={team.recent_outcomes} /> + {/* Charter + watching status side by side */} + <div className="grid gap-4 md:grid-cols-3"> + <section className="rounded-xl border border-border bg-surface p-5 md:col-span-2"> + <div className="flex items-start justify-between gap-3"> + <h2 className="text-sm font-semibold">Charter</h2> + {team.health && <HealthChip health={team.paused ? "Hibernating" : team.health} />} + </div> + <p className="mt-2 text-sm leading-relaxed text-foreground">{team.charter}</p> + <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-foreground-muted"> + <span> + Tier {team.tier} · {TIER_LABELS[team.tier] ?? "?"} + </span> + <span>Grants members up to Tier {team.authority_ceiling}</span> + <span>Delegation depth {team.delegation_depth}</span> + <span> + Runtime{" "} + {team.lifecycle_mode === "resourceOptimized" + ? `resource optimized · ${formatWarmIdle(team.warm_idle_seconds)}` + : team.lifecycle_mode} + </span> + </div> + <div className="mt-4"> + <TeamEdit + ns={ns} + name={team.name} + charter={team.charter} + paused={team.paused} + everyMinutes={team.every_minutes} + lifecycleMode={team.lifecycle_mode} + warmIdleSeconds={team.warm_idle_seconds} + mcpServers={team.mcp_servers} + availableMcp={(options?.mcp_servers ?? []).filter((server) => server.namespace === ns)} + egress={team.egress} + egressMode={team.egress_mode} + model={team.model} + modelFallbacks={team.model_fallbacks} + models={options?.models ?? []} + memory={team.memory} + availableMemories={(options?.memories ?? []).filter((memory) => memory.namespace === ns)} + gitWriteRepos={team.git_write_repos} + executionPlan={team.execution_plan} + /> + </div> + </section> + <WatchingStatus + nextRunAt={team.next_run_at} + lastRunAt={team.last_run_at} + everyMinutes={team.every_minutes} + lifecycleMode={team.lifecycle_mode} + warmIdleSeconds={team.warm_idle_seconds} + runtimeState={team.runtime_state} + currentAssignment={team.current_assignment_task ?? team.current_assignment_nonce} + idleDeadlineAt={team.idle_deadline_at} + memoryEntries={team.commons_entry_count} + paused={team.paused} + health={team.health} + /> + </div> + + </div> + ), + }, + { + id: "work", + label: "Work queue", + badge: queuedTasks + activeTasks, + node: ( + <div className="space-y-6"> + <EngineeringIntake + team={team.name} + initialSource={engineeringSource} + connection={githubConnection} + /> + <TeamTasks team={team.name} tasks={team.tasks} paused={team.paused} /> + </div> + ), + }, + { + id: "org", + label: "Org & access", + badge: team.roster.length, + node: ( + <div className="space-y-6"> + {/* Accesses — what this team can use and reach, for management at a glance. */} + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Accesses</h2> + <p className="mt-0.5 text-xs text-foreground-muted">What the team is allowed to use and reach. Members get a verified subset; nothing wider. Values marked <span className="font-medium">default</span> are inherited from the cluster, not explicitly set on this team.</p> + <dl className="mt-4 grid gap-3 sm:grid-cols-2"> + <Access label="Model" value={team.model ?? "controller default"} isDefault={team.model_default} /> + <Access label="Harness" value={team.runtime ?? "OpenClaw"} isDefault={team.runtime_default} /> + <Access + label="Runtime lifecycle" + value={ + team.lifecycle_mode === "resourceOptimized" + ? `resource optimized · suspends ${formatWarmIdle(team.warm_idle_seconds) === "immediately" ? "immediately when idle" : `after ${formatWarmIdle(team.warm_idle_seconds)} idle`}` + : team.lifecycle_mode + } + isDefault={team.lifecycle_mode === "ephemeral"} + /> + <Access label="Isolation" value={team.isolation ?? "standard"} isDefault={team.isolation === null} /> + <Access label="Tool policy" value={team.tool_policy ?? "none — model only"} isDefault={team.tool_policy_default} /> + <Access label="Shared memory" value={team.knowledge_commons ?? `${team.name} (default)`} isDefault={team.knowledge_commons === null} /> + <Access label="Connected services (MCP)" value={team.mcp_servers.length ? team.mcp_servers.map(humanizeMcp).join(", ") : "none connected"} isDefault={team.mcp_servers.length === 0} /> + <Access + label="Network egress" + value={team.egress.length ? team.egress.join(", ") : team.network_posture} + isDefault={team.egress.length === 0} + /> + {team.learned_egress.length > 0 && ( + <Access + label="Domains reached (live)" + value={team.learned_egress.join(", ")} + /> + )} + <Access + label="Reports via" + value={team.channels.length ? team.channels.join(", ") : "no channels — Inbox only"} + isDefault={team.channels.length === 0} + /> + </dl> + </section> + + <TeamChannels + team={team.name} + enabled={teamChannels?.enabled ?? team.channels} + statuses={teamChannels?.statuses ?? []} + /> + + {/* Org chart */} + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Org chart</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The standing org. Each member's authority is a verified subset of the team's — + the reporting line is the trust boundary. + </p> + </div> + <div className="flex shrink-0 items-center gap-2"> + {options ? ( + <TeamRosterEdit + ns={ns} + name={team.name} + roster={team.roster} + options={{ + ...options, + skills: options.skills.filter((skill) => skill.namespace === ns), + mcp_servers: options.mcp_servers.filter((server) => server.namespace === ns), + memories: options.memories.filter((memory) => memory.namespace === ns), + }} + /> + ) : ( + <button + type="button" + disabled + title="Org editing is unavailable — the run environment is unreachable right now." + className="cursor-not-allowed rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted opacity-60" + > + Edit org + </button> + )} + {!team.paused && <PromoteControl team={team.name} currentTier={team.tier} />} + </div> + </div> + <div className="mt-4"> + <OrgTree + principal={{ + id: "principal", + title: team.display_name ?? team.name, + role: `Principal · team lead · Tier ${team.tier} · grants up to Tier ${team.authority_ceiling}`, + status: "principal", + chips: [ + { + label: team.runtime ?? "OpenClaw", + tone: "accent", + }, + { + label: team.model ?? "cluster default model", + tone: "muted", + }, + { + label: team.tool_policy ?? "kars-default", + tone: "signal", + }, + ], + }} + members={team.roster.map((role) => ({ + id: role.name, + title: role.name, + role: role.tier != null ? `Tier ${role.tier} · ${TIER_LABELS[role.tier] ?? "?"}` : undefined, + detail: role.system_prompt || undefined, + status: role.member_task ? "verified" : "pending", + chips: [ + { label: role.runtime ?? "team default", tone: "accent" as const }, + { label: role.model ?? "team default" }, + ...role.skills.map((s) => ({ label: s, tone: "muted" as const })), + ], + }))} + /> + {team.roster.length === 0 && ( + <p className="mt-2 px-3 py-2 text-center text-xs text-foreground-muted"> + No member roles — the team operates through its charter loop alone. + </p> + )} + </div> + </section> + + </div> + ), + }, + { + id: "runs", + label: "Outcomes", + badge: team.recent_outcomes.length, + node: ( + <div className="space-y-6"> + <TeamRunHistory + team={team.name} + runs={[ + ...new Set([ + ...team.recent_outcomes.map((outcome) => outcome.run), + ...team.generated_tasks, + ]), + ]} + outcomes={team.recent_outcomes} + generated={team.generated_task_count} + /> + + </div> + ), + }, + { + id: "knowledge", + label: "Memory", + badge: commons?.count ?? 0, + node: ( + <div className="space-y-6"> + {/* Shared memory — the team's knowledge commons */} + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-sm font-semibold">Shared memory</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The team's knowledge commons — what each run learned, accumulated with + provenance. New runs build on this instead of starting cold. + </p> + </div> + <span className="rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium text-foreground-muted"> + {commons?.count ?? 0} entries + </span> + </div> + {!commons || commons.entries.length === 0 ? ( + <p className="mt-4 text-xs text-foreground-muted"> + No shared knowledge yet. The first standing-operation run to complete will deposit what + it learned here. + </p> + ) : ( + <> + {commons.count > commons.entries.length && ( + <p className="mt-3 text-[11px] text-foreground-muted"> + Showing the {commons.entries.length} most recent of {commons.count} entries. + </p> + )} + <ul className="mt-4 space-y-3"> + {commons.entries.map((e) => ( + <li key={e.id}> + <details className="group rounded-lg border border-border bg-surface-muted/40"> + <summary className="cursor-pointer list-none p-4"> + <div className="flex items-start justify-between gap-3"> + <p className="text-sm font-medium">{e.title}</p> + <span className="shrink-0 font-mono text-[10px] text-foreground-muted"> + {e.digest} + </span> + </div> + {e.content && ( + <p className="mt-1 line-clamp-2 text-xs text-foreground-muted group-open:hidden"> + {toPlainPreview(e.content)} + </p> + )} + <p className="mt-2 text-[11px] text-foreground-muted"> + Learned by{" "} + <Link + href={`/workspace/teams/${encodeURIComponent(team.name)}/runs/${encodeURIComponent(e.source_task)}`} + className="font-mono hover:underline" + > + {e.author} + </Link>{" "} + · {new Date(e.created_at).toLocaleString()} · click to read + </p> + </summary> + {e.content && ( + <div className="border-t border-border bg-surface px-4 py-4"> + <DeliverableBody output={e.content} /> + </div> + )} + </details> + </li> + ))} + </ul> + </> + )} + </section> + + </div> + ), + }, + { + id: "ledger", + label: "Diagnostics", + node: ( + <div className="space-y-6"> + {/* Continuous ledger (§14) — the streaming record of everything done */} + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Diagnostic ledger</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Low-level deliveries, failures, knowledge writes, token evidence, and digests. Use the + Outcomes tab for the customer-facing record. + </p> + {ledger.length === 0 ? ( + <p className="mt-4 text-xs text-foreground-muted">No activity recorded yet.</p> + ) : ( + <TeamLedger team={team.name} ledger={ledger} /> + )} + </section> + + {/* Provenance */} + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Provenance</h2> + <dl className="mt-3 grid gap-3 sm:grid-cols-2"> + <div> + <dt className="text-xs text-foreground-muted">Trust envelope</dt> + <dd className="mt-1"> + {team.envelope_digest ? ( + <EnvelopeDigest digest={team.envelope_digest} /> + ) : ( + <span className="text-xs text-foreground-muted">pending</span> + )} + </dd> + </div> + <div> + <dt className="text-xs text-foreground-muted">Knowledge commons</dt> + <dd className="mt-1 text-sm"> + {team.knowledge_commons ?? ( + <span className="text-foreground-muted">{team.name} (default)</span> + )} + </dd> + </div> + </dl> + {team.detail && <p className="mt-4 text-xs text-foreground-muted">{team.detail}</p>} + </section> + </div> + ), + }, + ]} + /> + </div> + ); +} + +type TeamServerTab = { + id: string; + label: string; + badge?: number | string | null; + node: ReactNode; + live?: boolean; +}; + +function TeamServerTabs({ + tabs, + active, + basePath, +}: { + tabs: TeamServerTab[]; + active?: string; + basePath: string; +}) { + const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; + return ( + <div> + <div + role="tablist" + aria-label="Team sections" + className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" + > + {tabs.map((tab) => { + const selected = tab.id === current.id; + return ( + <Link + key={tab.id} + href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} + role="tab" + aria-selected={selected} + className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ + selected + ? "bg-signal/10 text-foreground" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" + }`} + > + {tab.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} + {tab.label} + {tab.badge != null && tab.badge !== 0 && ( + <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ + selected + ? "bg-signal/20 text-signal" + : "bg-surface-muted text-foreground-muted" + }`}> + {tab.badge} + </span> + )} + </Link> + ); + })} + </div> + <div role="tabpanel" className="kb-rise space-y-6"> + {current.node} + </div> + </div> + ); +} + +function NowHero({ + teamName, + health, + active, + runRunning, + runInFlight, + everyMinutes, + commonsEntries, + nextRunAt, + lastRunAt, + delivered, + generated, + latestRun, + latestOutcome, + lifecycleMode, + runtimeState, + idleDeadlineAt, +}: { + teamName: string; + health: string | null; + active: boolean; + runRunning: boolean; + runInFlight: boolean; + everyMinutes: number | null; + commonsEntries: number; + nextRunAt: string | null; + lastRunAt: string | null; + delivered: number; + generated: number; + latestRun: string | null; + latestOutcome: "paused" | "running" | "delivered" | "delivered_with_issues" | "incomplete" | "failed" | null; + lifecycleMode: TeamDetail["lifecycle_mode"]; + runtimeState: TeamDetail["runtime_state"]; + idleDeadlineAt: string | null; +}) { + const tone: Record<string, string> = { + Healthy: "border-emerald-500/30 bg-emerald-500/5", + Watching: "border-sky-500/30 bg-sky-500/5", + AwaitingReview: "border-amber-500/30 bg-amber-500/5", + Unproductive: "border-amber-500/30 bg-amber-500/5", + Stalled: "border-rose-500/30 bg-rose-500/5", + Hibernating: "border-border bg-surface-muted/40", + }; + const cls = tone[health ?? ""] ?? "border-border bg-surface"; + const headline = !active + ? "Hibernating — no runs are being generated" + : health === "AwaitingReview" + ? "Awaiting your review — no further assignment or memory promotion will proceed" + : health === "Stalled" + ? "On watch, but recent runs aren't delivering — needs a look" + : health === "Unproductive" + ? "On watch — runs are costly relative to outcomes" + : everyMinutes + ? "On watch — generating governed runs on cadence" + : "On watch — waiting for queued work or Run now"; + + const mode: { label: string; dot: string; note: string } = !active + ? { + label: "Hibernating", + dot: "bg-foreground-muted", + note: "Paused — no sandbox is running and no runs are minted until you resume.", + } + : health === "AwaitingReview" + ? { + label: "Waiting on your decision", + dot: "bg-amber-500", + note: + "The latest governed outcome is retained in Inbox. The team will not promote it to shared memory or start dependent work until you approve or deny it.", + } + : runRunning + ? { + label: "Working now", + dot: "bg-signal", + note: "A run sandbox is live and executing the charter right now.", + } + : runInFlight + ? { + label: "Starting — run in flight", + dot: "bg-amber-500", + note: + "A run has been launched and is materializing (or recovering). If it never reaches Working, check the latest run below for a materialization or gateway error — it is NOT idle.", + } + : lifecycleMode === "persistent" + ? { + label: "Online — waiting for work", + dot: "bg-sky-500", + note: + "The stable principal stays online between assignments. No assignment is active right now; the next queued task reuses this same principal and its approved memory.", + } + : lifecycleMode === "resourceOptimized" && runtimeState === "Hibernating" + ? { + label: "Hibernating — resumes on demand", + dot: "bg-foreground-muted", + note: + "The stable principal is suspended to save resources. The next queued task resumes the same principal identity with approved memory intact.", + } + : lifecycleMode === "resourceOptimized" + ? { + label: "Warm — no assignment active", + dot: "bg-sky-500", + note: + `The stable principal is retained between assignments${ + idleDeadlineAt ? ` until ${new Date(idleDeadlineAt).toLocaleTimeString()}` : "" + }, then hibernates. The next task reuses the same identity and approved memory.`, + } + : { + label: "Idle — spins up on demand", + dot: "bg-sky-500", + note: + `Ephemeral mode starts a fresh governed sandbox on the next ${everyMinutes ? "cadence tick" : "task or Run now"}. ` + + `It rehydrates ${commonsEntries} approved ${commonsEntries === 1 ? "memory" : "memories"} and tears the sandbox down after delivery.`, + }; + + return ( + <section className={`kb-rise rounded-xl border p-5 ${cls}`}> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="min-w-0"> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Right now</p> + <p className="mt-0.5 text-sm font-medium">{headline}</p> + </div> + <div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm"> + <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-2.5 py-1"> + <span className={`inline-block h-2 w-2 rounded-full ${mode.dot} ${runRunning ? "animate-pulse" : ""}`} /> + <span className="text-xs font-medium">{mode.label}</span> + </span> + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">Delivered</span> + <span className="font-semibold tabular-nums"> + {delivered} + <span className="text-foreground-muted">/{generated}</span> + </span> + </span> + {active && nextRunAt && ( + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">Next tick</span> + <span className="font-medium">{new Date(nextRunAt).toLocaleTimeString()}</span> + </span> + )} + {!active && lastRunAt && ( + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">Last run</span> + <span className="font-medium">{new Date(lastRunAt).toLocaleDateString()}</span> + </span> + )} + </div> + </div> + <p className="mt-3 flex items-start gap-2 border-t border-border/60 pt-3 text-xs text-foreground-muted"> + <span aria-hidden>♻️</span> + <span>{mode.note}</span> + </p> + {latestRun && ( + <p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-foreground-muted"> + <span>Latest team run</span> + <Link + href={`/workspace/teams/${encodeURIComponent(teamName)}/runs/${encodeURIComponent(latestRun)}`} + className="font-mono text-signal hover:underline" + > + {latestRun} + </Link> + {latestOutcome && ( + <span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${ + latestOutcome === "delivered" + ? "bg-signal/10 text-signal" + : latestOutcome === "running" + ? "bg-sky-500/10 text-sky-600" + : latestOutcome === "paused" + ? "bg-surface-muted text-foreground-muted" + : latestOutcome === "failed" + ? "bg-danger/10 text-danger" + : "bg-warning/10 text-warning" + }`}> + {latestOutcome === "delivered_with_issues" + ? "Delivered with issues" + : latestOutcome.replace(/_/g, " ")} + </span> + )} + </p> + )} + </section> + ); +} + +function Access({ label, value, isDefault, href }: { label: string; value: string; isDefault?: boolean; href?: string }) { + return ( + <div className="rounded-lg border border-border px-3 py-2"> + <dt className="flex items-center gap-1.5 text-[11px] uppercase tracking-wide text-foreground-muted"> + {label} + {isDefault && ( + <span className="rounded-full bg-surface-muted px-1.5 text-[9px] font-medium normal-case tracking-normal text-foreground-muted" title="Inherited from the cluster — not explicitly set on this team."> + default + </span> + )} + </dt> + <dd className="mt-0.5 text-sm"> + {href ? ( + <a href={href} className="text-signal underline-offset-2 hover:underline"> + {value} + </a> + ) : ( + value + )} + </dd> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts b/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts new file mode 100644 index 000000000..574d3b0bd --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts @@ -0,0 +1,41 @@ +// kars Bridge — team promotion server action (§12). Records a requested higher +// tier; the controller opens a human approval and widens the envelope only on +// approval (the BFF never raises the envelope directly). +"use server"; + +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function requestPromotion( + team: string, + tier: number, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/promote`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ tier }), + }, + ); + if (!res.ok) { + let message = `Promotion request failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // keep status message + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + revalidatePath("/workspace/inbox"); + return { error: null }; +} diff --git a/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx b/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx new file mode 100644 index 000000000..fec4693bb --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx @@ -0,0 +1,99 @@ +"use client"; + +// kars Bridge — team promotion control (§12). Requests a higher autonomy tier; +// the controller opens a human approval and only widens the envelope on +// approval, so this never grants authority directly. + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { requestPromotion } from "./promote-actions"; + +const TIER_LABELS: Record<number, string> = { + 1: "Manual", + 2: "Shared", + 3: "Conditional", + 4: "Supervised", + 5: "Full", +}; + +export function PromoteControl({ team, currentTier }: { team: string; currentTier: number }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [open, setOpen] = useState(false); + const [tier, setTier] = useState(Math.min(currentTier + 1, 5)); + const [error, setError] = useState<string | null>(null); + const [done, setDone] = useState(false); + + if (currentTier >= 5 && !done) { + return null; // already at the ceiling + } + + function submit() { + setError(null); + startTransition(async () => { + const res = await requestPromotion(team, tier); + if (res.error) { + setError(res.error); + return; + } + setDone(true); + setOpen(false); + router.refresh(); + }); + } + + if (done) { + return ( + <p className="rounded-lg border border-sky-500/30 bg-sky-500/5 px-3 py-1.5 text-xs text-foreground-muted"> + Promotion requested — awaiting approval in the inbox. + </p> + ); + } + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted" + > + Request promotion + </button> + ); + } + + return ( + <div className="flex flex-wrap items-center gap-2"> + <select + value={tier} + onChange={(e) => setTier(Number(e.target.value))} + className="rounded-lg border border-border bg-surface px-2 py-1.5 text-xs" + > + {[currentTier + 1, currentTier + 2, currentTier + 3, currentTier + 4] + .filter((t) => t <= 5) + .map((t) => ( + <option key={t} value={t}> + Tier {t} · {TIER_LABELS[t]} + </option> + ))} + </select> + <button + type="button" + disabled={pending} + onClick={submit} + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Requesting…" : "Request approval"} + </button> + <button + type="button" + disabled={pending} + onClick={() => setOpen(false)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted" + > + Cancel + </button> + {error && <span className="text-xs text-rose-600">{error}</span>} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/run-actions.ts b/bridge/web/src/app/workspace/teams/[name]/run-actions.ts new file mode 100644 index 000000000..0b2ed752a --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/run-actions.ts @@ -0,0 +1,37 @@ +// kars Bridge — "Run now" server action. Triggers an immediate team run by +// setting the controller's `run-now` annotation via the BFF. The controller +// mints one taskforce run under the normal readiness gates and clears the +// annotation, so this is a single-shot request (idempotent per click). +"use server"; + +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function runTeamNow(team: string): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/run`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + }, + ); + if (!res.ok) { + let message = `Run request failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + // keep status message + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; +} diff --git a/bridge/web/src/app/workspace/teams/[name]/run-control.tsx b/bridge/web/src/app/workspace/teams/[name]/run-control.tsx new file mode 100644 index 000000000..7c73ccddb --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/run-control.tsx @@ -0,0 +1,110 @@ +"use client"; + +// kars Bridge — "Run now" control. Triggers an immediate team run so a team +// (especially a cadence-less "on demand" one) actually acts, and the operator +// can watch the principal launch → spawn sub-agents → deliver. Disabled while +// paused. + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { runTeamNow } from "./run-actions"; + +export function RunNowControl({ + team, + paused, + inFlight, +}: { + team: string; + paused: boolean; + inFlight: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState<string | null>(null); + const [done, setDone] = useState(false); + // Run now mints a real, budget-spending run — require an explicit confirm so a + // single stray click can't launch work (audit B30). + const [confirming, setConfirming] = useState(false); + + function submit() { + setError(null); + setConfirming(false); + startTransition(async () => { + const res = await runTeamNow(team); + if (res.error) { + setError(res.error); + return; + } + setDone(true); + router.refresh(); + for (const delay of [2_000, 5_000, 10_000, 20_000]) { + setTimeout(() => router.refresh(), delay); + } + }); + } + + if (paused) { + return ( + <span + className="rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs text-foreground-muted" + title="Resume the team to run it." + > + Hibernating + </span> + ); + } + + if (inFlight) { + return ( + <span className="rounded-lg border border-sky-500/30 bg-sky-500/5 px-3 py-1.5 text-xs text-sky-600"> + Run in progress + </span> + ); + } + + if (done) { + return ( + <span className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-1.5 text-xs text-emerald-600"> + Run requested — launching… + </span> + ); + } + + if (confirming) { + return ( + <div className="flex items-center gap-2"> + <span className="text-xs text-foreground-muted">Mint a run now? It spends budget.</span> + <button + type="button" + disabled={pending} + onClick={submit} + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Requesting…" : "Confirm run"} + </button> + <button + type="button" + onClick={() => setConfirming(false)} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted hover:bg-surface-muted" + > + Cancel + </button> + </div> + ); + } + + return ( + <div className="flex items-center gap-2"> + <button + type="button" + disabled={pending} + onClick={() => setConfirming(true)} + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + title="Trigger an immediate run of this team (asks for confirmation)." + > + Run now + </button> + {error && <span className="text-xs text-rose-600">{error}</span>} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx new file mode 100644 index 000000000..8ffe21af5 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function HaltTeamRunButton({ + ns, + team, + run, +}: { + ns: string; + team: string; + run: string; +}) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + async function halt() { + setBusy(true); + setError(null); + try { + const response = await fetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(run)}/halt`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reason: reason.trim() || null }), + }, + ); + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.error?.message ?? `Emergency stop failed (${response.status})`); + } + setOpen(false); + router.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Emergency stop failed"); + } finally { + setBusy(false); + } + } + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="inline-flex items-center gap-1.5 rounded-lg border border-danger/40 bg-danger/[0.06] px-3 py-1.5 text-xs font-semibold text-danger hover:bg-danger/10" + title="Stop this run, preserve its evidence, and pause the standing team" + > + <span aria-hidden>■</span> Emergency stop + </button> + ); + } + + return ( + <div className="rounded-xl border border-danger/40 bg-danger/[0.05] p-4"> + <p className="text-sm font-semibold text-danger">Stop this team run?</p> + <p className="mt-1 text-xs text-foreground-muted"> + The active run sandbox is torn down, retained evidence remains, and the standing team is + paused so it cannot immediately launch replacement work. + </p> + <input + type="text" + value={reason} + onChange={(event) => setReason(event.target.value)} + placeholder="Reason — e.g. runaway cost, wrong scope, unsafe behavior" + className="mt-3 w-full rounded-lg border border-border bg-surface px-3 py-2 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger" + /> + <div className="mt-3 flex items-center gap-2"> + <button + type="button" + onClick={halt} + disabled={busy} + className="rounded-lg bg-danger px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50" + > + {busy ? "Stopping…" : "Confirm stop"} + </button> + <button + type="button" + onClick={() => setOpen(false)} + disabled={busy} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground-muted" + > + Cancel + </button> + {error && <span className="text-xs text-danger">{error}</span>} + </div> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx new file mode 100644 index 000000000..3f52af2ea --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx @@ -0,0 +1,785 @@ +import Link from "next/link"; +import { DeliverableBody } from "@/components/deliverable-view"; +import { HonestState } from "@/components/honest-state"; +import { ExecutionExplorer } from "@/components/execution-explorer"; +import { LiveRefresh } from "@/components/live-refresh"; +import { TeamRunFlow } from "@/components/team-run-flow"; +import { TaskCheckpointPanel } from "@/components/task-checkpoint"; +import { TaskApprovalsPanel } from "@/app/tasks/[name]/task-approvals-panel"; +import { DeployTimeline } from "@/app/workspace/missions/[name]/deploy-timeline"; +import { EgressRequest } from "@/app/workspace/missions/[name]/egress-request"; +import { MissionBlockers } from "@/app/workspace/missions/[name]/mission-blockers"; +import { authWired, defaultNamespace, operatorIdentity } from "@/lib/config"; +import { getArchivedTeamRun, getTask, getTeam, listTaskApprovals } from "@/lib/bff"; +import { analyzeTeamRun } from "@/lib/team-run-evidence"; +import { currentPrincipal } from "@/lib/session"; +import type { ReactNode } from "react"; +import { HaltTeamRunButton } from "./halt-button"; + +export const dynamic = "force-dynamic"; + +const OUTCOME = { + paused: { + label: "Paused", + detail: "This run was paused with its evidence preserved. It is not executing while the standing team is hibernating.", + tone: "border-border bg-surface-muted/50 text-foreground-muted", + }, + running: { + label: "Running", + detail: "The principal and role workers are still executing. Outcome classification appears only after the run terminates.", + tone: "border-sky-500/40 bg-sky-500/[0.07] text-sky-600", + }, + delivered: { + label: "Delivered", + detail: "Every selected role returned a durable handback in the core assignment ledger.", + tone: "border-signal/40 bg-signal/[0.07] text-signal", + }, + delivered_with_issues: { + label: "Delivered with coordination issues", + detail: "A usable outcome landed, but one or more mesh handoffs or evidence steps failed and required recovery.", + tone: "border-warning/50 bg-warning/[0.08] text-warning", + }, + incomplete: { + label: "Incomplete", + detail: "The run stopped for a human request or ended without durable evidence from every selected role.", + tone: "border-warning/50 bg-warning/[0.08] text-warning", + }, + failed: { + label: "Failed", + detail: "The principal did not produce a successful run result.", + tone: "border-danger/50 bg-danger/[0.08] text-danger", + }, +} as const; + +export default async function TeamRunPage({ + params, + searchParams, +}: { + params: Promise<{ name: string; run: string }>; + searchParams: Promise<{ tab?: string }>; +}) { + const { name, run } = await params; + const { tab } = await searchParams; + const ns = defaultNamespace(); + const [team, task, allApprovals, principal] = await Promise.all([ + getTeam(ns, name).catch(() => null), + getTask(ns, run).catch(() => null), + listTaskApprovals(ns, run).catch(() => []), + currentPrincipal(), + ]); + if (!team) return <RunUnavailable run={run} />; + if (!task) { + const archived = await getArchivedTeamRun(ns, name, run).catch(() => null); + if (!archived) return <RunUnavailable run={run} />; + const pullRequests = archivedPullRequests(archived.content ?? "", team.git_write_repos); + return ( + <div className="space-y-6"> + <nav aria-label="Breadcrumb" className="flex flex-wrap items-center text-sm text-foreground-muted"> + <Link href="/workspace/teams" prefetch={false} className="hover:text-foreground hover:underline">Teams</Link> + <span className="px-1.5" aria-hidden>/</span> + <Link href={`/workspace/teams/${encodeURIComponent(name)}`} prefetch={false} className="hover:text-foreground hover:underline"> + {team.display_name ?? team.name} + </Link> + <span className="px-1.5" aria-hidden>/</span> + <span className="text-foreground">Archived run</span> + </nav> + <div> + <p className="font-mono text-xs text-foreground-muted">{run}</p> + <h1 className="mt-1 text-2xl font-semibold tracking-tight">{archived.title}</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Completed {new Date(archived.created_at).toLocaleString()} · retained in team shared memory + </p> + </div> + <section className="rounded-xl border border-signal/35 bg-signal/[0.05] px-5 py-4"> + <p className="text-sm font-semibold">Archived team delivery</p> + <p className="mt-1 text-xs text-foreground-muted"> + The disposable run resources reached their retention limit. The principal synthesis, + provenance digest, and PR references remain durable here; per-tool telemetry and transient + worker artifacts are no longer available. + </p> + </section> + {pullRequests.length > 0 && ( + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">Pull request deliverables</h2> + <ul className="mt-3 flex flex-wrap gap-2"> + {pullRequests.map((pr) => ( + <li key={pr.url}> + <a href={pr.url} target="_blank" rel="noreferrer" className="rounded-lg border border-signal/30 bg-signal/5 px-3 py-2 text-sm font-medium text-signal hover:bg-signal/10"> + {pr.repo} PR #{pr.number} ↗ + </a> + </li> + ))} + </ul> + </section> + )} + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">Principal synthesis</h2> + {archived.content ? ( + <div className="mt-3"><DeliverableBody output={archived.content} /></div> + ) : ( + <HonestState variant="empty" title="Archived content unavailable" detail="The commons index remains, but its retained content was pruned." /> + )} + </section> + <dl className="flex flex-wrap gap-x-6 gap-y-2 rounded-xl border border-border bg-surface px-5 py-4 text-xs text-foreground-muted"> + <div><dt>Digest</dt><dd className="font-mono text-foreground">{archived.digest}</dd></div> + <div><dt>Stored size</dt><dd className="text-foreground">{archived.size_bytes.toLocaleString()} bytes</dd></div> + <div><dt>Source</dt><dd className="font-mono text-foreground">{archived.source_task}</dd></div> + </dl> + </div> + ); + } + const approvals = allApprovals.filter( + (approval) => + approval.run_nonce == null + || task.current_run_nonce == null + || approval.run_nonce === task.current_run_nonce, + ); + if (task.team !== name) return <RunUnavailable run={run} />; + + const evidence = analyzeTeamRun(team, task); + const rosterRuntimes = [...new Set(team.roster.flatMap((role) => role.runtime ? [role.runtime] : []))]; + const rosterModels = [...new Set(team.roster.flatMap((role) => role.model ? [role.model] : []))]; + const principalRuntime = task.composition?.runtime ?? (rosterRuntimes.length === 1 ? rosterRuntimes[0] : null); + const principalModel = task.composition?.model ?? (rosterModels.length === 1 ? rosterModels[0] : null); + const synthesizedGraphAgents = evidence.roles + .filter((role) => role.state !== "skipped") + .map((role) => ({ + name: role.role.member_task ?? role.role.name, + namespace: ns, + phase: + role.state === "delivered" + ? "Completed" + : role.state === "failed" + ? "Failed" + : role.state === "working" + ? "Running" + : "Ready", + runtime: role.role.runtime ?? principalRuntime, + role: role.role.name, + parent: run, + logical_agent_id: role.role.name, + model: role.role.model ?? principalModel, + })); + const normalizeRole = (value: string | null) => + (value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, ""); + const graphSubAgents = (task.sub_agents.length > 0 ? task.sub_agents : synthesizedGraphAgents) + .map((agent) => { + const rosterRole = team.roster.find((role) => { + const target = normalizeRole(role.name); + return target === normalizeRole(agent.role) || normalizeRole(agent.name).includes(target); + }); + return { + ...agent, + runtime: agent.runtime ?? rosterRole?.runtime ?? principalRuntime, + model: agent.model ?? rosterRole?.model ?? principalModel, + }; + }); + const outcome = OUTCOME[evidence.outcome]; + const awaitingAssignment = Boolean( + task.current_run_nonce + && task.assignment?.task_id !== task.current_run_nonce, + ); + const assignmentInFlight = + task.assignment?.completed_at == null + && ( + task.assignment?.state === "Assigned" + || task.assignment?.state === "Running" + ); + const running = + task.launched && !team.paused && (awaitingAssignment || assignmentInFlight); + const roleDelivered = evidence.roles.filter((r) => r.state === "delivered").length; + const roleTarget = evidence.roles.filter((r) => r.state !== "skipped").length; + const lastActivityAt = + [ + task.assignment?.last_progress_at, + task.assignment?.completed_at, + task.result?.finished_at, + ] + .filter((value): value is string => value != null) + .sort() + .at(-1) ?? null; + const evidenceArtifacts = task.artifacts.filter( + (a) => + !a.name.endsWith("collaboration.jsonl") && + !a.name.endsWith("research-evidence.jsonl") && + !a.name.endsWith("subagent-telemetry.jsonl") && + !a.name.endsWith("execution-contract.json") && + !a.name.endsWith("task-checkpoint.json"), + ); + const missingSelectedRoles = evidence.roles.filter( + (role) => role.state === "missing" || role.state === "failed", + ); + const failedToolCalls = task.activity.filter( + (event) => event.kind === "tool" && !event.ok, + ); + const promptShare = + task.result?.total_tokens && task.result.prompt_tokens + ? Math.round((task.result.prompt_tokens / task.result.total_tokens) * 100) + : null; + const claimedPullRequest = + task.result?.status === "error" && + /github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i.test(task.result.output); + + return ( + <div className="space-y-6"> + <LiveRefresh active={running} /> + <nav aria-label="Breadcrumb" className="flex flex-wrap items-center text-sm text-foreground-muted"> + <Link href="/workspace/teams" prefetch={false} className="hover:text-foreground">Teams</Link> + <span className="px-1.5" aria-hidden>/</span> + <Link href={`/workspace/teams/${encodeURIComponent(name)}`} prefetch={false} className="font-medium text-signal hover:underline"> + {team.display_name ?? team.name} + </Link> + <span className="px-1.5" aria-hidden>/</span> + <span className="text-foreground">Run</span> + </nav> + + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <p className="font-mono text-xs text-foreground-muted">{run}</p> + <h1 className="mt-1 text-2xl font-semibold tracking-tight">Team run</h1> + <p className="mt-1 text-sm text-foreground-muted"> + A managed execution of this standing team: assignments, handoffs, decisions, and final result. + </p> + </div> + {running && <HaltTeamRunButton ns={ns} team={name} run={run} />} + </div> + <dl className="flex flex-wrap gap-x-6 gap-y-2 rounded-xl border border-border bg-surface px-5 py-3 text-xs text-foreground-muted"> + <div> + <dt>Started</dt> + <dd className="font-medium text-foreground"> + {task.created_at ? new Date(task.created_at).toLocaleString() : "Unknown"} + </dd> + </div> + <div> + <dt>Last activity</dt> + <dd className="font-medium text-foreground"> + {lastActivityAt + ? new Date(lastActivityAt).toLocaleString() + : "No activity recorded"} + </dd> + </div> + <div> + <dt>Completed</dt> + <dd className="font-medium text-foreground"> + {task.result?.finished_at ? new Date(task.result.finished_at).toLocaleString() : "In progress"} + </dd> + </div> + </dl> + + <section className={`rounded-xl border px-5 py-4 ${outcome.tone}`}> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="font-semibold">{outcome.label}</h2> + <p className="mt-1 max-w-3xl text-sm text-foreground-muted">{outcome.detail}</p> + </div> + <div className="grid grid-cols-3 gap-5 text-right text-xs text-foreground-muted"> + <div><strong className="block text-base text-foreground">{roleDelivered}/{roleTarget}</strong>selected roles</div> + <div><strong className="block text-base text-foreground">{evidenceArtifacts.length}</strong>artifacts</div> + <div><strong className="block text-base text-foreground">{(task.result?.total_tokens ?? 0).toLocaleString()}</strong>tokens</div> + </div> + </div> + </section> + {task.checkpoint && <TaskCheckpointPanel checkpoint={task.checkpoint} />} + + {evidence.evidenceMode !== "ledger" && task.result != null && ( + <section className="rounded-xl border border-warning/40 bg-warning/[0.06] px-5 py-4"> + <h2 className="font-semibold">Historical run — assignment ledger unavailable</h2> + <p className="mt-1 text-sm text-foreground-muted"> + This run predates durable child lifecycle events. Its retained output and artifacts remain visible, but Bridge will not infer Delivered N/N from them. + </p> + </section> + )} + + {task.result?.blocked && ( + <section className="rounded-xl border border-warning/50 bg-warning/[0.06] px-5 py-4"> + <h2 className="font-semibold">Run stopped before delivery</h2> + <p className="mt-1 text-sm text-foreground-muted">{task.result.blocked.detail}</p> + </section> + )} + {task.result?.status === "error" && !task.result.blocked && ( + <section className="rounded-xl border border-danger/50 bg-danger/[0.06] px-5 py-4"> + <h2 className="font-semibold">Why this run failed</h2> + <p className="mt-1 text-sm text-foreground-muted"> + The agent produced a confident narrative, but the retained execution evidence did not + support a successful outcome. + </p> + <ul className="mt-3 space-y-1.5 text-sm"> + {missingSelectedRoles.length > 0 && ( + <li> + • Missing required handback:{" "} + {missingSelectedRoles.map((role) => role.role.name).join(", ")}. + </li> + )} + {failedToolCalls.length > 0 && ( + <li> + • {failedToolCalls.length} tool call{failedToolCalls.length === 1 ? "" : "s"} failed; + inspect the grouped execution flow for the exact sequence. + </li> + )} + {claimedPullRequest && ( + <li> + • The raw narrative claimed a pull request, but failed output is not accepted as a + PR deliverable. No verified PR is attached to this run. + </li> + )} + {promptShare != null && promptShare >= 85 && ( + <li> + • {promptShare}% of the {(task.result.total_tokens ?? 0).toLocaleString()} tokens + were prompt/context tokens, indicating repeated context replay rather than useful + completion. + </li> + )} + </ul> + <Link + href={`/workspace/teams/${encodeURIComponent(name)}/runs/${encodeURIComponent(run)}?tab=activity`} + prefetch={false} + className="mt-4 inline-flex rounded-lg border border-danger/30 bg-surface px-3 py-2 text-xs font-semibold text-danger hover:bg-danger/[0.06]" + > + Open searchable execution flow → + </Link> + <details className="mt-4 rounded-lg border border-danger/25 bg-surface/60"> + <summary className="cursor-pointer px-3 py-2 text-xs font-medium"> + Raw agent narrative + <span className="ml-2 font-normal text-foreground-muted"> + untrusted because the run failed + </span> + </summary> + <p className="max-h-96 overflow-auto whitespace-pre-wrap border-t border-danger/20 px-3 py-3 text-xs text-foreground-muted"> + {task.result.output} + </p> + </details> + </section> + )} + + <TeamRunTabs + active={tab} + basePath={`/workspace/teams/${encodeURIComponent(name)}/runs/${encodeURIComponent(run)}`} + tabs={[ + { + id: "overview", + label: "Overview", + node: ( + <div className="space-y-5"> + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">Role delivery</h2> + <p className="mt-1 text-xs text-foreground-muted"> + Who was selected, who completed their assignment, and which outputs were retained. + </p> + <div className="mt-4 grid gap-3 md:grid-cols-2"> + {evidence.roles.map((r) => ( + <div key={r.role.name} className="rounded-lg border border-border bg-surface-muted/30 p-4"> + <div className="flex items-center justify-between gap-3"> + <p className="font-medium">{r.role.name.replace(/-/g, " ")}</p> + <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${ + r.state === "delivered" + ? "bg-signal/10 text-signal" + : r.state === "skipped" + ? "bg-surface-muted text-foreground-muted" + : r.state === "working" + ? "bg-sky-500/10 text-sky-600" + : r.state === "failed" + ? "bg-danger/10 text-danger" + : "bg-warning/10 text-warning" + }`}> + {r.state === "delivered" + ? "Handback received" + : r.state === "skipped" + ? "Skipped for this task" + : r.state === "working" + ? "Working" + : r.state === "failed" + ? "Handback failed" + : "No handback"} + </span> + </div> + <p className="mt-1 text-xs text-foreground-muted">{r.role.system_prompt}</p> + <p className="mt-2 text-[11px] text-foreground-muted"> + {r.artifacts.length} artifact{r.artifacts.length === 1 ? "" : "s"} · {r.handbacks.length} structured handback{r.handbacks.length === 1 ? "" : "s"} + {r.artifactAttribution === "inferred" ? " · artifact ownership inferred from retained file names and paths" : ""} + </p> + {r.handbacks.at(-1)?.at && ( + <p className="mt-1 text-[11px] text-foreground-muted" suppressHydrationWarning> + Latest handback: {new Date(r.handbacks.at(-1)!.at!).toLocaleString()} + </p> + )} + </div> + ))} + </div> + </section> + + {evidence.issues.length > 0 && ( + <section className="rounded-xl border border-warning/50 bg-warning/[0.06] p-5"> + <h2 className="text-sm font-semibold">Coordination issues</h2> + <p className="mt-1 text-xs text-foreground-muted"> + These statements came from retained worker reports or the principal deliverable; they are not inferred from a green status badge. + </p> + <ul className="mt-3 space-y-2 text-sm"> + {evidence.issues.map((issue) => <li key={issue}>• {issue}</li>)} + </ul> + </section> + )} + </div> + ), + }, + { + id: "activity", + label: "Execution flow", + badge: evidence.collaboration.length + evidence.research.length, + node: ( + <div className="space-y-4"> + {task.result == null && <DeployTimeline task={task} />} + <ExecutionExplorer + running={running} + activity={task.activity} + telemetry={task.telemetry} + assignmentEvents={task.assignment_events} + approvals={approvals} + ns={ns} + name={run} + agentLabel={team.display_name ?? team.name} + agentPhase={team.paused ? "Hibernating" : task.assignment?.state ?? task.execution_phase ?? task.phase} + agentRuntime={principalRuntime} + agentModel={principalModel} + subAgents={graphSubAgents} + identity={task.agent_identity} + envelopeDigest={task.envelope_digest ?? team.envelope_digest} + /> + <MissionBlockers + ns={ns} + task={run} + approvals={approvals} + activity={task.activity} + running={running} + decider={principal.name || operatorIdentity()} + authWired={authWired()} + /> + <TaskApprovalsPanel + approvals={approvals} + decider={principal.name || operatorIdentity()} + authWired={authWired()} + /> + {task.execution_phase === "Running" && <EgressRequest mission={run} />} + <TeamRunFlow task={task} evidence={evidence} /> + </div> + ), + }, + { + id: "deliverables", + label: "Deliverables", + badge: evidenceArtifacts.length + (task.pull_requests?.length ?? 0), + node: ( + <div className="space-y-5"> + {(task.pull_requests?.length ?? 0) > 0 && ( + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">Pull requests</h2> + <ul className="mt-3 space-y-2"> + {task.pull_requests!.map((pr) => ( + <li key={pr.url}> + <a href={pr.url} target="_blank" rel="noreferrer" className="font-medium text-signal hover:underline"> + {pr.url} ↗ + </a> + </li> + ))} + </ul> + </section> + )} + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">Role artifacts</h2> + <div className="mt-3 space-y-3"> + {evidence.roles.flatMap((r) => r.artifacts.map((a) => ( + <details key={`${r.role.name}-${a.name}`} className="rounded-lg border border-border bg-surface-muted/30"> + <summary className="cursor-pointer px-4 py-3"> + <span className="font-medium">{a.name}</span> + <span className="ml-2 text-xs text-foreground-muted"> + from {r.role.name.replace(/-/g, " ")} + {r.artifactAttribution === "inferred" ? " · inferred attribution" : ""} + </span> + </summary> + <div className="border-t border-border px-4 py-3"> + <ArtifactBody + artifact={a} + downloadHref={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(run)}/artifact/${encodeURIComponent(a.name)}`} + /> + </div> + </details> + )))} + {evidence.unattributedArtifacts.map((a) => ( + <details key={`unattributed-${a.name}`} className="rounded-lg border border-border bg-surface-muted/30"> + <summary className="cursor-pointer px-4 py-3"> + <span className="font-medium">{a.name}</span> + <span className="ml-2 text-xs text-foreground-muted">principal or unattributed</span> + </summary> + <div className="border-t border-border px-4 py-3"> + <ArtifactBody + artifact={a} + downloadHref={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(run)}/artifact/${encodeURIComponent(a.name)}`} + /> + </div> + </details> + ))} + </div> + </section> + {task.result?.output && task.result.status !== "error" && !task.result.blocked && ( + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">Principal synthesis</h2> + <div className="mt-3"><DeliverableBody output={task.result.output} /></div> + </section> + )} + </div> + ), + }, + { + id: "research", + label: "Research & egress", + badge: evidence.research.length || null, + node: evidence.research.length > 0 ? ( + <section className="rounded-xl border border-border bg-surface p-5"> + <h2 className="text-sm font-semibold">External egress attempts</h2> + <p className="mt-1 text-xs text-foreground-muted"> + Router-authoritative HTTP egress events include the credential-safe source URL, enforcement outcome, status, and timing. Legacy runs fall back to clearly labelled agent-reported evidence. + </p> + <ul className="mt-4 space-y-2"> + {evidence.research.map((e, i) => ( + <li key={`${e.url}-${i}`} className="rounded-lg border border-border bg-surface-muted/30 px-4 py-3"> + <a href={e.url} target="_blank" rel="noreferrer" className="break-all text-sm font-medium text-signal hover:underline">{e.url} ↗</a> + <p className="mt-1 font-mono text-[11px] text-foreground-muted"> + {e.source} · {e.agent ?? "agent"} · {e.outcome ?? "unknown"}{e.status ? ` · HTTP ${e.status}` : ""}{e.digest ? ` · ${e.digest}` : ""} + </p> + </li> + ))} + </ul> + </section> + ) : ( + <HonestState + variant="empty" + title="No external research observed" + detail="This run did not retain governed HTTP source evidence. Connected services and model calls are not presented as external research." + /> + ), + }, + ]} + /> + </div> + ); +} + +function RunUnavailable({ run }: { run: string }) { + return ( + <div className="space-y-6"> + <nav aria-label="Breadcrumb" className="flex flex-wrap items-center text-sm text-foreground-muted"> + <Link href="/workspace/teams" prefetch={false} className="hover:text-foreground hover:underline">Teams</Link> + <span className="px-1.5" aria-hidden>/</span> + <span className="text-foreground">Run unavailable</span> + </nav> + <HonestState + variant="not_wired" + title="This run isn’t available to the current identity" + detail={`Run ${run} may belong to another team owner, or its disposable record expired before a durable archive was retained. Switch to the identity that owns the team (for local teams, usually “operator”) and try again.`} + /> + </div> + ); +} + +function archivedPullRequests( + text: string, + repos: string[], +): Array<{ repo: string; number: number; url: string }> { + const found = new Map<string, { repo: string; number: number; url: string }>(); + for (const segment of text.split("github.com/").slice(1)) { + const match = segment.match(/^([^/\s]+)\/([^/\s]+)\/pulls?\/(\d+)/); + if (!match) continue; + const repo = `${match[1]}/${match[2]}`; + const number = Number(match[3]); + const url = `https://github.com/${repo}/pull/${number}`; + found.set(url, { repo, number, url }); + } + if (repos.length === 1) { + for (const match of text.matchAll(/\bPR\s*#(\d+)\b/gi)) { + const number = Number(match[1]); + const repo = repos[0]; + const url = `https://github.com/${repo}/pull/${number}`; + found.set(url, { repo, number, url }); + } + } + return [...found.values()]; +} + +type RunTab = { + id: string; + label: string; + badge?: number | null; + node: ReactNode; +}; + +function TeamRunTabs({ + tabs, + active, + basePath, +}: { + tabs: RunTab[]; + active?: string; + basePath: string; +}) { + const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; + return ( + <div> + <div + role="tablist" + aria-label="Team run sections" + className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" + > + {tabs.map((tab) => { + const selected = tab.id === current.id; + return ( + <Link + key={tab.id} + href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} + prefetch={false} + role="tab" + aria-selected={selected} + className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ + selected + ? "bg-signal/10 text-foreground" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" + }`} + > + {tab.label} + {tab.badge != null && tab.badge !== 0 && ( + <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ + selected + ? "bg-signal/20 text-signal" + : "bg-surface-muted text-foreground-muted" + }`}> + {tab.badge} + </span> + )} + </Link> + ); + })} + </div> + <div role="tabpanel" className="kb-rise space-y-6"> + {current.node} + </div> + </div> + ); +} + +function ArtifactBody({ + artifact, + downloadHref, +}: { + artifact: import("@/lib/types").MissionArtifact; + downloadHref: string; +}) { + const actions = ( + <span className="flex flex-wrap gap-2"> + <a + href={downloadHref} + target="_blank" + rel="noreferrer" + className="inline-flex rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-signal hover:bg-surface-muted" + > + Open full artifact ↗ + </a> + <a + href={downloadHref} + download={artifact.name} + className="inline-flex rounded-lg bg-signal px-2.5 py-1.5 text-xs font-medium text-signal-fg hover:opacity-90" + > + Download + </a> + </span> + ); + if (artifact.content == null) { + return ( + <div className="flex flex-wrap items-center justify-between gap-3"> + <p className="text-xs text-foreground-muted"> + {artifact.content_truncated ? "Preview omitted to keep this run page responsive." : "Binary artifact retained."} + </p> + {actions} + </div> + ); + } + if (artifact.content.length === 0) { + return ( + <div className="flex flex-wrap items-center justify-between gap-3"> + <p className="text-xs text-foreground-muted">Empty text artifact retained.</p> + {actions} + </div> + ); + } + if (artifact.content_truncated) { + return ( + <div className="space-y-3"> + <p className="text-xs text-foreground-muted"> + Showing a bounded preview of {(artifact.content_bytes ?? artifact.size_bytes ?? 0).toLocaleString()} bytes. + </p> + <pre className="max-h-96 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-muted/30 p-3 font-mono text-xs"> + {artifact.content} + </pre> + {actions} + </div> + ); + } + if (artifact.name.endsWith(".json")) { + let parsed: unknown; + try { + parsed = JSON.parse(artifact.content); + } catch { + // Invalid JSON remains inspectable as text instead of disappearing. + return ( + <div className="space-y-3"> + <DeliverableBody output={artifact.content} /> + {actions} + </div> + ); + } + return ( + <div className="max-h-[42rem] overflow-auto rounded-lg border border-border bg-surface-muted/30 p-3"> + <StructuredJson value={parsed} /> + <div className="mt-3">{actions}</div> + </div> + ); + } + return ( + <div className="space-y-3"> + <DeliverableBody output={artifact.content} /> + {actions} + </div> + ); +} + +function StructuredJson({ value, depth = 0 }: { value: unknown; depth?: number }) { + if (value === null || typeof value !== "object") { + return <span className="break-words font-mono text-xs">{String(value)}</span>; + } + if (depth >= 3) { + return ( + <pre className="whitespace-pre-wrap break-words font-mono text-xs"> + {JSON.stringify(value, null, 2)} + </pre> + ); + } + if (Array.isArray(value)) { + return ( + <ol className="space-y-2"> + {value.map((entry, index) => ( + <li key={index} className="rounded-md border border-border bg-surface px-3 py-2"> + <span className="mb-1 block text-[10px] font-medium uppercase tracking-wide text-foreground-muted">Item {index + 1}</span> + <StructuredJson value={entry} depth={depth + 1} /> + </li> + ))} + </ol> + ); + } + return ( + <dl className="divide-y divide-border"> + {Object.entries(value as Record<string, unknown>).map(([key, entry]) => ( + <div key={key} className="grid gap-1 py-2 sm:grid-cols-[12rem_minmax(0,1fr)] sm:gap-3"> + <dt className="break-words font-mono text-[11px] font-medium text-foreground-muted">{key}</dt> + <dd className="min-w-0"><StructuredJson value={entry} depth={depth + 1} /></dd> + </div> + ))} + </dl> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/task-actions.ts b/bridge/web/src/app/workspace/teams/[name]/task-actions.ts new file mode 100644 index 000000000..69822514b --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/task-actions.ts @@ -0,0 +1,93 @@ +// kars Bridge — team task backlog server actions. Add/remove discrete tasks on a +// standing team; the controller drains the oldest `pending` task on its next run +// (cadence or Run now) and marks it `done` when that run delivers. +"use server"; + +import { revalidatePath } from "next/cache"; +import { defaultNamespace } from "@/lib/config"; +import { authenticatedBffFetch } from "@/lib/bff"; + +export async function addTeamTask( + team: string, + title: string, + description: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/tasks`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title, description }), + }, + ); + if (!res.ok) { + let message = `Add task failed (${res.status}).`; + try { + const body = await res.json(); + if (body?.error?.message) message = body.error.message; + } catch { + /* keep status message */ + } + return { error: message }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; +} + +export async function deleteTeamTask( + team: string, + taskId: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const res = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/tasks/${encodeURIComponent(taskId)}`, + { method: "DELETE", cache: "no-store" }, + ); + if (!res.ok && res.status !== 404) { + return { error: `Remove task failed (${res.status}).` }; + } + } catch (err) { + return { error: err instanceof Error ? err.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; +} + +export async function reviewTeamTask( + team: string, + taskId: string, + decision: "approve" | "request_changes", + feedback?: string, +): Promise<{ error: string | null }> { + const ns = defaultNamespace(); + try { + const response = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(ns)}/teams/${encodeURIComponent(team)}/tasks/${encodeURIComponent(taskId)}/review`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision, feedback: feedback?.trim() || null }), + }, + ); + if (!response.ok) { + const payload = await response.json().catch(() => null); + return { + error: + payload?.error?.message + ?? `Milestone review failed (${response.status}).`, + }; + } + } catch (error) { + return { error: error instanceof Error ? error.message : "unknown error" }; + } + revalidatePath(`/workspace/teams/${team}`); + return { error: null }; +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx b/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx new file mode 100644 index 000000000..b70cf3a75 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx @@ -0,0 +1,170 @@ +"use client"; + +// kars Bridge — team communication channels. Part of a standing team's envelope: +// wire Telegram / Slack / Discord / WhatsApp so the team reports its progress and +// deliverables to the operator. SECURITY: tokens are write-only — typed into a +// password field, sent to the BFF (stored only in a K8s Secret), and never shown +// back. The UI knows which channels are enabled plus whether the current team +// route has retained qualification evidence for each adapter. + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { setTeamChannel, deleteTeamChannel } from "./channel-actions"; +import { Icon } from "@/components/icon"; +import type { TeamChannelStatus } from "@/lib/types"; + +const CHANNELS: { id: string; label: string; glyph: "message"; token: string; hint: string; extra?: string }[] = [ + { id: "telegram", label: "Telegram", glyph: "message", token: "Bot token", hint: "From @BotFather", extra: "Allowed user IDs (comma-separated)" }, + { id: "slack", label: "Slack", glyph: "message", token: "Bot OAuth token", hint: "xoxb-…" }, + { id: "discord", label: "Discord", glyph: "message", token: "Bot token", hint: "From the Discord developer portal" }, + { id: "whatsapp", label: "WhatsApp", glyph: "message", token: "Enable", hint: "Type 'true' to enable pairing" }, +]; + +export function TeamChannels({ + team, + enabled, + statuses = [], +}: { + team: string; + enabled: string[]; + statuses?: TeamChannelStatus[]; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [open, setOpen] = useState<string | null>(null); + const [token, setToken] = useState(""); + const [allowFrom, setAllowFrom] = useState(""); + const [error, setError] = useState<string | null>(null); + const statusByChannel = new Map(statuses.map((status) => [status.channel, status] as const)); + + function save(channel: string) { + if (!token.trim()) return; + setError(null); + startTransition(async () => { + const res = await setTeamChannel(team, channel, token.trim(), allowFrom.trim() || undefined); + if (res.error) { + setError(res.error); + return; + } + setToken(""); + setAllowFrom(""); + setOpen(null); + router.refresh(); + }); + } + + function disable(channel: string) { + setError(null); + startTransition(async () => { + const res = await deleteTeamChannel(team, channel); + if (res?.error) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Communication channels</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Part of the team's envelope. Wire a channel and the team reports milestones and its + deliverables to you there. Tokens are stored encrypted-at-rest as a Kubernetes secret and are + never shown again. + </p> + + <div className="mt-4 grid gap-2 sm:grid-cols-2"> + {CHANNELS.map((ch) => { + const status = statusByChannel.get(ch.id); + const on = status?.enabled ?? enabled.includes(ch.id); + const isOpen = open === ch.id; + return ( + <div key={ch.id} className="rounded-lg border border-border p-3"> + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-2"> + <span aria-hidden className="text-base leading-none"><Icon name={ch.glyph} size={16} /></span> + <span className="text-sm font-medium">{ch.label}</span> + {on && ( + <span className="rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600"> + connected + </span> + )} + {status?.qualified === true && ( + <span className="rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600"> + qualified + </span> + )} + {status?.qualified === false && ( + <span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700"> + unqualified + </span> + )} + </div> + {on ? ( + <button + type="button" + disabled={pending} + onClick={() => disable(ch.id)} + className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground-muted transition hover:text-rose-600 disabled:opacity-50" + > + Disconnect + </button> + ) : ( + <button + type="button" + onClick={() => { + setOpen(isOpen ? null : ch.id); + setToken(""); + setAllowFrom(""); + setError(null); + }} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium transition hover:bg-surface-muted" + > + {isOpen ? "Cancel" : "Connect"} + </button> + )} + </div> + {status?.detail && ( + <p className="mt-2 text-[11px] text-foreground-muted">{status.detail}</p> + )} + {isOpen && !on && ( + <div className="mt-2 space-y-2"> + <input + type="password" + autoComplete="off" + value={token} + onChange={(e) => setToken(e.target.value)} + placeholder={ch.token} + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + {ch.extra && ( + <input + type="text" + value={allowFrom} + onChange={(e) => setAllowFrom(e.target.value)} + placeholder={ch.extra} + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + )} + <div className="flex items-center justify-between"> + <span className="text-[10px] text-foreground-muted">{ch.hint}</span> + <button + type="button" + disabled={pending || !token.trim()} + onClick={() => save(ch.id)} + className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Saving…" : "Save"} + </button> + </div> + </div> + )} + </div> + ); + })} + </div> + {error && <p className="mt-2 text-xs text-rose-600">{error}</p>} + </section> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx b/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx new file mode 100644 index 000000000..aede22da6 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { RepoAccess } from "@/components/repo-access"; +import type { ExecutionPlan, TeamLifecycleMode } from "@/lib/types"; + +type McpOption = { name: string; summary?: string | null }; +type MemoryOption = { name: string }; + +function parseEgress(value: string): { host: string; port?: number }[] { + return value + .split(/\r?\n|,/) + .map((entry) => entry.trim()) + .filter(Boolean) + .flatMap((entry) => { + const match = entry.match(/^([^:]+?)(?::(\d{1,5}))?$/); + if (!match) return []; + const port = match[2] ? Number(match[2]) : undefined; + if (port != null && (port < 1 || port > 65535)) return []; + return [{ host: match[1].toLowerCase(), ...(port ? { port } : {}) }]; + }); +} + +function moveRoute(routes: string[], index: number, delta: number): string[] { + const next = index + delta; + if (next < 0 || next >= routes.length) return routes; + const copy = [...routes]; + [copy[index], copy[next]] = [copy[next], copy[index]]; + return copy; +} + +// Day-to-day, non-amplifying launch-package edits. Tier changes still go through +// governed promote; charter, cadence, MCP and network intent stay human-editable. +export function TeamEdit({ + ns, + name, + charter, + paused, + everyMinutes, + lifecycleMode, + warmIdleSeconds, + mcpServers, + availableMcp, + egress, + egressMode, + model, + modelFallbacks, + models, + memory, + availableMemories, + gitWriteRepos, + executionPlan, +}: { + ns: string; + name: string; + charter: string; + paused: boolean; + everyMinutes: number | null; + lifecycleMode: TeamLifecycleMode; + warmIdleSeconds: number | null; + mcpServers: string[]; + availableMcp: McpOption[]; + egress: string[]; + egressMode: string | null; + model: string | null; + modelFallbacks: string[]; + models: { provider: string; deployment: string; is_default?: boolean }[]; + memory: string | null; + availableMemories: MemoryOption[]; + gitWriteRepos: string[]; + executionPlan: ExecutionPlan | null; +}) { + const [open, setOpen] = useState(false); + const [ch, setCh] = useState(charter); + const [cad, setCad] = useState(everyMinutes ?? 0); + const [lifecycle, setLifecycle] = useState<TeamLifecycleMode>(lifecycleMode); + const [warmIdle, setWarmIdle] = useState(warmIdleSeconds ?? 900); + const [mcp, setMcp] = useState(mcpServers); + const [networkMode, setNetworkMode] = useState<"learning" | "strict">( + egressMode?.toLowerCase().startsWith("strict") ? "strict" : "learning", + ); + const [egressText, setEgressText] = useState(egress.join("\n")); + const [modelRoute, setModelRoute] = useState(() => { + const option = model + ? models.find((entry) => + model.includes("::") + ? `${entry.provider}::${entry.deployment}` === model + : entry.deployment === model, + ) + : undefined; + return option ? `${option.provider}::${option.deployment}` : ""; + }); + const [fallbackRoutes, setFallbackRoutes] = useState(modelFallbacks); + const [memoryBinding, setMemoryBinding] = useState(memory ?? ""); + const [selectedGitWriteRepos, setSelectedGitWriteRepos] = useState(gitWriteRepos); + const [executionPlanText, setExecutionPlanText] = useState( + executionPlan ? JSON.stringify(executionPlan, null, 2) : "", + ); + const [pending, start] = useTransition(); + const [error, setError] = useState<string | null>(null); + const router = useRouter(); + // PATCH with EXACTLY the given body — so Pause/Resume never smuggles an + // unsaved charter/cadence edit into the request (that was a silent commit). + const patch = (body: object) => start(async () => { + setError(null); + try { + const res = await fetch(`/api/namespaces/${ns}/teams/${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const b = await res.json().catch(() => null); + throw new Error(b?.error?.message ?? `Save failed (${res.status})`); + } + router.refresh(); + setOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } + }); + return ( + <div> + <div className="flex gap-2"> + <button onClick={() => patch({ paused: !paused })} disabled={pending} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">{paused ? "Resume" : "Pause"}</button> + <button onClick={() => setOpen(!open)} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">Edit</button> + </div> + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + {open && ( + <div className="mt-3 space-y-3 rounded-lg border border-border bg-surface-muted/40 p-4"> + <label className="block text-xs text-foreground-muted">Charter / standing prompt + <textarea value={ch} onChange={(e) => setCh(e.target.value)} rows={4} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs" /> + </label> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="text-xs text-foreground-muted">Cadence minutes (0 = passive) + <input type="number" min={0} value={cad} onChange={(e) => setCad(Number(e.target.value))} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs" /> + </label> + <label className="text-xs text-foreground-muted">Runtime lifecycle + <select value={lifecycle} onChange={(event) => setLifecycle(event.target.value as TeamLifecycleMode)} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs"> + <option value="resourceOptimized">Resource optimized (recommended)</option> + <option value="persistent">Persistent</option> + <option value="ephemeral">Ephemeral</option> + </select> + </label> + {lifecycle === "resourceOptimized" && ( + <label className="text-xs text-foreground-muted">Warm idle window (seconds) + <input type="number" min={0} value={warmIdle} onChange={(event) => setWarmIdle(Number(event.target.value))} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs" /> + </label> + )} + <label className="text-xs text-foreground-muted">Egress mode + <select value={networkMode} onChange={(event) => setNetworkMode(event.target.value as "learning" | "strict")} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs"> + <option value="learning">Learning · observe new public hosts</option> + <option value="strict">Strict · enforce reviewed hosts only</option> + </select> + </label> + <label className="text-xs text-foreground-muted">Principal/default model + <select value={modelRoute} onChange={(event) => { + const route = event.target.value; + setModelRoute(route); + setFallbackRoutes((current) => current.filter((fallback) => fallback !== route)); + }} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs"> + <option value="">cluster default</option> + {models.map((entry) => ( + <option key={`${entry.provider}::${entry.deployment}`} value={`${entry.provider}::${entry.deployment}`}> + {entry.deployment} · {entry.provider}{entry.is_default ? " (default)" : ""} + </option> + ))} + </select> + </label> + <label className="text-xs text-foreground-muted">Qualified fallback routes + <select + multiple + value={fallbackRoutes} + onChange={(event) => { + const selected = new Set( + Array.from(event.currentTarget.selectedOptions, (option) => option.value), + ); + setFallbackRoutes((current) => [ + ...current.filter((route) => selected.has(route)), + ...Array.from(selected).filter((route) => !current.includes(route)), + ].slice(0, 8)); + }} + className="mt-1 min-h-20 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs" + > + {models + .map((entry) => `${entry.provider}::${entry.deployment}`) + .filter((route) => route !== modelRoute) + .map((route) => <option key={route} value={route}>{route}</option>)} + </select> + {fallbackRoutes.map((route, index) => ( + <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> + <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> + <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setFallbackRoutes((current) => moveRoute(current, index, -1))}>↑</button> + <button type="button" aria-label={`Move ${route} later`} disabled={index === fallbackRoutes.length - 1} onClick={() => setFallbackRoutes((current) => moveRoute(current, index, 1))}>↓</button> + </span> + ))} + </label> + <label className="text-xs text-foreground-muted">Shared memory + <select value={memoryBinding} onChange={(event) => setMemoryBinding(event.target.value)} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs"> + <option value="">No shared memory binding</option> + {availableMemories.map((entry) => ( + <option key={entry.name} value={entry.name}>{entry.name}</option> + ))} + </select> + </label> + </div> + <label className="block text-xs text-foreground-muted">External hosts (one host[:port] per line) + <textarea value={egressText} onChange={(event) => setEgressText(event.target.value)} rows={3} className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 font-mono text-xs" /> + </label> + <fieldset> + <legend className="text-xs text-foreground-muted">Connected MCP services</legend> + <div className="mt-1.5 grid gap-2 sm:grid-cols-2"> + {availableMcp.map((server) => ( + <label key={server.name} className="flex items-start gap-2 rounded border border-border bg-surface px-2.5 py-2 text-xs"> + <input + type="checkbox" + checked={mcp.includes(server.name)} + onChange={(event) => + setMcp((current) => + event.target.checked + ? [...new Set([...current, server.name])] + : current.filter((entry) => entry !== server.name), + ) + } + /> + <span><span className="font-medium text-foreground">{server.name}</span>{server.summary && <span className="block text-[11px] text-foreground-muted">{server.summary}</span>}</span> + </label> + ))} + </div> + </fieldset> + <RepoAccess + ns={ns} + initialSelected={gitWriteRepos} + onSelectionChange={setSelectedGitWriteRepos} + /> + <label className="block text-xs text-foreground-muted"> + Typed execution plan + <span className="ml-1 text-[11px]"> + Adjust role token budgets without changing role names or capabilities. + </span> + <textarea + value={executionPlanText} + onChange={(event) => setExecutionPlanText(event.target.value)} + rows={14} + spellCheck={false} + className="mt-1 w-full rounded border border-border bg-surface px-2 py-1.5 font-mono text-xs" + /> + </label> + <div className="flex justify-end"> + <button + onClick={() => { + let parsedExecutionPlan: ExecutionPlan | undefined; + try { + parsedExecutionPlan = executionPlanText.trim() + ? JSON.parse(executionPlanText) as ExecutionPlan + : undefined; + } catch { + setError("Typed execution plan must be valid JSON."); + return; + } + patch({ + charter: ch, + cadence_minutes: Number(cad), + lifecycle_mode: lifecycle, + warm_idle_seconds: lifecycle === "resourceOptimized" ? warmIdle : undefined, + mcp_servers: mcp, + model: modelRoute, + model_fallbacks: fallbackRoutes, + memory: memoryBinding, + egress_mode: networkMode, + egress: parseEgress(egressText), + git_write_repos: selectedGitWriteRepos, + execution_plan: parsedExecutionPlan, + }); + }} + disabled={pending} + className="rounded bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg" + > + {pending ? "Saving…" : "Save launch package"} + </button> + </div> + </div> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx b/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx new file mode 100644 index 000000000..bd8d3102f --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx @@ -0,0 +1,73 @@ +"use client"; + +// kars Bridge — team activity ledger list. Client component so the operator can +// expand past the latest 30 events (audit BUG-20: the list truncated at 30 with +// no way to reach the rest). Rendering matches the server-side list it replaced. + +import { useState } from "react"; +import Link from "next/link"; +import type { LedgerEvent } from "@/lib/types"; +import { toPlainPreview } from "@/components/deliverable-view"; + +const PAGE = 30; + +export function TeamLedger({ team, ledger }: { team: string; ledger: LedgerEvent[] }) { + const [showAll, setShowAll] = useState(false); + + if (ledger.length === 0) { + return <p className="mt-4 text-xs text-foreground-muted">No activity recorded yet.</p>; + } + + const shown = showAll ? ledger : ledger.slice(0, PAGE); + + return ( + <> + <ul className="mt-4 space-y-1.5"> + {shown.map((e, i) => ( + <li key={`${e.at}-${i}`} className="flex items-start gap-3 text-xs"> + <span + className={`mt-1 h-1.5 w-1.5 shrink-0 rounded-full ${ + e.kind === "delivery" + ? "bg-emerald-500" + : e.kind === "delivery_error" + ? "bg-rose-500" + : e.kind === "knowledge" + ? "bg-sky-500" + : "bg-foreground-muted" + }`} + /> + <span className="w-32 shrink-0 text-foreground-muted"> + {new Date(e.at).toLocaleString()} + </span> + <span className="w-20 shrink-0 font-medium capitalize"> + {e.kind.replace("_", " ")} + </span> + <span className="min-w-0 flex-1 truncate text-foreground-muted"> + {e.task ? ( + <Link href={`/workspace/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(e.task)}`} className="hover:text-foreground hover:underline"> + {toPlainPreview(e.summary)} + </Link> + ) : ( + toPlainPreview(e.summary) + )} + </span> + {e.tokens != null && ( + <span className="shrink-0 text-foreground-muted">{e.tokens.toLocaleString()}t</span> + )} + </li> + ))} + </ul> + {ledger.length > PAGE && ( + <button + type="button" + onClick={() => setShowAll((v) => !v)} + className="mt-3 text-[11px] font-medium text-signal hover:underline" + > + {showAll + ? `Show latest ${PAGE} only` + : `Show all ${ledger.length} events`} + </button> + )} + </> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx b/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx new file mode 100644 index 000000000..91123643f --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx @@ -0,0 +1,300 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { TeamOutcome, TeamOutcomeSummary } from "@/lib/types"; + +const DISPOSITION = { + change_proposed: { + label: "Change proposed", + glyph: "↗", + cls: "border-sky-500/30 bg-sky-500/10 text-sky-600", + }, + no_action_needed: { + label: "No action needed", + glyph: "✓", + cls: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600", + }, + completed: { + label: "Completed", + glyph: "✓", + cls: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600", + }, + failed: { + label: "Failed", + glyph: "!", + cls: "border-rose-500/30 bg-rose-500/10 text-rose-600", + }, +} as const; + +function compactNumber(value: number): string { + return new Intl.NumberFormat(undefined, { + notation: value >= 10_000 ? "compact" : "standard", + maximumFractionDigits: 1, + }).format(value); +} + +function duration(seconds: number | null): string | null { + if (seconds == null) return null; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +function OutcomeRow({ team, outcome }: { team: string; outcome: TeamOutcome }) { + const meta = DISPOSITION[outcome.disposition]; + const elapsed = duration(outcome.duration_seconds); + return ( + <li> + <Link + href={`/workspace/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(outcome.run)}`} + className="group grid gap-3 rounded-xl border border-border bg-surface px-4 py-3 transition hover:border-signal/40 hover:bg-surface-muted sm:grid-cols-[auto_minmax(0,1fr)_auto]" + > + <span + className={`mt-0.5 inline-flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold ${meta.cls}`} + aria-hidden + > + {meta.glyph} + </span> + <span className="min-w-0"> + <span className="flex flex-wrap items-center gap-2"> + <span className="text-sm font-semibold group-hover:text-signal">{outcome.headline}</span> + <span className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${meta.cls}`}> + {meta.label} + </span> + </span> + {outcome.objective && ( + <span className="mt-0.5 block line-clamp-1 text-xs text-foreground-muted"> + {outcome.objective} + </span> + )} + <span className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-foreground-muted"> + {outcome.finished_at && ( + <span suppressHydrationWarning> + {new Date(outcome.finished_at).toLocaleString()} + </span> + )} + {elapsed && <span>{elapsed}</span>} + {outcome.artifact_count > 0 && ( + <span>{outcome.artifact_count} evidence file{outcome.artifact_count === 1 ? "" : "s"}</span> + )} + {outcome.pull_requests.map((pr) => ( + <span key={pr.url}>PR #{pr.number}</span> + ))} + </span> + </span> + <span className="self-center text-right text-[11px] text-foreground-muted"> + {outcome.tokens != null ? `${compactNumber(outcome.tokens)} tokens` : "cost unavailable"} + </span> + </Link> + </li> + ); +} + +export function TeamValueSummary({ + summary, + generated, + retained, + queued, + active, + tokens, +}: { + summary: TeamOutcomeSummary; + generated: number; + retained: number; + queued: number; + active: number; + tokens: number; +}) { + const resolved = + summary.change_proposed + summary.no_action_needed + summary.completed; + const tokensPerResolved = resolved > 0 ? Math.round(tokens / resolved) : null; + return ( + <section className="rounded-2xl border border-border bg-surface p-6"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Value & operating health</h2> + <p className="mt-0.5 max-w-2xl text-xs text-foreground-muted"> + Outcomes from retained evidence. A scheduled check that correctly finds nothing to do is + resolved work, not a failed delivery. + </p> + </div> + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-xs text-foreground-muted"> + {retained} retained outcomes · {generated.toLocaleString()} checks all-time + </span> + </div> + <dl className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> + {[ + ["Changes proposed", summary.change_proposed, "text-sky-600"], + ["No action needed", summary.no_action_needed, "text-emerald-600"], + ["Other completed", summary.completed, "text-emerald-600"], + ["Failed", summary.failed, summary.failed > 0 ? "text-rose-600" : ""], + ["Work queued", queued, ""], + ["Working now", active, active > 0 ? "text-sky-600" : ""], + ].map(([label, value, cls]) => ( + <div key={String(label)} className="rounded-xl border border-border bg-surface-muted/25 p-3"> + <dt className="text-[11px] text-foreground-muted">{label}</dt> + <dd className={`mt-1 text-xl font-semibold tabular-nums ${cls}`}>{value}</dd> + </div> + ))} + </dl> + <p className="mt-4 text-[11px] text-foreground-muted"> + Observed token volume: {compactNumber(tokens)} + {tokensPerResolved != null + ? ` · ${compactNumber(tokensPerResolved)} per resolved retained outcome` + : ""} + . Cost is diagnostic context, not a measure of value. + </p> + </section> + ); +} + +export function RecentTeamOutcomes({ + team, + outcomes, + limit = 4, +}: { + team: string; + outcomes: TeamOutcome[]; + limit?: number; +}) { + const shown = outcomes.slice(0, limit); + return ( + <section className="rounded-2xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Recent outcomes</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + What changed, what was safely ruled out, and what failed. + </p> + </div> + <Link + href={`/workspace/teams/${encodeURIComponent(team)}?tab=runs`} + className="text-xs font-medium text-signal hover:underline" + > + Full history → + </Link> + </div> + {shown.length === 0 ? ( + <p className="mt-4 text-xs text-foreground-muted">No retained outcomes yet.</p> + ) : ( + <ul className="mt-4 space-y-2"> + {shown.map((outcome) => ( + <OutcomeRow key={outcome.run} team={team} outcome={outcome} /> + ))} + </ul> + )} + </section> + ); +} + +export function TeamRunHistory({ + team, + runs, + outcomes, + generated, +}: { + team: string; + runs: string[]; + outcomes: TeamOutcome[]; + generated: number; +}) { + const byRun = new Map(outcomes.map((outcome) => [outcome.run, outcome])); + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const [order, setOrder] = useState<"newest" | "oldest">("newest"); + const normalizedQuery = query.trim().toLowerCase(); + const visibleRuns = runs + .filter((run) => { + const outcome = byRun.get(run); + if (filter !== "all" && outcome?.disposition !== filter) return false; + if (!normalizedQuery) return true; + return [run, outcome?.headline, outcome?.detail, outcome?.objective] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(normalizedQuery)); + }) + .sort((left, right) => { + const time = (run: string) => { + const finished = byRun.get(run)?.finished_at; + if (finished) { + const parsed = new Date(finished).getTime(); + if (Number.isFinite(parsed)) return parsed; + } + const suffix = run.match(/(\d{10,})$/)?.[1]; + return suffix ? Number(suffix) : 0; + }; + return order === "newest" ? time(right) - time(left) : time(left) - time(right); + }); + return ( + <section className="rounded-2xl border border-border bg-surface p-6"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Outcome history</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Searchable outcome history is the customer record; opaque run IDs are retained only as + provenance. + </p> + </div> + <span className="rounded-full bg-surface-muted px-2.5 py-1 text-xs text-foreground-muted"> + {runs.length} retained · {generated.toLocaleString()} checks all-time + </span> + </div> + <div className="mt-4 flex flex-wrap gap-2"> + <input + type="search" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search outcomes, work items, repositories, or run IDs" + className="min-w-64 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + <select + value={filter} + onChange={(event) => setFilter(event.target.value)} + aria-label="Filter outcome disposition" + className="rounded-lg border border-border bg-surface px-3 py-2 text-xs" + > + <option value="all">All outcomes</option> + <option value="change_proposed">Changes proposed</option> + <option value="no_action_needed">No action needed</option> + <option value="completed">Completed</option> + <option value="failed">Failed</option> + </select> + <select + value={order} + onChange={(event) => setOrder(event.target.value as "newest" | "oldest")} + aria-label="Sort team outcomes" + className="rounded-lg border border-border bg-surface px-3 py-2 text-xs" + > + <option value="newest">Newest first</option> + <option value="oldest">Oldest first</option> + </select> + </div> + <ul className="mt-4 space-y-2"> + {visibleRuns.map((run) => { + const outcome = byRun.get(run); + if (outcome) return <OutcomeRow key={run} team={team} outcome={outcome} />; + return ( + <li key={run}> + <Link + href={`/workspace/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(run)}`} + className="flex items-center justify-between gap-4 rounded-xl border border-border px-4 py-3 text-sm hover:bg-surface-muted" + > + <span> + <span className="font-medium">Outcome unavailable</span> + <span className="mt-0.5 block text-xs text-foreground-muted"> + The retained run record has no durable outcome payload. + </span> + </span> + <span className="font-mono text-[10px] text-foreground-muted">{run}</span> + </Link> + </li> + ); + })} + </ul> + {visibleRuns.length === 0 && ( + <p className="mt-4 text-xs text-foreground-muted">No outcomes match this search.</p> + )} + </section> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx b/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx new file mode 100644 index 000000000..725dfeb99 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx @@ -0,0 +1,240 @@ +"use client"; + +// kars Bridge — edit a standing team's org post-create (REQ13 "edit everything"). +// The org chart is not frozen at creation: you can add/remove member roles and +// change each member's prompt, harness, model, and skills, then save. PATCHes +// the team's spec.roster; the controller reconciles member tasks to match. +// Mirrors the team composer's role grammar so create and edit feel identical. + +import { useState, useTransition } from "react"; +import { MEMBER_ARCHETYPES } from "@/lib/member-archetypes"; +import { useRouter } from "next/navigation"; +import type { Options, TeamRole } from "@/lib/types"; +import { ViewportPortal } from "@/components/viewport-portal"; + +type EditRole = { + id: number; + name: string; + system_prompt: string; + runtime: string; + model: string; + skills: string[]; +}; + +let RID = 1; + +export function TeamRosterEdit({ + ns, + name, + roster, + options, +}: { + ns: string; + name: string; + roster: TeamRole[]; + options: Options; +}) { + const [open, setOpen] = useState(false); + const [roles, setRoles] = useState<EditRole[]>(() => + roster.map((r) => { + const opt = r.model + ? options.models.find((m) => + r.model?.includes("::") + ? `${m.provider}::${m.deployment}` === r.model + : m.deployment === r.model, + ) + : undefined; + return { + id: RID++, + name: r.name, + system_prompt: r.system_prompt ?? "", + runtime: r.runtime ?? "", + model: opt ? `${opt.provider}::${opt.deployment}` : "", + skills: r.skills, + }; + }), + ); + const [pending, start] = useTransition(); + const [error, setError] = useState<string | null>(null); + const router = useRouter(); + + const patch = (id: number, p: Partial<EditRole>) => + setRoles((rs) => rs.map((r) => (r.id === id ? { ...r, ...p } : r))); + const add = () => + setRoles((rs) => [...rs, { id: RID++, name: "", system_prompt: "", runtime: "", model: "", skills: [] }]); + const remove = (id: number) => setRoles((rs) => rs.filter((r) => r.id !== id)); + const availableSkills = new Set((options.skills ?? []).map((s) => s.name)); + const addFromArchetype = (aid: string) => { + const a = MEMBER_ARCHETYPES.find((x) => x.id === aid); + if (!a) return; + setRoles((rs) => [ + ...rs, + { id: RID++, name: a.id, system_prompt: a.system_prompt, runtime: "", model: "", skills: a.suggestedSkills.filter((s) => availableSkills.has(s)) }, + ]); + }; + + const save = () => + start(async () => { + setError(null); + const payload = roles + .filter((r) => r.name.trim()) + .map(({ name, system_prompt, runtime, model, skills }) => ({ + name, + system_prompt, + runtime: runtime || undefined, + model: model || undefined, + skills, + })); + try { + const res = await fetch(`/api/namespaces/${ns}/teams/${name}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ roles: payload }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error?.message ?? `Save failed (${res.status})`); + } + router.refresh(); + setOpen(false); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } + }); + + const wired = options.runtimes.filter((r) => r.wired); + + if (!open) { + return ( + <button + onClick={() => setOpen(true)} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted" + > + Edit org + </button> + ); + } + + return ( + <ViewportPortal onClose={() => setOpen(false)}> + <div + role="dialog" + aria-modal="true" + aria-label="Edit team org" + className="fixed inset-0 z-[100] overflow-auto bg-surface p-5" + > + <div className="flex items-center justify-between"> + <div> + <p className="text-base font-semibold">Edit team org</p> + <p className="text-xs text-foreground-muted">Full-width role, harness, model, prompt, and skill editor</p> + </div> + <button onClick={() => setOpen(false)} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted"> + Close + </button> + </div> + <p className="mt-0.5 text-xs text-foreground-muted"> + A visual org: the team leads; each card is a member reporting to it. Edit a card in place, add + from an archetype, or remove. Saving reconciles the running org. + </p> + + {/* Visual org tree — principal on top, a spine, then member cards. */} + <div className="mt-4 flex flex-col items-center"> + <div className="w-full max-w-xs rounded-xl border border-signal/40 bg-signal/[0.08] px-4 py-2.5 text-center shadow-sm"> + <p className="truncate text-sm font-semibold">{name}</p> + <p className="text-[10px] font-semibold uppercase tracking-wide text-signal">Principal · team lead</p> + </div> + {roles.length > 0 && <div className="h-4 w-px bg-border" aria-hidden />} + {roles.length > 1 && <div className="h-px w-4/5 bg-border" aria-hidden />} + + <ul className="mt-0 flex w-full max-w-full flex-nowrap items-start justify-start gap-3 overflow-x-auto pb-2 sm:justify-center"> + {roles.map((r) => ( + <li key={r.id} className="flex flex-col items-center"> + <div className="h-3 w-px bg-border" aria-hidden /> + <div className="w-60 rounded-xl border border-border bg-surface p-3 shadow-sm"> + <div className="flex items-center gap-2"> + <input + value={r.name} + onChange={(e) => patch(r.id, { name: e.target.value })} + placeholder="role name (e.g. researcher)" + className="flex-1 rounded-lg border border-border bg-surface px-2 py-1 text-sm font-medium" + /> + <button + onClick={() => remove(r.id)} + title="Remove member" + className="rounded-lg border border-border px-1.5 py-1 text-xs text-foreground-muted hover:bg-surface-muted" + > + ✕ + </button> + </div> + <textarea + value={r.system_prompt} + onChange={(e) => patch(r.id, { system_prompt: e.target.value })} + rows={2} + placeholder="what this member does" + className="mt-2 w-full resize-y rounded-lg border border-border bg-surface px-2 py-1 text-[11px]" + /> + <div className="mt-2 grid grid-cols-1 gap-1.5"> + <select + value={r.model} + onChange={(e) => patch(r.id, { model: e.target.value })} + className="rounded-lg border border-border bg-surface px-2 py-1 text-[11px]" + > + <option value="">model: team default</option> + {options.models.map((m) => ( + <option key={`${m.provider}::${m.deployment}`} value={`${m.provider}::${m.deployment}`}>{m.deployment}</option> + ))} + </select> + <select + value={r.runtime} + onChange={(e) => patch(r.id, { runtime: e.target.value })} + className="rounded-lg border border-border bg-surface px-2 py-1 text-[11px]" + > + <option value="">harness: team default</option> + {wired.map((rt) => ( + <option key={rt.kind} value={rt.kind}>{rt.label}</option> + ))} + </select> + {r.skills.length > 0 && ( + <p className="text-[10px] text-foreground-muted">skills: {r.skills.join(", ")}</p> + )} + </div> + </div> + </li> + ))} + {/* Add-member node, visually part of the tree. */} + <li className="flex flex-col items-center"> + {roles.length > 0 && <div className="h-3 w-px bg-border" aria-hidden />} + <div className="grid w-60 place-items-center gap-2 rounded-xl border border-dashed border-border bg-surface-muted/30 p-3"> + <button onClick={add} className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium hover:bg-surface-muted"> + + Add member + </button> + <select + value="" + onChange={(e) => { if (e.target.value) addFromArchetype(e.target.value); e.target.value = ""; }} + className="w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-xs text-foreground-muted" + title="Add a pre-defined member archetype" + > + <option value="">+ from archetype…</option> + {MEMBER_ARCHETYPES.map((a) => ( + <option key={a.id} value={a.id}>{a.icon} {a.title}</option> + ))} + </select> + </div> + </li> + </ul> + </div> + + <div className="mt-4 flex items-center"> + <button + onClick={save} + disabled={pending} + className="ml-auto rounded-lg bg-signal px-4 py-1.5 text-xs font-semibold text-signal-fg disabled:opacity-50" + > + {pending ? "Saving…" : "Save org"} + </button> + </div> + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + </div> + </ViewportPortal> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx b/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx new file mode 100644 index 000000000..908b0ca33 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx @@ -0,0 +1,104 @@ +"use client"; + +// kars Bridge — Team console tabs. Tames the standing-team monolith: instead of +// eight stacked full-width sections, the body is organised into tabs (Overview / +// Org & access / Runs / Knowledge / Ledger). The header + "Now" hero stay above +// as a persistent summary. Each tab is server-rendered and passed in as a node. +// We mount only the tabs the user has actually opened (lazy), so a heavy tab — +// e.g. Knowledge with dozens of entry bodies — costs nothing in the DOM until +// viewed, then stays mounted to preserve scroll/interaction. Mirrors MissionTabs. + +import { useState, type KeyboardEvent, type ReactNode } from "react"; + +export type TeamTab = { + id: string; + label: string; + badge?: number | string | null; + node: ReactNode; + live?: boolean; +}; + +export function TeamTabs({ tabs, initial }: { tabs: TeamTab[]; initial?: string }) { + const visible = tabs.filter((t) => t.node); + const first = initial && visible.some((t) => t.id === initial) ? initial : visible[0]?.id; + const [active, setActive] = useState(first); + // If the active tab is no longer present (e.g. a live run's Activity tab + // disappears when the run ends), fall back to the first visible tab instead of + // rendering an empty body. + const activeId = visible.some((t) => t.id === active) ? active : visible[0]?.id; + // Track which tabs have been opened so we mount their node lazily (on first + // view) and keep it mounted thereafter — a heavy tab doesn't hit the DOM until + // the user actually navigates to it. + const [opened, setOpened] = useState<Set<string>>(() => new Set(first ? [first] : [])); + const open = (id: string) => { + setActive(id); + setOpened((prev) => (prev.has(id) ? prev : new Set(prev).add(id))); + }; + // Arrow-key roving-tab navigation (WAI-ARIA tabs pattern). + const onKey = (e: KeyboardEvent) => { + const idx = visible.findIndex((t) => t.id === activeId); + if (idx < 0) return; + let next = idx; + if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % visible.length; + else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + visible.length) % visible.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = visible.length - 1; + else return; + e.preventDefault(); + const id = visible[next]?.id; + if (id) { + open(id); + document.getElementById(`teamtab-${id}`)?.focus(); + } + }; + return ( + <div> + <div + role="tablist" + aria-label="Team sections" + onKeyDown={onKey} + className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" + > + {visible.map((t) => { + const on = t.id === activeId; + return ( + <button + key={t.id} + type="button" + role="tab" + id={`teamtab-${t.id}`} + aria-selected={on} + aria-controls={`teamtabpanel-${t.id}`} + tabIndex={on ? 0 : -1} + onClick={() => open(t.id)} + className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${on ? "bg-signal/10 text-foreground" : "text-foreground-muted hover:bg-surface-muted hover:text-foreground"}`} + > + {t.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} + {t.label} + {t.badge != null && t.badge !== 0 && ( + <span + className={`rounded-full px-1.5 text-[11px] tabular-nums ${on ? "bg-signal/20 text-signal" : "bg-surface-muted text-foreground-muted"}`} + > + {t.badge} + </span> + )} + </button> + ); + })} + </div> + <div className="kb-rise space-y-6"> + {visible.map((t) => ( + <div + key={t.id} + role="tabpanel" + id={`teamtabpanel-${t.id}`} + aria-labelledby={`teamtab-${t.id}`} + hidden={t.id !== activeId} + > + {opened.has(t.id) ? t.node : null} + </div> + ))} + </div> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx b/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx new file mode 100644 index 000000000..b196364d9 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx @@ -0,0 +1,277 @@ +"use client"; + +// kars Bridge — team task backlog. A standing team is a persistent org you +// assign discrete tasks to (a, b, c, d). Each run picks up the oldest pending +// task, works it, and marks it done — so a "finance" or "marketing" team keeps a +// visible, progressing worklist beyond its always-on charter. + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import type { TeamTask } from "@/lib/types"; +import { addTeamTask, deleteTeamTask, reviewTeamTask } from "./task-actions"; + +const STATUS_META: Record<string, { label: string; cls: string; glyph: string }> = { + pending: { + label: "Queued", + cls: "border-border bg-surface-muted text-foreground-muted", + glyph: "○", + }, + active: { + label: "Running", + cls: "border-sky-500/40 bg-sky-500/10 text-sky-600", + glyph: "◐", + }, + awaiting_review: { + label: "Awaiting review", + cls: "border-amber-500/40 bg-amber-500/10 text-amber-600", + glyph: "◆", + }, + done: { + label: "Done", + cls: "border-emerald-500/40 bg-emerald-500/10 text-emerald-600", + glyph: "✓", + }, +}; + +export function TeamTasks({ + team, + tasks, + paused, +}: { + team: string; + tasks: TeamTask[]; + paused: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState<string | null>(null); + const [reviewFeedback, setReviewFeedback] = useState<Record<string, string>>({}); + const [showCompleted, setShowCompleted] = useState(false); + + const pendingCount = tasks.filter((t) => t.status === "pending").length; + const activeCount = tasks.filter((t) => t.status === "active").length; + const completedCount = tasks.filter((t) => t.status === "done").length; + const completedIds = new Set(tasks.filter((task) => task.status === "done").map((task) => task.id)); + const visibleTasks = [ + ...tasks.filter((task) => task.status === "active"), + ...tasks.filter((task) => task.status === "awaiting_review"), + ...tasks.filter((task) => task.status === "pending"), + ...(showCompleted ? tasks.filter((task) => task.status === "done") : []), + ]; + + function submit() { + if (!title.trim()) return; + setError(null); + startTransition(async () => { + const res = await addTeamTask(team, title.trim(), description.trim()); + if (res.error) { + setError(res.error); + return; + } + setTitle(""); + setDescription(""); + router.refresh(); + }); + } + + function remove(id: string) { + setError(null); + startTransition(async () => { + const res = await deleteTeamTask(team, id); + if (res?.error) { + setError(res.error); + return; + } + router.refresh(); + }); + } + + function review(id: string, decision: "approve" | "request_changes") { + setError(null); + startTransition(async () => { + const result = await reviewTeamTask(team, id, decision, reviewFeedback[id]); + if (result.error) { + setError(result.error); + return; + } + router.refresh(); + }); + } + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Task backlog</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Discrete tasks this team works through, one per run — beyond its always-on charter. The + team picks up the oldest queued task on its next run and marks it done when delivered. + </p> + </div> + {tasks.length > 0 && ( + <span className="shrink-0 rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium text-foreground-muted"> + {activeCount > 0 ? `${activeCount} running · ` : ""} + {pendingCount} queued + </span> + )} + </div> + + {/* Add task */} + <div className="mt-4 rounded-lg border border-border bg-surface-muted/30 p-3"> + <input + type="text" + value={title} + onChange={(e) => setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }} + placeholder="Task title — e.g. Draft Q3 board deck outline" + className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Optional details, constraints, links…" + rows={2} + className="mt-2 w-full resize-y rounded-md border border-border bg-surface px-3 py-2 text-xs outline-none focus:border-signal" + /> + <div className="mt-2 flex items-center justify-between gap-3"> + <p className="text-[11px] text-foreground-muted"> + {paused + ? "Team is paused — queued tasks run once you resume it." + : "Runs on the next cadence tick, or immediately via Run now."} + </p> + <button + type="button" + disabled={pending || !title.trim()} + onClick={submit} + title={!title.trim() ? "Enter a task title first" : undefined} + className="rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {pending ? "Adding…" : "+ Add task"} + </button> + {!title.trim() && !pending && ( + <span className="text-[11px] text-foreground-muted">Enter a title to add</span> + )} + </div> + {error && <p className="mt-1.5 text-xs text-rose-600">{error}</p>} + </div> + + {/* Backlog list */} + {tasks.length === 0 ? ( + <p className="mt-4 text-xs text-foreground-muted"> + No tasks yet. Add one above to give this team discrete work to progress through. + </p> + ) : ( + <> + <ul className="mt-4 space-y-2"> + {visibleTasks.map((t) => { + const meta = STATUS_META[t.status] ?? STATUS_META.pending; + const blockedBy = t.depends_on.filter((dependency) => !completedIds.has(dependency)); + return ( + <li + key={t.id} + className="flex items-start justify-between gap-3 rounded-lg border border-border px-3 py-2.5" + > + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span + className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${meta.cls}`} + > + <span aria-hidden>{meta.glyph}</span> {meta.label} + </span> + <p className="truncate text-sm font-medium">{t.title}</p> + </div> + {t.description && ( + <p className="mt-0.5 line-clamp-2 text-xs text-foreground-muted"> + {t.description} + </p> + )} + {blockedBy.length > 0 && ( + <p className="mt-1 text-[11px] text-warning"> + Waiting for: {blockedBy.join(", ")} + </p> + )} + {t.acceptance_criteria.length > 0 && ( + <p className="mt-1 text-[11px] text-foreground-muted"> + Acceptance: {t.acceptance_criteria.join(" · ")} + </p> + )} + {t.run && ( + <Link + href={`/workspace/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(t.run)}`} + className="mt-1 inline-block text-[11px] text-signal hover:underline" + > + {t.status === "done" ? "View result →" : "View run →"} + </Link> + )} + {t.status === "awaiting_review" && ( + <div className="mt-2 space-y-2"> + <textarea + value={reviewFeedback[t.id] ?? ""} + onChange={(event) => + setReviewFeedback((current) => ({ + ...current, + [t.id]: event.target.value, + })) + } + rows={2} + placeholder="Feedback required when requesting changes" + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs" + /> + <div className="flex flex-wrap gap-2"> + <button + type="button" + disabled={pending} + onClick={() => review(t.id, "approve")} + className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-signal-fg disabled:opacity-50" + > + Approve milestone + </button> + <button + type="button" + disabled={pending || !(reviewFeedback[t.id] ?? "").trim()} + onClick={() => review(t.id, "request_changes")} + className="rounded-md border border-warning/50 px-2.5 py-1 text-[11px] font-semibold text-warning disabled:opacity-50" + > + Request changes + </button> + </div> + </div> + )} + </div> + <button + type="button" + disabled={pending} + onClick={() => remove(t.id)} + title="Remove from backlog" + className="shrink-0 rounded-md border border-border px-2 py-1 text-[11px] text-foreground-muted transition hover:bg-surface-muted hover:text-rose-600 disabled:opacity-50" + > + Remove + </button> + </li> + ); + })} + </ul> + {completedCount > 0 && ( + <button + type="button" + onClick={() => setShowCompleted((value) => !value)} + className="mt-3 text-[11px] font-medium text-signal hover:underline" + > + {showCompleted + ? "Hide completed work" + : `Show ${completedCount} completed item${completedCount === 1 ? "" : "s"}`} + </button> + )} + </> + )} + </section> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx b/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx new file mode 100644 index 000000000..17c120837 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx @@ -0,0 +1,168 @@ +"use client"; + +// kars Bridge Workspace — a team's live "watching" status. Shows a calm, +// continuously-updating countdown to the next charter tick so the operator can +// see, at a glance, that the team is actively on watch (not stalled). + +import { useEffect, useState } from "react"; +import type { TeamLifecycleMode, TeamRuntimeState } from "@/lib/types"; +import { formatWarmIdle } from "@/lib/format"; + +function fmtDelta(ms: number): string { + if (ms <= 0) return "any moment now"; + const s = Math.round(ms / 1000); + if (s < 60) return `in ${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + if (m < 60) return rem ? `in ${m}m ${rem}s` : `in ${m}m`; + const h = Math.floor(m / 60); + return `in ${h}h ${m % 60}m`; +} + +function fmtAgo(ms: number): string { + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m ago`; +} + +export function WatchingStatus({ + nextRunAt, + lastRunAt, + everyMinutes, + lifecycleMode, + warmIdleSeconds, + runtimeState, + currentAssignment, + idleDeadlineAt, + memoryEntries, + paused, + health, +}: { + nextRunAt: string | null; + lastRunAt: string | null; + everyMinutes: number | null; + lifecycleMode: TeamLifecycleMode; + warmIdleSeconds: number | null; + runtimeState: TeamRuntimeState | null; + currentAssignment: string | null; + idleDeadlineAt: string | null; + memoryEntries: number; + paused: boolean; + health?: string | null; +}) { + const [now, setNow] = useState<number | null>(null); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + if (paused) { + const detail = + lifecycleMode === "resourceOptimized" + ? "The principal runtime is suspended without losing team identity or approved memory. New work resumes it." + : lifecycleMode === "persistent" + ? "The persistent principal is explicitly suspended. It stays stopped until you resume the team." + : "No assignment runtime is allocated while the team is paused."; + return ( + <div className="rounded-xl border border-border bg-surface-muted p-5"> + <p className="text-sm font-medium">Hibernating</p> + <p className="mt-1 text-xs text-foreground-muted">{detail}</p> + </div> + ); + } + + const next = nextRunAt ? new Date(nextRunAt).getTime() : null; + const last = lastRunAt ? new Date(lastRunAt).getTime() : null; + const idleDeadline = idleDeadlineAt ? new Date(idleDeadlineAt).getTime() : null; + + const tone = runtimeState === "Working" + ? { + box: "border-sky-500/30 bg-sky-500/5", + dot: "bg-sky-500", + label: "Working", + note: currentAssignment ? `Current assignment: ${currentAssignment}` : "An assignment is active.", + } + : runtimeState === "Warm" + ? { + box: "border-emerald-500/30 bg-emerald-500/5", + dot: "bg-emerald-500", + label: lifecycleMode === "persistent" ? "Warm · persistent" : "Warm", + note: + idleDeadline != null && now != null + ? `Hibernates ${fmtDelta(idleDeadline - now)} unless new work arrives.` + : "Ready for the next assignment.", + } + : runtimeState === "Hibernating" + ? { + box: "border-border bg-surface-muted", + dot: "bg-foreground-muted", + label: "Hibernating", + note: "No runtime compute is active. Eligible work resumes the retained team identity.", + } + : health === "Stalled" + ? { + box: "border-rose-500/30 bg-rose-500/5", + dot: "bg-rose-500", + label: "On watch — recent runs failing", + note: "The last runs errored or timed out. It keeps its schedule; check the runs below.", + } + : health === "Unproductive" + ? { + box: "border-amber-500/30 bg-amber-500/5", + dot: "bg-amber-500", + label: "On watch — little new output", + note: "Recent runs completed but produced little new material for the commons.", + } + : { + box: "border-emerald-500/30 bg-emerald-500/5", + dot: "bg-emerald-500", + label: "Idle · ready", + note: null as string | null, + }; + + return ( + <div className={`rounded-xl border p-5 ${tone.box}`}> + <div className="flex items-center gap-2"> + <span className="relative flex h-2.5 w-2.5"> + <span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-70 ${tone.dot}`} /> + <span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${tone.dot}`} /> + </span> + <p className="text-sm font-medium">{tone.label}</p> + </div> + {tone.note && <p className="mt-1 text-xs text-foreground-muted">{tone.note}</p>} + <p className="mt-2 text-sm"> + {next != null ? ( + <> + Next check{" "} + <span className="font-medium"> + {now == null ? "scheduled" : fmtDelta(next - now)} + </span> + </> + ) : ( + "Standing by" + )} + {everyMinutes != null && ( + <span className="text-foreground-muted"> · every {everyMinutes} min</span> + )} + </p> + {last != null && ( + <p className="mt-1 text-xs text-foreground-muted"> + Last check {now == null ? "recorded" : fmtAgo(now - last)} + </p> + )} + <p className="mt-1 text-xs text-foreground-muted"> + {memoryEntries} approved memor{memoryEntries === 1 ? "y" : "ies"} available to the next assignment + </p> + <p className="mt-2 text-[11px] text-foreground-muted"> + {lifecycleMode === "persistent" + ? "Persistent runtime · remains ready until explicitly paused" + : lifecycleMode === "resourceOptimized" + ? `Resource optimized · suspends ${formatWarmIdle(warmIdleSeconds) === "immediately" ? "immediately when idle" : `after ${formatWarmIdle(warmIdleSeconds)} idle`}` + : "Ephemeral runtime · a clean sandbox is created for each assignment"} + </p> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/loading.tsx b/bridge/web/src/app/workspace/teams/loading.tsx new file mode 100644 index 000000000..2a4a12d65 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/loading.tsx @@ -0,0 +1,5 @@ +import { ListSkeleton } from "@/components/list-skeleton"; + +export default function Loading() { + return <ListSkeleton />; +} diff --git a/bridge/web/src/app/workspace/teams/new/actions.ts b/bridge/web/src/app/workspace/teams/new/actions.ts new file mode 100644 index 000000000..1f9779fdf --- /dev/null +++ b/bridge/web/src/app/workspace/teams/new/actions.ts @@ -0,0 +1,241 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { + authenticatedBffFetch, + BffError, + createTeam, + composeTeam, + putEngineeringSource, + type CreateRole, +} from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import type { ComposeTeamResponse, EngineeringSignal, ExecutionPlan, TeamLifecycleMode } from "@/lib/types"; + +export interface NewTeamState { error: string | null } + +/** Ask the orchestrator to compose an org chart from a charter (REQ: team + * orchestration, efficiency-driven). Falls back honestly when unavailable. */ +export async function composeTeamAction(charter: string): Promise<ComposeTeamResponse> { + try { + return await composeTeam(defaultNamespace(), charter); + } catch (error) { + const timedOut = error instanceof Error && error.name === "TimeoutError"; + return { + available: false, + reason: timedOut + ? "The org orchestrator did not finish its bounded proposal and repair workflow within five minutes — start from the editable suggested roster below." + : "The org orchestrator is unavailable — start from the editable suggested roster below.", + proposal: null, + rationale: null, + source: null, + }; + } +} + +export async function createTeamAction(_p: NewTeamState, form: FormData): Promise<NewTeamState> { + const displayName = String(form.get("display_name") ?? "").trim(); + const charter = String(form.get("charter") ?? "").trim(); + const slugify = (s: string) => + s.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); + // Auto-derive the slug from the display name or charter when the operator + // didn't type one, so "Create team" never silently no-ops on an empty Name + // (audit f41). An explicit Name still wins. + let name = slugify(String(form.get("name") ?? "")); + if (!name) name = slugify(displayName || charter); + const tier = Number(form.get("tier") ?? 3); + const cadence = Number(form.get("cadence") ?? 0); + const lifecycleMode = String(form.get("lifecycle_mode") ?? "resourceOptimized") as TeamLifecycleMode; + const warmIdleSeconds = Math.min( + Number.MAX_SAFE_INTEGER, + Math.max(0, Number(form.get("warm_idle_seconds") ?? 900)), + ); + const reporting = String(form.get("reporting_to") ?? "").trim(); + const toolPolicy = String(form.get("tool_policy") ?? "").trim(); + const runtime = String(form.get("runtime") ?? "").trim(); + const model = String(form.get("model") ?? "").trim(); + let modelFallbacks: string[] = []; + try { + const parsed = JSON.parse(String(form.get("model_fallbacks_json") ?? "[]")); + if (Array.isArray(parsed)) { + modelFallbacks = parsed + .filter((route): route is string => typeof route === "string") + .map((route) => route.trim()) + .filter(Boolean); + } + } catch { + modelFallbacks = []; + } + const mcpServers = String(form.get("mcp_servers") ?? "") + .split(",") + .map((server) => server.trim()) + .filter(Boolean); + const knowledgeCommons = String(form.get("knowledge_commons") ?? "").trim(); + const memory = String(form.get("memory") ?? "").trim(); + const egressMode = String(form.get("egress_mode") ?? "learning") === "strict" + ? "strict" + : "learning"; + let egress: { host: string; port?: number }[] = []; + try { + egress = JSON.parse(String(form.get("egress_json") ?? "[]")); + } catch { + egress = []; + } + // Governance: teams are created PAUSED by default. Launching is an explicit + // human approval — only auto-launch when the operator ticked "launch now". + const launch = String(form.get("launch") ?? "") === "on"; + let roles: CreateRole[] = []; + try { + roles = JSON.parse(String(form.get("roles_json") ?? "[]")); + } catch { + roles = []; + } + let milestones: Array<{ + id: string; + title: string; + description: string; + owner_role: string | null; + depends_on: string[]; + acceptance_criteria: string[]; + review_required: boolean; + }> = []; + try { + milestones = JSON.parse(String(form.get("milestones_json") ?? "[]")); + } catch { + milestones = []; + } + let executionPlan: ExecutionPlan | undefined; + try { + const parsed = JSON.parse(String(form.get("execution_plan_json") ?? "null")); + if (parsed && typeof parsed === "object") executionPlan = parsed as ExecutionPlan; + } catch { + executionPlan = undefined; + } + const gitWriteRepos = String(form.get("git_write_repos") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const engineeringEnabled = String(form.get("engineering_enabled") ?? "") === "true"; + const engineeringSignals = String(form.get("engineering_signals") ?? "") + .split(",") + .map((signal) => signal.trim()) + .filter(Boolean) as EngineeringSignal[]; + const engineeringPoll = Math.min( + 86400, + Math.max(300, Number(form.get("engineering_poll_interval_seconds") ?? 900)), + ); + const engineeringAutoRun = String(form.get("engineering_auto_run") ?? "") !== "false"; + if (!name || charter.length < 8) return { error: "A real charter (8+ characters) is required — the team name is derived from it if you leave Name blank." }; + if (engineeringEnabled && (gitWriteRepos.length === 0 || engineeringSignals.length === 0)) { + return { + error: + "Continuous engineering intake requires at least one connected repository and one signal.", + }; + } + const { currentPrincipal } = await import("@/lib/session"); + const principal = await currentPrincipal(); + try { + await createTeam(defaultNamespace(), { + name, charter, tier, + display_name: displayName || undefined, + cadence_minutes: cadence || undefined, + lifecycle_mode: lifecycleMode, + warm_idle_seconds: lifecycleMode === "resourceOptimized" ? warmIdleSeconds : undefined, + reporting_to: reporting || undefined, + tool_policy: toolPolicy || undefined, + runtime: runtime || undefined, + model: model || undefined, + model_fallbacks: modelFallbacks, + mcp_servers: mcpServers, + egress: egress.filter((entry) => entry.host?.trim()), + egress_mode: egressMode, + knowledge_commons: knowledgeCommons || undefined, + memory: memory || undefined, + launch: launch && milestones.length === 0, + roles: roles.filter((r) => r.name?.trim()), + execution_plan: executionPlan, + git_write_repos: gitWriteRepos.length ? gitWriteRepos : undefined, + created_by: principal.name, + }); + try { + for (const milestone of milestones) { + const response = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(defaultNamespace())}/teams/${encodeURIComponent(name)}/tasks`, + { + method: "POST", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: milestone.id, + title: milestone.title, + description: [ + milestone.description, + milestone.owner_role ? `Preferred owner role: ${milestone.owner_role}` : "", + ].filter(Boolean).join("\n\n"), + depends_on: milestone.depends_on, + acceptance_criteria: milestone.acceptance_criteria, + review_required: milestone.review_required, + }), + }, + ); + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw new Error( + payload?.error?.message + ?? `Milestone ${milestone.id || milestone.title} could not be created (${response.status}).`, + ); + } + } + if (engineeringEnabled) { + await putEngineeringSource(defaultNamespace(), name, { + enabled: true, + auto_run: engineeringAutoRun, + repos: gitWriteRepos, + signals: engineeringSignals, + poll_interval_seconds: engineeringPoll, + }); + } + if (launch && milestones.length > 0) { + const resume = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(defaultNamespace())}/teams/${encodeURIComponent(name)}`, + { + method: "PATCH", + cache: "no-store", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ paused: false }), + }, + ); + if (!resume.ok) { + const payload = await resume.json().catch(() => null); + throw new Error( + payload?.error?.message + ?? `Team milestones were created, but launch failed (${resume.status}).`, + ); + } + } + } catch (error) { + const cleanup = await authenticatedBffFetch( + `/api/namespaces/${encodeURIComponent(defaultNamespace())}/teams/${encodeURIComponent(name)}`, + { method: "DELETE", cache: "no-store" }, + ).catch(() => null); + if (cleanup == null || (!cleanup.ok && cleanup.status !== 404)) { + return { + error: + `Team ${name} was created, but milestone/intake setup and automatic cleanup both failed. ` + + "Open the team and either finish configuration or delete it explicitly.", + }; + } + throw error; + } + } catch (e) { + return { + error: + e instanceof BffError + ? e.message || e.code + : e instanceof Error + ? e.message + : "create failed", + }; + } + redirect(`/workspace/teams/${name}`); +} diff --git a/bridge/web/src/app/workspace/teams/new/page.tsx b/bridge/web/src/app/workspace/teams/new/page.tsx new file mode 100644 index 000000000..d36936264 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/new/page.tsx @@ -0,0 +1,66 @@ +// kars Bridge — New team. Server shell that loads the real cluster building +// blocks (models, harnesses) so the org-chart composer can offer per-role +// harness + model choices. When ?profile=<name> is present, the team is +// instantiated from that vetted KarsProfile (charter + roster + envelope +// prefilled). The composition itself happens client-side. + +import { getOptions, listProfiles } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import type { Options, ProfileSummary } from "@/lib/types"; +import { HonestState } from "@/components/honest-state"; +import { TeamComposer } from "./team-composer"; + +export const dynamic = "force-dynamic"; + +export default async function NewTeamPage({ + searchParams, +}: { + searchParams: Promise<{ profile?: string; intent?: string }>; +}) { + const { profile: profileName, intent } = await searchParams; + let options: Options | null = null; + let optionsError = false; + try { + options = await getOptions(); + const namespace = defaultNamespace(); + options = { + ...options, + mcp_servers: options.mcp_servers.filter((server) => server.namespace === namespace), + tool_policies: options.tool_policies.filter((policy) => policy.namespace === namespace), + memories: options.memories.filter((memory) => memory.namespace === namespace), + skills: options.skills.filter((skill) => skill.namespace === namespace), + }; + } catch { + optionsError = true; + } + let profile: ProfileSummary | null = null; + if (profileName) { + try { + const all = await listProfiles(); + profile = all.find((p) => p.name === profileName) ?? null; + } catch { + profile = null; + } + } + return ( + <div className="space-y-6"> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Set up a team</h1> + <p className="mt-1 text-sm text-foreground-muted"> + {profile + ? `Instantiating from the “${profile.display_name ?? profile.name}” profile — its charter, roles, and access are prefilled. Edit anything before you create.` + : "A standing org that works continuously under a charter. Describe the mandate; kars proposes an editable org chart — each role can run its own harness and model."} + </p> + </div> + {optionsError || !options ? ( + <HonestState + variant="not_wired" + title="Can’t set up a team right now" + detail="The run environment isn’t reachable, so the available models and harnesses couldn’t be loaded. Try again shortly." + /> + ) : ( + <TeamComposer options={options} profile={profile} initialCharter={intent} /> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/new/team-composer.tsx b/bridge/web/src/app/workspace/teams/new/team-composer.tsx new file mode 100644 index 000000000..22940b09c --- /dev/null +++ b/bridge/web/src/app/workspace/teams/new/team-composer.tsx @@ -0,0 +1,1011 @@ +"use client"; + +// kars Bridge — Team composer. Intent → a visual org chart. The user gives a +// charter; the composer proposes a principal + roles, each an editable node with +// its own harness, model, and skills (different members can run on different +// harnesses/models). This makes the team-orchestration flow legible: you SEE the +// org being designed before it is created. The roster is submitted with the team. + +import { useActionState, useEffect, useMemo, useRef, useState } from "react"; +import { createTeamAction, composeTeamAction, type NewTeamState } from "./actions"; +import { RepoAccess } from "@/components/repo-access"; +import type { + ComposeTeamMilestone, + ExecutionPlan, + Options, + ProfileSummary, + TeamLifecycleMode, + ValidationResult, +} from "@/lib/types"; +import { PreflightCheck } from "@/components/preflight-check"; +import { OrchestrationCube } from "@/components/orchestration-cube"; +import { LoopDesigner } from "@/components/loop-designer"; +import { Icon, type IconName } from "@/components/icon"; +import { MEMBER_ARCHETYPES } from "@/lib/member-archetypes"; + +const init: NewTeamState = { error: null }; + +type Role = { id: number; name: string; system_prompt: string; runtime: string; model: string; skills: string[] }; + +let RID = 1; + +function proposeRoles(): Omit<Role, "id">[] { + return []; +} + +function parseEgressLines(value: string): { host: string; port?: number }[] { + return value + .split(/\r?\n|,/) + .map((entry) => entry.trim()) + .filter(Boolean) + .flatMap((entry) => { + const match = entry.match(/^([^:]+?)(?::(\d{1,5}))?$/); + if (!match) return []; + const port = match[2] ? Number(match[2]) : undefined; + if (port != null && (port < 1 || port > 65535)) return []; + return [{ host: match[1].toLowerCase(), ...(port ? { port } : {}) }]; + }); +} + +function moveFallback(routes: string[], index: number, delta: number): string[] { + const next = index + delta; + if (next < 0 || next >= routes.length) return routes; + const copy = [...routes]; + [copy[index], copy[next]] = [copy[next], copy[index]]; + return copy; +} + +export function TeamComposer({ options, profile, initialCharter }: { options: Options; profile?: ProfileSummary | null; initialCharter?: string }) { + const availableSkillNames = useMemo( + () => new Set(options.skills.map((skill) => skill.name)), + [options.skills], + ); + const availableMcpNames = useMemo( + () => new Set(options.mcp_servers.map((server) => server.name)), + [options.mcp_servers], + ); + const availableMemoryNames = useMemo( + () => new Set(options.memories.map((entry) => entry.name)), + [options.memories], + ); + const [state, action, pending] = useActionState(createTeamAction, init); + const [name, setName] = useState(""); + const [displayName, setDisplayName] = useState(profile?.display_name ?? ""); + const [charter, setCharter] = useState(profile?.charter_template ?? initialCharter ?? ""); + const [tier, setTier] = useState(profile?.tier ?? 3); + const [addNote, setAddNote] = useState<string | null>(null); + useEffect(() => { if (!addNote) return; const t = setTimeout(() => setAddNote(null), 2500); return () => clearTimeout(t); }, [addNote]); + const [cadence, setCadence] = useState(0); + const [lifecycleMode, setLifecycleMode] = useState<TeamLifecycleMode>("resourceOptimized"); + const [warmIdleMinutes, setWarmIdleMinutes] = useState(15); + const [reporting, setReporting] = useState(""); + const [toolPolicy, setToolPolicy] = useState(profile?.tool_policy ?? ""); + const [runtime, setRuntime] = useState(""); + const [model, setModel] = useState(""); + const [modelFallbacks, setModelFallbacks] = useState<string[]>([]); + const [mcp, setMcp] = useState<string[]>([]); + const [memory, setMemory] = useState(""); + const [egressMode, setEgressMode] = useState<"learning" | "strict">("learning"); + const [egressText, setEgressText] = useState(""); + const [commons, setCommons] = useState(profile?.knowledge_commons ?? ""); + const [engineeringEnabled, setEngineeringEnabled] = useState(false); + const [engineeringSignals, setEngineeringSignals] = useState<Set<string>>(new Set()); + const [engineeringPoll, setEngineeringPoll] = useState(900); + const [engineeringAutoRun, setEngineeringAutoRun] = useState(true); + const [selectedRepos, setSelectedRepos] = useState<string[]>([]); + const [roles, setRoles] = useState<Role[]>( + () => + profile?.roles.map((r) => ({ + id: RID++, + name: r.name, + system_prompt: r.system_prompt ?? "", + runtime: "", + model: "", + skills: (r.skills ?? []).filter((skill) => availableSkillNames.has(skill)), + })) ?? [], + ); + const [milestones, setMilestones] = useState<ComposeTeamMilestone[]>([]); + const [executionPlan, setExecutionPlan] = useState<ExecutionPlan | null>(null); + const [executionPlanDraft, setExecutionPlanDraft] = useState(""); + const [executionPlanError, setExecutionPlanError] = useState<string | null>(null); + const [validation, setValidation] = useState<ValidationResult | null>(null); + const [validatedFingerprint, setValidatedFingerprint] = useState<string | null>(null); + const [launch, setLaunch] = useState(false); + // The team's effective package for the shared pre-flight — the same blueprint + // shape a mission validates. The controller defaults toolPolicy to kars-default + // and the model to the controller default, so we validate those effective + // values. Roster-level model overrides ride on top per member. + const teamBlueprint = useMemo(() => { + const roleModel = roles.map((r) => r.model).find((m) => m && m.includes("::")); + const effectiveModel = model.includes("::") ? model : roleModel; + const modelRoute = effectiveModel + ? { provider: effectiveModel.split("::")[0], deployment: effectiveModel.split("::")[1] } + : null; + return { + runtime: runtime.trim() || undefined, + tool_policy: toolPolicy.trim() || "kars-default", + model: modelRoute, + model_fallbacks: modelFallbacks.map((route) => { + const [provider, deployment] = route.split("::"); + return { provider, deployment }; + }), + mcp_servers: mcp, + memory: memory.trim() || undefined, + skills: [...new Set(roles.flatMap((role) => role.skills))], + egress: parseEgressLines(egressText), + egress_mode: egressMode, + execution_plan: executionPlan, + }; + }, [runtime, toolPolicy, roles, mcp, memory, model, modelFallbacks, egressText, egressMode, executionPlan]); + const teamFingerprint = useMemo( + () => JSON.stringify({ blueprint: teamBlueprint, tier }), + [teamBlueprint, tier], + ); + const currentValidation = validatedFingerprint === teamFingerprint ? validation : null; + const selectedMemoryOption = useMemo( + () => options.memories.find((entry) => entry.name === memory) ?? null, + [memory, options.memories], + ); + // When instantiating from a profile, skip the charter-intent step and go + // straight to the editable org chart (everything is already prefilled). + const [composed, setComposed] = useState(!!profile); + const [composing, setComposing] = useState(false); + const [rationale, setRationale] = useState<string | null>( + profile ? `Prefilled from the “${profile.display_name ?? profile.name}” profile (${profile.domain ?? "team"} domain). Edit anything before you create.` : null, + ); + const [modelBasis, setModelBasis] = useState<string | null>(null); + const [expectedTokens, setExpectedTokens] = useState<number | null>(null); + const [efficiencyRuns, setEfficiencyRuns] = useState(0); + const [composeNote, setComposeNote] = useState<string | null>(null); + + async function compose() { + if (charter.trim().length < 8) return; + setComposing(true); + setRationale(null); + setModelBasis(null); + setExpectedTokens(null); + setEfficiencyRuns(0); + setComposeNote(null); + try { + const res = await composeTeamAction(charter.trim()); + if (res.available && res.proposal) { + // AI orchestrator composed the org — adopt its roster + team settings. + const p = res.proposal; + setTier(p.tier); + setCadence(p.cadence_minutes); + setModel(p.model ?? ""); + setModelFallbacks(p.model_fallbacks ?? []); + setMcp((p.mcp_servers ?? []).filter((server) => availableMcpNames.has(server))); + setMemory(p.memory && availableMemoryNames.has(p.memory) ? p.memory : ""); + setEgressMode(p.egress_mode ?? "learning"); + setEgressText( + (p.egress ?? []) + .map((entry) => `${entry.host}${entry.port ? `:${entry.port}` : ""}`) + .join("\n"), + ); + setEngineeringEnabled(p.engineering_enabled); + setEngineeringSignals(new Set(p.engineering_signals ?? [])); + setEngineeringPoll(p.engineering_poll_interval_seconds ?? 900); + setEngineeringAutoRun(p.engineering_auto_run ?? true); + setExecutionPlan(p.execution_plan); + setExecutionPlanDraft(p.execution_plan ? JSON.stringify(p.execution_plan, null, 2) : ""); + setExecutionPlanError(null); + const proposedRoles = p.roles.filter((role) => role.name.trim().toLowerCase() !== "principal"); + const unavailableSkillCount = proposedRoles.reduce( + (count, role) => + count + (role.skills ?? []).filter((skill) => !availableSkillNames.has(skill)).length, + 0, + ); + setRoles( + (proposedRoles.length ? proposedRoles : proposeRoles()).map((r) => ({ + id: RID++, + name: r.name, + system_prompt: r.system_prompt, + runtime: "runtime" in r ? r.runtime : "", + model: "model" in r ? r.model : "", + skills: (r.skills ?? []).filter((skill) => availableSkillNames.has(skill)), + })), + ); + if (unavailableSkillCount > 0) { + setComposeNote( + `${unavailableSkillCount} suggested skill${unavailableSkillCount === 1 ? "" : "s"} ` + + "were not in the live attested catalogue and were left out.", + ); + } + setMilestones(p.milestones ?? []); + setRationale(res.rationale ?? null); + setModelBasis(p.model_basis ?? null); + setExpectedTokens(p.expected_tokens_per_outcome ?? null); + setEfficiencyRuns(p.efficiency_sample_runs ?? 0); + } else { + // Honest fallback to the heuristic starter roster. + setComposeNote(res.reason ?? null); + setRoles(proposeRoles().map((r) => ({ ...r, id: RID++ }))); + setExecutionPlan(null); + setExecutionPlanDraft(""); + setMilestones([]); + if (/dependabot|code quality|code scanning|security finding|repository maintenance/i.test(charter)) { + setEngineeringEnabled(true); + setEngineeringSignals( + new Set([ + "dependabot_pr", + "dependabot_alert", + "code_scanning_alert", + "secret_scanning_alert", + ]), + ); + setEngineeringAutoRun(true); + } + } + } catch { + setComposeNote("Couldn't reach the orchestrator — starting from a suggested roster you can edit."); + setRoles(proposeRoles().map((r) => ({ ...r, id: RID++ }))); + setExecutionPlan(null); + setExecutionPlanDraft(""); + setMilestones([]); + } finally { + setComposing(false); + setComposed(true); + } + } + + // Unified intake: when arriving with a prefilled charter (from the single + // intent-first entry that already classified this as standing team work), + // compose the org chart immediately — the user lands on the editable roster, + // not an empty charter box. Profiles already arrive pre-composed. + const autoRan = useRef(false); + useEffect(() => { + if (!autoRan.current && !profile && initialCharter && initialCharter.trim().length >= 8) { + autoRan.current = true; + void compose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const addRole = () => setRoles((r) => [...r, { id: RID++, name: "", system_prompt: "", runtime: "", model: "", skills: [] }]); + // Drop a reusable member archetype (e.g. Rust Engineer) into the roster, + // pre-filled with its charge; runtime/model stay unset to inherit the team + // default. Skills are applied only if this cluster actually has them. + const addFromArchetype = (id: string) => { + const a = MEMBER_ARCHETYPES.find((x) => x.id === id); + if (!a) return; + // B2: dedup — the archetype dropdown gives no confirmation and resets to + // its placeholder, so users spam-click it thinking nothing happened and + // spray duplicate roles. Adding an archetype already in the roster is a + // no-op (a role can still be added manually via "+ Add role" if a second + // instance is genuinely wanted). Also flash a visible note so the add isn't + // silent (audit B2). + setRoles((r) => { + if (r.some((x) => x.name === a.id)) { + setAddNote(`“${a.title ?? a.id}” is already in the roster`); + return r; + } + setAddNote(`Added “${a.title ?? a.id}” to the roster`); + return [ + ...r, + { + id: RID++, + name: a.id, + system_prompt: a.system_prompt, + runtime: "", + model: "", + skills: a.suggestedSkills.filter((s) => availableSkillNames.has(s)), + }, + ]; + }); + }; + const removeRole = (id: number) => setRoles((r) => r.filter((x) => x.id !== id)); + const patchRole = (id: number, p: Partial<Role>) => setRoles((r) => r.map((x) => (x.id === id ? { ...x, ...p } : x))); + const addMilestone = () => + setMilestones((current) => [ + ...current, + { + id: `milestone-${current.length + 1}`, + title: "", + description: "", + owner_role: null, + depends_on: current.length > 0 ? [current[current.length - 1].id] : [], + acceptance_criteria: [], + review_required: false, + }, + ]); + const patchMilestone = (index: number, patch: Partial<ComposeTeamMilestone>) => + setMilestones((current) => + current.map((milestone, currentIndex) => + currentIndex === index ? { ...milestone, ...patch } : milestone, + ), + ); + + const rolesJson = useMemo(() => JSON.stringify(roles.map(({ name, system_prompt, runtime, model, skills }) => ({ name, system_prompt, runtime, model, skills }))), [roles]); + const milestonesJson = useMemo(() => JSON.stringify(milestones), [milestones]); + const executionPlanJson = useMemo( + () => JSON.stringify(executionPlan), + [executionPlan], + ); + + // Step 1 — intent. + if (!composed) { + return ( + <div className="space-y-5"> + {composing && ( + <OrchestrationCube + title="Composing your team" + done={false} + active={0} + phases={[ + { icon: "layers", label: "Reading the cluster palette", detail: `${options.models.length} model${options.models.length === 1 ? "" : "s"} · ${options.runtimes.filter((r) => r.wired).length} harness${options.runtimes.filter((r) => r.wired).length === 1 ? "" : "es"}` }, + { icon: "branch", label: "Proposing the org chart", detail: "principal + roles, each a bounded subset of the team envelope" }, + { icon: "gear", label: "Assigning per-role harness & model", detail: "informed by the efficiency frontier where available" }, + { icon: "note", label: "Preparing the org for review", detail: charter.trim().slice(0, 72) || "your charter" }, + ]} + /> + )} + <div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]"> + <div className="kb-card kb-canvas p-6"> + <h2 className="text-sm font-semibold">What should this team do?</h2> + <p className="mt-0.5 text-xs text-foreground-muted">A standing team runs continuously under a charter. Describe its mandate — kars proposes an org chart you can shape.</p> + <label className="mt-4 block text-xs font-medium text-foreground-muted">Charter</label> + <textarea value={charter} onChange={(e) => setCharter(e.target.value)} rows={5} autoFocus placeholder="e.g. Keep the kars repo healthy: triage new issues, watch open PRs, and report failing checks to the steering inbox." className="mt-1.5 w-full resize-y rounded-xl border border-border bg-surface px-4 py-3 text-sm leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" /> + {/* Loop engineering: the standing team runs its charter as a loop on + every cadence tick; design it here so the loop + eval criteria + reach the harness and every sub-agent the principal spawns. */} + <div className="mt-3"> + <LoopDesigner surface="team" initialGoal={charter} onApply={setCharter} /> + </div> + <div className="mt-5 flex justify-end"> + <button type="button" onClick={compose} disabled={charter.trim().length < 8 || composing} className="inline-flex items-center gap-2 rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg disabled:opacity-50"> + {composing ? ( + <> + <span className="relative flex h-2 w-2" aria-hidden> + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-signal-fg/70" /> + <span className="relative inline-flex h-2 w-2 rounded-full bg-signal-fg" /> + </span> + Composing the org… + </> + ) : ( + "Compose the org →" + )} + </button> + </div> + </div> + <div className="kb-card p-6"> + <h2 className="text-sm font-semibold">How a team is built</h2> + <ul className="mt-4 space-y-2.5"> + {([["person", "Principal", "the team itself — holds the trust envelope and reporting line"], ["handshake", "Roles", "members that each do part of the charter"], ["gear", "Per-role harness & model", "different members can run different harnesses/models"], ["bolt", "Skills", "versioned capability bundles a role acquires"], ["loop", "Cadence", "how often the team wakes to act"]] as [IconName, string, string][]).map(([i, t, d]) => ( + <li key={t} className="flex items-start gap-2.5"><span className="mt-0.5"><Icon name={i} size={15} /></span><div><p className="text-sm font-medium">{t}</p><p className="text-xs text-foreground-muted">{d}</p></div></li> + ))} + </ul> + <p className="mt-5 rounded-lg bg-surface-muted/60 px-3 py-2 text-[11px] text-foreground-muted">Each role’s authority is a verified subset of the team’s. Nothing runs until you create + launch.</p> + </div> + </div> + </div> + ); + } + + // Step 2 — the org chart, editable. + return ( + <form action={action} className="space-y-5"> + <input type="hidden" name="charter" value={charter} /> + <input type="hidden" name="tier" value={tier} /> + <input type="hidden" name="cadence" value={cadence} /> + <input type="hidden" name="lifecycle_mode" value={lifecycleMode} /> + <input type="hidden" name="warm_idle_seconds" value={warmIdleMinutes * 60} /> + <input type="hidden" name="reporting_to" value={reporting} /> + <input type="hidden" name="display_name" value={displayName} /> + <input type="hidden" name="tool_policy" value={toolPolicy} /> + <input type="hidden" name="runtime" value={runtime} /> + <input type="hidden" name="model" value={model} /> + <input type="hidden" name="model_fallbacks_json" value={JSON.stringify(modelFallbacks)} /> + <input type="hidden" name="mcp_servers" value={mcp.join(",")} /> + <input type="hidden" name="memory" value={memory} /> + <input type="hidden" name="egress_mode" value={egressMode} /> + <input type="hidden" name="egress_json" value={JSON.stringify(parseEgressLines(egressText))} /> + <input type="hidden" name="execution_plan_json" value={executionPlanJson} /> + <input type="hidden" name="knowledge_commons" value={commons} /> + <input type="hidden" name="roles_json" value={rolesJson} /> + <input type="hidden" name="milestones_json" value={milestonesJson} /> + <input type="hidden" name="engineering_enabled" value={engineeringEnabled ? "true" : "false"} /> + <input type="hidden" name="engineering_signals" value={[...engineeringSignals].join(",")} /> + <input type="hidden" name="engineering_poll_interval_seconds" value={engineeringPoll} /> + <input type="hidden" name="engineering_auto_run" value={engineeringAutoRun ? "true" : "false"} /> + + {rationale && ( + <div className="rounded-xl border border-signal/30 bg-signal/5 p-4"> + <p className="text-xs font-semibold text-foreground">Why this org</p> + <p className="mt-1 text-sm text-foreground-muted">{rationale}</p> + {modelBasis && ( + <p className="mt-2 text-xs text-foreground"> + <span className="font-medium">Model route:</span> {modelBasis} + </p> + )} + <p className="mt-2 text-[11px] text-foreground-muted"> + {expectedTokens != null + ? `Historical expectation: about ${expectedTokens.toLocaleString()} tokens per delivered outcome across ${efficiencyRuns} retained run${efficiencyRuns === 1 ? "" : "s"}. ` + : "No reliable cost expectation is available yet. "} + Composed from your charter, the cluster's live capabilities, and retained efficiency + evidence. Edit anything below. + </p> + </div> + )} + {composeNote && ( + <div className="rounded-xl border border-border bg-surface-muted/50 p-4 text-sm text-foreground-muted"> + {composeNote} + </div> + )} + + <div className="kb-card p-5 sm:p-6"> + <h2 className="text-sm font-semibold">Team basics</h2> + <fieldset className="mt-3 rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="person" size={13} /> Identity</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="text-xs text-foreground-muted">Name<input name="name" value={name} onChange={(e) => setName(e.target.value)} required placeholder="repo-watch" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /></label> + <label className="text-xs text-foreground-muted">Display name (optional)<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Repo Watch" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /></label> + </div> + </fieldset> + <fieldset className="mt-3 rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="loop" size={13} /> Cadence & authority</legend> + <div className="grid gap-3 sm:grid-cols-3"> + <label className="text-xs text-foreground-muted">Autonomy tier<select value={tier} onChange={(e) => setTier(Number(e.target.value))} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"><option value={1}>1 Manual</option><option value={2}>2 Shared</option><option value={3}>3 Conditional</option><option value={4}>4 Supervised</option><option value={5}>5 Full</option></select></label> + <label className="text-xs text-foreground-muted">Cadence (min, 0 = passive)<input type="number" min={0} value={cadence} onChange={(e) => setCadence(Number(e.target.value))} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /></label> + <label className="text-xs text-foreground-muted">Reports to (optional)<input value={reporting} onChange={(e) => setReporting(e.target.value)} placeholder="steering inbox" className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" /></label> + </div> + </fieldset> + <fieldset className="mt-3 rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="box" size={13} /> Runtime lifecycle</legend> + <div className="grid gap-3 sm:grid-cols-3"> + <label className="text-xs text-foreground-muted"> + Retention mode + <select + value={lifecycleMode} + onChange={(event) => setLifecycleMode(event.target.value as TeamLifecycleMode)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + <option value="resourceOptimized">Resource optimized (recommended)</option> + <option value="persistent">Persistent</option> + <option value="ephemeral">Ephemeral</option> + </select> + </label> + {lifecycleMode === "resourceOptimized" && ( + <label className="text-xs text-foreground-muted"> + Warm idle window (minutes) + <input + type="number" + min={0} + value={warmIdleMinutes} + onChange={(event) => setWarmIdleMinutes(Number(event.target.value))} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + </label> + )} + <p className="self-end text-xs text-foreground-muted sm:col-span-1"> + {lifecycleMode === "persistent" + ? "Keeps the principal ready until you pause the team." + : lifecycleMode === "resourceOptimized" + ? "Reuses the same principal while warm, then suspends it without losing identity or memory." + : "Starts a clean isolated runtime for each assignment and tears it down after evidence is retained."} + </p> + </div> + </fieldset> + {/* Advanced governance — real team-level access controls the create API + supports: the tool policy that bounds every run, and the shared + knowledge commons its runs read/write. Defaults are safe when unset. */} + <details className="mt-3"> + <summary className="cursor-pointer text-xs font-medium text-foreground-muted hover:text-foreground"> + Advanced governance & access{mcp.length > 0 ? ` · ${mcp.length} MCP selected` : ""} + </summary> + <fieldset className="mt-3 rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="shield" size={13} /> Governance & access</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="text-xs text-foreground-muted"> + Tool policy + <select aria-label="Tool policy" value={toolPolicy} onChange={(e) => setToolPolicy(e.target.value)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">cluster default (kars-default)</option> + {options.tool_policies.map((tp) => <option key={tp.name} value={tp.name}>{tp.name}{tp.summary ? ` — ${tp.summary}` : ""}</option>)} + </select> + </label> + <label className="text-xs text-foreground-muted"> + Knowledge commons name + <input + value={commons} + onChange={(e) => setCommons(e.target.value)} + placeholder={`${name || "<team>"} (default)`} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + <span className="mt-1 block text-[11px] text-foreground-muted"> + The team's durable shared archive and backlog namespace. Leave blank to use the + team default. + </span> + </label> + <label className="text-xs text-foreground-muted"> + Runtime memory backend + <select + aria-label="Team runtime memory" + value={memory} + onChange={(e) => setMemory(e.target.value)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + <option value="">None — rely on the team's own commons</option> + {options.memories.map((entry) => ( + <option key={entry.name} value={entry.name}> + {entry.name} + {entry.summary ? ` · ${entry.summary}` : ""} + {entry.qualified_routes?.length ? " · qualified" : " · unqualified"} + </option> + ))} + </select> + {selectedMemoryOption && ( + <span className="mt-1 block text-[11px] text-foreground-muted"> + {selectedMemoryOption.backend ?? "unknown backend"} + {selectedMemoryOption.compiled_digest ? ` · digest ${selectedMemoryOption.compiled_digest}` : ""} + {selectedMemoryOption.readiness ? ` · ${selectedMemoryOption.readiness}` : ""} + {selectedMemoryOption.qualified_routes?.length + ? ` · qualified on ${selectedMemoryOption.qualified_routes.join(", ")}` + : " · not resource-qualified"} + </span> + )} + </label> + <label className="text-xs text-foreground-muted"> + Harness (runtime for every run) + <select aria-label="Team harness" value={runtime} onChange={(e) => setRuntime(e.target.value)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">sandbox default (OpenClaw)</option> + {options.runtimes.filter((rt) => rt.wired).map((rt) => <option key={rt.kind} value={rt.kind}>{rt.label}</option>)} + </select> + </label> + <label className="text-xs text-foreground-muted"> + Principal/default model + <select aria-label="Team principal model" value={model} onChange={(event) => { + const route = event.target.value; + setModel(route); + setModelFallbacks((current) => current.filter((fallback) => fallback !== route)); + }} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">cluster default</option> + {options.models.map((option) => ( + <option key={`${option.provider}::${option.deployment}`} value={`${option.provider}::${option.deployment}`}> + {option.deployment} · {option.provider}{option.is_default ? " (default)" : ""} + </option> + ))} + </select> + </label> + <label className="text-xs text-foreground-muted sm:col-span-2"> + Qualified fallback routes + <select + multiple + aria-label="Team model fallback routes" + value={modelFallbacks} + onChange={(event) => { + const selected = new Set( + Array.from(event.currentTarget.selectedOptions, (option) => option.value), + ); + setModelFallbacks((current) => [ + ...current.filter((route) => selected.has(route)), + ...Array.from(selected).filter((route) => !current.includes(route)), + ].slice(0, 8)); + }} + className="mt-1 min-h-24 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + {options.models + .map((option) => `${option.provider}::${option.deployment}`) + .filter((route) => route !== model) + .map((route) => ( + <option key={route} value={route}> + {route} + </option> + ))} + </select> + {modelFallbacks.map((route, index) => ( + <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> + <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> + <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setModelFallbacks((current) => moveFallback(current, index, -1))}>↑</button> + <button type="button" aria-label={`Move ${route} later`} disabled={index === modelFallbacks.length - 1} onClick={() => setModelFallbacks((current) => moveFallback(current, index, 1))}>↓</button> + </span> + ))} + <span className="mt-1 block text-[11px]"> + Bridge accepts a fallback only when retained evidence proves the complete Team plan and resources on that route. + </span> + </label> + <fieldset className="sm:col-span-2"> + <legend className="text-xs text-foreground-muted">Connected services (MCP)</legend> + {options.mcp_servers.length === 0 ? ( + <p className="mt-1.5 text-xs text-foreground-muted">No MCP servers are installed.</p> + ) : ( + <div className="mt-1.5 grid gap-2 sm:grid-cols-2"> + {options.mcp_servers.map((server) => { + const checked = mcp.includes(server.name); + return ( + <label key={server.name} className="flex items-start gap-2 rounded-lg border border-border px-3 py-2 text-sm"> + <input + type="checkbox" + checked={checked} + disabled={!checked && mcp.length >= 8} + onChange={(event) => + setMcp((current) => + event.target.checked + ? [...new Set([...current, server.name])] + : current.filter((name) => name !== server.name), + ) + } + className="mt-0.5 h-3.5 w-3.5 rounded border-border" + /> + <span> + <span className="font-medium text-foreground">{server.name}</span> + {server.summary && <span className="block text-[11px] text-foreground-muted">{server.summary}</span>} + <span className="block text-[11px] text-foreground-muted"> + {server.mode ? `mode ${server.mode}` : "mode unknown"} + {server.discovered_tools?.length ? ` · tools ${server.discovered_tools.slice(0, 4).join(", ")}` : ""} + {server.tool_schema_digest ? ` · schema ${server.tool_schema_digest}` : " · schema missing"} + {server.qualified_routes?.length + ? ` · qualified ${server.qualified_routes.join(", ")}` + : " · not resource-qualified"} + </span> + </span> + </label> + ); + })} + </div> + )} + </fieldset> + <label className="text-xs text-foreground-muted"> + Egress mode + <select value={egressMode} onChange={(event) => setEgressMode(event.target.value as "learning" | "strict")} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="learning">Learning · observe new public hosts</option> + <option value="strict">Strict · enforce reviewed hosts only</option> + </select> + </label> + <label className="text-xs text-foreground-muted sm:col-span-2"> + External hosts (one host[:port] per line) + <textarea value={egressText} onChange={(event) => setEgressText(event.target.value)} rows={3} placeholder={"api.example.com:443\nstatus.example.com:443"} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + <span className="mt-1 block text-[11px] text-foreground-muted">Public DNS only. Private/internal targets stay fail-closed; expose those through an approved in-cluster MCP or service integration.</span> + </label> + </div> + <p className="mt-2 text-[11px] text-foreground-muted">Every team run is bounded by a tool policy — leave unset to inherit the cluster default. Selected MCP services and the runtime memory backend are inherited by every run and validated before launch. The knowledge commons remains the team's durable shared archive. The harness is the runtime every run executes on (a chat-only adapter is corrected to OpenClaw).</p> + </fieldset> + </details> + <p className="mt-3 text-xs text-foreground-muted">Charter: <span className="text-foreground">{charter}</span></p> + </div> + + {/* The org chart. */} + <div className="kb-card p-5 sm:p-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-sm font-semibold">Org chart</h2> + <p className="mt-0.5 text-xs text-foreground-muted">Each role’s authority is a verified subset of the team’s — members can run different harnesses & models.</p> + </div> + <div className="flex items-center gap-2"> + <select + value="" + onChange={(e) => { if (e.target.value) addFromArchetype(e.target.value); e.target.value = ""; }} + className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-muted hover:bg-surface-muted" + title="Add a pre-defined member archetype (e.g. Rust Engineer, Financial Analyst)" + > + <option value="">+ Add from archetype…</option> + {MEMBER_ARCHETYPES.map((a) => ( + <option key={a.id} value={a.id}>{a.icon} {a.title}</option> + ))} + </select> + <button type="button" onClick={addRole} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">+ Add role</button> + </div> + {addNote && ( + <p role="status" aria-live="polite" className="mt-1.5 text-right text-[11px] font-medium text-signal">{addNote}</p> + )} + </div> + + {/* Principal node */} + <div className="mt-4 rounded-xl border border-signal/40 bg-signal/[0.05] p-3"> + <div className="flex items-center justify-between"> + <p className="text-sm font-semibold">{name || "this team"} <span className="font-normal text-foreground-muted">· Principal</span></p> + <span className="text-xs text-foreground-muted">Tier {tier} · grants members up to Tier {Math.max(1, tier - 1)}</span> + </div> + </div> + + {/* Role nodes — connected to the principal as a visual org tree. */} + <div className="relative mt-3 space-y-3 kb-stagger sm:pl-6"> + <span aria-hidden className="pointer-events-none absolute left-3 top-0 hidden h-full w-px bg-border sm:block" /> + {roles.map((r) => ( + <div key={r.id} className="relative rounded-xl border border-border bg-surface p-3"> + <span aria-hidden className="pointer-events-none absolute -left-3 top-6 hidden h-px w-3 bg-border sm:block" /> + <div className="flex items-center gap-2"> + <input value={r.name} onChange={(e) => patchRole(r.id, { name: e.target.value })} placeholder="role name (e.g. triager)" className="flex-1 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm font-medium" /> + <span className="text-[11px] text-foreground-muted">Member</span> + <button type="button" onClick={() => removeRole(r.id)} className="text-xs text-foreground-muted hover:text-danger">Remove</button> + </div> + <textarea value={r.system_prompt} onChange={(e) => patchRole(r.id, { system_prompt: e.target.value })} rows={2} placeholder="what this role does…" className="mt-2 w-full resize-y rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" /> + <div className="mt-2 grid gap-2 sm:grid-cols-2"> + <select aria-label="Role model" value={r.model} onChange={(e) => patchRole(r.id, { model: e.target.value })} className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs"> + <option value="">model: team default</option> + {options.models.map((m) => <option key={`${m.provider}::${m.deployment}`} value={`${m.provider}::${m.deployment}`}>{m.deployment}</option>)} + </select> + <select aria-label="Role harness" value={r.runtime} onChange={(e) => patchRole(r.id, { runtime: e.target.value })} className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs"> + <option value="">harness: OpenClaw</option> + {options.runtimes.filter((rt) => rt.wired).map((rt) => <option key={rt.kind} value={rt.kind}>{rt.label}</option>)} + </select> + </div> + {/* Per-role skills — a real picker from the attested KarsSkills the + cluster offers, so "Skills" isn't a taught concept with no control. */} + {options.skills.length > 0 ? ( + <div className="mt-2"> + <p className="text-[11px] text-foreground-muted">Skills (attested capability bundles this role acquires)</p> + <div className="mt-1 flex flex-wrap gap-1.5"> + {options.skills.map((sk) => { + const on = r.skills.includes(sk.name); + return ( + <button + key={sk.name} + type="button" + title={[ + sk.summary, + sk.version ? `version ${sk.version}` : null, + sk.version_digest ? `digest ${sk.version_digest}` : null, + sk.recipe ? `recipe ${sk.recipe}` : null, + sk.qualified_routes?.length + ? `qualified ${sk.qualified_routes.join(", ")}` + : "not resource-qualified", + ].filter(Boolean).join(" · ") || undefined} + onClick={() => + patchRole(r.id, { + skills: on ? r.skills.filter((x) => x !== sk.name) : [...r.skills, sk.name], + }) + } + className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${ + on ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground" + }`} + > + {on ? "✓ " : ""}{sk.name} + </button> + ); + })} + </div> + </div> + ) : ( + r.skills.length > 0 && ( + <p className="mt-2 text-[11px] text-foreground-muted">Skills: {r.skills.join(", ")}</p> + ) + )} + </div> + ))} + {roles.length === 0 && <p className="text-xs text-foreground-muted">No roles — add at least one, or the team runs as a single principal.</p>} + </div> + </div> + + <div className="kb-card p-5 sm:p-6"> + <h2 className="text-sm font-semibold">Typed execution plan</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Explicit dependencies, phases, capabilities, tool-call bounds, and synthesis. No permissions are inferred from role names. + </p> + {executionPlanDraft ? ( + <> + <textarea + value={executionPlanDraft} + onChange={(event) => { + const next = event.target.value; + setExecutionPlanDraft(next); + try { + const parsed = JSON.parse(next) as ExecutionPlan; + setExecutionPlan(parsed); + setExecutionPlanError(null); + } catch { + setExecutionPlanError("The execution plan must be valid JSON."); + } + }} + rows={18} + spellCheck={false} + className="mt-3 w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + {executionPlanError && ( + <p className="mt-2 text-xs text-danger">{executionPlanError}</p> + )} + </> + ) : ( + <p className="mt-3 rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger"> + No execution plan is available. Re-run composition before creating the team. + </p> + )} + </div> + + <div className="kb-card p-5 sm:p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Milestone graph</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Durable, resumable work packets. A milestone runs only after every dependency is done; + acceptance criteria and artifact ownership travel with the assignment. + </p> + </div> + <button + type="button" + onClick={addMilestone} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted" + > + + Add milestone + </button> + </div> + <div className="mt-4 space-y-3"> + {milestones.map((milestone, index) => ( + <div key={`${milestone.id}-${index}`} className="rounded-xl border border-border bg-surface p-3"> + <div className="grid gap-2 sm:grid-cols-[0.8fr_1.5fr_auto]"> + <input + value={milestone.id} + onChange={(event) => patchMilestone(index, { id: event.target.value })} + placeholder="stable-id" + className="rounded-lg border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" + /> + <input + value={milestone.title} + onChange={(event) => patchMilestone(index, { title: event.target.value })} + placeholder="Milestone title" + className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm font-medium" + /> + <button + type="button" + onClick={() => setMilestones((current) => current.filter((_, i) => i !== index))} + className="text-xs text-foreground-muted hover:text-danger" + > + Remove + </button> + </div> + <textarea + value={milestone.description} + onChange={(event) => patchMilestone(index, { description: event.target.value })} + rows={2} + placeholder="Work, expected artifact, and handoff boundary" + className="mt-2 w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" + /> + <div className="mt-2 grid gap-2 sm:grid-cols-3"> + <label className="text-[11px] text-foreground-muted"> + Owner role + <select + value={milestone.owner_role ?? ""} + onChange={(event) => patchMilestone(index, { owner_role: event.target.value || null })} + className="mt-1 w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" + > + <option value="">Principal / assign dynamically</option> + {roles.filter((role) => role.name.trim()).map((role) => ( + <option key={role.id} value={role.name}>{role.name}</option> + ))} + </select> + </label> + <label className="text-[11px] text-foreground-muted"> + Depends on + <input + value={milestone.depends_on.join(", ")} + onChange={(event) => + patchMilestone(index, { + depends_on: event.target.value.split(",").map((value) => value.trim()).filter(Boolean), + }) + } + placeholder="earlier-id" + className="mt-1 w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 font-mono text-xs" + /> + </label> + <label className="text-[11px] text-foreground-muted"> + Acceptance criteria + <textarea + value={milestone.acceptance_criteria.join("\n")} + onChange={(event) => + patchMilestone(index, { + acceptance_criteria: event.target.value.split("\n").map((value) => value.trim()).filter(Boolean), + }) + } + rows={2} + placeholder={"Tests pass\nArtifact is reviewable"} + className="mt-1 w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" + /> + </label> + </div> + <label className="mt-2 flex items-center gap-2 text-[11px] font-medium text-foreground-muted"> + <input + type="checkbox" + checked={milestone.review_required} + onChange={(event) => patchMilestone(index, { review_required: event.target.checked })} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Pause after delivery for customer review before dependent milestones unlock + </label> + </div> + ))} + {milestones.length === 0 && ( + <p className="rounded-lg border border-dashed border-border px-3 py-4 text-center text-xs text-foreground-muted"> + No finite milestone graph — appropriate for continuous monitoring. Add milestones for + builds, launches, migrations, research programs, and campaigns. + </p> + )} + </div> + </div> + + <PreflightCheck + key={teamFingerprint} + blueprint={teamBlueprint} + tier={tier} + workload="team" + onResult={(result) => { + setValidation(result); + setValidatedFingerprint(teamFingerprint); + }} + /> + + <fieldset className="rounded-xl border border-border p-4"> + <legend className="px-1 text-xs font-medium text-foreground-muted"> + Continuous engineering intake + </legend> + <label className="flex items-center gap-2 text-sm font-medium"> + <input + type="checkbox" + checked={engineeringEnabled} + onChange={(event) => setEngineeringEnabled(event.target.checked)} + className="h-4 w-4 rounded border-border accent-signal" + /> + Monitor the selected repositories and queue new work + </label> + {engineeringEnabled && ( + <div className="mt-3 space-y-3"> + <div className="grid gap-2 sm:grid-cols-2"> + {[ + ["dependabot_pr", "Dependabot pull requests"], + ["dependabot_alert", "Dependabot vulnerability alerts"], + ["code_scanning_alert", "Code scanning / code-quality alerts"], + ["secret_scanning_alert", "Secret scanning alerts"], + ].map(([signal, label]) => ( + <label key={signal} className="flex items-center gap-2 text-xs"> + <input + type="checkbox" + checked={engineeringSignals.has(signal)} + onChange={(event) => + setEngineeringSignals((current) => { + const next = new Set(current); + if (event.target.checked) next.add(signal); + else next.delete(signal); + return next; + }) + } + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + {label} + </label> + ))} + </div> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="text-xs text-foreground-muted"> + Poll interval (seconds) + <input + type="number" + min={300} + max={86400} + value={engineeringPoll} + onChange={(event) => setEngineeringPoll(Number(event.target.value))} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + </label> + <label className="flex items-center gap-2 self-end pb-2 text-xs font-medium"> + <input + type="checkbox" + checked={engineeringAutoRun} + onChange={(event) => setEngineeringAutoRun(event.target.checked)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + Start the team when new work is queued + </label> + </div> + <p className="text-[11px] text-foreground-muted"> + Repository access below is used both for intake and keyless PR delivery. GitHub + security signals remain honest Partial/Unavailable when permissions or products are + not enabled. + </p> + </div> + )} + </fieldset> + + <RepoAccess onSelectionChange={setSelectedRepos} /> + + <div className="flex flex-wrap items-center gap-3"> + <button type="submit" disabled={pending || !name.trim() || executionPlan === null || executionPlanError !== null || (engineeringEnabled && (selectedRepos.length === 0 || engineeringSignals.size === 0)) || (currentValidation !== null && !currentValidation.ok) || (launch && currentValidation?.ok !== true)} className="rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg disabled:opacity-50">{pending ? "Creating…" : "Create team"}</button> + <label className="inline-flex items-center gap-2 text-xs text-foreground-muted"> + <input type="checkbox" name="launch" checked={launch} onChange={(event) => setLaunch(event.target.checked)} className="h-3.5 w-3.5 rounded border-border" /> + Launch immediately (otherwise created paused for your approval) + </label> + <button type="button" onClick={() => setComposed(false)} className="text-sm text-foreground-muted hover:text-foreground">← Back to charter</button> + {!name.trim() && <p className="text-xs text-danger">Give the team a name to create it.</p>} + {currentValidation !== null && !currentValidation.ok && <p className="text-xs text-danger">Fix the failing pre-flight checks before creating.</p>} + {launch && currentValidation === null && <p className="text-xs text-danger">Validate the current package before launching immediately.</p>} + {state.error && <p className="text-xs text-danger">{state.error}</p>} + </div> + </form> + ); +} diff --git a/bridge/web/src/app/workspace/teams/page.tsx b/bridge/web/src/app/workspace/teams/page.tsx new file mode 100644 index 000000000..459d423d3 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/page.tsx @@ -0,0 +1,54 @@ +// kars Bridge Workspace — Teams list. Standing orgs that run continuously +// under a charter — distinct from Missions (finite task forces). Each Team's +// charter loop mints task-force work on a cadence (autonomous monitoring). + +import Link from "next/link"; +import { HonestState } from "@/components/honest-state"; +import { listTeams } from "@/lib/bff"; +import { defaultNamespace } from "@/lib/config"; +import { type TeamSummary } from "@/lib/types"; +import { TeamsList } from "./teams-list"; + +export const dynamic = "force-dynamic"; + +export default async function TeamsPage() { + const ns = defaultNamespace(); + let teams: TeamSummary[] = []; + let error = false; + try { + teams = await listTeams(ns); + } catch { + error = true; + } + + return ( + <div className="space-y-6"> + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Teams</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Standing teams that work continuously under a charter — watching a repo, an org, or a + system, and acting on a schedule. Unlike a mission, a team doesn't finish. + </p> + </div> + <Link href="/workspace/teams/new" className="shrink-0 rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg">+ New team</Link> + </div> + + {error ? ( + <HonestState + variant="not_wired" + title="Teams are unavailable" + detail="The run environment isn't reachable right now. Try again shortly." + /> + ) : teams.length === 0 ? ( + <HonestState + variant="empty" + title="No standing teams yet" + detail="A team is a durable org with a charter and a cadence — stand one up to watch a repo or an org continuously." + /> + ) : ( + <TeamsList teams={teams} /> + )} + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/teams-list.tsx b/bridge/web/src/app/workspace/teams/teams-list.tsx new file mode 100644 index 000000000..69de709b8 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/teams-list.tsx @@ -0,0 +1,164 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { TeamTiming } from "@/components/team-timing"; +import { formatWarmIdle } from "@/lib/format"; +import { TIER_LABELS, type TeamSummary } from "@/lib/types"; + +type Sort = "recent" | "newest" | "oldest" | "name"; + +function lifecycleLabel(team: TeamSummary): string { + if (team.lifecycle_mode === "resourceOptimized") { + const idle = formatWarmIdle(team.warm_idle_seconds); + return idle === "immediately" + ? "Resource optimized · immediate hibernation" + : `Resource optimized · ${idle} warm`; + } + return team.lifecycle_mode === "persistent" ? "Persistent runtime" : "Ephemeral runtime"; +} + +function PhaseDot({ team }: { team: TeamSummary }) { + const awaitingReview = team.health === "AwaitingReview"; + const unhealthy = + team.health === "Stalled" || team.health === "Unproductive" || team.phase === "Degraded"; + const label = team.paused + ? "Hibernating" + : awaitingReview + ? "Awaiting review" + : unhealthy + ? (team.health ?? team.phase) + : (team.runtime_state ?? team.phase); + const color = team.paused + ? "bg-foreground-muted" + : awaitingReview + ? "bg-amber-500" + : unhealthy + ? team.health === "Stalled" || team.phase === "Degraded" + ? "bg-rose-500" + : "bg-amber-500" + : label === "Working" + ? "bg-sky-500" + : label === "Warm" + ? "bg-emerald-500" + : label === "Hibernating" + ? "bg-foreground-muted" + : "bg-amber-500"; + return ( + <span className="inline-flex items-center gap-1.5 text-xs text-foreground-muted"> + <span className={`h-2 w-2 rounded-full ${color}`} aria-hidden /> + {label} + </span> + ); +} + +function timestamp(value: string | null): number { + if (!value) return 0; + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +} + +function recentTimestamp(team: TeamSummary): number { + return Math.max( + timestamp(team.last_activity_at), + timestamp(team.last_run_at), + timestamp(team.created_at), + ); +} + +export function TeamsList({ teams }: { teams: TeamSummary[] }) { + const [query, setQuery] = useState(""); + const [sort, setSort] = useState<Sort>("recent"); + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return teams + .filter((team) => + !needle || + [team.display_name ?? "", team.name, team.charter] + .join(" ") + .toLowerCase() + .includes(needle), + ) + .sort((left, right) => { + if (sort === "name") { + return (left.display_name ?? left.name).localeCompare(right.display_name ?? right.name); + } + if (sort === "newest") return timestamp(right.created_at) - timestamp(left.created_at); + if (sort === "oldest") return timestamp(left.created_at) - timestamp(right.created_at); + return recentTimestamp(right) - recentTimestamp(left); + }); + }, [query, sort, teams]); + + return ( + <div className="space-y-4"> + <div className="flex flex-col gap-2 sm:flex-row"> + <input + type="search" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search teams by name or charter" + className="min-w-64 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + <select + value={sort} + onChange={(event) => setSort(event.target.value as Sort)} + aria-label="Sort teams" + className="rounded-lg border border-border bg-surface px-3 py-2 text-xs" + > + <option value="recent">Latest activity first</option> + <option value="newest">Newest team first</option> + <option value="oldest">Oldest team first</option> + <option value="name">Name A–Z</option> + </select> + </div> + {visible.length === 0 ? ( + <p className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-foreground-muted"> + No teams match this search. + </p> + ) : ( + <ul className="grid gap-4 sm:grid-cols-2"> + {visible.map((team) => ( + <li key={team.name}> + <Link + href={`/workspace/teams/${encodeURIComponent(team.name)}`} + className="flex h-full flex-col rounded-xl border border-border bg-surface p-5 transition hover:border-signal/40 hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <div className="flex items-start justify-between gap-3"> + <p className="truncate text-sm font-semibold">{team.display_name ?? team.name}</p> + <PhaseDot team={team} /> + </div> + <p className="mt-2 line-clamp-2 text-xs text-foreground-muted"> + {team.charter?.trim() ? team.charter : <span className="italic">No charter configured — open to set one.</span>} + </p> + <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-foreground-muted"> + <span>Tier {team.tier} · {TIER_LABELS[team.tier] ?? "?"}</span> + <span>{team.member_count} {team.member_count === 1 ? "member" : "members"}</span> + {team.every_minutes != null && <span>Checks every {team.every_minutes} min</span>} + <span>{lifecycleLabel(team)}</span> + {team.current_assignment_task && <span>Assignment: {team.current_assignment_task}</span>} + <span>{team.generated_task_count.toLocaleString()} checks all-time</span> + <span className={team.retained_failed > 0 ? "text-rose-600" : ""}> + Retained: {team.retained_delivered} delivered · {team.retained_no_action} no-action ·{" "} + {team.retained_failed} failed + </span> + {team.created_at && ( + <span suppressHydrationWarning>Created {new Date(team.created_at).toLocaleString()}</span> + )} + </div> + <TeamTiming + compact + lastActivityAt={team.last_activity_at} + nextActivityAt={team.paused ? null : team.next_run_at} + paused={team.paused} + /> + {team.reporting_to && ( + <p className="mt-3 text-xs text-foreground-muted">Reports to {team.reporting_to}</p> + )} + </Link> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/bridge/web/src/components/activity-stream.tsx b/bridge/web/src/components/activity-stream.tsx new file mode 100644 index 000000000..f968d69e6 --- /dev/null +++ b/bridge/web/src/components/activity-stream.tsx @@ -0,0 +1,401 @@ +// kars Bridge Workspace — live activity stream. +// +// The plan's Mission Map right-rail: the real tool-call / round trace and token +// burn the agent emitted as it worked. Events are REAL — the controller +// persists the agent's live execution trace; this surface renders it verbatim, +// and while a mission is running it tails the SSE telemetry stream so events +// tick in flight. When a mission has not run there is no trace, and we say so +// plainly rather than faking ticks. + +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { HonestState } from "@/components/honest-state"; +import { LivePulse } from "@/components/live-refresh"; +import type { ActivityEvent, MissionTelemetry } from "@/lib/types"; +import { Icon } from "@/components/icon"; + +function fmtMs(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(1)} s`; +} + +export function ActivityStream({ + running, + activity, + telemetry, + ns, + name, + events: externalEvents, + title = "Activity", + detail = "The real tool calls, model rounds, and token burn the agent emitted as it worked.", + focusQuery, + principalAgentName, +}: { + running: boolean; + activity: ActivityEvent[]; + telemetry: MissionTelemetry | null; + ns?: string; + name?: string; + /** When a parent supplies the merged live events (one shared stream), use + * them and do NOT open a second EventSource. */ + events?: ActivityEvent[]; + title?: string; + detail?: string; + focusQuery?: string; + principalAgentName?: string; +}) { + const [live, setLive] = useState<ActivityEvent[]>([]); + const [query, setQuery] = useState(focusQuery ?? ""); + // Tail the SSE telemetry stream while running so the trace ticks in flight. + useEffect(() => { + if (externalEvents || !running || !ns || !name) return; + const es = new EventSource(`/api/namespaces/${ns}/tasks/${name}/stream`); + es.onmessage = (m) => { + try { + setLive((prev) => [...prev, JSON.parse(m.data)]); + } catch { + /* ignore malformed frame */ + } + }; + es.addEventListener("done", () => es.close()); + return () => es.close(); + }, [running, ns, name, externalEvents]); + + const merged = externalEvents ?? (activity.length >= live.length ? activity : live); + const hasActivity = merged && merged.length > 0; + const toolEvents = hasActivity ? merged.filter((e) => e.kind === "tool").length : 0; + const roundEvents = hasActivity ? merged.filter((e) => e.kind === "round").length : 0; + const rounds = telemetry?.rounds ?? roundEvents; + const toolCalls = telemetry?.tool_calls ?? toolEvents; + const visible = useMemo(() => { + const rawQuery = query.trim(); + const needle = rawQuery.toLowerCase(); + if (!needle) return merged; + if (needle.startsWith("actions:")) { + const parameters = new URLSearchParams(rawQuery.slice("actions:".length)); + const agent = parameters.get("agent")?.trim().toLowerCase() ?? ""; + const instance = parameters.get("instance")?.trim().toLowerCase() ?? ""; + const sequences = new Set( + (parameters.get("seqs") ?? "") + .split(",") + .map((value) => Number(value)) + .filter(Number.isInteger), + ); + const fallbackEvents = (() => { + try { + const parsed = JSON.parse(parameters.get("events") ?? "[]"); + return Array.isArray(parsed) ? parsed as Array<{ + round: number; + ts: string; + tool: string; + args: string; + result: string; + instance: string | null; + }> : []; + } catch { + return []; + } + })(); + const through = parameters.get("through") ?? ""; + const throughTime = new Date(through).getTime(); + return merged.filter((event) => { + const agentMatches = agent === "principal" + ? event.agentRole !== "subagent" + : event.agent?.trim().toLowerCase() === agent; + const instanceMatches = + !instance || event.agentInstance?.trim().toLowerCase() === instance; + if (sequences.size > 0) { + return agentMatches + && instanceMatches + && event.seq != null + && sequences.has(event.seq); + } + if (fallbackEvents.length > 0) { + return agentMatches + && instanceMatches + && fallbackEvents.some((candidate) => + event.round === candidate.round + && event.ts === candidate.ts + && (event.kind === "round" ? "model.round" : event.name) === candidate.tool + && (event.kind === "round" ? `${event.tool_calls} tool call${event.tool_calls === 1 ? "" : "s"} requested` : event.args_preview) === candidate.args + && (event.kind === "round" ? `${event.finish_reason || "unknown finish"}; ${event.total_tokens.toLocaleString("en-US")} tokens` : event.result_preview) === candidate.result + && (!candidate.instance || event.agentInstance === candidate.instance) + ); + } + const eventTime = new Date(event.ts).getTime(); + return agentMatches + && instanceMatches + && Number.isFinite(throughTime) + && Number.isFinite(eventTime) + && eventTime <= throughTime; + }); + } + if (needle.startsWith("action:")) { + const parameters = new URLSearchParams(rawQuery.slice("action:".length)); + const agent = parameters.get("agent")?.trim().toLowerCase() ?? ""; + const instance = parameters.get("instance")?.trim().toLowerCase() ?? ""; + const sequenceValue = parameters.get("seq"); + const sequence = sequenceValue == null ? Number.NaN : Number(sequenceValue); + const round = Number(parameters.get("round")); + const timestamp = parameters.get("ts"); + const tool = parameters.get("tool")?.trim().toLowerCase() ?? ""; + const args = parameters.get("args") ?? ""; + const result = parameters.get("result") ?? ""; + return merged.filter((event) => { + const agentMatches = agent === "principal" + ? event.agentRole !== "subagent" + : event.agent?.trim().toLowerCase() === agent; + const instanceMatches = + !instance || event.agentInstance?.trim().toLowerCase() === instance; + if (Number.isInteger(sequence)) { + return agentMatches && instanceMatches && event.seq === sequence; + } + const toolMatches = event.kind === "round" + ? tool === "model.round" + : event.name.trim().toLowerCase() === tool; + return agentMatches + && instanceMatches + && Number.isInteger(round) + && event.round === round + && event.ts === timestamp + && toolMatches + && (event.kind === "round" ? `${event.tool_calls} tool call${event.tool_calls === 1 ? "" : "s"} requested` : event.args_preview) === args + && (event.kind === "round" ? `${event.finish_reason || "unknown finish"}; ${event.total_tokens.toLocaleString("en-US")} tokens` : event.result_preview) === result; + }); + } + if (needle.startsWith("agent-instance:")) { + const instance = needle.slice("agent-instance:".length).trim(); + return merged.filter( + (event) => event.agentInstance?.trim().toLowerCase() === instance, + ); + } + if (needle.startsWith("agent:")) { + const agent = needle.slice("agent:".length).trim(); + return merged.filter((event) => { + if (agent === "principal" || agent === principalAgentName?.trim().toLowerCase()) { + return event.agentRole !== "subagent"; + } + return event.agent?.trim().toLowerCase() === agent; + }); + } + return merged.filter((event) => { + if (event.kind === "round") { + return [ + `round ${event.round + 1}`, + event.finish_reason, + event.agent, + ].filter(Boolean).join(" ").toLowerCase().includes(needle); + } + return [ + event.name, + event.args_preview, + event.result_preview, + event.agent, + event.ok ? "success" : "failed", + ].filter(Boolean).join(" ").toLowerCase().includes(needle); + }); + }, [merged, principalAgentName, query]); + + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">{title}</h2> + <p className="mt-0.5 text-xs text-foreground-muted">{detail}</p> + </div> + {hasActivity ? ( + <div className="flex flex-wrap items-center justify-end gap-2"> + <label className="relative"> + <span className="sr-only">Search activity</span> + <Icon name="search" size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-foreground-muted" /> + <input + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search tools, agents, results" + className="w-56 rounded-lg border border-border bg-surface-muted/40 py-1.5 pl-8 pr-3 text-xs outline-none focus:border-signal" + /> + </label> + <span className="shrink-0 rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium"> + {query.trim() ? `${visible.length}/${merged.length} events` : `${rounds} round${rounds === 1 ? "" : "s"} · ${toolCalls} tool call${toolCalls === 1 ? "" : "s"}`} + </span> + </div> + ) : running ? ( + <LivePulse label="Working" /> + ) : null} + </div> + + <div className="mt-4"> + {hasActivity ? ( + visible.length > 0 ? ( + <SpanTree events={visible} /> + ) : ( + <HonestState + variant="empty" + compact + title="No matching activity" + detail="Try a tool name, agent name, host, argument, result, success, or failed." + /> + ) + ) : running ? ( + <HonestState + variant="needs_run" + compact + title="No activity captured yet" + detail="This mission's sandbox is running but hasn't executed a task yet. Run the mission to drive the agent loop; its real tool-call trace and token burn appear here as it works." + /> + ) : ( + <HonestState + variant="needs_run" + compact + title="No activity yet" + detail="This mission hasn't run. Launch it, then run it — the agent's real per-tool trace and token cost are captured and shown here." + /> + )} + </div> + </section> + ); +} + +/** A behavioral span tree: each model round is a parent span, and the tool calls + * it issued nest beneath it (both events carry the same `round` index). This is + * the LangSmith-style legibility — you read the loop shape (round → tools → + * round), not a flat interleave. Only round/tool spans are shown because those + * are the only spans the router actually emits; mesh/policy spans are not + * fabricated. */ +function SpanTree({ events }: { events: ActivityEvent[] }) { + const agentKey = (event: ActivityEvent) => + event.agentInstance + ?? event.agent + ?? (event.agentRole === "subagent" ? "subagent" : "principal"); + const roundKey = (event: ActivityEvent) => `${agentKey(event)}\u0000${event.round ?? 0}`; + // Every sandbox owns an independent round counter. Group by emitting sandbox + // plus round so principal/worker round 0 records can never merge. + const toolsByRound = new Map<string, Extract<ActivityEvent, { kind: "tool" }>[]>(); + const roundsByKey = new Map<string, Extract<ActivityEvent, { kind: "round" }>>(); + for (const e of events) { + if (e.kind === "tool") { + const key = roundKey(e); + const list = toolsByRound.get(key); + if (list) list.push(e); + else toolsByRound.set(key, [e]); + } else if (!roundsByKey.has(roundKey(e))) { + roundsByKey.set(roundKey(e), e); + } + } + const rounds = [...roundsByKey.entries()]; + // Rounds referenced only by a tool (no round event captured yet) still get a + // header so no tool is orphaned — e.g. an in-flight round mid-stream. + const extra = [...toolsByRound.keys()].filter((key) => !roundsByKey.has(key)); + + // Display rounds 1, 2, 3, ... in emission order, decoupled from the raw + // router-side round index (which is a cursor relative to the sandbox's + // telemetry stream and can start above 1 for a reused sandbox or a + // warm-up call before this delivery) — grouping above still keys off the + // raw value so tool association is unaffected. + const displayOrdinal = new Map<string, number>(); + [...rounds.map(([key]) => key), ...extra].forEach((key, index) => + displayOrdinal.set(key, index + 1) + ); + + return ( + <ol className="space-y-2"> + {rounds.map(([key, round]) => ( + <li key={`r-${key}`}> + <RoundRow + e={round} + display={displayOrdinal.get(key) ?? round.round + 1} + agentLabel={agentKey(round)} + /> + <RoundTools + tools={toolsByRound.get(key) ?? []} + display={displayOrdinal.get(key) ?? round.round + 1} + /> + </li> + ))} + {extra.map((key) => { + const tools = toolsByRound.get(key) ?? []; + const first = tools[0]; + return ( + <li key={`x-${key}`}> + <div className="flex items-center gap-2 px-3 py-1.5 text-xs"> + <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-border" aria-hidden /> + <span className="font-medium">Model round {displayOrdinal.get(key) ?? 1}</span> + {first && <span className="font-mono text-[10px] text-foreground-muted">· {agentKey(first)}</span>} + <span className="text-foreground-muted">· in flight</span> + </div> + <RoundTools tools={tools} display={displayOrdinal.get(key) ?? 1} /> + </li> + )})} + </ol> + ); +} + +/** The tool spans nested under a round, with a left rail so the tree is legible. */ +function RoundTools({ tools, display }: { tools: Extract<ActivityEvent, { kind: "tool" }>[]; display: number }) { + if (tools.length === 0) return null; + return ( + <ol className="ml-[7px] mt-1 space-y-1 border-l border-border pl-3"> + {tools.map((e, i) => ( + <li key={e.seq ?? `${e.agentInstance ?? e.agent ?? "agent"}-${e.round}-${e.ts}-${i}`}> + <ToolRow e={e} display={display} /> + </li> + ))} + </ol> + ); +} + +function ToolRow({ e, display }: { e: Extract<ActivityEvent, { kind: "tool" }>; display: number }) { + return ( + <details className="group rounded-lg border border-border bg-surface-muted/30"> + <summary className="flex cursor-pointer items-center gap-2 px-3 py-2"> + <span + className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${e.ok ? "bg-signal" : "bg-rose-500"}`} + aria-hidden + /> + <span className="font-mono text-xs font-medium">{e.name}</span> + <span className="truncate text-xs text-foreground-muted">{e.args_preview}</span> + <span className="ml-auto shrink-0 text-[11px] text-foreground-muted"> + r{display} · {fmtMs(e.ms)} + </span> + </summary> + <div className="space-y-2 border-t border-border px-3 py-2 text-xs"> + <div> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Arguments</p> + <p className="mt-0.5 break-words font-mono">{e.args_preview || "—"}</p> + </div> + <div> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted"> + Result {e.ok ? "" : "(error)"} + </p> + <p className="mt-0.5 break-words font-mono">{e.result_preview || "—"}</p> + </div> + </div> + </details> + ); +} + +function RoundRow({ + e, + display, + agentLabel, +}: { + e: Extract<ActivityEvent, { kind: "round" }>; + display: number; + agentLabel: string; +}) { + return ( + <div className="flex items-center gap-2 px-3 py-1.5 text-xs"> + <span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-border" aria-hidden /> + <span className="font-medium">Model round {display}</span> + <span className="font-mono text-[10px] text-foreground-muted">· {agentLabel}</span> + <span className="text-foreground-muted"> + {e.finish_reason ? `· ${e.finish_reason}` : ""} + {e.tool_calls > 0 ? ` · ${e.tool_calls} tool call${e.tool_calls === 1 ? "" : "s"}` : ""} + </span> + <span className="ml-auto shrink-0 text-[11px] text-foreground-muted"> + {e.total_tokens.toLocaleString("en-US")} tok · {fmtMs(e.ms)} + </span> + </div> + ); +} diff --git a/bridge/web/src/components/agent-graph.tsx b/bridge/web/src/components/agent-graph.tsx new file mode 100644 index 000000000..b206c8286 --- /dev/null +++ b/bridge/web/src/components/agent-graph.tsx @@ -0,0 +1,1531 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { Icon } from "@/components/icon"; +import type { + ActivityEvent, + AgentIdentity, + Receipt, + SubAgent, +} from "@/lib/types"; + +type ToolEvent = Extract<ActivityEvent, { kind: "tool" }>; + +interface AgentAction { + id: string; + human: string; + raw: string; + args: string; + result: string; + ok: boolean | null; + round: number; + ms: number; + ts: string; + seq: number | null; + agentInstance: string | null; +} + +interface AgentExecution { + id: string; + displayName: string; + technicalName: string; + role: string; + relationship: string; + phase: string; + runtime: string | null; + model: string | null; + parent: string | null; + parentId: string | null; + observedAgentName: string | null; + observedAgentInstance: string | null; + isPrincipal: boolean; + aliases: string[]; + rounds: number; + toolCalls: number; + failures: number; + lastActivity: string | null; + destinations: string[]; + actions: AgentAction[]; + identitySearchText: string; + searchText: string; + traceQuery: string; +} + +type GraphLeaf = + | { kind: "action"; key: string; agent: AgentExecution; action: AgentAction; label: string; searchText: string } + | { kind: "folded-actions"; key: string; agent: AgentExecution; actions: AgentAction[]; label: string; searchText: string } + | { kind: "destination"; key: string; agent: AgentExecution; destination: string; label: string; searchText: string } + | { kind: "folded-destinations"; key: string; agent: AgentExecution; destinations: string[]; label: string; searchText: string }; + +type GraphSelection = + | { kind: "agent"; key: string; agent: AgentExecution } + | { kind: "edge"; key: string; parent: AgentExecution; child: AgentExecution } + | GraphAggregate + | GraphLeaf; + +interface AgentLayout { + agent: AgentExecution; + x: number; + y: number; + leaves: Array<GraphLeaf & { x: number; y: number }>; +} + +interface GraphAggregate { + kind: "specialist-aggregate"; + key: string; + parent: AgentExecution; + agents: AgentExecution[]; + expanded: boolean; + label: string; + searchText: string; +} + +interface AggregateLayout { + aggregate: GraphAggregate; + x: number; + y: number; +} + +const AGENT_WIDTH = 248; +const AGENT_HEIGHT = 128; +const LEAF_WIDTH = 194; +const LEAF_HEIGHT = 42; +const LEAF_GAP = 9; +const LANE_WIDTH = 500; +const CLUSTER_WIDTH = AGENT_WIDTH + 28 + LEAF_WIDTH; +const GRAPH_PADDING = 40; +const ACTION_LIMIT = 3; +const DESTINATION_LIMIT = 2; +const SPECIALIST_LIMIT = 6; +const VISIBLE_AGENT_BUDGET = 24; +const COLLAPSED_DEPTH_LIMIT = 4; +const BASELINE_LAYER_LIMIT = 6; +const RECENT_ACTIVITY_MS = 90_000; + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function humanizeTool(name: string): string { + const tool = name.toLowerCase().replaceAll("-", "_"); + if (/(browser_)?navigate|open_url|goto/.test(tool)) return "Opened a browser page"; + if (/screenshot|capture_screen/.test(tool)) return "Captured a screenshot"; + if (/browser_(click|dblclick)|click_element/.test(tool)) return "Clicked a page control"; + if (/fill_form|browser_fill|select_option/.test(tool)) return "Filled in a form"; + if (/browser_type|press_key|keyboard/.test(tool)) return "Entered text on a page"; + if (/browser_snapshot|page_snapshot|accessibility_tree/.test(tool)) return "Inspected a browser page"; + if (/browser_wait|wait_for/.test(tool)) return "Waited for a page update"; + if (/network_request|network_requests/.test(tool)) return "Inspected browser network activity"; + if (/file_upload/.test(tool)) return "Uploaded a file"; + if (/(web_)?search|brave|tavily|exa|perplexity/.test(tool)) return "Searched the web"; + if (/fetch|http|curl|crawl|download/.test(tool)) return "Retrieved network content"; + if (/pull_request|create_pr|open_pr/.test(tool)) return "Worked with a pull request"; + if (/git|commit|branch|push|pull/.test(tool)) return "Worked with source control"; + if (/write|create_file|save|edit|patch|append/.test(tool)) return "Updated a file"; + if (/read|view|list|glob|grep|find|search_files/.test(tool)) return "Inspected files"; + if (/shell|bash|exec|run_command|terminal/.test(tool)) return "Ran a command"; + if (/message|handoff|send|relay/.test(tool)) return "Sent an agent message"; + return "Used a governed tool"; +} + +function meaningfulDestination(host: string): boolean { + const value = host.toLowerCase().replace(/^\[|\]$/g, ""); + return !/^(localhost|0\.0\.0\.0|127(?:\.\d+){3}|::1)(:\d+)?$/.test(value); +} + +function destinationsFrom(event: ToolEvent): string[] { + const values = `${event.args_preview} ${event.result_preview}`; + const destinations = new Set<string>(); + for (const match of values.matchAll(/https?:\/\/([^/\s"')\]]+)/gi)) { + const host = match[1].toLowerCase().replace(/[.,;]+$/, ""); + if (meaningfulDestination(host)) destinations.add(host); + } + return [...destinations]; +} + +function formatLastActivity(value: string | null): string { + if (!value) return "No recorded activity"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + const pad = (part: number) => String(part).padStart(2, "0"); + return `${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; +} + +function phaseKind(phase: string): "active" | "failed" | "paused" | "complete" | "idle" { + const value = phase.toLowerCase(); + if (/(failed|degraded|error|blocked)/.test(value)) return "failed"; + if (/(running|launching|active|working|executing)/.test(value)) return "active"; + if (/(paused|hibernating|suspended|waiting)/.test(value)) return "paused"; + if (/(completed|finished|succeeded|delivered)/.test(value)) return "complete"; + return "idle"; +} + +function phaseTone(phase: string): string { + switch (phaseKind(phase)) { + case "failed": + return "border-danger/35 bg-danger/10 text-danger"; + case "active": + return "border-signal/35 bg-signal/10 text-signal"; + case "paused": + return "border-accent/35 bg-accent/10 text-accent"; + case "complete": + return "border-signal/25 bg-signal/[0.06] text-foreground-muted"; + default: + return "border-border bg-surface-muted text-foreground-muted"; + } +} + +function actionFromEvent(event: ActivityEvent, index: number): AgentAction { + if (event.kind === "round") { + return { + id: `round-${event.agent ?? "principal"}-${event.round}-${index}`, + human: `Completed model round ${event.round + 1}`, + raw: "model.round", + args: `${event.tool_calls} tool call${event.tool_calls === 1 ? "" : "s"} requested`, + result: `${event.finish_reason || "unknown finish"}; ${event.total_tokens.toLocaleString("en-US")} tokens`, + ok: null, + round: event.round, + ms: event.ms, + ts: event.ts, + seq: event.seq ?? null, + agentInstance: event.agentInstance ?? null, + }; + } + return { + id: `tool-${event.agent ?? "principal"}-${event.round}-${index}`, + human: humanizeTool(event.name), + raw: event.name, + args: event.args_preview, + result: event.result_preview, + ok: event.ok, + round: event.round, + ms: event.ms, + ts: event.ts, + seq: event.seq ?? null, + agentInstance: event.agentInstance ?? null, + }; +} + +function leavesFor(agent: AgentExecution): GraphLeaf[] { + const foldedActions = agent.actions.slice(0, Math.max(0, agent.actions.length - ACTION_LIMIT)); + const visibleActions = agent.actions.slice(-ACTION_LIMIT); + const visibleDestinations = agent.destinations.slice(0, DESTINATION_LIMIT); + const foldedDestinations = agent.destinations.slice(DESTINATION_LIMIT); + const leaves: GraphLeaf[] = visibleActions.map((action) => ({ + kind: "action", + key: `action:${agent.id}:${action.id}`, + agent, + action, + label: action.human, + searchText: normalize(`${action.human} ${action.raw} ${action.args} ${action.result}`), + })); + if (foldedActions.length > 0) { + leaves.unshift({ + kind: "folded-actions", + key: `folded-actions:${agent.id}`, + agent, + actions: foldedActions, + label: `+${foldedActions.length} action${foldedActions.length === 1 ? "" : "s"}`, + searchText: normalize(foldedActions.flatMap((action) => [action.human, action.raw, action.args, action.result]).join(" ")), + }); + } + leaves.push(...visibleDestinations.map((destination) => ({ + kind: "destination" as const, + key: `destination:${agent.id}:${destination}`, + agent, + destination, + label: destination, + searchText: normalize(destination), + }))); + if (foldedDestinations.length > 0) { + leaves.push({ + kind: "folded-destinations", + key: `folded-destinations:${agent.id}`, + agent, + destinations: foldedDestinations, + label: `+${foldedDestinations.length} destination${foldedDestinations.length === 1 ? "" : "s"}`, + searchText: normalize(foldedDestinations.join(" ")), + }); + } + return leaves; +} + +function ellipsis(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +function queryAgentIdentity(agent: AgentExecution): string { + return agent.isPrincipal + ? "principal" + : normalize(agent.observedAgentName ?? agent.technicalName); +} + +function exactActionQuery(agent: AgentExecution, action: AgentAction): string { + const parameters = new URLSearchParams({ + agent: queryAgentIdentity(agent), + round: String(action.round), + ts: action.ts, + tool: action.raw, + args: action.args, + result: action.result, + }); + if (action.seq != null) parameters.set("seq", String(action.seq)); + if (action.agentInstance) parameters.set("instance", action.agentInstance); + return `action:${parameters.toString()}`; +} + +function exactActionsQuery(agent: AgentExecution, actions: AgentAction[]): string { + const through = actions.at(-1)?.ts ?? ""; + const parameters = new URLSearchParams({ + agent: queryAgentIdentity(agent), + through, + }); + const sequenceValues = actions.flatMap((action) => + action.seq == null ? [] : [String(action.seq)] + ); + if (sequenceValues.length === actions.length && sequenceValues.length > 0) { + parameters.set("seqs", sequenceValues.join(",")); + } else { + parameters.set( + "events", + JSON.stringify(actions.map((action) => ({ + round: action.round, + ts: action.ts, + tool: action.raw, + args: action.args, + result: action.result, + instance: action.agentInstance, + }))), + ); + } + const instances = [...new Set(actions.flatMap((action) => + action.agentInstance ? [action.agentInstance] : [] + ))]; + if (instances.length === 1) parameters.set("instance", instances[0]); + return `actions:${parameters.toString()}`; +} + +export function AgentGraph({ + running, + activity, + ns, + name, + agentLabel = "Agent", + agentPhase = null, + agentRuntime = null, + agentModel = null, + subAgents = [], + events: externalEvents, + identity = null, + envelopeDigest = null, + receipt = null, + onInspect, +}: { + running: boolean; + activity: ActivityEvent[]; + ns?: string; + name?: string; + agentLabel?: string; + agentPhase?: string | null; + agentRuntime?: string | null; + agentModel?: string | null; + subAgents?: SubAgent[]; + events?: ActivityEvent[]; + identity?: AgentIdentity | null; + envelopeDigest?: string | null; + receipt?: Receipt | null; + onInspect?: (query: string) => void; +}) { + const [live, setLive] = useState<ActivityEvent[]>([]); + const [query, setQuery] = useState(""); + const [selectedKey, setSelectedKey] = useState("agent:principal"); + const [selectedAgentId, setSelectedAgentId] = useState("principal"); + const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => new Set()); + const [clientNow, setClientNow] = useState<number | null>(null); + const [enlarged, setEnlarged] = useState(false); + + useEffect(() => { + const updateClock = () => setClientNow(Date.now()); + const frame = requestAnimationFrame(updateClock); + const timer = window.setInterval(updateClock, 15_000); + return () => { + cancelAnimationFrame(frame); + window.clearInterval(timer); + }; + }, []); + + useEffect(() => { + if (!enlarged) return; + const priorOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setEnlarged(false); + }; + window.addEventListener("keydown", closeOnEscape); + return () => { + document.body.style.overflow = priorOverflow; + window.removeEventListener("keydown", closeOnEscape); + }; + }, [enlarged]); + + useEffect(() => { + if (externalEvents || !running || !ns || !name) return; + const stream = new EventSource(`/api/namespaces/${ns}/tasks/${name}/stream`); + stream.onmessage = (message) => { + try { + setLive((previous) => [...previous, JSON.parse(message.data)]); + } catch { + // Ignore malformed telemetry frames. + } + }; + stream.addEventListener("done", () => stream.close()); + return () => stream.close(); + }, [externalEvents, name, ns, running]); + + const events = externalEvents ?? (activity.length >= live.length ? activity : live); + const hasRecentActivity = (agent: AgentExecution): boolean => { + if (!running || clientNow === null || !agent.lastActivity) return false; + const timestamp = new Date(agent.lastActivity).getTime(); + return Number.isFinite(timestamp) + && clientNow >= timestamp + && clientNow - timestamp <= RECENT_ACTIVITY_MS; + }; + + const agents = useMemo<AgentExecution[]>(() => { + const rootInactive = Boolean( + !running + && agentPhase + && /(completed|failed|hibernating|paused|idle|finished)/i.test(agentPhase), + ); + const definitions = [ + { + id: "principal", + displayName: agentLabel, + technicalName: name ?? agentLabel, + role: "Principal", + phase: agentPhase ?? (running ? "Running" : events.length > 0 ? "Finished" : "Ready"), + runtime: agentRuntime, + model: agentModel, + parent: null, + isPrincipal: true, + aliases: ["principal", agentLabel, name ?? ""].filter(Boolean), + }, + ...subAgents.map((agent) => ({ + id: `sub-${agent.name}`, + displayName: agent.logical_agent_id ?? agent.role ?? agent.name, + technicalName: agent.name, + role: agent.role ?? "Specialist", + phase: rootInactive ? agentPhase! : agent.phase ?? "Discovered", + runtime: agent.runtime, + model: agent.model, + parent: agent.parent, + isPrincipal: false, + aliases: [agent.name, agent.logical_agent_id ?? "", agent.role ?? ""].filter(Boolean), + })), + ]; + + const records = new Map<string, ActivityEvent[]>( + definitions.map((definition) => [definition.id, []]), + ); + const aliasToId = new Map<string, string>(); + for (const definition of definitions) { + for (const alias of definition.aliases) aliasToId.set(normalize(alias), definition.id); + } + + for (const event of events) { + let owner = "principal"; + if (event.agentRole === "subagent" && event.agent) { + const instance = event.agentInstance ?? event.agent; + owner = aliasToId.get(normalize(instance)) + ?? aliasToId.get(normalize(event.agent)) + ?? `live-${instance}`; + if (!records.has(owner)) records.set(owner, []); + } + records.get(owner)?.push(event); + } + + const allDefinitions = [...definitions]; + for (const [id] of records) { + if (!id.startsWith("live-")) continue; + const technicalName = id.slice(5); + allDefinitions.push({ + id, + displayName: technicalName, + technicalName, + role: "Specialist", + phase: running ? "Active" : "Observed", + runtime: null, + model: null, + parent: null, + isPrincipal: false, + aliases: [technicalName], + }); + } + + const completeAliasToId = new Map<string, string>(); + for (const definition of allDefinitions) { + for (const alias of definition.aliases) completeAliasToId.set(normalize(alias), definition.id); + completeAliasToId.set(normalize(definition.technicalName), definition.id); + } + const namesById = new Map(allDefinitions.map((definition) => [definition.id, definition.displayName])); + + return allDefinitions.map((definition) => { + const agentEvents = records.get(definition.id) ?? []; + const actions = agentEvents + .map(actionFromEvent) + .sort((left, right) => left.ts.localeCompare(right.ts)); + const tools = agentEvents.filter( + (event): event is ToolEvent => event.kind === "tool", + ); + const destinations = [...new Set(tools.flatMap(destinationsFrom))].sort(); + const lastActivity = agentEvents.reduce<string | null>( + (latest, event) => (!latest || event.ts > latest ? event.ts : latest), + null, + ); + const observedAgentName = agentEvents.find((event) => event.agent)?.agent ?? null; + const observedAgentInstance = + agentEvents.find((event) => event.agentInstance)?.agentInstance ?? null; + const parentId = definition.isPrincipal + ? null + : completeAliasToId.get(normalize(definition.parent ?? "")) ?? "principal"; + const parentName = parentId ? namesById.get(parentId) ?? agentLabel : null; + const relationship = definition.isPrincipal + ? "Orchestration root responsible for the execution" + : `Specialist delegated by ${parentName}`; + const searchText = [ + definition.displayName, + definition.technicalName, + definition.role, + relationship, + definition.runtime, + definition.model, + definition.phase, + ...destinations, + ...actions.flatMap((action) => [action.human, action.raw, action.args, action.result]), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + const identitySearchText = [ + definition.displayName, + definition.technicalName, + definition.role, + relationship, + definition.runtime, + definition.model, + definition.phase, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return { + ...definition, + relationship, + parentId, + observedAgentName, + observedAgentInstance, + rounds: agentEvents.filter((event) => event.kind === "round").length, + toolCalls: tools.length, + failures: tools.filter((event) => !event.ok).length, + lastActivity, + destinations, + actions, + identitySearchText, + searchText, + traceQuery: definition.isPrincipal + ? "agent:principal" + : observedAgentInstance + ? `agent-instance:${normalize(observedAgentInstance)}` + : `agent:${normalize(observedAgentName ?? definition.technicalName)}`, + }; + }); + }, [agentLabel, agentModel, agentPhase, agentRuntime, events, name, running, subAgents]); + + const normalizedQuery = normalize(query); + const graph = useMemo(() => { + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const principal = agents.find((agent) => agent.isPrincipal) ?? agents[0]; + const parentById = new Map<string, string | null>(); + + for (const agent of agents) { + if (agent.isPrincipal) { + parentById.set(agent.id, null); + continue; + } + const candidate = agent.parentId && byId.has(agent.parentId) + ? agent.parentId + : principal.id; + const seen = new Set([agent.id]); + let cursor: string | null = candidate; + let cyclic = false; + while (cursor) { + if (seen.has(cursor)) { + cyclic = true; + break; + } + seen.add(cursor); + cursor = byId.get(cursor)?.parentId ?? null; + } + parentById.set(agent.id, cyclic ? principal.id : candidate); + } + + const childrenByParent = new Map<string, AgentExecution[]>(); + for (const agent of agents) { + const parentId = parentById.get(agent.id); + if (!parentId) continue; + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) ?? []), agent]); + } + + const depthCache = new Map<string, number>([[principal.id, 0]]); + const depthOf = (agentId: string): number => { + const cached = depthCache.get(agentId); + if (cached != null) return cached; + const parentId = parentById.get(agentId); + const depth = parentId ? depthOf(parentId) + 1 : 0; + depthCache.set(agentId, depth); + return depth; + }; + + const selectedPath = new Set<string>([principal.id, selectedAgentId]); + let selectedParentId = parentById.get(selectedAgentId); + while (selectedParentId) { + selectedPath.add(selectedParentId); + selectedParentId = parentById.get(selectedParentId); + } + const searchPath = new Set<string>(); + if (normalizedQuery) { + for (const agent of agents) { + if (!agent.searchText.includes(normalizedQuery)) continue; + searchPath.add(agent.id); + let parentId = parentById.get(agent.id); + while (parentId) { + if (searchPath.has(parentId)) break; + searchPath.add(parentId); + parentId = parentById.get(parentId); + } + } + } + + const baselineVisible = new Set<string>([principal.id]); + const baselineLayerCounts = new Map<number, number>([[0, 1]]); + const queue: AgentExecution[] = [principal]; + for (let index = 0; index < queue.length && baselineVisible.size < VISIBLE_AGENT_BUDGET; index += 1) { + const parent = queue[index]; + const parentDepth = depthOf(parent.id); + if (parentDepth >= COLLAPSED_DEPTH_LIMIT) continue; + const childDepth = parentDepth + 1; + const children = childrenByParent.get(parent.id) ?? []; + for (const child of children.slice(0, SPECIALIST_LIMIT)) { + if (baselineVisible.size >= VISIBLE_AGENT_BUDGET) break; + const layerCount = baselineLayerCounts.get(childDepth) ?? 0; + if (layerCount >= BASELINE_LAYER_LIMIT) break; + baselineVisible.add(child.id); + baselineLayerCounts.set(childDepth, layerCount + 1); + queue.push(child); + } + } + + const visible = new Set<string>([ + ...baselineVisible, + ...selectedPath, + ...searchPath, + ]); + let expandedChanged = true; + while (expandedChanged) { + expandedChanged = false; + for (const parentId of expandedGroups) { + if (!visible.has(parentId)) continue; + for (const child of childrenByParent.get(parentId) ?? []) { + if (visible.has(child.id)) continue; + visible.add(child.id); + expandedChanged = true; + } + } + } + + const aggregates: GraphAggregate[] = []; + for (const parent of agents) { + if (!visible.has(parent.id)) continue; + const children = childrenByParent.get(parent.id) ?? []; + const expanded = expandedGroups.has(parent.id); + const hidden = children.filter((child) => !visible.has(child.id)); + if (hidden.length === 0 && !expanded) continue; + const aggregateAgents = expanded ? children : hidden; + if (aggregateAgents.length === 0) continue; + aggregates.push({ + kind: "specialist-aggregate", + key: `specialist-aggregate:${parent.id}`, + parent, + agents: aggregateAgents, + expanded, + label: expanded + ? `Collapse ${children.length} specialists` + : `+${hidden.length} specialist${hidden.length === 1 ? "" : "s"}`, + searchText: normalize([ + "specialists agents descendants folded expand collapse", + ...aggregateAgents.map((agent) => agent.searchText), + ].filter(Boolean).join(" ")), + }); + } + + type LayerItem = + | { kind: "agent"; agent: AgentExecution } + | { kind: "aggregate"; aggregate: GraphAggregate }; + const layers = new Map<number, LayerItem[]>(); + for (const agent of agents) { + if (!visible.has(agent.id)) continue; + const depth = depthOf(agent.id); + layers.set(depth, [...(layers.get(depth) ?? []), { kind: "agent", agent }]); + } + for (const aggregate of aggregates) { + const depth = depthOf(aggregate.parent.id) + 1; + layers.set(depth, [...(layers.get(depth) ?? []), { kind: "aggregate", aggregate }]); + } + + const orderedLayers = [...layers.entries()].sort(([left], [right]) => left - right); + const largestLayer = Math.max(1, ...orderedLayers.map(([, layer]) => layer.length)); + const width = Math.max(760, largestLayer * LANE_WIDTH + GRAPH_PADDING * 2); + const layouts: AgentLayout[] = []; + const aggregateLayouts: AggregateLayout[] = []; + let rankY = 54; + + for (const [, layer] of orderedLayers) { + const leafSets = layer.map((item) => item.kind === "agent" ? leavesFor(item.agent) : []); + const rankHeight = Math.max( + AGENT_HEIGHT, + ...leafSets.map((leaves) => Math.max(AGENT_HEIGHT, leaves.length * (LEAF_HEIGHT + LEAF_GAP) - LEAF_GAP)), + ); + const layerWidth = layer.length * LANE_WIDTH; + const layerStart = (width - layerWidth) / 2; + layer.forEach((item, index) => { + const laneStart = layerStart + index * LANE_WIDTH; + if (item.kind === "aggregate") { + aggregateLayouts.push({ + aggregate: item.aggregate, + x: laneStart + (LANE_WIDTH - AGENT_WIDTH) / 2, + y: rankY + (rankHeight - 86) / 2, + }); + return; + } + const leaves = leafSets[index]; + const leafStackHeight = leaves.length > 0 + ? leaves.length * (LEAF_HEIGHT + LEAF_GAP) - LEAF_GAP + : 0; + const x = laneStart + (LANE_WIDTH - CLUSTER_WIDTH) / 2; + const y = rankY + Math.max(0, (rankHeight - AGENT_HEIGHT) / 2); + const leafY = rankY + Math.max(0, (rankHeight - leafStackHeight) / 2); + layouts.push({ + agent: item.agent, + x, + y, + leaves: leaves.map((leaf, leafIndex) => ({ + ...leaf, + x: x + AGENT_WIDTH + 28, + y: leafY + leafIndex * (LEAF_HEIGHT + LEAF_GAP), + })), + }); + }); + rankY += rankHeight + 116; + } + + return { + width, + height: Math.max(330, rankY - 62), + layouts, + aggregateLayouts, + byId, + parentById, + byAgentId: new Map(layouts.map((layout) => [layout.agent.id, layout])), + }; + }, [agents, expandedGroups, normalizedQuery, selectedAgentId]); + + const selections = useMemo(() => { + const values = new Map<string, GraphSelection>(); + for (const layout of graph.layouts) { + values.set(`agent:${layout.agent.id}`, { + kind: "agent", + key: `agent:${layout.agent.id}`, + agent: layout.agent, + }); + for (const leaf of layout.leaves) values.set(leaf.key, leaf); + const parentId = graph.parentById.get(layout.agent.id); + if (parentId) { + const parent = graph.byId.get(parentId); + if (parent) { + const key = `edge:${parent.id}:${layout.agent.id}`; + values.set(key, { kind: "edge", key, parent, child: layout.agent }); + } + } + } + for (const layout of graph.aggregateLayouts) { + values.set(layout.aggregate.key, layout.aggregate); + } + return values; + }, [graph]); + + const principal = agents.find((agent) => agent.isPrincipal) ?? agents[0]; + const selected = selections.get(selectedKey) + ?? selections.get(`agent:${principal.id}`) + ?? { kind: "agent" as const, key: `agent:${principal.id}`, agent: principal }; + const matchAgent = (agent: AgentExecution) => + !normalizedQuery || agent.searchText.includes(normalizedQuery); + const matchLeaf = (leaf: GraphLeaf) => + !normalizedQuery + || leaf.searchText.includes(normalizedQuery) + || leaf.agent.identitySearchText.includes(normalizedQuery); + const matchCount = normalizedQuery + ? graph.layouts.reduce( + (count, layout) => + count + + (matchAgent(layout.agent) ? 1 : 0) + + layout.leaves.filter((leaf) => leaf.searchText.includes(normalizedQuery)).length, + 0, + ) + : 0; + + const activate = (selection: GraphSelection) => { + setSelectedKey(selection.key); + if (selection.kind === "agent") { + setSelectedAgentId(selection.agent.id); + onInspect?.(selection.agent.traceQuery); + } else if (selection.kind === "edge") { + setSelectedAgentId(selection.child.id); + onInspect?.(selection.child.traceQuery); + } else if (selection.kind === "specialist-aggregate") { + setSelectedAgentId(selection.parent.id); + setExpandedGroups((current) => { + const next = new Set(current); + if (selection.expanded) next.delete(selection.parent.id); + else next.add(selection.parent.id); + return next; + }); + onInspect?.(selection.parent.traceQuery); + } + else if (selection.kind === "action") { + setSelectedAgentId(selection.agent.id); + onInspect?.(exactActionQuery(selection.agent, selection.action)); + } else if (selection.kind === "folded-actions") { + setSelectedAgentId(selection.agent.id); + onInspect?.(exactActionsQuery(selection.agent, selection.actions)); + } else if (selection.kind === "destination") { + setSelectedAgentId(selection.agent.id); + onInspect?.(selection.destination); + } else { + setSelectedAgentId(selection.agent.id); + onInspect?.(selection.agent.traceQuery); + } + }; + + const graphSection = ( + <section + className={ + enlarged + ? "h-full w-full overflow-y-auto rounded-2xl border border-border bg-surface p-4 shadow-2xl sm:p-5" + : "rounded-2xl border border-border bg-surface p-4 sm:p-5" + } + aria-label={enlarged ? "Enlarged agent execution graph" : undefined} + > + <div className="flex flex-wrap items-start justify-between gap-3"> + <div className="max-w-2xl"> + <h2 id="execution-graph-title" className="text-sm font-semibold">Agent execution graph</h2> + <p id="execution-graph-description" className="mt-0.5 text-xs leading-relaxed text-foreground-muted"> + Principal-to-specialist topology with live action and destination leaves. Select nodes for retained evidence or connectors for relationship context. + </p> + </div> + <div className="flex flex-wrap items-center justify-end gap-2"> + <label className="relative"> + <span className="sr-only">Search agents, roles, actions, tools, and destinations</span> + <Icon + name="search" + size={13} + className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-foreground-muted" + /> + <input + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search graph" + className="w-full min-w-64 rounded-lg border border-border bg-surface-muted/40 py-1.5 pl-8 pr-3 text-xs outline-none focus:border-signal sm:w-72" + /> + </label> + {normalizedQuery && ( + <span className="rounded-full border border-border px-2 py-1 text-[10px] text-foreground-muted"> + {matchCount} match{matchCount === 1 ? "" : "es"} + </span> + )} + {running && ( + <span className="inline-flex items-center gap-1.5 rounded-full bg-accent/10 px-2 py-0.5 text-[11px] font-medium text-accent"> + <span className="kb-pulse inline-block h-1.5 w-1.5 rounded-full bg-accent" /> + live + </span> + )} + <button + type="button" + onClick={() => setEnlarged((current) => !current)} + className="rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground hover:bg-surface-muted" + aria-expanded={enlarged} + > + {enlarged ? "Close enlarged view" : "Enlarge graph"} + </button> + </div> + </div> + + <div + className={`mt-5 rounded-xl border border-border bg-surface-muted/20 ${ + enlarged ? "h-[calc(100vh-13rem)] min-h-[32rem] overflow-auto" : "overflow-x-auto" + }`} + aria-labelledby="execution-graph-title execution-graph-description" + > + <div + className="relative mx-auto" + style={{ width: graph.width, height: graph.height }} + > + <svg + className="absolute inset-0 h-full w-full" + viewBox={`0 0 ${graph.width} ${graph.height}`} + role="img" + aria-hidden="true" + > + <defs> + <linearGradient id="agent-graph-surface" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0%" stopColor="var(--surface)" stopOpacity="0.96" /> + <stop offset="100%" stopColor="var(--surface-muted)" stopOpacity="0.35" /> + </linearGradient> + <filter id="agent-graph-glow" x="-50%" y="-50%" width="200%" height="200%"> + <feGaussianBlur stdDeviation="4" result="blur" /> + <feMerge> + <feMergeNode in="blur" /> + <feMergeNode in="SourceGraphic" /> + </feMerge> + </filter> + </defs> + <rect width={graph.width} height={graph.height} rx="16" fill="url(#agent-graph-surface)" /> + <g opacity="0.32"> + {Array.from({ length: Math.ceil(graph.width / 32) }, (_, index) => ( + <line key={`grid-x-${index}`} x1={index * 32} y1="0" x2={index * 32} y2={graph.height} stroke="var(--border)" strokeWidth="0.5" /> + ))} + {Array.from({ length: Math.ceil(graph.height / 32) }, (_, index) => ( + <line key={`grid-y-${index}`} x1="0" y1={index * 32} x2={graph.width} y2={index * 32} stroke="var(--border)" strokeWidth="0.5" /> + ))} + </g> + + {graph.layouts.map((layout) => { + const parentId = graph.parentById.get(layout.agent.id); + if (!parentId) return null; + const parent = graph.byAgentId.get(parentId); + if (!parent) return null; + const active = hasRecentActivity(layout.agent); + const matched = !normalizedQuery || matchAgent(layout.agent) || matchAgent(parent.agent); + const startX = parent.x + AGENT_WIDTH / 2; + const startY = parent.y + AGENT_HEIGHT; + const endX = layout.x + AGENT_WIDTH / 2; + const endY = layout.y; + const bend = Math.max(44, (endY - startY) * 0.5); + return ( + <path + key={`delegation-path-${layout.agent.id}`} + d={`M ${startX} ${startY} C ${startX} ${startY + bend}, ${endX} ${endY - bend}, ${endX} ${endY}`} + fill="none" + stroke={phaseKind(layout.agent.phase) === "failed" ? "var(--danger)" : "var(--signal)"} + strokeWidth={active ? 2.5 : 1.75} + strokeDasharray={active ? "7 7" : undefined} + className={active ? "kb-graph-edge-active" : undefined} + opacity={matched ? 0.78 : 0.14} + /> + ); + })} + + {graph.aggregateLayouts.map((layout) => { + const parent = graph.byAgentId.get(layout.aggregate.parent.id); + if (!parent) return null; + const startX = parent.x + AGENT_WIDTH / 2; + const startY = parent.y + AGENT_HEIGHT; + const endX = layout.x + AGENT_WIDTH / 2; + const endY = layout.y; + const bend = Math.max(44, (endY - startY) * 0.5); + const matched = !normalizedQuery || layout.aggregate.searchText.includes(normalizedQuery); + return ( + <path + key={`aggregate-path-${layout.aggregate.key}`} + d={`M ${startX} ${startY} C ${startX} ${startY + bend}, ${endX} ${endY - bend}, ${endX} ${endY}`} + fill="none" + stroke="var(--accent)" + strokeWidth="1.75" + strokeDasharray="4 6" + opacity={matched ? 0.7 : 0.12} + /> + ); + })} + + {graph.layouts.flatMap((layout) => + layout.leaves.map((leaf) => { + const active = + hasRecentActivity(layout.agent) + && leaf.kind === "action" + && layout.agent.actions.at(-1)?.id === leaf.action.id; + const startX = layout.x + AGENT_WIDTH; + const startY = layout.y + AGENT_HEIGHT / 2; + const endX = leaf.x; + const endY = leaf.y + LEAF_HEIGHT / 2; + return ( + <path + key={`leaf-path-${leaf.key}`} + d={`M ${startX} ${startY} C ${startX + 16} ${startY}, ${endX - 16} ${endY}, ${endX} ${endY}`} + fill="none" + stroke={leaf.kind === "destination" || leaf.kind === "folded-destinations" ? "var(--accent)" : "var(--signal)"} + strokeWidth={active ? 2 : 1.25} + strokeDasharray={active ? "5 6" : leaf.kind.startsWith("folded") ? "3 5" : undefined} + className={active ? "kb-graph-edge-active" : undefined} + opacity={matchLeaf(leaf) ? 0.58 : 0.1} + /> + ); + }), + )} + </svg> + + {graph.layouts.map((layout) => { + const kind = phaseKind(layout.agent.phase); + const recentlyActive = hasRecentActivity(layout.agent); + const selectedAgent = selected.key === `agent:${layout.agent.id}`; + const latest = layout.agent.actions.at(-1); + return ( + <button + key={`agent-node-${layout.agent.id}`} + type="button" + onClick={() => activate({ + kind: "agent", + key: `agent:${layout.agent.id}`, + agent: layout.agent, + })} + aria-pressed={selectedAgent} + aria-label={`Inspect ${layout.agent.displayName}, ${layout.agent.role}, ${layout.agent.phase}`} + className={`absolute overflow-hidden rounded-2xl border px-4 py-3 text-left shadow-sm transition-[opacity,border-color,box-shadow,transform] hover:-translate-y-0.5 hover:shadow-lg ${ + selectedAgent + ? "border-signal bg-surface shadow-lg ring-2 ring-signal/20" + : layout.agent.isPrincipal + ? "border-signal/55 bg-surface" + : "border-border bg-surface" + } ${recentlyActive ? "kb-agent-node-active" : ""} ${matchAgent(layout.agent) ? "opacity-100" : "opacity-25"}`} + style={{ left: layout.x, top: layout.y, width: AGENT_WIDTH, height: AGENT_HEIGHT }} + > + <span className="flex items-center gap-2"> + <span className={`relative flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-[10px] font-bold uppercase ${ + layout.agent.isPrincipal + ? "border-signal/40 bg-signal/10 text-signal" + : "border-accent/35 bg-accent/10 text-accent" + }`}> + {layout.agent.isPrincipal ? "core" : "agt"} + {recentlyActive && <span className="kb-graph-orbit absolute -inset-1 rounded-[14px] border border-signal/45" />} + </span> + <span className="min-w-0 flex-1"> + <span className="flex items-center gap-1.5"> + <span className="truncate text-sm font-semibold">{layout.agent.displayName}</span> + {layout.agent.isPrincipal && ( + <span className="rounded-full bg-signal/10 px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-wider text-signal"> + root + </span> + )} + </span> + <span className="block truncate text-[10px] font-medium text-foreground-muted">{layout.agent.role}</span> + </span> + <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${ + kind === "failed" ? "bg-danger" : kind === "active" ? "bg-signal" : kind === "paused" ? "bg-accent" : "bg-foreground-muted/50" + }`} /> + </span> + <span className="mt-2 flex items-center gap-1.5 text-[9px]"> + <span className={`rounded-full border px-1.5 py-0.5 font-medium ${phaseTone(layout.agent.phase)}`}> + {layout.agent.phase} + </span> + {(layout.agent.model || layout.agent.runtime) && ( + <span className="truncate rounded-full border border-border bg-surface-muted/40 px-1.5 py-0.5 font-mono text-foreground-muted"> + {layout.agent.model ?? layout.agent.runtime} + </span> + )} + </span> + <span className="mt-2 block border-t border-border/70 pt-2 text-[10px] text-foreground-muted"> + <span className="font-medium text-foreground">{layout.agent.actions.length} records</span> + {" · "} + {ellipsis(latest?.human ?? "Awaiting first action", 34)} + </span> + </button> + ); + })} + + {graph.aggregateLayouts.map((layout) => { + const aggregate = layout.aggregate; + const matched = !normalizedQuery || aggregate.searchText.includes(normalizedQuery); + return ( + <button + key={`aggregate-node-${aggregate.key}`} + type="button" + onClick={() => activate(aggregate)} + aria-expanded={aggregate.expanded} + aria-pressed={selected.key === aggregate.key} + aria-label={`${aggregate.expanded ? "Collapse" : "Expand"} specialists delegated by ${aggregate.parent.displayName}`} + className={`absolute flex flex-col justify-center rounded-2xl border border-dashed px-4 py-3 text-left shadow-sm transition-[opacity,border-color,box-shadow,transform] hover:-translate-y-0.5 hover:shadow-md ${ + selected.key === aggregate.key + ? "border-accent bg-accent/[0.08] ring-2 ring-accent/20" + : "border-accent/45 bg-surface" + } ${matched ? "opacity-100" : "opacity-20"}`} + style={{ left: layout.x, top: layout.y, width: AGENT_WIDTH, height: 86 }} + > + <span className="flex w-full items-center gap-2"> + <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-accent/35 bg-accent/10 text-sm font-bold text-accent"> + {aggregate.expanded ? "−" : "+"} + </span> + <span className="min-w-0 flex-1"> + <span className="block truncate text-sm font-semibold">{aggregate.label}</span> + <span className="block text-[10px] text-foreground-muted"> + {aggregate.expanded ? "Activate to fold this sibling group" : "Activate to reveal this sibling group"} + </span> + </span> + </span> + </button> + ); + })} + + {graph.layouts.flatMap((layout) => + layout.leaves.map((leaf) => { + const leafSelected = selected.key === leaf.key; + const isDestination = leaf.kind === "destination" || leaf.kind === "folded-destinations"; + const isFolded = leaf.kind === "folded-actions" || leaf.kind === "folded-destinations"; + const failed = leaf.kind === "action" && leaf.action.ok === false; + return ( + <button + key={`leaf-node-${leaf.key}`} + type="button" + onClick={() => activate(leaf)} + aria-pressed={leafSelected} + aria-label={`Inspect ${leaf.label} for ${layout.agent.displayName}`} + className={`absolute flex items-center gap-2 rounded-xl border px-3 py-2 text-left shadow-sm transition-[opacity,border-color,box-shadow,transform] hover:-translate-y-0.5 hover:shadow-md ${ + leafSelected + ? "border-signal bg-surface ring-2 ring-signal/20" + : failed + ? "border-danger/30 bg-danger/[0.06]" + : isDestination + ? "border-accent/30 bg-surface" + : "border-border bg-surface" + } ${matchLeaf(leaf) ? "opacity-100" : "opacity-20"}`} + style={{ left: leaf.x, top: leaf.y, width: LEAF_WIDTH, height: LEAF_HEIGHT }} + > + <span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[10px] font-bold ${ + failed + ? "bg-danger/10 text-danger" + : isDestination + ? "bg-accent/10 text-accent" + : isFolded + ? "border border-dashed border-signal/40 text-signal" + : "bg-signal/10 text-signal" + }`}> + {failed ? "!" : isDestination ? "↗" : isFolded ? "+" : "→"} + </span> + <span className="min-w-0"> + <span className="block truncate text-[10px] font-semibold">{leaf.label}</span> + <span className="block truncate text-[9px] text-foreground-muted"> + {isDestination ? "destination" : isFolded ? "folded cluster" : "recorded action"} + </span> + </span> + </button> + ); + }), + )} + + {graph.layouts.map((layout) => { + const parentId = graph.parentById.get(layout.agent.id); + if (!parentId) return null; + const parent = graph.byAgentId.get(parentId); + if (!parent) return null; + const key = `edge:${parent.agent.id}:${layout.agent.id}`; + const edge = selections.get(key); + if (!edge || edge.kind !== "edge") return null; + const x = ((parent.x + AGENT_WIDTH / 2) + (layout.x + AGENT_WIDTH / 2)) / 2; + const y = ((parent.y + AGENT_HEIGHT) + layout.y) / 2; + const matched = !normalizedQuery || matchAgent(parent.agent) || matchAgent(layout.agent); + return ( + <button + key={`edge-control-${key}`} + type="button" + onClick={() => activate(edge)} + aria-pressed={selected.key === key} + aria-label={`Inspect delegation from ${parent.agent.displayName} to ${layout.agent.displayName}`} + className={`absolute flex h-7 w-7 items-center justify-center rounded-full border bg-surface text-[11px] font-bold shadow-sm transition hover:scale-110 ${ + selected.key === key ? "border-signal text-signal ring-2 ring-signal/20" : "border-border text-foreground-muted" + } ${matched ? "opacity-100" : "opacity-20"}`} + style={{ left: x - 14, top: y - 14 }} + > + ↓ + </button> + ); + })} + + {graph.aggregateLayouts.map((layout) => { + const parent = graph.byAgentId.get(layout.aggregate.parent.id); + if (!parent) return null; + const x = ((parent.x + AGENT_WIDTH / 2) + (layout.x + AGENT_WIDTH / 2)) / 2; + const y = ((parent.y + AGENT_HEIGHT) + layout.y) / 2; + const matched = !normalizedQuery || layout.aggregate.searchText.includes(normalizedQuery); + return ( + <button + key={`aggregate-edge-control-${layout.aggregate.key}`} + type="button" + onClick={() => activate(layout.aggregate)} + aria-label={`${layout.aggregate.expanded ? "Collapse" : "Expand"} folded specialist connection`} + className={`absolute flex h-7 w-7 items-center justify-center rounded-full border border-accent/40 bg-surface text-[11px] font-bold text-accent shadow-sm transition hover:scale-110 ${ + matched ? "opacity-100" : "opacity-20" + }`} + style={{ left: x - 14, top: y - 14 }} + > + {layout.aggregate.expanded ? "−" : "+"} + </button> + ); + })} + + {graph.layouts.flatMap((layout) => + layout.leaves.map((leaf) => { + const x = (layout.x + AGENT_WIDTH + leaf.x) / 2; + const y = ((layout.y + AGENT_HEIGHT / 2) + (leaf.y + LEAF_HEIGHT / 2)) / 2; + return ( + <button + key={`leaf-edge-control-${leaf.key}`} + type="button" + onClick={() => activate(leaf)} + aria-label={`Inspect connection to ${leaf.label}`} + className={`absolute flex h-5 w-5 items-center justify-center rounded-full border border-border bg-surface text-[9px] text-foreground-muted shadow-sm transition hover:border-signal hover:text-signal ${ + matchLeaf(leaf) ? "opacity-100" : "opacity-15" + }`} + style={{ left: x - 10, top: y - 10 }} + > + › + </button> + ); + }), + )} + + {agents.length === 1 && graph.layouts[0]?.leaves.length === 0 && ( + <div + className="absolute rounded-xl border border-dashed border-border bg-surface/85 px-4 py-3 text-xs text-foreground-muted" + style={{ + left: graph.layouts[0].x + AGENT_WIDTH + 28, + top: graph.layouts[0].y + 30, + width: LEAF_WIDTH, + }} + > + {running + ? "The principal is live. Action leaves will unfold here." + : events.length > 0 + ? "This execution completed without retained tool actions." + : "Launch the execution to populate its action graph."} + </div> + )} + </div> + </div> + + <SelectionInspector selection={selected} /> + + <ProofPoints + envelopeDigest={envelopeDigest} + identity={identity} + subCount={agents.filter((agent) => !agent.isPrincipal).length} + receipt={receipt} + /> + </section> + ); + if (enlarged && typeof document !== "undefined") { + return createPortal( + <div className="fixed inset-0 z-[100] bg-background/85 p-2 backdrop-blur-sm sm:p-4"> + {graphSection} + </div>, + document.body, + ); + } + return graphSection; +} + +function SelectionInspector({ selection }: { selection: GraphSelection }) { + if (selection.kind === "agent") return <AgentInspector agent={selection.agent} />; + if (selection.kind === "edge") { + const childKind = phaseKind(selection.child.phase); + return ( + <InspectorShell eyebrow="Delegation relationship" title={`${selection.parent.displayName} → ${selection.child.displayName}`}> + <p className="text-xs leading-relaxed text-foreground-muted"> + Runtime metadata identifies{" "} + <span className="font-medium text-foreground">{selection.parent.displayName}</span> + {" as the parent of "} + <span className="font-medium text-foreground">{selection.child.displayName}</span> + {selection.child.role ? ` as ${selection.child.role}` : ""}. The child is currently{" "} + <span className={childKind === "failed" ? "font-medium text-danger" : "font-medium text-foreground"}> + {selection.child.phase} + </span>. + </p> + <p className="mt-2 text-[10px] leading-relaxed text-foreground-muted"> + No delegation-event ledger is attached to this trace. The drill-down below focuses the child agent's exact retained activity, not an inferred edge event. + </p> + <div className="mt-3 grid gap-2 sm:grid-cols-3"> + <Metric label="Child runtime" value={selection.child.runtime ?? "Not reported"} small /> + <Metric label="Child model" value={selection.child.model ?? "Not reported"} small /> + <Metric label="Latest activity" value={formatLastActivity(selection.child.lastActivity)} small /> + </div> + </InspectorShell> + ); + } + if (selection.kind === "specialist-aggregate") { + return ( + <InspectorShell eyebrow="Folded specialist group" title={selection.label}> + <p className="text-xs text-foreground-muted"> + Specialists sharing <span className="font-medium text-foreground">{selection.parent.displayName}</span> as their runtime parent. + </p> + <FoldedList + items={selection.agents.map((agent) => ({ + title: `${agent.displayName} · ${agent.role}`, + detail: `${agent.technicalName} · ${agent.model ?? "model not reported"} · ${agent.phase}`, + failed: phaseKind(agent.phase) === "failed", + }))} + /> + </InspectorShell> + ); + } + if (selection.kind === "action") { + const action = selection.action; + return ( + <InspectorShell eyebrow="Recorded action" title={action.human}> + <div className="flex flex-wrap gap-2 text-[10px] text-foreground-muted"> + <span className="rounded-full border border-border px-2 py-1">Agent {selection.agent.displayName}</span> + <span className="rounded-full border border-border px-2 py-1">Round {action.round + 1}</span> + <span className="rounded-full border border-border px-2 py-1">{action.ms} ms</span> + <span className={`rounded-full border px-2 py-1 ${ + action.ok === false ? "border-danger/30 text-danger" : action.ok === true ? "border-signal/30 text-signal" : "border-border" + }`}> + {action.ok === false ? "Failed" : action.ok === true ? "Succeeded" : "Model round"} + </span> + <span className="rounded-full border border-border px-2 py-1">{formatLastActivity(action.ts)}</span> + </div> + <p className="mt-3 break-all rounded-lg bg-surface-muted/45 px-3 py-2 font-mono text-[10px]"> + <span className="font-sans font-medium text-foreground-muted">Raw tool: </span>{action.raw} + </p> + <div className="mt-2 grid gap-2 text-[10px] sm:grid-cols-2"> + <DetailBlock label="Arguments" value={action.args || "No input preview retained"} /> + <DetailBlock label={action.ok === false ? "Failure / result" : "Result"} value={action.result || "No result preview retained"} /> + </div> + </InspectorShell> + ); + } + if (selection.kind === "folded-actions") { + return ( + <InspectorShell eyebrow="Folded action cluster" title={`${selection.actions.length} earlier actions`}> + <FoldedList + items={selection.actions.map((action) => ({ + title: action.human, + detail: `${action.raw} · round ${action.round + 1} · ${action.ms} ms · ${formatLastActivity(action.ts)}`, + failed: action.ok === false, + }))} + /> + </InspectorShell> + ); + } + if (selection.kind === "destination") { + return ( + <InspectorShell eyebrow="Network destination" title={selection.destination}> + <p className="text-xs text-foreground-muted"> + Referenced by retained action evidence from <span className="font-medium text-foreground">{selection.agent.displayName}</span>. + </p> + </InspectorShell> + ); + } + return ( + <InspectorShell eyebrow="Folded destination cluster" title={`${selection.destinations.length} additional destinations`}> + <FoldedList items={selection.destinations.map((destination) => ({ title: destination, detail: "Referenced in retained action evidence" }))} /> + </InspectorShell> + ); +} + +function AgentInspector({ agent }: { agent: AgentExecution }) { + const latest = agent.actions.at(-1); + return ( + <InspectorShell eyebrow={agent.isPrincipal ? "Principal agent" : "Specialist agent"} title={agent.displayName}> + <div className="flex flex-wrap items-center gap-2"> + <span className={`rounded-full border px-2 py-1 text-[10px] font-medium ${phaseTone(agent.phase)}`}> + {agent.phase} + </span> + <span className="text-[11px] text-foreground-muted">{agent.relationship}</span> + </div> + <dl className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> + <Metric label="Role" value={agent.role} small /> + <Metric label="Runtime" value={agent.runtime ?? "Not reported"} small /> + <Metric label="Model" value={agent.model ?? "Not reported"} small /> + <Metric label="Exact agent name" value={agent.technicalName} small /> + </dl> + <div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> + <Metric label="Retained rounds" value={agent.rounds} /> + <Metric label="Tool calls" value={agent.toolCalls} /> + <Metric label="Failures" value={agent.failures} danger={agent.failures > 0} /> + <Metric label="Latest activity" value={formatLastActivity(agent.lastActivity)} small /> + </div> + <div className="mt-2 rounded-lg border border-border bg-surface px-3 py-2"> + <p className="text-[9px] font-medium uppercase tracking-wide text-foreground-muted">Latest action</p> + <p className="mt-0.5 text-xs font-medium">{latest?.human ?? "No retained action yet"}</p> + </div> + </InspectorShell> + ); +} + +function InspectorShell({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( + <div className="mt-4 rounded-xl border border-signal/25 bg-signal/[0.035] p-4" aria-live="polite"> + <p className="text-[9px] font-semibold uppercase tracking-[0.16em] text-signal">{eyebrow}</p> + <h3 className="mt-0.5 break-words text-sm font-semibold">{title}</h3> + <div className="mt-2">{children}</div> + </div> + ); +} + +function DetailBlock({ label, value }: { label: string; value: string }) { + return ( + <p className="break-words rounded-lg bg-surface-muted/45 px-3 py-2"> + <span className="font-medium text-foreground-muted">{label}: </span> + <span className="font-mono">{value}</span> + </p> + ); +} + +function FoldedList({ + items, +}: { + items: Array<{ title: string; detail: string; failed?: boolean }>; +}) { + return ( + <ol className="max-h-56 space-y-1.5 overflow-y-auto pr-1"> + {items.map((item, index) => ( + <li key={`${item.title}-${index}`} className="flex gap-2 rounded-lg border border-border bg-surface px-3 py-2"> + <span className={`mt-1 h-1.5 w-1.5 shrink-0 rounded-full ${item.failed ? "bg-danger" : "bg-signal"}`} /> + <span className="min-w-0"> + <span className="block text-[11px] font-medium">{item.title}</span> + <span className="block break-all font-mono text-[9px] text-foreground-muted">{item.detail}</span> + </span> + </li> + ))} + </ol> + ); +} + +function Metric({ + label, + value, + danger = false, + small = false, +}: { + label: string; + value: string | number; + danger?: boolean; + small?: boolean; +}) { + return ( + <div className="rounded-md border border-border bg-surface px-2 py-1.5"> + <dt className="text-[9px] uppercase tracking-wide text-foreground-muted">{label}</dt> + <dd className={`mt-0.5 break-words ${small ? "text-[10px] leading-tight" : "font-semibold tabular-nums"} ${danger ? "text-danger" : ""}`}> + {value} + </dd> + </div> + ); +} + +function ProofPoints({ + envelopeDigest, + identity, + subCount, + receipt, +}: { + envelopeDigest: string | null; + identity: AgentIdentity | null; + subCount: number; + receipt: Receipt | null; +}) { + const short = (value: string, head = 10, tail = 6) => + value.length > head + tail + 1 + ? `${value.slice(0, head)}...${value.slice(-tail)}` + : value; + const points = [ + { + when: "At admission", + title: "Trust envelope signed", + detail: envelopeDigest + ? `Digest ${short(envelopeDigest.replace(/^sha256:/, ""))} - tier, budget, tools, and reach were sealed before launch.` + : "Tier, budget, tools, and reach are sealed into a signed envelope before launch.", + proven: Boolean(envelopeDigest), + }, + { + when: "At registration", + title: "Agent mesh identity (DID)", + detail: identity?.did + ? `${short(identity.did, 16, 8)} - signed mesh participant${identity.reputation_score != null ? `, reputation ${identity.reputation_score}` : ""}.` + : "No per-run DID registration proof is attached to this retained view.", + proven: Boolean(identity?.did), + }, + { + when: "At spawn", + title: "Sub-agent attenuation enforced", + detail: + subCount > 0 + ? `${subCount} specialist${subCount === 1 ? "" : "s"} spawned after the controller verified each envelope was a strict subset of the principal's authority.` + : "If the principal delegates, the controller rejects any sub-agent envelope that is not a strict authority subset.", + proven: subCount > 0, + }, + { + when: "At delivery", + title: "Governance receipt (DSSE)", + detail: receipt + ? `${receipt.scheme || "DSSE"} - key ${short(receipt.key_id || "-", 8, 6)}${receipt.inclusion_seq != null ? ` - inclusion log #${receipt.inclusion_seq}` : ""}.` + : "No per-run DSSE receipt object is attached to this retained view.", + proven: Boolean(receipt), + }, + ]; + const verified = points.filter((point) => point.proven).length; + const notRetained = points.length - verified; + + return ( + <details className="mt-4 rounded-xl border border-border bg-surface-muted/20"> + <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-medium text-foreground-muted"> + <Icon name="seal" size={13} /> + Cryptographic proofs and attestations + <span className="ml-auto text-[10px]"> + {verified} verified · {notRetained} not retained + </span> + </summary> + <ol className="space-y-2 border-t border-border p-3"> + {points.map((point) => ( + <li key={point.title} className="flex gap-2.5"> + <span className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] ${point.proven ? "bg-signal/15 text-signal" : "border border-dashed border-border text-foreground-muted"}`}> + {point.proven ? "ok" : "n/a"} + </span> + <div className="min-w-0"> + <p className="text-[11px] font-medium"> + {point.title} + <span className="ml-2 rounded-full border border-border px-1.5 py-0.5 text-[9px] font-normal text-foreground-muted"> + {point.when} + </span> + </p> + <p className="text-[11px] leading-relaxed text-foreground-muted">{point.detail}</p> + </div> + </li> + ))} + </ol> + <p className="border-t border-border px-3 py-2 text-[10px] leading-relaxed text-foreground-muted"> + “Verified” means the exact per-run proof object is attached here. “Not retained” means this + archived run predates that retained evidence surface; it is not counted as cryptographic proof, + even when the platform control was enforced. + </p> + </details> + ); +} diff --git a/bridge/web/src/components/app-shell.tsx b/bridge/web/src/components/app-shell.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/bridge/web/src/components/approval-decision.tsx b/bridge/web/src/components/approval-decision.tsx new file mode 100644 index 000000000..147829949 --- /dev/null +++ b/bridge/web/src/components/approval-decision.tsx @@ -0,0 +1,114 @@ +"use client"; + +// kars Bridge — approval decision controls (Approve / Deny). +// +// A client component so the decision is interactive (optional reason, pending +// state, inline error). The actual write is a server action — the browser +// never touches the cluster. The decider identity is supplied by the server +// (see config.operatorIdentity) and shown plainly with its honesty caveat. + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { decide } from "@/app/inbox/approval-actions"; + +export function ApprovalDecision({ + name, + decider, + authWired, + resourceVersion, + boundEnvelopeDigest, + compact = false, + requireReason = false, + approveLabel = "Approve", + denyLabel = "Deny", + requireDenyReason = false, + reasonPlaceholder, +}: { + name: string; + decider: string; + authWired: boolean; + resourceVersion: string; + boundEnvelopeDigest: string | null; + compact?: boolean; + requireReason?: boolean; + approveLabel?: string; + denyLabel?: string; + requireDenyReason?: boolean; + reasonPlaceholder?: string; +}) { + const router = useRouter(); + const [reason, setReason] = useState(""); + const [error, setError] = useState<string | null>(null); + const [pending, startTransition] = useTransition(); + + function act(verdict: "approve" | "deny") { + setError(null); + if (verdict === "deny" && requireDenyReason && !reason.trim()) { + setError("Describe the changes required before this work can run again."); + return; + } + startTransition(async () => { + const res = await decide(name, verdict, resourceVersion, boundEnvelopeDigest, reason); + if (res.error) { + setError(res.error); + } else { + router.refresh(); + } + }); + } + + return ( + <div className="space-y-2"> + {(!compact || requireReason || requireDenyReason) && ( + <input + type="text" + value={reason} + onChange={(e) => setReason(e.target.value)} + placeholder={ + reasonPlaceholder + ?? (requireReason + ? "Answer required to continue this run" + : "Reason (recorded in the receipt) — optional") + } + className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + )} + {requireDenyReason && !reason.trim() && ( + <p className="text-xs text-foreground-muted"> + Written feedback is required to request changes. + </p> + )} + <div className="flex items-center gap-2"> + <button + type="button" + disabled={pending || (requireReason && !reason.trim())} + onClick={() => act("approve")} + className="rounded-lg bg-signal px-3 py-1.5 text-sm font-medium text-signal-fg hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal disabled:opacity-50" + > + {pending ? "…" : approveLabel} + </button> + <button + type="button" + disabled={pending || (requireDenyReason && !reason.trim())} + onClick={() => act("deny")} + className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-1.5 text-sm font-medium text-danger hover:bg-danger/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-danger disabled:opacity-50" + > + {pending ? "…" : denyLabel} + </button> + <span className="text-xs text-foreground-muted"> + as <span className="font-mono">{decider}</span> + {!authWired && ( + <span className="ml-1 text-warning" title="The Bridge has no authenticated session yet; decisions are attributed to the configured operator identity."> + · shared operator identity + </span> + )} + </span> + </div> + {error && ( + <p role="alert" className="text-xs text-danger"> + {error} + </p> + )} + </div> + ); +} diff --git a/bridge/web/src/components/approval-phase-badge.tsx b/bridge/web/src/components/approval-phase-badge.tsx new file mode 100644 index 000000000..58e8c1056 --- /dev/null +++ b/bridge/web/src/components/approval-phase-badge.tsx @@ -0,0 +1,34 @@ +// kars Bridge — approval phase badge. + +export function ApprovalPhaseBadge({ phase }: { phase: string }) { + const map: Record<string, string> = { + Pending: "border-warning/40 bg-warning/10 text-warning", + Approved: "border-ok/40 bg-ok/10 text-ok", + Denied: "border-danger/40 bg-danger/10 text-danger", + Expired: "border-border bg-surface-muted text-foreground-muted", + Stale: "border-border bg-surface-muted text-foreground-muted", + }; + const cls = map[phase] ?? "border-border bg-surface-muted text-foreground-muted"; + return ( + <span + className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${cls}`} + > + {phase} + </span> + ); +} + +const ACTION_LABELS: Record<string, string> = { + toolCall: "Tool call", + egress: "Egress", + checkpoint: "Checkpoint", + tierRaise: "Tier raise", + budgetRaise: "Budget increase", + clarification: "Clarification", + irreversible: "Irreversible action", + custom: "Action", +}; + +export function actionLabel(kind: string): string { + return ACTION_LABELS[kind] ?? kind; +} diff --git a/bridge/web/src/components/audit-report.tsx b/bridge/web/src/components/audit-report.tsx new file mode 100644 index 000000000..13a964db9 --- /dev/null +++ b/bridge/web/src/components/audit-report.tsx @@ -0,0 +1,116 @@ +"use client"; + +// Downloadable audit report. Compiles the REAL governance proofs already on +// this page — the signed receipt (envelope digest, signatures, claims, +// transparency-log inclusion, the exact independent verify command) plus the +// execution trace summary and enforced egress — into a human-readable Markdown +// report and the raw JSON evidence bundle, and lets the operator download both. +// Nothing is synthesized: every line traces to a real receipt/trace field. + +import type { ActivityEvent, Receipt } from "@/lib/types"; + +function download(filename: string, content: string, mime: string) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +function buildMarkdown(task: string, receipt: Receipt, activity: ActivityEvent[], egress: string[]): string { + const tools = activity.filter((e) => e.kind === "tool"); + const rounds = activity.filter((e) => e.kind === "round"); + const failed = tools.filter((e) => e.kind === "tool" && !e.ok).length; + const totalTokens = rounds.reduce((s, e) => s + (e.kind === "round" ? e.total_tokens : 0), 0); + const lines: string[] = []; + lines.push(`# Audit report — ${task}`); + lines.push(""); + lines.push(`_Generated ${new Date().toISOString()} from the mission's signed Governance Receipt and execution trace._`); + lines.push(""); + lines.push("## Attestation"); + lines.push(`- **Receipt**: \`${receipt.name}\` (namespace \`${receipt.namespace}\`)`); + lines.push(`- **Predicate**: ${receipt.predicate_type}`); + lines.push(`- **Envelope digest**: \`${receipt.envelope_digest}\``); + lines.push(`- **Issued**: ${receipt.issued_at ?? "—"}`); + lines.push(`- **Signature scheme**: ${receipt.scheme} (key \`${receipt.key_id}\`)`); + lines.push(`- **Signatures**: ${receipt.signatures.length}`); + if (receipt.inclusion_seq !== null) { + lines.push(`- **Transparency log**: entry #${receipt.inclusion_seq}, hash \`${receipt.inclusion_entry_hash ?? "—"}\``); + } + if (receipt.checkpoint) { + lines.push(`- **Signed checkpoint (STH)**: present — cross-receipt tamper-evidence chain`); + } + lines.push(""); + lines.push("## Claims"); + if (receipt.claims.length === 0) { + lines.push("_No claims recorded._"); + } else { + for (const c of receipt.claims) { + lines.push(`- **${c.class}** — ${c.status}: ${c.detail}`); + } + } + lines.push(""); + lines.push("## Enforced egress"); + lines.push(egress.length === 0 ? "- Model path only — all other egress denied at the boundary." : egress.map((h) => `- \`${h}\``).join("\n")); + lines.push(""); + lines.push("## Execution trace summary"); + lines.push(`- Model rounds: ${rounds.length}`); + lines.push(`- Tool calls: ${tools.length} (${failed} failed)`); + lines.push(`- Tokens observed in trace: ${totalTokens.toLocaleString()}`); + lines.push(` _(agent-side per-round count from the execution trace; the billed total on the mission scorecard may differ — it includes system-prompt and tool-call overhead the per-round trace doesn't.)_`); + const toolNames = [...new Set(tools.map((e) => (e.kind === "tool" ? e.name : "")))].filter(Boolean); + if (toolNames.length) lines.push(`- Tools used: ${toolNames.join(", ")}`); + lines.push(""); + lines.push("## Independent verification"); + lines.push("Anyone can verify this receipt's signature without trusting the Bridge:"); + lines.push(""); + lines.push("```"); + lines.push(receipt.verify_command || "(verify command unavailable)"); + lines.push("```"); + lines.push(""); + return lines.join("\n"); +} + +export function AuditReportDownload({ + task, + receipt, + activity, + egress, +}: { + task: string; + receipt: Receipt; + activity: ActivityEvent[]; + egress: string[]; +}) { + const stamp = new Date().toISOString().slice(0, 10); + return ( + <div className="inline-flex gap-2"> + <button + type="button" + onClick={() => download(`audit-${task}-${stamp}.md`, buildMarkdown(task, receipt, activity, egress), "text/markdown")} + className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium hover:bg-surface-muted" + title="Human-readable Markdown report of the signed proofs" + > + ⬇ Download report + </button> + <button + type="button" + onClick={() => + download( + `audit-${task}-${stamp}.json`, + JSON.stringify({ receipt, activity, egress, generated_at: new Date().toISOString() }, null, 2), + "application/json", + ) + } + className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground-muted hover:bg-surface-muted" + title="Raw evidence bundle (receipt + trace + egress) as JSON" + > + ⬇ Evidence JSON + </button> + </div> + ); +} diff --git a/bridge/web/src/components/audit-view.tsx b/bridge/web/src/components/audit-view.tsx new file mode 100644 index 000000000..27675d9ea --- /dev/null +++ b/bridge/web/src/components/audit-view.tsx @@ -0,0 +1,145 @@ +// kars Bridge — the shared Auditor view. Rendered both inside the Operator +// Console (/console/audit) and on the dedicated read-only Auditor surface +// (/audit), so the two never drift. It is entirely read-only: a chain-integrity +// verdict, the four claim classes, and every Governance Receipt as an +// inspectable, independently-verifiable unit. + +import { Section, Stat, Badge } from "@/components/ui"; +import { HonestState } from "@/components/honest-state"; +import { Icon, type IconName } from "@/components/icon"; +import { AuditSearch } from "@/app/console/audit/audit-search"; +import type { Audit } from "@/lib/types"; + +const CLAIMS: { icon: IconName; title: string; q: string; d: string }[] = [ + { icon: "seal", title: "Integrity", q: "Authentic & unaltered?", d: "Each receipt is Ed25519-signed and its signing key checks against the cluster's published anchor." }, + { icon: "target", title: "Conformance", q: "Stayed within authority?", d: "The run never exceeded its envelope — tier, tool policy, budget, and egress were enforced." }, + { icon: "check-cycle", title: "Completeness", q: "All controls enforced & recorded?", d: "Every governance control that should have run did, and left a durable, re-derivable record." }, + { icon: "scale", title: "Regulatory", q: "Anchored & witnessed?", d: "The receipt sits in a hash-chained inclusion log an independent checkpoint co-signs." }, +]; + +export function AuditView({ audit, error }: { audit: Audit | null; error: boolean }) { + const haveCheckpoint = !!audit?.checkpoint; + const missingSeq = audit ? audit.receipts.filter((r) => r.inclusion_seq == null).length : 0; + const allIncluded = !!audit && missingSeq === 0; + // The REAL verdict comes from server-side cryptographic verification: the whole + // hash chain recomputed + the signed checkpoint verified against the published + // anchor. Field presence (inclusion_seq set, checkpoint CM exists) is NOT + // verification — a green banner must mean the crypto actually checked out. + const integrity = audit?.integrity; + const chainOk = + !!integrity && + integrity.chain_consistent && + integrity.checkpoint_verified && + allIncluded && + (audit?.receipts.length ?? 0) > 0; + const witnessPresent = !!integrity?.witness_present; + const anchorPinned = !!integrity?.anchor_pinned; + + return ( + <div className="space-y-6"> + {/* What an auditor can verify here — the four claim classes, up front. */} + <section aria-label="What you can verify" className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> + {CLAIMS.map((c) => ( + <div key={c.title} className="rounded-xl border border-border bg-surface p-4"> + <p className="flex items-center gap-1.5 text-sm font-semibold"><Icon name={c.icon} className="text-signal" /> {c.title}</p> + <p className="mt-0.5 text-[11px] font-medium text-foreground-muted">{c.q}</p> + <p className="mt-1.5 text-xs text-foreground-muted">{c.d}</p> + </div> + ))} + </section> + + {/* Chain-integrity verdict — the auditor's first question, answered. */} + {audit && !error && ( + <section + className={`kb-rise flex flex-wrap items-start justify-between gap-4 rounded-xl border p-5 ${ + chainOk ? "border-emerald-500/30 bg-emerald-500/[0.05]" : "border-amber-500/30 bg-amber-500/[0.05]" + }`} + > + <div className="flex items-start gap-3"> + <span aria-hidden className={`grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-surface ${chainOk ? "text-ok" : "text-warning"}`}> + <Icon name={chainOk ? "shield" : "target"} size={20} /> + </span> + <div> + <p className="text-base font-semibold"> + {chainOk + ? witnessPresent + ? "Log verified — chain recomputed, checkpoint signature valid, witness co-signature present" + : "Log verified — chain recomputed and checkpoint signature valid" + : "Log not fully verified — see details"} + </p> + <p className="mt-0.5 max-w-2xl text-sm text-foreground-muted"> + {chainOk + ? `All ${audit.receipts.length} receipts sit in a hash-chained inclusion log of ${audit.inclusion_log_size} entries that recomputes genesis → head, and the signed checkpoint's Ed25519 signature verifies${anchorPinned ? " against an out-of-band-pinned anchor — so no entry can be altered, removed, or forked without detection." : " against the cluster's published anchor key. Because that anchor lives in the same trust domain as the log, this proves consistency and authenticity for anyone outside that domain; for independent tamper-evidence, verify against an out-of-band-pinned key (kars receipt verify)."}${witnessPresent ? " An independent transparency-witness co-signature is present (shown, not re-verified in V0)." : ""}` + : integrity && !integrity.chain_consistent && integrity.tree_size > 0 + ? "The inclusion log did NOT recompute cleanly — an entry hash, sequence, or prev-hash link does not check out. The history is not tamper-evident; investigate before trusting these receipts." + : integrity && integrity.chain_consistent && !integrity.checkpoint_verified + ? "The chain recomputes, but the signed checkpoint's signature did not verify against the published anchor (or no checkpoint is published). Until the log head is witnessed by a valid signed checkpoint, the history is not fully tamper-evident." + : missingSeq > 0 + ? `${missingSeq} of ${audit.receipts.length} receipt(s) have no inclusion-log position yet${haveCheckpoint ? "" : " and no signed checkpoint is published"}. Until every receipt is chained${haveCheckpoint ? "" : " and a checkpoint is published"}, the history is not fully tamper-evident.` + : "No signed checkpoint is published yet. Until the log head is witnessed by a signed checkpoint, the history is not fully tamper-evident."} + </p> + </div> + </div> + <Badge tone={chainOk ? "ok" : "warn"} dot> + {chainOk ? (anchorPinned ? "Tamper-evident" : "Consistent + signed") : "Not yet sealed"} + </Badge> + </section> + )} + + {error || !audit ? ( + <HonestState variant="not_wired" title="Audit substrate unreachable" detail="The Bridge backend can't reach the audit log right now." /> + ) : ( + <> + <div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> + <Stat label="Receipts issued" value={audit.receipts.length} /> + <Stat label="Inclusion-log entries" value={audit.inclusion_log_size} /> + <div className="rounded-xl border border-signal/30 bg-signal/[0.05] p-4"> + {audit.checkpoint ? ( + <> + <p className="inline-flex items-center gap-1.5 text-sm font-semibold text-signal"> + <Icon name="shield" /> Signed checkpoint + </p> + <p className="mt-1 font-mono text-xs text-foreground-muted"> + tree size {audit.checkpoint.tree_size} · root {audit.checkpoint.root_hash.slice(0, 16)}… + </p> + </> + ) : ( + <p className="text-sm text-foreground-muted">No checkpoint yet</p> + )} + </div> + </div> + + <Section + title="Governance receipts" + subtitle="Search a run or agent to pull its receipts and chain of custody, or filter by verdict. Expand any row to see exactly what was checked and how to verify it yourself." + action={<Badge tone="muted">{audit.receipts.length}</Badge>} + > + <AuditSearch receipts={audit.receipts} /> + </Section> + + {/* How to read a receipt — the auditor's primer, once. */} + <Section title="How to read a receipt"> + <ul className="space-y-2 text-sm text-foreground-muted"> + <li className="flex gap-2"> + <span aria-hidden>①</span> + <span><strong className="text-foreground">Verdict</strong> — the required verification classes are <em>integrity</em> (authentic & unaltered), <em>conformance</em> (stayed within authority), and <em>completeness</em> (all applicable controls enforced & recorded). <em>Regulatory</em> reports platform anchoring maturity separately and is advisory until the external-KMS roadmap lands.</span> + </li> + <li className="flex gap-2"> + <span aria-hidden>②</span> + <span><strong className="text-foreground">Inclusion log</strong> — the position number proves the receipt is chained into history. Removing or altering one breaks the chain.</span> + </li> + <li className="flex gap-2"> + <span aria-hidden>③</span> + <span><strong className="text-foreground">Verify yourself</strong> — hit <em>Verify now</em> on any row. The backend re-checks the Ed25519 signature, the trust-envelope binding, and the signing key against the cluster's published anchor, live — no tooling to install, and no trust in this screen required.</span> + </li> + <li className="flex gap-2"> + <span aria-hidden>④</span> + <span><strong className="text-foreground">Why a receipt reads “Partial”</strong> — one or more required per-run evidence axes were not bound when that receipt was signed, such as a retained eBPF datapath verdict for a sandbox that has already been retired. The detail names the missing axis. The regulatory/KMS roadmap disclosure does <em>not</em> by itself prevent a “Verified” verdict.</span> + </li> + </ul> + </Section> + </> + )} + </div> + ); +} diff --git a/bridge/web/src/components/bar-chart.tsx b/bridge/web/src/components/bar-chart.tsx new file mode 100644 index 000000000..6e48d3985 --- /dev/null +++ b/bridge/web/src/components/bar-chart.tsx @@ -0,0 +1,35 @@ +// kars Bridge — simple, dependency-free horizontal bar chart for count data. +// Renders real values only; an empty series renders nothing (caller shows the +// honest empty state). + +export function BarChart({ + data, + colorClass = "bg-signal", +}: { + data: Array<{ label: string; count: number }>; + colorClass?: string; +}) { + const max = Math.max(1, ...data.map((d) => d.count)); + return ( + <ul className="space-y-2.5"> + {data.map((d) => ( + <li key={d.label} className="flex items-center gap-3"> + <span className="w-24 shrink-0 truncate text-xs text-foreground-muted">{d.label}</span> + <div className="relative h-5 flex-1 overflow-hidden rounded-md bg-surface-muted ring-1 ring-inset ring-border/60"> + <div + className={`relative h-full rounded-md ${colorClass} transition-[width] duration-500 ease-out`} + style={{ width: `${Math.max(4, (d.count / max) * 100)}%` }} + > + {/* subtle top sheen for a lit, premium fill */} + <span + aria-hidden + className="absolute inset-x-0 top-0 h-1/2 rounded-t-md bg-gradient-to-b from-white/25 to-transparent" + /> + </div> + </div> + <span className="w-8 shrink-0 text-right text-xs font-medium tabular-nums">{d.count}</span> + </li> + ))} + </ul> + ); +} diff --git a/bridge/web/src/components/clarification-answer.tsx b/bridge/web/src/components/clarification-answer.tsx new file mode 100644 index 000000000..2f34a6896 --- /dev/null +++ b/bridge/web/src/components/clarification-answer.tsx @@ -0,0 +1,93 @@ +"use client"; + +// kars Bridge — clarification answer control. A team run asked the human a +// question (a `clarification` KarsApproval raised by the principal). The human +// types an answer here; it is recorded on the same decision path (verdict +// "approve", the answer as the reason), and the controller delivers it into the +// team's commons so the next run reads it. Answering IS the approval — there is +// no separate "deny" for a question, though the human can dismiss it. + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { decide } from "@/app/inbox/approval-actions"; + +export function ClarificationAnswer({ + name, + decider, + authWired, + resourceVersion, + boundEnvelopeDigest, +}: { + name: string; + decider: string; + authWired: boolean; + resourceVersion: string; + boundEnvelopeDigest: string | null; +}) { + const router = useRouter(); + const [answer, setAnswer] = useState(""); + const [error, setError] = useState<string | null>(null); + const [pending, startTransition] = useTransition(); + + function submit(verdict: "approve" | "deny") { + setError(null); + if (verdict === "approve" && answer.trim().length === 0) { + setError("Type an answer so the team can proceed."); + return; + } + startTransition(async () => { + const res = await decide( + name, + verdict, + resourceVersion, + boundEnvelopeDigest, + answer.trim() || undefined, + ); + if (res.error) { + setError(res.error); + } else { + router.refresh(); + } + }); + } + + return ( + <div className="space-y-2"> + <textarea + value={answer} + onChange={(e) => setAnswer(e.target.value)} + rows={2} + placeholder="Your answer — delivered to the active run…" + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-1.5 text-sm placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <div className="flex items-center gap-2"> + <button + type="button" + disabled={pending} + onClick={() => submit("approve")} + className="rounded-lg bg-signal px-3 py-1.5 text-sm font-medium text-signal-fg hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal disabled:opacity-50" + > + {pending ? "…" : "Send answer"} + </button> + <button + type="button" + disabled={pending} + onClick={() => submit("deny")} + className="rounded-lg border border-border px-3 py-1.5 text-sm font-medium text-foreground-muted hover:bg-surface-muted disabled:opacity-50" + title="Dismiss without answering — the team is told no guidance is coming." + > + {pending ? "…" : "Dismiss"} + </button> + <span className="text-xs text-foreground-muted"> + as <span className="font-mono">{decider}</span> + {!authWired && ( + <span className="ml-1 text-warning" title="The Bridge has no authenticated session yet; decisions are attributed to the configured operator identity."> + · shared operator identity + </span> + )} + </span> + </div> + {error && <p role="alert" className="text-xs text-danger">{error}</p>} + </div> + ); +} diff --git a/bridge/web/src/components/compliance-pack.tsx b/bridge/web/src/components/compliance-pack.tsx new file mode 100644 index 000000000..0dbca1063 --- /dev/null +++ b/bridge/web/src/components/compliance-pack.tsx @@ -0,0 +1,149 @@ +"use client"; + +// kars Bridge — compliance evidence pack. The mission's signed Governance +// Receipt expressed in the auditor's frameworks: each receipt claim is mapped to +// EU AI Act articles and NIST AI RMF functions, backed by the signed envelope +// digest + transparency-log inclusion. No competitor ships this from first-party +// audit data. Status is inherited verbatim from the signed claim — a PARTIAL +// control is never rendered as satisfied. +// +// `advisory` controls (the `regulatory` claim class) are a NAMED V0 product +// limitation — an external transparency anchor is a V1 roadmap item, so this +// claim reads PARTIAL on every receipt kars issues today, not a gap specific +// to this mission. Mirrors the same distinction AuditReceiptRow already makes +// when computing its "Verified" verdict (`isAdvisoryClaim`) — without this, +// the pack's own satisfied/partial count contradicted the receipt row sitting +// right above it. + +import { Icon } from "@/components/icon"; +import type { CompliancePack } from "@/lib/types"; + +function statusTone(s: string): string { + const u = s.toUpperCase(); + if (u === "PASS") return "border-ok/40 bg-ok/10 text-ok"; + if (u === "PARTIAL") return "border-warning/40 bg-warning/10 text-warning"; + return "border-danger/40 bg-danger/10 text-danger"; +} + +export function CompliancePackView({ pack }: { pack: CompliancePack }) { + const coreControls = pack.controls.filter((c) => !c.advisory); + const advisoryControls = pack.controls.filter((c) => c.advisory); + const frameworks = [...new Set(coreControls.map((c) => c.framework))]; + const advisoryCount = pack.advisory ?? advisoryControls.length; + + function download() { + const blob = new Blob([JSON.stringify(pack, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `compliance-evidence-${pack.task}.json`; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + return ( + <section className="rounded-xl border border-border bg-surface p-5"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Compliance evidence pack</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + This mission's signed receipt mapped to EU AI Act & NIST AI RMF controls — + first-party audit evidence, generated from the attestation, not a self-assessment. + </p> + </div> + <button + type="button" + onClick={download} + className="shrink-0 rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted/70" + title="Download the evidence pack as JSON for an auditor" + > + <Icon name="download" size={12} className="inline mr-1" /> Download pack + </button> + </div> + + <div className="mt-3 flex flex-wrap items-center gap-2 text-xs"> + <span className="rounded-full border border-ok/40 bg-ok/10 px-2 py-0.5 text-ok">{pack.satisfied} satisfied</span> + <span className="rounded-full border border-warning/40 bg-warning/10 px-2 py-0.5 text-warning">{pack.partial} partial</span> + {advisoryCount > 0 && ( + <span + className="rounded-full border border-border bg-surface-muted px-2 py-0.5 text-foreground-muted" + title="Named V0 product limitation, not a gap in this mission — see below" + > + {advisoryCount} roadmap (V1) + </span> + )} + <span className="text-foreground-muted">across {frameworks.join(" · ")}</span> + </div> + + <div className="mt-4 space-y-4"> + {frameworks.map((fw) => ( + <div key={fw}> + <p className="text-xs font-semibold text-foreground-muted">{fw}</p> + <ul className="mt-1.5 divide-y divide-border overflow-hidden rounded-lg border border-border"> + {coreControls + .filter((c) => c.framework === fw) + .map((c) => ( + <li key={c.control_id} className="flex items-start gap-3 bg-surface px-3 py-2.5"> + <span className={`mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-semibold ${statusTone(c.status)}`}> + {c.status.toUpperCase()} + </span> + <div className="min-w-0"> + <p className="text-sm font-medium">{c.reference}</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + Evidence (receipt claim “{c.receipt_class}”): {c.evidence} + </p> + </div> + </li> + ))} + </ul> + </div> + ))} + </div> + + {/* Advisory (roadmap) controls — separated so they never blend into the + "real gap" partial count above. Same claim, same framing the receipt + row's own verdict already applies; the pack now agrees with it. */} + {advisoryControls.length > 0 && ( + <div className="mt-4"> + <p className="flex items-center gap-1.5 text-xs font-semibold text-foreground-muted"> + <Icon name="compass" size={13} /> Roadmap — not yet available in this product version + </p> + <p className="mt-0.5 text-[11px] text-foreground-muted"> + These controls read PARTIAL on every receipt kars issues today — an external + transparency anchor is a named V1 item, not a gap in this specific mission. They never + block the mission's own “Verified” verdict. + </p> + <ul className="mt-1.5 divide-y divide-border overflow-hidden rounded-lg border border-dashed border-border"> + {advisoryControls.map((c) => ( + <li key={c.control_id} className="flex items-start gap-3 bg-surface-muted/30 px-3 py-2.5"> + <span className="mt-0.5 shrink-0 rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-semibold text-foreground-muted"> + ROADMAP + </span> + <div className="min-w-0"> + <p className="text-sm font-medium text-foreground-muted">{c.reference}</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + Evidence (receipt claim “{c.receipt_class}”): {c.evidence} + </p> + </div> + </li> + ))} + </ul> + </div> + )} + + <div className="mt-4 rounded-lg border border-border bg-surface-muted/40 p-3 text-[11px] text-foreground-muted"> + <p> + Backed by envelope digest <span className="font-mono">{pack.envelope_digest.slice(0, 24)}…</span>, + signature <span className="font-mono">{pack.signature_scheme}</span> + {pack.inclusion_seq != null && <> , transparency-log entry #{pack.inclusion_seq}</>}. + </p> + <p className="mt-1"> + Verify independently: <span className="font-mono">{pack.verify_command}</span> + </p> + </div> + </section> + ); +} diff --git a/bridge/web/src/components/connect-channels.tsx b/bridge/web/src/components/connect-channels.tsx new file mode 100644 index 000000000..f4b9bffde --- /dev/null +++ b/bridge/web/src/components/connect-channels.tsx @@ -0,0 +1,168 @@ +"use client"; + +// kars Bridge — workspace communication channels (agent-agnostic). Configured on +// the Connections tab alongside GitHub: wire Telegram / Slack / Discord / WhatsApp +// once for the whole WORKSPACE and every agent — mission or team — can report over +// them (the controller propagates the token into each run sandbox). SECURITY: +// tokens are write-only — typed into a password field, sent to the BFF (stored +// only in a K8s Secret), never shown back. The UI only knows which are enabled. + +import { useCallback, useEffect, useState } from "react"; +import { Icon } from "@/components/icon"; + +const CHANNELS: { id: string; label: string; glyph: "message"; token: string; hint: string; extra?: string }[] = [ + { id: "telegram", label: "Telegram", glyph: "message", token: "Bot token", hint: "From @BotFather. Add chat IDs below so agents can proactively DM you updates.", extra: "Allowed chat IDs (required for agents to send you updates)" }, + { id: "slack", label: "Slack", glyph: "message", token: "Bot OAuth token", hint: "xoxb-… — inbound conversation (agents reply to your DMs)." }, + { id: "discord", label: "Discord", glyph: "message", token: "Bot token", hint: "From the Discord developer portal — inbound conversation." }, + { id: "whatsapp", label: "WhatsApp", glyph: "message", token: "Enable", hint: "Type 'true' to enable pairing — inbound conversation." }, +]; + +export function ConnectChannels({ ns }: { ns: string }) { + const [enabled, setEnabled] = useState<string[] | null>(null); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState<string | null>(null); + const [token, setToken] = useState(""); + const [allowFrom, setAllowFrom] = useState(""); + const [error, setError] = useState<string | null>(null); + + const load = useCallback(async () => { + try { + const r = await fetch(`/api/namespaces/${ns}/channels`, { cache: "no-store" }); + const d = await r.json(); + setEnabled(d.enabled ?? []); + } catch { + setError("Couldn't load channels."); + setEnabled([]); + } + }, [ns]); + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(timer); + }, [load]); + + async function save(channel: string) { + if (!token.trim()) return; + setBusy(true); + setError(null); + try { + const r = await fetch(`/api/namespaces/${ns}/channels`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ channel, token: token.trim(), allow_from: allowFrom.trim() || undefined }), + }); + if (!r.ok) { + const b = await r.json().catch(() => null); + throw new Error(b?.error?.message || "save failed"); + } + setToken(""); + setAllowFrom(""); + setOpen(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "save failed"); + } finally { + setBusy(false); + } + } + + async function disable(channel: string) { + setBusy(true); + setError(null); + try { + await fetch(`/api/namespaces/${ns}/channels/${channel}`, { method: "DELETE" }); + await load(); + } catch { + setError("Disconnect failed."); + } finally { + setBusy(false); + } + } + + return ( + <div> + <div className="grid gap-2 sm:grid-cols-2"> + {CHANNELS.map((ch) => { + const on = enabled?.includes(ch.id) ?? false; + const isOpen = open === ch.id; + return ( + <div key={ch.id} className="rounded-lg border border-border p-3"> + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-2"> + <span aria-hidden className="text-base leading-none"><Icon name={ch.glyph} size={16} /></span> + <span className="text-sm font-medium">{ch.label}</span> + {on && ( + <span className="rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600"> + connected + </span> + )} + </div> + {on ? ( + <button + type="button" + disabled={busy} + onClick={() => disable(ch.id)} + className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground-muted transition hover:text-rose-600 disabled:opacity-50" + > + Disconnect + </button> + ) : ( + <button + type="button" + onClick={() => { + setOpen(isOpen ? null : ch.id); + setToken(""); + setAllowFrom(""); + setError(null); + }} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium transition hover:bg-surface-muted" + > + {isOpen ? "Cancel" : "Connect"} + </button> + )} + </div> + {isOpen && !on && ( + <div className="mt-2 space-y-2"> + <input + type="password" + autoComplete="off" + value={token} + onChange={(e) => setToken(e.target.value)} + placeholder={ch.token} + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + {ch.extra && ( + <input + type="text" + value={allowFrom} + onChange={(e) => setAllowFrom(e.target.value)} + placeholder={ch.extra} + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + )} + <div className="flex items-center justify-between"> + <span className="text-[10px] text-foreground-muted">{ch.hint}</span> + <button + type="button" + disabled={busy || !token.trim()} + onClick={() => save(ch.id)} + className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {busy ? "Saving…" : "Save"} + </button> + </div> + </div> + )} + </div> + ); + })} + </div> + {error && <p className="mt-2 text-xs text-rose-600">{error}</p>} + <p className="mt-3 text-[11px] text-foreground-muted"> + Agent-agnostic: a channel wired here reaches every mission and team in this workspace — the + controller injects the token into each run sandbox, and the agent’s harness wires up the + channel from it. No agent ever holds the token beyond its sandbox. + </p> + </div> + ); +} diff --git a/bridge/web/src/components/connect-github.tsx b/bridge/web/src/components/connect-github.tsx new file mode 100644 index 000000000..a65213494 --- /dev/null +++ b/bridge/web/src/components/connect-github.tsx @@ -0,0 +1,151 @@ +"use client"; + +// Each signed-in user connects an installation of the admin-configured shared +// GitHub App. The connection and selected repos are isolated to that principal. + +import { useEffect, useState } from "react"; + +type AppInfo = { configured: boolean; slug: string | null; install_url: string | null }; +type Connection = { connected: boolean; account: string | null; repos: string[] }; + +export function ConnectGithub({ ns }: { ns: string }) { + const [app, setApp] = useState<AppInfo | null>(null); + const [conn, setConn] = useState<Connection | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + let cancelled = false; + Promise.all([ + fetch("/api/github/app").then((r) => r.json()), + fetch(`/api/namespaces/${ns}/github/connection`).then((r) => r.json()), + ]).then( + ([a, c]) => { + if (!cancelled) { + setApp(a); + setConn(c); + } + }, + () => { + if (!cancelled) setError("Could not load GitHub connection state."); + }, + ); + return () => { + cancelled = true; + }; + }, [ns]); + + async function connect() { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/namespaces/${ns}/github/connect`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const body = await res.json().catch(() => null); + if (!res.ok) throw new Error(body?.error?.message ?? `Connect failed (${res.status})`); + setConn(body); + } catch (e) { + setError( + e instanceof Error + ? `${e.message}. Install the app on your repos first (button above), then Connect.` + : "Connect failed", + ); + } finally { + setBusy(false); + } + } + + async function disconnect() { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/namespaces/${ns}/github/connection`, { method: "DELETE" }); + const body = await res.json().catch(() => null); + if (!res.ok) throw new Error(body?.error?.message ?? `Disconnect failed (${res.status})`); + setConn(body); + } catch (e) { + setError(e instanceof Error ? e.message : "Disconnect failed"); + } finally { + setBusy(false); + } + } + + if (app && !app.configured) { + return ( + <p className="text-xs text-foreground-muted"> + The kars GitHub App isn’t set up on this platform yet. Ask an operator to configure it + once (Console → Configuration); then you can connect your repos here. + </p> + ); + } + + return ( + <div className="space-y-4"> + <p className="max-w-2xl text-xs text-foreground-muted"> + Let your agents open pull requests on <strong className="text-foreground">your</strong> repos — + without ever handling a credential. Install the kars GitHub App on the repositories you want, + then Connect. This connection is private to your signed-in user. The router mints a short-lived, + repo-scoped token per mission; the agent never sees it. Disconnect any time to remove your grant. + </p> + + <div className="flex flex-wrap items-center gap-2"> + {app?.install_url && ( + <a + href={app.install_url} + target="_blank" + rel="noreferrer" + className="rounded-lg border border-border bg-surface-muted px-3 py-1.5 text-xs font-medium hover:bg-surface" + > + 1 · Install the app on your repos ↗ + </a> + )} + <button + type="button" + onClick={connect} + disabled={busy} + className="cursor-pointer rounded-lg bg-signal px-3 py-1.5 text-xs font-semibold text-signal-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" + > + {busy ? "Connecting…" : conn?.connected ? "2 · Re-sync repos" : "2 · Connect"} + </button> + {conn?.connected && ( + <button + type="button" + onClick={disconnect} + disabled={busy} + className="cursor-pointer rounded-lg border border-rose-500/40 px-3 py-1.5 text-xs font-medium text-rose-600 hover:bg-rose-500/5 disabled:opacity-50" + > + Disconnect + </button> + )} + </div> + + {conn?.connected ? ( + <div className="rounded-lg border border-ok/30 bg-ok/[0.04] p-3"> + <p className="text-xs font-medium text-ok"> + ✓ Connected as <span className="font-mono">{conn.account}</span> + </p> + {conn.repos.length > 0 ? ( + <ul className="mt-2 flex flex-wrap gap-1.5"> + {conn.repos.map((r) => ( + <li key={r} className="rounded-full border border-border bg-surface-muted px-2 py-0.5 font-mono text-[11px]"> + {r} + </li> + ))} + </ul> + ) : ( + <p className="mt-1 text-[11px] text-foreground-muted"> + No repositories selected in the installation yet — add some on GitHub, then Re-sync. + </p> + )} + </div> + ) : ( + <p className="text-xs text-foreground-muted">Not connected. Install the app, then Connect.</p> + )} + + {error && <p className="text-xs text-rose-600">{error}</p>} + </div> + ); +} diff --git a/bridge/web/src/components/connect-teams.tsx b/bridge/web/src/components/connect-teams.tsx new file mode 100644 index 000000000..73f95cf11 --- /dev/null +++ b/bridge/web/src/components/connect-teams.tsx @@ -0,0 +1,196 @@ +"use client"; + +// kars Bridge — Microsoft Teams channel configuration. +// +// Separate fields for Client ID, Tenant ID, Client Secret, and Allowed Entra +// Subjects (no composite strings). Write-only secrets — the UI only shows +// whether Teams is enabled, never the credentials themselves. Admin-consent +// and Bot resource guidance is surfaced inline. + +import { useEffect, useState } from "react"; +import { Icon } from "@/components/icon"; + +export function ConnectTeams({ ns }: { ns: string }) { + const [enabled, setEnabled] = useState<boolean | null>(null); + const [busy, setBusy] = useState(false); + const [open, setOpen] = useState(false); + const [error, setError] = useState<string | null>(null); + + const [clientId, setClientId] = useState(""); + const [tenantId, setTenantId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [allowedSubjects, setAllowedSubjects] = useState(""); + + async function load() { + try { + const r = await fetch(`/api/namespaces/${ns}/channels`, { cache: "no-store" }); + const d = await r.json(); + setEnabled((d.enabled ?? []).includes("teams")); + } catch { + setError("Couldn't load channel status."); + setEnabled(false); + } + } + + useEffect(() => { + let active = true; + fetch(`/api/namespaces/${ns}/channels`, { cache: "no-store" }) + .then((response) => response.json()) + .then((data) => { + if (active) setEnabled((data.enabled ?? []).includes("teams")); + }) + .catch(() => { + if (active) { + setError("Couldn't load channel status."); + setEnabled(false); + } + }); + return () => { + active = false; + }; + }, [ns]); + + async function save() { + if (!clientId.trim() || !tenantId.trim() || !clientSecret.trim() || !allowedSubjects.trim()) { + setError("All fields are required."); + return; + } + setBusy(true); + setError(null); + try { + const r = await fetch(`/api/namespaces/${ns}/channels`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + channel: "teams", + teams: { + client_id: clientId.trim(), + tenant_id: tenantId.trim(), + client_secret: clientSecret.trim(), + entra_role_map: allowedSubjects.trim(), + }, + }), + }); + if (!r.ok) { + const b = await r.json().catch(() => null); + throw new Error(b?.error?.message || "save failed"); + } + setClientId(""); + setTenantId(""); + setClientSecret(""); + setAllowedSubjects(""); + setOpen(false); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "save failed"); + } finally { + setBusy(false); + } + } + + async function disconnect() { + setBusy(true); + setError(null); + try { + await fetch(`/api/namespaces/${ns}/channels/teams`, { method: "DELETE" }); + await load(); + } catch { + setError("Disconnect failed."); + } finally { + setBusy(false); + } + } + + return ( + <div className="rounded-lg border border-border p-4 space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span aria-hidden className="text-base leading-none"><Icon name="message" size={16} /></span> + <span className="text-sm font-medium">Microsoft Teams</span> + {enabled && ( + <span className="rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600"> + connected + </span> + )} + </div> + {enabled ? ( + <button + type="button" + disabled={busy} + onClick={disconnect} + className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground-muted transition hover:text-rose-600 disabled:opacity-50" + > + Disconnect + </button> + ) : ( + <button + type="button" + onClick={() => { setOpen(!open); setError(null); }} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium transition hover:bg-surface-muted" + > + {open ? "Cancel" : "Connect"} + </button> + )} + </div> + + {open && !enabled && ( + <div className="space-y-2"> + <div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2"> + <p className="text-[11px] text-foreground-muted"> + <strong>Prerequisites:</strong> Register an Entra ID App Registration with <code>BotFramework Channel</code> enabled. Grant admin consent for <code>TeamsActivity.Send</code>. The Bot must be installed in your target Teams channel/chat. + </p> + </div> + + <input + type="text" + autoComplete="off" + value={clientId} + onChange={(e) => setClientId(e.target.value)} + placeholder="Client ID (App Registration)" + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + <input + type="text" + autoComplete="off" + value={tenantId} + onChange={(e) => setTenantId(e.target.value)} + placeholder="Tenant ID" + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + <input + type="password" + autoComplete="off" + value={clientSecret} + onChange={(e) => setClientSecret(e.target.value)} + placeholder="Client Secret (write-only, never shown again)" + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs outline-none focus:border-signal" + /> + <textarea + autoComplete="off" + value={allowedSubjects} + onChange={(e) => setAllowedSubjects(e.target.value)} + placeholder={'[{"entra_subject":"<entra-oid>","bridge_subject":"<bridge-oidc-sub>","roles":["operator","user"],"name":"Alice"}]'} + rows={3} + className="w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs font-mono outline-none focus:border-signal" + /> + + <div className="flex items-center justify-between"> + <span className="text-[10px] text-foreground-muted"> + HITL approvals and proactive updates — no assistant mediation. + </span> + <button + type="button" + disabled={busy || !clientId.trim() || !tenantId.trim() || !clientSecret.trim() || !allowedSubjects.trim()} + onClick={save} + className="rounded-md bg-signal px-2.5 py-1 text-[11px] font-semibold text-signal-fg transition hover:opacity-90 disabled:opacity-50" + > + {busy ? "Saving…" : "Save"} + </button> + </div> + </div> + )} + + {error && <p className="text-xs text-rose-600">{error}</p>} + </div> + ); +} diff --git a/bridge/web/src/components/console-nav.tsx b/bridge/web/src/components/console-nav.tsx new file mode 100644 index 000000000..3ef17f1b4 --- /dev/null +++ b/bridge/web/src/components/console-nav.tsx @@ -0,0 +1,85 @@ +"use client"; + +// kars Bridge Operator Console — primary navigation (platform/SRE surface). +// Dense, resource/policy/audit vocabulary. Kubernetes concepts are fine here. + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +// Grouped by what an operator reaches for: run the fleet, prove/govern it, set it +// up. Cohesive sections make a growing console navigable instead of a flat list. +const GROUPS = [ + { + title: "Health", + items: [ + { href: "/console", label: "Fleet Health", exact: true, hint: "Live sandbox status" }, + { href: "/console/fleet", label: "Sandboxes", exact: false, hint: "Every agent pod" }, + { href: "/console/troubleshooting", label: "Troubleshooting", exact: false, hint: "Live diagnostics" }, + { href: "/console/insights", label: "Insights", exact: false, hint: "Fleet efficiency & governance" }, + ], + }, + { + title: "Governance", + items: [ + { href: "/console/policies", label: "Policies", exact: false, hint: "Tools, budgets, egress" }, + { href: "/console/approvals", label: "Approvals", exact: false, hint: "Egress grants" }, + { href: "/console/datapath", label: "Datapath witness", exact: false, hint: "eBPF egress attestation" }, + { href: "/console/evals", label: "Safety evals", exact: false, hint: "Conformance / jailbreak drift" }, + { href: "/console/audit", label: "Auditor view", exact: false, hint: "Receipts & evidence" }, + { href: "/console/sre-actions", label: "SRE Actions", exact: false, hint: "kars-sre remediation proposals" }, + ], + }, + { + title: "Setup", + items: [ + { href: "/console/configuration", label: "Configuration", exact: false, hint: "Cluster & inference provider" }, + { href: "/console/capabilities", label: "Agent capabilities", exact: false, hint: "Skills, team profiles, MCP, credentials" }, + { href: "/console/access", label: "Access & roles", exact: false, hint: "Users, RBAC, permissions" }, + ], + }, +] as const; + +export function ConsoleNav() { + const pathname = usePathname(); + return ( + <nav aria-label="Operator Console" className="hidden w-52 shrink-0 md:block"> + <p className="px-3 pb-2 text-[11px] font-semibold uppercase tracking-wider text-foreground-muted"> + Operator Console + </p> + <div className="space-y-4"> + {GROUPS.map((group) => ( + <div key={group.title}> + <p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-foreground-muted/70"> + {group.title} + </p> + <ul className="space-y-0.5"> + {group.items.map((item) => { + const active = item.exact + ? pathname === item.href + : pathname.startsWith(item.href); + return ( + <li key={item.href}> + <Link + href={item.href} + aria-current={active ? "page" : undefined} + aria-label={`${item.label} — ${item.hint}`} + className={[ + "relative block rounded-md px-3 py-1.5 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal", + active + ? "bg-signal/10 font-medium text-foreground before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-signal" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground", + ].join(" ")} + > + <span className="block leading-tight">{item.label}</span> + <span className="block text-[11px] text-foreground-muted">{item.hint}</span> + </Link> + </li> + ); + })} + </ul> + </div> + ))} + </div> + </nav> + ); +} diff --git a/bridge/web/src/components/copy-digest.tsx b/bridge/web/src/components/copy-digest.tsx new file mode 100644 index 000000000..c413a8d5a --- /dev/null +++ b/bridge/web/src/components/copy-digest.tsx @@ -0,0 +1,53 @@ +"use client"; + +// kars Bridge — copyable digest block. +// +// The envelope digest is cryptographic evidence — it must read as such: +// full-width monospace, never truncated mid-hash, with one-click copy. This is +// the value an auditor reconciles against the Governance Receipt, so it gets +// first-class, precise treatment. + +import { useState } from "react"; + +export function CopyDigest({ + digest, + label = "Copy digest", +}: { + digest: string | null; + label?: string; +}) { + const [copied, setCopied] = useState(false); + + if (!digest) { + return ( + <span className="text-sm text-foreground-muted italic">pending…</span> + ); + } + + async function copy() { + try { + await navigator.clipboard.writeText(digest!); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard unavailable (e.g. insecure context) — no-op; the value is + // still fully visible and selectable. + } + } + + return ( + <div className="flex items-stretch overflow-hidden rounded-lg border border-border bg-surface-muted"> + <code className="min-w-0 flex-1 break-all px-3 py-2 font-mono text-xs leading-relaxed"> + {digest} + </code> + <button + type="button" + onClick={copy} + aria-label={label} + className="shrink-0 border-l border-border px-3 text-xs font-medium text-foreground-muted hover:bg-surface hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {copied ? "Copied" : "Copy"} + </button> + </div> + ); +} diff --git a/bridge/web/src/components/deliverable-view.tsx b/bridge/web/src/components/deliverable-view.tsx new file mode 100644 index 000000000..6436cfa90 --- /dev/null +++ b/bridge/web/src/components/deliverable-view.tsx @@ -0,0 +1,398 @@ +// kars Bridge — smart deliverable renderer. Turns an agent's raw markdown output +// into a well-formatted, TYPED document: it classifies what the agent produced +// (report / recommendation / action plan / note), lifts a summary into a +// callout, and renders the body with rich, themed markdown components (tables, +// headings, links, code, task-lists, callouts) instead of raw browser defaults. +// +// Server-renderable: react-markdown + remark-gfm run fine in RSC. + +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Children, isValidElement } from "react"; +import type { Components } from "react-markdown"; +import { Icon, type IconName } from "@/components/icon"; +import { MermaidDiagram } from "@/components/mermaid-diagram"; + +export type DeliverableKind = "report" | "recommendation" | "action" | "note"; + +const KIND_META: Record< + DeliverableKind, + { icon: IconName; label: string; hint: string; accent: string } +> = { + report: { + icon: "chart", + label: "Report", + hint: "A structured briefing with findings and evidence.", + accent: "border-signal/40 bg-signal/5", + }, + recommendation: { + icon: "lightbulb", + label: "Recommendation", + hint: "The agent's assessment and what it advises.", + accent: "border-violet-500/40 bg-violet-500/5", + }, + action: { + icon: "check", + label: "Action plan", + hint: "Concrete next steps the agent proposes.", + accent: "border-emerald-500/40 bg-emerald-500/5", + }, + note: { + icon: "note", + label: "Note", + hint: "The agent's written output.", + accent: "border-border bg-surface", + }, +}; + +/** Strip glyphs that can't survive the plaintext transport (emoji → `?`), which + * otherwise litter headings and table headers with orphan "?" marks. Removes a + * leading "? " after markdown heading/list/table markers and collapses a bare + * "?" cell. Purely cosmetic — never touches real content words. */ +export function cleanDeliverable(raw: string): string { + return raw + // "## ? Title" / "### ? ? Title" → strip leading icon glyph(s) + .replace(/^(#{1,6}\s+)(?:\?\s+)+/gm, "$1") + // "- ? item" / "* ? item" → "- item" + .replace(/^(\s*[-*]\s+)(?:\?\s+)+(?=\S)/gm, "$1") + // table header/body cell that leads with "?" (mangled emoji column icon) + .replace(/\|\s*\?\s+(?=\S)/g, "| ") + // "**P0:** ? text" — icon glyph right after a bold lead-in + .replace(/(\*\*[^*\n]{1,40}\*\*)\s+\?\s+/g, "$1 ") + // "11? /" — icon glyph fused to a metric number, before whitespace/pipe + .replace(/(\d)\s*\?(?=[\s|])/g, "$1") + // "**? Do-not:**" — icon glyph fused inside the opening of a bold span + .replace(/(\*\*)\?\s+/g, "$1") + // standalone " ? " glyph between tokens: real punctuation attaches to the + // preceding word ("done?"), so a space-isolated "?" is a mangled emoji. + .replace(/ \?(?=[\s|)])/g, "") + .replace(/[ \t]{2,}/g, " ") + .replace(/[ \t]+([.,;:])/g, "$1") + .trim(); +} + +/** Classify what the agent produced from lightweight textual signals. Order + * matters: an explicit action list wins over a report, a recommendation over a + * plain note. Conservative — defaults to "note" when nothing clearly matches. */ +export function classifyDeliverable(raw: string): DeliverableKind { + const t = raw.toLowerCase(); + const checkboxes = (raw.match(/^\s*[-*]\s+\[[ xX]\]/gm) ?? []).length; + if ( + checkboxes >= 2 || + /^\s*#{1,6}\s+(action items|next steps|recommended actions|to ?do)\b/im.test(raw) + ) { + return "action"; + } + if ( + /\b(i recommend|we recommend|recommendation:|my assessment|verdict:|bottom line:|in my opinion)\b/i.test( + t, + ) || + /^\s*#{1,6}\s+(recommendation|assessment|verdict|opinion)\b/im.test(raw) + ) { + return "recommendation"; + } + const headings = (raw.match(/^#{1,6}\s+/gm) ?? []).length; + const tables = (raw.match(/^\|.+\|\s*$/gm) ?? []).length; + if (headings >= 2 || tables >= 2 || /executive summary|baseline|findings/i.test(t)) { + return "report"; + } + return "note"; +} + +/** Pull a short lead summary to surface as a callout, and report whether it came + * from a named section (so the body can drop that section to avoid showing it + * twice) vs. the first paragraph (which stays inline). */ +export function extractSummary(raw: string): { text: string; fromSection: boolean } | null { + const secMatch = raw.match( + /^#{1,6}\s+(?:executive summary|summary|tl;?dr|overview)\s*\n+([\s\S]*?)(?=\n#{1,6}\s+|\n\s*\|)/im, + ); + if (secMatch) { + const s = secMatch[1].trim(); + if (s.length > 0) { + return { text: s.length > 600 ? s.slice(0, 600).trimEnd() + "…" : s, fromSection: true }; + } + } + // First non-heading, non-table paragraph. + const para = raw + .split(/\n{2,}/) + .map((p) => p.trim()) + .find((p) => p.length > 40 && !p.startsWith("#") && !p.startsWith("|")); + if (para && para.length > 80) { + return { + text: para.length > 480 ? para.slice(0, 480).trimEnd() + "…" : para, + fromSection: false, + }; + } + return null; +} + +/** Remove the first "Executive summary"/"Summary"/"TL;DR"/"Overview" section + * (heading + body up to the next heading/table) so it isn't shown twice when + * it's already surfaced in the callout. */ +export function stripLeadSummarySection(raw: string): string { + return raw + .replace( + /^#{1,6}\s+(?:executive summary|summary|tl;?dr|overview)\s*\n+[\s\S]*?(?=\n#{1,6}\s+|\n\s*\|)/im, + "", + ) + .replace(/^\n+/, "") + .trim(); +} + +/** Remove the first lead paragraph (the one [`extractSummary`] lifts when there + * is no titled summary section) so a first-paragraph summary isn't rendered + * twice — once in the callout and again at the top of the body. Matches the + * same predicate `extractSummary` uses to pick that paragraph. */ +export function stripLeadParagraph(raw: string): string { + const blocks = raw.split(/\n{2,}/); + const idx = blocks.findIndex((p) => { + const t = p.trim(); + return t.length > 40 && !t.startsWith("#") && !t.startsWith("|"); + }); + if (idx === -1) return raw.trim(); + blocks.splice(idx, 1); + return blocks.join("\n\n").replace(/^\n+/, "").trim(); +} + +const mdComponents: Components = { + h1: ({ children }) => ( + <h1 className="mt-6 mb-3 border-b border-border pb-1.5 text-lg font-semibold tracking-tight first:mt-0"> + {children} + </h1> + ), + h2: ({ children }) => ( + <h2 className="mt-6 mb-2 flex items-center gap-2 text-base font-semibold tracking-tight first:mt-0"> + <span className="h-3.5 w-1 rounded-full bg-signal/70" aria-hidden /> + {children} + </h2> + ), + h3: ({ children }) => ( + <h3 className="mt-4 mb-1.5 text-sm font-semibold text-foreground first:mt-0">{children}</h3> + ), + h4: ({ children }) => ( + <h4 className="mt-3 mb-1 text-xs font-semibold uppercase tracking-wide text-foreground-muted first:mt-0"> + {children} + </h4> + ), + p: ({ children }) => <p className="my-2 text-sm leading-relaxed text-foreground">{children}</p>, + ul: ({ children }) => <ul className="my-2 space-y-1 pl-1 text-sm">{children}</ul>, + ol: ({ children }) => ( + <ol className="my-2 list-decimal space-y-1 pl-5 text-sm marker:text-foreground-muted"> + {children} + </ol> + ), + li: ({ children, className }) => { + // remark-gfm task-list items carry `task-list-item`; render a clean checkbox. + const isTask = typeof className === "string" && className.includes("task-list-item"); + if (isTask) { + return <li className="flex list-none items-start gap-2 leading-relaxed">{children}</li>; + } + return ( + <li className="relative pl-4 leading-relaxed before:absolute before:left-0 before:top-[0.55em] before:h-1.5 before:w-1.5 before:rounded-full before:bg-signal/60"> + {children} + </li> + ); + }, + input: ({ checked }) => ( + <span + className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px] ${ + checked + ? "border-emerald-500/50 bg-emerald-500/15 text-emerald-600" + : "border-border bg-surface text-transparent" + }`} + aria-hidden + > + {checked ? "✓" : ""} + </span> + ), + a: ({ href, children }) => ( + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className="font-medium text-signal underline-offset-2 hover:underline" + > + {children} + <span className="ml-0.5 text-[0.7em] opacity-60" aria-hidden> + ↗ + </span> + </a> + ), + strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>, + blockquote: ({ children }) => ( + <blockquote className="my-3 rounded-r-md border-l-2 border-signal/50 bg-surface-muted/50 px-3 py-1.5 text-sm text-foreground-muted"> + {children} + </blockquote> + ), + hr: () => <hr className="my-4 border-border" />, + code: ({ className, children }) => { + if (className === "language-mermaid") { + return ( + <MermaidDiagram + chart={String(children).replace(/\n$/, "")} + className="language-mermaid" + /> + ); + } + const isBlock = typeof className === "string" && className.startsWith("language-"); + if (isBlock) { + return ( + <code className={`${className} block`}>{children}</code> + ); + } + return ( + <code className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[0.85em] text-foreground"> + {children} + </code> + ); + }, + pre: ({ children }) => { + const child = Children.count(children) === 1 ? Children.only(children) : null; + if ( + isValidElement<{ className?: string }>(child) + && child.props.className === "language-mermaid" + ) { + return child; + } + return ( + <pre className="my-3 overflow-x-auto rounded-lg border border-border bg-surface-muted p-3 font-mono text-xs leading-relaxed"> + {children} + </pre> + ); + }, + table: ({ children }) => ( + <div className="my-3 overflow-x-auto rounded-lg border border-border"> + <table className="w-full border-collapse text-sm">{children}</table> + </div> + ), + thead: ({ children }) => <thead className="bg-surface-muted">{children}</thead>, + th: ({ children }) => ( + <th className="border-b border-border px-3 py-2 text-left text-xs font-semibold text-foreground-muted"> + {children} + </th> + ), + td: ({ children }) => ( + <td className="border-b border-border/60 px-3 py-2 align-top text-foreground">{children}</td> + ), +}; + +/** Reduce agent markdown to a clean one-line-ish plain preview: strip heading + * markers, emphasis, links (keep text), table pipes, and mangled-emoji "?". + * For compact previews (commons entries, cards) where full markdown would be + * noise in a clamped box. */ +export function toPlainPreview(raw: string): string { + return cleanDeliverable(raw) + .replace(/^#{1,6}\s+/gm, "") // heading markers + .replace(/\*\*([^*]+)\*\*/g, "$1") // bold + .replace(/\*([^*]+)\*/g, "$1") // italic + .replace(/`([^`]+)`/g, "$1") // inline code + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → text + .replace(/^\s*[-*]\s+/gm, "• ") // list markers → bullet + .replace(/^\s*\|.*\|\s*$/gm, "") // drop table rows + .replace(/^\s*[-:| ]+\s*$/gm, "") // drop table separators + .replace(/\n{2,}/g, " · ") // paragraph breaks → separator + .replace(/\s+/g, " ") + .trim(); +} + +export function DeliverableBody({ output }: { output: string }) { + const clean = cleanDeliverable(output); + return ( + <div className="text-sm"> + <Markdown remarkPlugins={[remarkGfm]} components={mdComponents}> + {clean} + </Markdown> + </div> + ); +} + +export function DeliverableView({ + output, + model, + totalTokens, + finishedAt, + artifactCount, +}: { + output: string; + model?: string | null; + totalTokens?: number | null; + finishedAt?: string | null; + artifactCount?: number | null; +}) { + const clean = cleanDeliverable(output); + const kind = classifyDeliverable(clean); + const meta = KIND_META[kind]; + const summary = extractSummary(clean); + const body = summary + ? summary.fromSection + ? stripLeadSummarySection(clean) + : stripLeadParagraph(clean) + : clean; + + return ( + <section className={`overflow-hidden rounded-xl border ${meta.accent}`}> + {/* Header */} + <div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/60 px-5 py-4"> + <div className="flex items-start gap-3"> + <span className="text-xl leading-none" aria-hidden> + <Icon name={meta.icon} size={22} /> + </span> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-sm font-semibold">Deliverable</h2> + <span className="rounded-full border border-border bg-surface px-2 py-0.5 text-[10px] font-medium text-foreground-muted"> + {meta.label} + </span> + </div> + <p className="mt-0.5 text-xs text-foreground-muted">{meta.hint}</p> + </div> + </div> + <dl className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-foreground-muted"> + {model && ( + <div className="flex items-center gap-1"> + <dt><Icon name="brain" size={13} /></dt> + <dd className="font-medium text-foreground">{model}</dd> + </div> + )} + {totalTokens != null && ( + <div className="flex items-center gap-1"> + <dt>tokens</dt> + <dd className="font-medium text-foreground">{totalTokens.toLocaleString()}</dd> + </div> + )} + {artifactCount != null && artifactCount > 0 && ( + <div className="flex items-center gap-1"> + <dt>files</dt> + <dd className="font-medium text-foreground">{artifactCount}</dd> + </div> + )} + {finishedAt && ( + <div className="flex items-center gap-1"> + <dt>produced</dt> + <dd className="font-medium text-foreground"> + {new Date(finishedAt).toLocaleString()} + </dd> + </div> + )} + </dl> + </div> + + {/* Summary callout */} + {summary && ( + <div className="border-b border-border/60 bg-surface/60 px-5 py-3"> + <p className="text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + {kind === "recommendation" ? "The gist" : "In brief"} + </p> + <div className="mt-1"> + <DeliverableBody output={summary.text} /> + </div> + </div> + )} + + {/* Full body */} + <div className="max-h-[32rem] overflow-auto bg-surface px-5 py-4"> + <DeliverableBody output={body} /> + </div> + </section> + ); +} diff --git a/bridge/web/src/components/envelope-card.tsx b/bridge/web/src/components/envelope-card.tsx new file mode 100644 index 000000000..40bd22301 --- /dev/null +++ b/bridge/web/src/components/envelope-card.tsx @@ -0,0 +1,145 @@ +// kars Bridge web — trust-envelope visualization. +// +// Renders the authority a task holds as a precise, scannable card: the +// autonomy tier (as the same 1–5 scale used in the create form), the +// authority ceiling that bounds descendants, the delegation budget, and the +// policy bounds. Permissive defaults (no tool policy / no egress allow-list) +// are surfaced as explicit, mildly-cautioned statements — to a CISO an empty +// allow-list is a finding, not a blank. + +import { StatusBadge } from "@/components/status-badge"; +import { formatInt, formatUsdMicros } from "@/lib/format"; +import { TIER_LABELS, type Envelope } from "@/lib/types"; + +function TierScale({ value, ceiling }: { value: number; ceiling: number }) { + return ( + <div className="flex items-center gap-1.5" role="img" + aria-label={`Tier ${value} of 5; authority ceiling at tier ${ceiling}`}> + {[1, 2, 3, 4, 5].map((t) => { + const active = t <= value; + const isCeiling = t === ceiling; + return ( + <span + key={t} + title={`Tier ${t} — ${TIER_LABELS[t]}`} + className={[ + "h-6 w-6 rounded grid place-items-center text-xs font-semibold border", + active + ? "bg-signal/15 text-signal border-signal/40" + : "bg-surface-muted text-foreground-muted border-border", + isCeiling + ? "ring-2 ring-warning ring-offset-1 ring-offset-surface" + : "", + ].join(" ")} + > + {t} + </span> + ); + })} + </div> + ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + <div className="flex items-center justify-between gap-4 py-2"> + <dt className="text-sm text-foreground-muted">{label}</dt> + <dd className="text-sm font-medium tabular-nums">{children}</dd> + </div> + ); +} + +/** A permissive-default value: shown as an explicit, mildly-cautioned fact. */ +function PolicyBound({ value }: { value: string | null }) { + if (value) { + return <span className="font-mono text-xs">{value}</span>; + } + return ( + <span className="inline-flex items-center gap-1 text-xs font-medium text-warning"> + <svg aria-hidden viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor"> + <path d="M8 1.5 1 14h14L8 1.5Zm0 4.25a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5.75ZM8 11a.9.9 0 1 0 0 1.8A.9.9 0 0 0 8 11Z" /> + </svg> + none — unrestricted + </span> + ); +} + +export function EnvelopeCard({ envelope }: { envelope: Envelope }) { + const usd = formatUsdMicros(envelope.budget?.usd_micros ?? null); + const tokens = envelope.budget?.tokens ?? null; + + return ( + <section + aria-labelledby="envelope-heading" + className="rounded-xl border border-border bg-surface p-6" + > + <div className="flex items-center justify-between"> + <h2 id="envelope-heading" className="text-sm font-semibold"> + Trust envelope + </h2> + <StatusBadge + tone="muted" + label={`Tier ${envelope.tier} · ${TIER_LABELS[envelope.tier] ?? "?"}`} + /> + </div> + + <div className="mt-4"> + <div className="flex items-center justify-between"> + <span className="text-sm text-foreground-muted">Autonomy</span> + <TierScale value={envelope.tier} ceiling={envelope.authority_ceiling} /> + </div> + <p className="mt-2 text-xs text-foreground-muted"> + Filled cells show the tier this task holds. The{" "} + <span className="text-warning">ringed</span> cell is the authority + ceiling — the highest tier any delegated child may hold. + </p> + </div> + + <dl className="mt-4 divide-y divide-border"> + <Field label="Authority ceiling"> + Tier {envelope.authority_ceiling} ·{" "} + {TIER_LABELS[envelope.authority_ceiling] ?? "?"} + </Field> + <Field label="Delegation depth remaining"> + {envelope.delegation_depth} + </Field> + <Field label="Token budget"> + {tokens != null ? ( + <> + {formatInt(tokens)}{" "} + <span className="text-xs font-normal text-foreground-muted"> + tokens / subtree + </span> + </> + ) : ( + "—" + )} + </Field> + <Field label="Spend budget"> + {usd ? ( + <> + {usd}{" "} + <span className="text-xs font-normal text-foreground-muted"> + / subtree + </span> + </> + ) : ( + "—" + )} + </Field> + <Field label="Tool policy"> + <PolicyBound value={envelope.tool_policy} /> + </Field> + <Field label="Egress allow-list"> + <PolicyBound value={envelope.egress_allowlist} /> + </Field> + </dl> + </section> + ); +} diff --git a/bridge/web/src/components/envelope-digest.tsx b/bridge/web/src/components/envelope-digest.tsx new file mode 100644 index 000000000..b3f2388a2 --- /dev/null +++ b/bridge/web/src/components/envelope-digest.tsx @@ -0,0 +1,26 @@ +// kars Bridge web — render an envelope digest as a verifiable, copyable +// monospace chip. The digest is the value a Governance Receipt binds to, so +// it is presented as evidence, not decoration. + +export function EnvelopeDigest({ digest }: { digest: string | null }) { + if (!digest) { + return <span className="text-foreground-muted">digest pending…</span>; + } + const short = digest.replace(/^sha256:/, "").slice(0, 12); + return ( + <span + title={digest} + className="inline-flex items-center gap-1 font-mono text-xs text-foreground-muted" + > + <svg + aria-hidden + viewBox="0 0 16 16" + className="h-3 w-3 text-signal" + fill="currentColor" + > + <path d="M8 1a3 3 0 0 0-3 3v2H4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1V4a3 3 0 0 0-3-3Zm-1 5V4a1 1 0 1 1 2 0v2H7Z" /> + </svg> + sha256:{short}… + </span> + ); +} diff --git a/bridge/web/src/components/execution-explorer.tsx b/bridge/web/src/components/execution-explorer.tsx new file mode 100644 index 000000000..1fbbaaed7 --- /dev/null +++ b/bridge/web/src/components/execution-explorer.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { ActivityStream } from "@/components/activity-stream"; +import { AgentGraph } from "@/components/agent-graph"; +import { ExecutionLifetime } from "@/components/execution-lifetime"; +import { useLiveTrace } from "@/components/use-live-trace"; +import type { + ActivityEvent, + Approval, + MissionTelemetry, + Receipt, + SubAgent, + TaskAssignmentEvent, +} from "@/lib/types"; + +function focusLabel(query: string): string { + if (query.startsWith("agent:")) return query.slice("agent:".length); + if (query.startsWith("actions:")) { + const parameters = new URLSearchParams(query.slice("actions:".length)); + return `${parameters.get("agent") ?? "agent"} · folded actions`; + } + if (query.startsWith("action:")) { + const parameters = new URLSearchParams(query.slice("action:".length)); + const tool = parameters.get("tool") ?? "action"; + const round = Number(parameters.get("round")); + return `${tool} · round ${Number.isInteger(round) ? round + 1 : "?"}`; + } + return query; +} + +export function ExecutionExplorer({ + running, + activity, + telemetry, + assignmentEvents, + approvals, + ns, + name, + agentLabel, + agentPhase = null, + agentRuntime = null, + agentModel = null, + subAgents = [], + identity = null, + envelopeDigest = null, + receipt = null, +}: { + running: boolean; + activity: ActivityEvent[]; + telemetry: MissionTelemetry | null; + assignmentEvents: TaskAssignmentEvent[]; + approvals: Approval[]; + ns: string; + name: string; + agentLabel: string; + agentPhase?: string | null; + agentRuntime?: string | null; + agentModel?: string | null; + subAgents?: SubAgent[]; + identity?: import("@/lib/types").AgentIdentity | null; + envelopeDigest?: string | null; + receipt?: Receipt | null; +}) { + const events = useLiveTrace(ns, name, running, activity); + const [tab, setTab] = useState<"lifetime" | "tools">("lifetime"); + const [focus, setFocus] = useState(""); + + const inspect = (query: string) => { + setFocus(query); + setTab("tools"); + }; + + return ( + <div className="space-y-4"> + <AgentGraph + running={running} + activity={activity} + events={events} + ns={ns} + name={name} + agentLabel={agentLabel} + agentPhase={agentPhase} + agentRuntime={agentRuntime} + agentModel={agentModel} + subAgents={subAgents} + identity={identity} + envelopeDigest={envelopeDigest} + receipt={receipt} + onInspect={inspect} + /> + <section className="rounded-2xl border border-border bg-surface p-2"> + <div className="flex flex-wrap items-center justify-between gap-2 px-2 py-1"> + <div> + <h2 className="text-sm font-semibold">Drill-down</h2> + <p className="text-[11px] text-foreground-muted"> + Select an agent to focus its exact round and tool records, or inspect the complete chronological lifetime. + </p> + </div> + <div className="flex items-center gap-1 rounded-lg border border-border bg-surface-muted/40 p-1"> + <button + type="button" + onClick={() => { + setFocus(""); + setTab("lifetime"); + }} + className={`rounded-md px-3 py-1.5 text-xs font-medium ${tab === "lifetime" ? "bg-surface text-foreground shadow-sm" : "text-foreground-muted"}`} + > + Complete lifetime + </button> + <button + type="button" + onClick={() => setTab("tools")} + className={`rounded-md px-3 py-1.5 text-xs font-medium ${tab === "tools" ? "bg-surface text-foreground shadow-sm" : "text-foreground-muted"}`} + > + Rounds & tools + </button> + </div> + </div> + {focus && ( + <div className="mx-2 mt-2 flex items-center gap-2 rounded-lg border border-signal/30 bg-signal/5 px-3 py-2 text-xs"> + <span className="text-foreground-muted">Focused from graph:</span> + <span className="font-mono font-semibold">{focusLabel(focus)}</span> + <button type="button" onClick={() => setFocus("")} className="ml-auto text-foreground-muted hover:text-foreground"> + Clear + </button> + </div> + )} + <div className="mt-2"> + {tab === "lifetime" ? ( + <ExecutionLifetime + key={`lifetime-${focus}`} + running={running} + activity={events} + assignmentEvents={assignmentEvents} + approvals={approvals} + focusQuery={focus} + /> + ) : ( + <ActivityStream + key={`tools-${focus}`} + running={running} + activity={activity} + events={events} + telemetry={telemetry} + ns={ns} + name={name} + focusQuery={focus} + principalAgentName={name} + title="Rounds and tool records" + detail="Every retained model round and tool invocation, including arguments, result preview, duration, agent, and success state." + /> + )} + </div> + </section> + </div> + ); +} diff --git a/bridge/web/src/components/execution-lifetime.tsx b/bridge/web/src/components/execution-lifetime.tsx new file mode 100644 index 000000000..74ae945cf --- /dev/null +++ b/bridge/web/src/components/execution-lifetime.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Icon } from "@/components/icon"; +import { LivePulse } from "@/components/live-refresh"; +import type { ActivityEvent, Approval, TaskAssignmentEvent } from "@/lib/types"; + +type LifetimeEvent = { + id: string; + at: string; + kind: "lifecycle" | "round" | "tool" | "approval"; + title: string; + summary: string; + detail: string | null; + ok: boolean | null; +}; + +function fmtAt(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const pad = (part: number, width = 2) => String(part).padStart(width, "0"); + return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)} UTC`; +} + +export function ExecutionLifetime({ + running, + activity, + assignmentEvents, + approvals, + focusQuery, +}: { + running: boolean; + activity: ActivityEvent[]; + assignmentEvents: TaskAssignmentEvent[]; + approvals: Approval[]; + focusQuery?: string; +}) { + const [query, setQuery] = useState(focusQuery ?? ""); + const events = useMemo<LifetimeEvent[]>(() => { + const rows: LifetimeEvent[] = []; + for (const event of assignmentEvents) { + rows.push({ + id: `assignment-${event.event_id}`, + at: event.at, + kind: "lifecycle", + title: event.event_type.replace(/_/g, " "), + summary: [ + event.state, + event.stage, + event.child_role, + event.worker_did ? `worker ${event.worker_did}` : null, + ].filter(Boolean).join(" · "), + detail: event.message ?? event.outcome, + ok: event.state === "Failed" ? false : null, + }); + } + for (const [index, event] of activity.entries()) { + if (event.kind === "round") { + rows.push({ + id: `round-${event.round}-${index}`, + at: event.ts, + kind: "round", + title: `Model round ${event.round + 1}`, + summary: `${event.total_tokens.toLocaleString("en-US")} tokens · ${event.tool_calls} tool call${event.tool_calls === 1 ? "" : "s"} · ${event.ms} ms`, + detail: event.finish_reason || null, + ok: null, + }); + } else { + rows.push({ + id: `tool-${event.round}-${index}`, + at: event.ts, + kind: "tool", + title: event.name, + summary: event.args_preview || "No arguments retained", + detail: event.result_preview || null, + ok: event.ok, + }); + } + } + for (const approval of approvals) { + const at = approval.decided_at ?? approval.requested_at; + if (!at) continue; + rows.push({ + id: `approval-${approval.name}-${approval.phase}`, + at, + kind: "approval", + title: approval.phase === "Pending" ? "Approval requested" : `Approval ${approval.phase.toLowerCase()}`, + summary: approval.summary, + detail: [ + approval.detail, + approval.decider ? `Decider: ${approval.decider}` : null, + ].filter(Boolean).join("\n") || null, + ok: approval.phase === "Approved" ? true : approval.phase === "Denied" || approval.phase === "Expired" ? false : null, + }); + } + return rows.sort((a, b) => a.at.localeCompare(b.at)); + }, [activity, approvals, assignmentEvents]); + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return events; + return events.filter((event) => + [event.kind, event.title, event.summary, event.detail] + .filter(Boolean) + .join(" ") + .toLowerCase() + .includes(needle), + ); + }, [events, query]); + + return ( + <section className="rounded-2xl border border-border bg-surface p-5"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Complete execution lifetime</h2> + <p className="mt-0.5 max-w-2xl text-xs text-foreground-muted"> + Every retained assignment, model round, tool call, approval, handback, and failure in timestamp order. + </p> + </div> + <div className="flex flex-wrap items-center gap-2"> + <label className="relative"> + <span className="sr-only">Search execution lifetime</span> + <Icon name="search" size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-foreground-muted" /> + <input + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search the complete lifetime" + className="w-64 rounded-lg border border-border bg-surface-muted/40 py-1.5 pl-8 pr-3 text-xs outline-none focus:border-signal" + /> + </label> + {running ? <LivePulse label="Recording live" /> : ( + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-xs font-medium text-foreground-muted"> + {visible.length}/{events.length} events + </span> + )} + </div> + </div> + <ol className="mt-4 space-y-2"> + {visible.map((event) => ( + <li key={event.id}> + <details className="group rounded-xl border border-border bg-surface-muted/25"> + <summary className="flex cursor-pointer list-none items-start gap-3 px-3 py-2.5"> + <span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${ + event.ok === false ? "bg-danger" : event.ok === true ? "bg-ok" : event.kind === "approval" ? "bg-warning" : "bg-signal" + }`} /> + <span className="w-32 shrink-0 font-mono text-[10px] text-foreground-muted">{fmtAt(event.at)}</span> + <span className="min-w-0 flex-1"> + <span className="flex flex-wrap items-center gap-2"> + <span className="font-mono text-xs font-semibold">{event.title}</span> + <span className="rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-foreground-muted"> + {event.kind} + </span> + </span> + <span className="mt-0.5 block truncate text-xs text-foreground-muted">{event.summary}</span> + </span> + <span className="text-xs text-foreground-muted transition group-open:rotate-180">⌄</span> + </summary> + <div className="border-t border-border px-3 py-3"> + <p className="whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed"> + {event.detail || event.summary} + </p> + </div> + </details> + </li> + ))} + </ol> + {visible.length === 0 && ( + <p className="mt-4 rounded-lg border border-dashed border-border p-4 text-center text-xs text-foreground-muted"> + No retained event matches this search. + </p> + )} + </section> + ); +} diff --git a/bridge/web/src/components/fleet-live.tsx b/bridge/web/src/components/fleet-live.tsx new file mode 100644 index 000000000..20c753243 --- /dev/null +++ b/bridge/web/src/components/fleet-live.tsx @@ -0,0 +1,174 @@ +"use client"; + +// kars Bridge Workspace — fleet live telemetry. The "at scale" view: instead of +// drilling into one mission, watch the WHOLE fleet's live tool-by-tool work in +// one stream, with aggregate live metrics that tick as agents work. Polls the +// fleet endpoint on a fast cadence; everything shown is real trace data — an +// idle fleet shows an honest calm state, never fabricated ticks. + +import { useEffect, useRef, useState } from "react"; +import type { FleetTelemetry, FleetActivityItem } from "@/lib/types"; + +function fmtMs(ms: number | null): string { + if (ms == null) return ""; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function FleetLive({ initial }: { initial: FleetTelemetry | null }) { + const [fleet, setFleet] = useState<FleetTelemetry | null>(initial); + const [flash, setFlash] = useState(0); + const lastKey = useRef<string>(""); + + useEffect(() => { + let alive = true; + async function tick() { + try { + const res = await fetch("/api/agents/fleet", { cache: "no-store" }); + if (!res.ok) return; + const data: FleetTelemetry = await res.json(); + if (!alive) return; + // Flash the feed when the newest event changed (a fresh action landed). + const key = data.feed[0] ? `${data.feed[0].agent}:${data.feed[0].round}:${data.feed[0].seq}` : ""; + if (key && key !== lastKey.current) { + lastKey.current = key; + setFlash((f) => f + 1); + } + setFleet(data); + } catch { + /* transient — keep last good */ + } + } + const id = setInterval(tick, 3000); + void tick(); + return () => { + alive = false; + clearInterval(id); + }; + }, []); + + const working = fleet?.working ?? 0; + const idle = working === 0; + // Distinguish genuinely-working from just-booted: sandboxes are up but no + // model round/tool call has landed yet. Prevents "Working now 4" reading as + // broken next to "Model rounds 0" — it's warmup, and we say so. + const warming = working > 0 && (fleet?.rounds ?? 0) === 0 && (fleet?.tool_calls ?? 0) === 0; + + return ( + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6"> + <LiveStat label={warming ? "Starting up" : "Working now"} value={working} accent={working > 0} pulse={working > 0} hint={warming ? "sandboxes up — no model round yet" : undefined} /> + <LiveStat label="Teams active" value={fleet?.teams_active ?? 0} /> + <LiveStat label="Sub-agents" value={fleet?.sub_agents ?? 0} /> + <LiveStat label="Tokens in flight" value={(fleet?.tokens_in_flight ?? 0).toLocaleString()} /> + <LiveStat label="Tool calls" value={fleet?.tool_calls ?? 0} /> + <LiveStat label="Model rounds" value={fleet?.rounds ?? 0} /> + </div> + + <section className="kb-card overflow-hidden"> + <div className="flex items-center justify-between border-b border-border px-5 py-3"> + <div className="flex items-center gap-2"> + <h2 className="text-sm font-semibold">Live fleet activity</h2> + {!idle && ( + <span className="inline-flex items-center gap-1.5 rounded-full bg-ok/10 px-2 py-0.5 text-[11px] font-medium text-ok"> + <span className="h-1.5 w-1.5 rounded-full bg-ok kb-pulse" aria-hidden /> + streaming + </span> + )} + </div> + <span className="text-[11px] text-foreground-muted">every 3s · newest first</span> + </div> + + {(fleet?.feed.length ?? 0) === 0 ? ( + <div className="px-5 py-10 text-center"> + {idle ? ( + <> + <p className="text-sm font-medium">The fleet is calm</p> + <p className="mt-1 text-xs text-foreground-muted"> + No agent is working this moment. When a mission or standing team runs, every tool + call and model round streams here live — across the whole fleet. + </p> + </> + ) : ( + <> + <p className="text-sm font-medium"> + {working} agent{working === 1 ? "" : "s"} warming up… + </p> + <p className="mt-1 text-xs text-foreground-muted"> + The sandbox is up; the first model rounds and tool calls will stream here the moment + they happen. + </p> + </> + )} + </div> + ) : ( + <ul key={flash} className="kb-stagger divide-y divide-border"> + {fleet!.feed.map((e, i) => ( + <FeedRow key={`${e.agent}-${e.round}-${e.seq}-${i}`} e={e} fresh={i === 0} /> + ))} + </ul> + )} + </section> + </div> + ); +} + +function LiveStat({ + label, + value, + accent, + pulse, + hint, +}: { + label: string; + value: string | number; + accent?: boolean; + pulse?: boolean; + hint?: string; +}) { + return ( + <div + className={`relative overflow-hidden rounded-xl border p-3.5 shadow-sm ${ + accent ? "border-signal/30 bg-gradient-to-br from-signal/[0.08] to-transparent" : "border-border bg-surface" + }`} + > + {pulse && <span aria-hidden className="pointer-events-none absolute -right-5 -top-5 h-14 w-14 rounded-full bg-signal/15 blur-2xl" />} + <p className={`text-xl font-semibold tabular-nums leading-none ${accent ? "text-signal" : "text-foreground"}`}>{value}</p> + <p className="mt-1.5 text-[11px] font-medium text-foreground-muted">{label}</p> + {hint && <p className="mt-0.5 text-[10px] text-foreground-muted/80">{hint}</p>} + </div> + ); +} + +function FeedRow({ e, fresh }: { e: FleetActivityItem; fresh: boolean }) { + const isTool = e.kind === "tool"; + const dotCls = e.failed ? "bg-rose-500" : isTool ? "bg-signal" : "bg-foreground-muted"; + return ( + <li className={`flex items-center gap-3 px-5 py-2.5 ${fresh ? "bg-signal/[0.03]" : ""}`}> + <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${dotCls} ${fresh && !e.failed ? "kb-pulse" : ""}`} aria-hidden /> + <div className="min-w-0 flex-1"> + <div className="flex items-baseline gap-2"> + <span className="truncate text-xs font-medium">{e.display_name ?? e.agent}</span> + {e.team && <span className="shrink-0 rounded bg-surface-muted px-1.5 py-0.5 text-[10px] text-foreground-muted">{e.team}</span>} + </div> + <p className="truncate text-xs text-foreground-muted"> + {isTool ? ( + <> + called <span className="font-mono text-foreground">{e.label}</span> + {e.detail ? <span className="text-foreground-muted"> · {e.detail}</span> : null} + {e.failed ? <span className="ml-1 font-medium text-rose-500">failed</span> : null} + </> + ) : ( + <> + model round <span className="text-foreground-muted">· {e.label}</span> + </> + )} + </p> + </div> + <div className="shrink-0 text-right"> + <p className="text-[11px] tabular-nums text-foreground-muted">r{e.round}</p> + {e.ms != null && <p className="text-[10px] tabular-nums text-foreground-muted/70">{fmtMs(e.ms)}</p>} + </div> + </li> + ); +} diff --git a/bridge/web/src/components/honest-state.tsx b/bridge/web/src/components/honest-state.tsx new file mode 100644 index 000000000..233583cb7 --- /dev/null +++ b/bridge/web/src/components/honest-state.tsx @@ -0,0 +1,79 @@ +// kars Bridge — the honesty grammar. +// +// A single, reusable component for the three distinct "no data" situations the +// product must never conflate (UX spec §4): +// - empty : a legitimate zero — encourage the next action. +// - not_wired : a capability is genuinely absent — state it factually. +// - needs_run : the feature exists but has no data until a real run happens. +// +// Conflating "no data source" with "value is 0" is the most common honesty +// failure; this component keeps them visibly different. Never error-red. + +type Variant = "empty" | "not_wired" | "needs_run"; + +const ICONS: Record<Variant, React.ReactNode> = { + empty: ( + <svg viewBox="0 0 24 24" fill="none" className="h-6 w-6" aria-hidden> + <path + d="M12 5v14M5 12h14" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + /> + </svg> + ), + not_wired: ( + <svg viewBox="0 0 24 24" fill="none" className="h-6 w-6" aria-hidden> + <path + d="M9 17H7A5 5 0 0 1 7 7h1m6 10h2a5 5 0 0 0 0-10h-1M8 12h8" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + strokeDasharray="2 2.5" + /> + </svg> + ), + needs_run: ( + <svg viewBox="0 0 24 24" fill="none" className="h-6 w-6" aria-hidden> + <circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" /> + <path + d="M12 8v4l2.5 2" + stroke="currentColor" + strokeWidth="1.6" + strokeLinecap="round" + /> + </svg> + ), +}; + +export function HonestState({ + variant, + title, + detail, + action, + compact = false, +}: { + variant: Variant; + title: string; + detail?: string; + action?: React.ReactNode; + compact?: boolean; +}) { + return ( + <div + className={[ + "flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-surface-muted/40 text-center", + compact ? "px-6 py-8" : "px-6 py-14", + ].join(" ")} + > + <span className="grid h-11 w-11 place-items-center rounded-full bg-surface text-foreground-muted"> + {ICONS[variant]} + </span> + <p className="mt-3 text-sm font-medium text-foreground">{title}</p> + {detail && ( + <p className="mt-1 max-w-md text-xs text-foreground-muted">{detail}</p> + )} + {action && <div className="mt-4">{action}</div>} + </div> + ); +} diff --git a/bridge/web/src/components/how-it-works.tsx b/bridge/web/src/components/how-it-works.tsx new file mode 100644 index 000000000..8fcc07783 --- /dev/null +++ b/bridge/web/src/components/how-it-works.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; + +/// The three-beat "how kars Bridge works" explainer. Extracted so it can be +/// shown BOTH in the first-run empty state AND via a persistent "How it works" +/// disclosure in the hero — so a returning user (who has missions/teams, and so +/// never hits the empty state) can still re-orient at any time (audit f1). +const STEPS = [ + { n: 1, t: "Describe the outcome", d: "Type what you want done. No config — plain language. Bridge picks the loop and composes an agent (or a whole team) for it." }, + { n: 2, t: "Review the plan", d: "You see the exact model, tools, network reach, autonomy, and budget before anything runs — and can change any of it. Nothing acts until you launch." }, + { n: 3, t: "Get verifiable work", d: "It starts automatically, runs live, steers to you when it needs a decision, and delivers with a signed receipt you can independently verify." }, +] as const; + +export function HowItWorksSteps({ withExamples = false }: { withExamples?: boolean }) { + return ( + <> + <ol className="mt-4 grid gap-4 sm:grid-cols-3"> + {STEPS.map((s) => ( + <li key={s.n} className="rounded-xl border border-border bg-surface-muted/30 p-4"> + <span className="grid h-6 w-6 place-items-center rounded-full bg-signal/15 text-xs font-semibold text-signal">{s.n}</span> + <p className="mt-2 text-sm font-medium">{s.t}</p> + <p className="mt-1 text-xs leading-relaxed text-foreground-muted">{s.d}</p> + </li> + ))} + </ol> + {withExamples && ( + <div className="mt-4 flex flex-wrap gap-2"> + <span className="text-xs text-foreground-muted">Try:</span> + {["Summarise the latest changes in the Azure/kars repo", "Draft a competitive brief on agent runtimes", "Watch our repo and report failing CI daily"].map((ex) => ( + <Link key={ex} href={`/workspace/new?intent=${encodeURIComponent(ex)}`} className="rounded-full border border-signal/30 bg-signal/5 px-3 py-1 text-xs text-signal hover:bg-signal/10"> + {ex} + </Link> + ))} + </div> + )} + </> + ); +} diff --git a/bridge/web/src/components/icon.tsx b/bridge/web/src/components/icon.tsx new file mode 100644 index 000000000..9346bbb44 --- /dev/null +++ b/bridge/web/src/components/icon.tsx @@ -0,0 +1,318 @@ +// kars Bridge — a small, dependency-free line-icon set. Emoji render +// differently per-OS, can't inherit color/size, and read as amateur; these are +// consistent 1.5px-stroke glyphs that inherit `currentColor` and align to a +// grid. Add new icons here as needed rather than reaching for emoji. + +import type { SVGProps } from "react"; + +export type IconName = + | "loop" + | "mirror" + | "map" + | "check-cycle" + | "branch" + | "eye" + | "target" + | "shield" + | "brain" + | "gear" + | "globe" + | "plug" + | "database" + | "wrench" + | "stethoscope" + | "scale" + | "seal" + | "coin" + | "chart" + | "note" + | "layers" + | "pencil" + | "file" + | "check" + | "cross" + | "warning" + | "lock" + | "download" + | "refresh" + | "bolt" + | "compass" + | "person" + | "search" + | "lightbulb" + | "puzzle" + | "box" + | "link" + | "message" + | "flask" + | "crown" + | "terminal" + | "handshake" + | "chevron-down"; + +const PATHS: Record<IconName, React.ReactNode> = { + // A closed feedback loop (observe → act → repeat). + loop: ( + <> + <path d="M3 8a5 5 0 0 1 9-3l1 1" /> + <path d="M13 3v3h-3" /> + <path d="M13 8a5 5 0 0 1-9 3l-1-1" /> + <path d="M3 13v-3h3" /> + </> + ), + // Reflection — a mirrored pair (reflect/critique). + mirror: ( + <> + <path d="M8 2v12" /> + <path d="M6 5 3 8l3 3" /> + <path d="m10 5 3 3-3 3" /> + </> + ), + // Plan/route — a waypointed path (plan-execute). + map: ( + <> + <path d="M4 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" /> + <path d="M12 6a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" /> + <path d="M5.5 10.5 10.5 5" strokeDasharray="1.5 1.5" /> + </> + ), + // Evaluate + iterate — a check inside a cycle. + "check-cycle": ( + <> + <path d="M13 8A5 5 0 1 1 11 4" /> + <path d="M6 8l1.5 1.5L11 5" /> + </> + ), + // Explore/branch — a forking tree. + branch: ( + <> + <path d="M5 2v5" /> + <path d="M5 7c0 2 3 2 3 4v3" /> + <path d="M5 7c0 2-3 2-3 4v0" /> + <circle cx="5" cy="2" r="1.4" /> + <circle cx="8" cy="14" r="1.4" /> + </> + ), + // Standing watch — an eye. + eye: ( + <> + <path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z" /> + <circle cx="8" cy="8" r="1.8" /> + </> + ), + target: ( + <> + <circle cx="8" cy="8" r="5.5" /> + <circle cx="8" cy="8" r="2.5" /> + </> + ), + shield: <path d="M8 1.5 3 3.5v4C3 11 5.5 13.5 8 14.5 10.5 13.5 13 11 13 7.5v-4Z" />, + brain: ( + <> + <path d="M6.5 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.5A2 2 0 0 0 5 11.5a2 2 0 0 0 1.5.5V2.5Z" /> + <path d="M9.5 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.5 2 2 0 0 1-1.5 3.5 2 2 0 0 1-1.5.5V2.5Z" /> + </> + ), + gear: ( + <> + <circle cx="8" cy="8" r="2" /> + <path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8 3.4 3.4" /> + </> + ), + globe: ( + <> + <circle cx="8" cy="8" r="6" /> + <path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12" /> + </> + ), + plug: ( + <> + <path d="M6 2v3M10 2v3" /> + <path d="M4.5 5h7v2a3.5 3.5 0 0 1-7 0Z" /> + <path d="M8 10.5V14" /> + </> + ), + database: ( + <> + <ellipse cx="8" cy="4" rx="5" ry="2" /> + <path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4" /> + <path d="M3 8c0 1.1 2.2 2 5 2s5-.9 5-2" /> + </> + ), + wrench: <path d="M11.5 2.5a3 3 0 0 0-3.9 3.9l-5 5a1.5 1.5 0 0 0 2 2l5-5a3 3 0 0 0 3.9-3.9L11 4.5 9.5 4 9 2.5Z" />, + stethoscope: ( + <> + <path d="M4 2v3a3 3 0 0 0 6 0V2" /> + <path d="M7 8v1.5a3.5 3.5 0 0 0 7 0V8" /> + <circle cx="12.5" cy="6.5" r="1.2" /> + </> + ), + scale: ( + <> + <path d="M8 2v11M4 13h8M3 5l5-1 5 1" /> + <path d="M3 5 1.5 8.5a2 2 0 0 0 3 0Z" /> + <path d="M13 5l-1.5 3.5a2 2 0 0 0 3 0Z" /> + </> + ), + seal: ( + <> + <circle cx="8" cy="6.5" r="4" /> + <path d="M6 10l-1 4 3-1.5L11 14l-1-4" /> + </> + ), + coin: ( + <> + <circle cx="8" cy="8" r="6" /> + <path d="M8 5v6M6.3 6.2h2.4a1.3 1.3 0 0 1 0 2.6H6.3M6.3 8.8h2.6" /> + </> + ), + chart: ( + <> + <path d="M2 2v12h12" /> + <path d="M5 10l2.5-3 2 2L13 4" /> + </> + ), + note: ( + <> + <path d="M4 2h6l3 3v9H4Z" /> + <path d="M10 2v3h3M6 8h5M6 11h5" /> + </> + ), + layers: ( + <> + <path d="M8 2 2 5l6 3 6-3Z" /> + <path d="M2 8.5 8 11.5 14 8.5M2 11.5 8 14.5 14 11.5" /> + </> + ), + pencil: ( + <> + <path d="M10.5 2.5 13.5 5.5 5 14H2v-3Z" /> + </> + ), + file: ( + <> + <path d="M4 1.5h5.5L12 4v10.5H4Z" /> + <path d="M9.5 1.5v3H12" /> + </> + ), + check: <path d="M2.5 8.5 6 12l7.5-8" />, + cross: <path d="M3 3l10 10M13 3 3 13" />, + warning: ( + <> + <path d="M8 1.5 14.5 13H1.5Z" /> + <path d="M8 6.5v3M8 11.5v.01" /> + </> + ), + lock: ( + <> + <path d="M4 7V4.5a4 4 0 0 1 8 0V7" /> + <path d="M2.5 7h11v7h-11Z" /> + </> + ), + download: ( + <> + <path d="M8 1.5v8M5 6.5 8 9.5l3-3" /> + <path d="M2.5 12.5v2h11v-2" /> + </> + ), + refresh: ( + <> + <path d="M2.5 8a5.5 5.5 0 0 1 9.5-3.8l1 1" /> + <path d="M13 2.5v3h-3" /> + <path d="M13.5 8a5.5 5.5 0 0 1-9.5 3.8l-1-1" /> + <path d="M3 13.5v-3h3" /> + </> + ), + bolt: <path d="M8.5 1.5 3 9h4l-.5 5.5L13 7H9Z" />, + compass: ( + <> + <circle cx="8" cy="8" r="6.5" /> + <path d="M10.5 5.5 9 9l-3.5 1.5L7 7Z" /> + </> + ), + person: ( + <> + <circle cx="8" cy="5" r="2.5" /> + <path d="M2.5 14a5.5 5.5 0 0 1 11 0" /> + </> + ), + search: ( + <> + <circle cx="7" cy="7" r="4.5" /> + <path d="M10.2 10.2 14 14" /> + </> + ), + lightbulb: ( + <> + <path d="M8 1.5a4.5 4.5 0 0 0-2.5 8.25V11.5h5V9.75A4.5 4.5 0 0 0 8 1.5Z" /> + <path d="M6 13.5h4M6.5 15h3" /> + </> + ), + puzzle: ( + <> + <path d="M4 4h3V2.5a1.2 1.2 0 1 1 2.4 0V4H12v3.4a1.2 1.2 0 1 0 0 2.4V13H8.6a1.2 1.2 0 1 0-2.4 0H4V9.6a1.2 1.2 0 1 1 0-2.4Z" /> + </> + ), + box: ( + <> + <path d="M8 1.5 14 4.5v7L8 14.5 2 11.5v-7Z" /> + <path d="M2 4.5 8 7.5v7M14 4.5 8 7.5" /> + </> + ), + link: ( + <> + <path d="M6.5 9.5 9.5 6.5" /> + <path d="M7 4.5 8.7 2.8a2.6 2.6 0 0 1 3.7 3.7L10.6 8.2" /> + <path d="M9 11.5 7.3 13.2a2.6 2.6 0 0 1-3.7-3.7L5.4 7.8" /> + </> + ), + message: ( + <> + <path d="M2 3h12v8H6l-3 3v-3H2Z" /> + </> + ), + flask: ( + <> + <path d="M6.5 2h3M7 2v4l-4 7a1 1 0 0 0 .9 1.5h8.2A1 1 0 0 0 13 13l-4-7V2" /> + <path d="M5 10.5h6" /> + </> + ), + crown: <path d="M2.5 12.5 1.5 5 5.5 8 8 3.5 10.5 8l4-3-1 7.5Z" />, + terminal: ( + <> + <path d="M2 2.5h12v11H2Z" /> + <path d="M4.5 6 7 8.5 4.5 11M8.5 11h3" /> + </> + ), + handshake: ( + <> + <path d="M1.5 8.5 4 6l2.5 2-1 1.5" /> + <path d="M14.5 8.5 12 6l-2.5 2 1 1.5" /> + <path d="M6.5 8l1.5 1.5L9.5 8" /> + </> + ), + "chevron-down": <path d="M3.5 6 8 10.5 12.5 6" />, +}; + +export function Icon({ + name, + size = 16, + ...props +}: { name: IconName; size?: number } & Omit<SVGProps<SVGSVGElement>, "name">) { + return ( + <svg + width={size} + height={size} + viewBox="0 0 16 16" + fill="none" + stroke="currentColor" + strokeWidth={1.5} + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden + {...props} + > + {PATHS[name]} + </svg> + ); +} diff --git a/bridge/web/src/components/inference-budgets.tsx b/bridge/web/src/components/inference-budgets.tsx new file mode 100644 index 000000000..c2e8926a1 --- /dev/null +++ b/bridge/web/src/components/inference-budgets.tsx @@ -0,0 +1,590 @@ +"use client"; + +// kars Bridge Operator Console — hierarchical, EDITABLE inference token budgets. +// +// The aggregate levels above the per-sandbox policy: a cluster-wide cap and +// per-workspace caps, each with a live measured daily-usage meter and one of +// three enforcement modes: +// • passive — alert-only; never blocks a launch. +// • buffer — allows up to +N% headroom, then blocks (admin must raise). +// • strict — blocks at 100%; only an admin can raise. +// Edits PUT straight to /api/operator/inference-budgets/* and re-read the live +// hierarchy (usage recomputed server-side from completed runs). + +import { useCallback, useEffect, useState } from "react"; +import type { InferenceBudgets, BudgetLevel } from "@/lib/types"; +import { Icon } from "@/components/icon"; + +const MODES = [ + { id: "passive", label: "Passive — alert only" }, + { id: "buffer", label: "Buffer — allow +headroom, then block" }, + { id: "strict", label: "Strict — block at limit" }, +] as const; + +const STATUS_META: Record< + BudgetLevel["status"], + { label: string; tone: string; bar: string } +> = { + ok: { label: "Within budget", tone: "text-signal", bar: "bg-signal" }, + alert: { label: "Over budget — alerting", tone: "text-warning", bar: "bg-warning" }, + over_buffer_headroom: { label: "In buffer headroom", tone: "text-warning", bar: "bg-warning" }, + blocking: { label: "Blocking new work", tone: "text-danger", bar: "bg-danger" }, +}; + +function fmt(n: number): string { + return n.toLocaleString(); +} + +export function InferenceBudgets({ isAdmin = true }: { isAdmin?: boolean }) { + const [data, setData] = useState<InferenceBudgets | null>(null); + const [error, setError] = useState<string | null>(null); + const [addingNs, setAddingNs] = useState(false); + const [addingUser, setAddingUser] = useState(false); + + const load = useCallback(async () => { + try { + const r = await fetch("/api/operator/inference-budgets", { cache: "no-store" }); + if (!r.ok) throw new Error(); + setData(await r.json()); + setError(null); + } catch { + setError("Couldn't load inference budgets."); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(timer); + }, [load]); + + if (error) { + return <p className="text-xs text-danger">{error}</p>; + } + if (!data) { + return <p className="text-xs text-foreground-muted">Loading measured usage…</p>; + } + + const clusterUsed = data.cluster_used_today; + + return ( + <div className="space-y-4"> + <p className="text-xs text-foreground-muted"> + Aggregate caps over inference token spend, above the per-sandbox policy. The meter is the + real daily utilization measured from completed runs (UTC day). Passive alerts; buffer allows + a headroom then blocks; strict blocks at the limit — only an admin raises it. + </p> + + {/* Active budget alerts — the real "raise alerts" surface (item 2 passive + mode + any breach). Prominent so a breach isn't buried in a meter. */} + {(data.alerts?.length ?? 0) > 0 && ( + <div className="space-y-1.5"> + {data.alerts!.map((a) => ( + <div + key={`${a.scope}-${a.severity}`} + className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${ + a.severity === "blocking" + ? "border-danger/40 bg-danger/[0.06] text-danger" + : "border-warning/40 bg-warning/[0.06] text-warning" + }`} + > + <span aria-hidden>{a.severity === "blocking" ? <Icon name="cross" size={13} /> : <Icon name="warning" size={13} />}</span> + <span>{a.message}</span> + </div> + ))} + </div> + )} + + {/* Cluster level */} + <BudgetCard + title="Cluster" + subtitle="The whole cluster's daily inference token cap." + level={data.cluster} + fallbackUsed={clusterUsed} + canEdit={isAdmin} + onSave={async (body) => { + await fetch("/api/operator/inference-budgets/cluster", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + await load(); + }} + /> + + {/* Workspace levels */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="text-xs font-semibold text-foreground-muted">Workspaces</h4> + {!addingNs && isAdmin && ( + <button + type="button" + onClick={() => setAddingNs(true)} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium hover:bg-surface-muted" + > + + Add workspace budget + </button> + )} + </div> + {data.workspaces.length === 0 && !addingNs && ( + <p className="text-[11px] text-foreground-muted"> + No per-workspace caps. Add one to bound a specific workspace (namespace) below the + cluster cap. + </p> + )} + {data.workspaces.map((w) => ( + <BudgetCard + key={w.scope} + title={w.label} + subtitle={`Workspace “${w.scope}” daily cap.`} + level={w} + fallbackUsed={w.used_today} + canEdit={isAdmin} + onSave={async (body) => { + await fetch(`/api/operator/inference-budgets/workspaces/${encodeURIComponent(w.scope)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + await load(); + }} + onRemove={async () => { + await fetch(`/api/operator/inference-budgets/workspaces/${encodeURIComponent(w.scope)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clear: true }), + }); + await load(); + }} + /> + ))} + {addingNs && ( + <AddWorkspace + suggestions={data.unbudgeted_namespaces} + defaultNs={data.default_namespace} + onCancel={() => setAddingNs(false)} + onCreate={async (ns, body) => { + await fetch(`/api/operator/inference-budgets/workspaces/${encodeURIComponent(ns)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + setAddingNs(false); + await load(); + }} + /> + )} + </div> + + {/* Per-user levels (the spec's per-user tier, keyed by created-by). */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="text-xs font-semibold text-foreground-muted">Users</h4> + {!addingUser && isAdmin && ( + <button + type="button" + onClick={() => setAddingUser(true)} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium hover:bg-surface-muted" + > + + Add user budget + </button> + )} + </div> + {data.users.length === 0 && !addingUser && ( + <p className="text-[11px] text-foreground-muted"> + No per-user caps. Bound an individual user’s daily spend below the workspace cap — + attributed from the mission/team creator. + </p> + )} + {data.users.map((u) => ( + <BudgetCard + key={u.scope} + title={u.label} + subtitle={`User “${u.scope}” daily cap.`} + level={u} + fallbackUsed={u.used_today} + canEdit={isAdmin} + onSave={async (body) => { + await fetch(`/api/operator/inference-budgets/users/${encodeURIComponent(u.scope)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + await load(); + }} + onRemove={async () => { + await fetch(`/api/operator/inference-budgets/users/${encodeURIComponent(u.scope)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ clear: true }), + }); + await load(); + }} + /> + ))} + {addingUser && ( + <AddWorkspace + label="User identity" + placeholder="e.g. alice@local" + suggestions={data.unbudgeted_users} + defaultNs={data.unbudgeted_users[0] ?? ""} + onCancel={() => setAddingUser(false)} + onCreate={async (u, body) => { + await fetch(`/api/operator/inference-budgets/users/${encodeURIComponent(u)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + setAddingUser(false); + await load(); + }} + /> + )} + </div> + </div> + ); +} + +function Meter({ level, used }: { level: BudgetLevel | null; used: number }) { + if (!level || level.daily_tokens === 0) { + return ( + <p className="text-[11px] text-foreground-muted"> + Measured today: <span className="font-medium text-foreground">{fmt(used)}</span> tokens · no + cap + </p> + ); + } + const meta = STATUS_META[level.status]; + const pct = Math.min(100, Math.round(level.percent * 100)); + return ( + <div> + <div className="flex items-center justify-between text-[11px]"> + <span className={meta.tone}>{meta.label}</span> + <span className="tabular-nums text-foreground-muted"> + {fmt(used)} / {fmt(level.daily_tokens)} + {level.mode === "buffer" && level.hard_cap > level.daily_tokens + ? ` (hard ${fmt(level.hard_cap)})` + : ""}{" "} + · {Math.round(level.percent * 100)}% + </span> + </div> + <div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-surface-muted"> + <div className={`h-full ${meta.bar}`} style={{ width: `${pct}%` }} /> + </div> + </div> + ); +} + +type SaveBody = { daily_tokens: number; mode: string; buffer_percent: number }; + +function BudgetCard({ + title, + subtitle, + level, + fallbackUsed, + canEdit = true, + onSave, + onRemove, +}: { + title: string; + subtitle: string; + level: BudgetLevel | null; + fallbackUsed: number; + canEdit?: boolean; + onSave: (body: SaveBody) => Promise<void>; + onRemove?: () => Promise<void>; +}) { + const [editing, setEditing] = useState(false); + const [daily, setDaily] = useState(level?.daily_tokens ?? 0); + const [mode, setMode] = useState<string>(level?.mode ?? "passive"); + const [buffer, setBuffer] = useState(level?.buffer_percent ?? 20); + const [busy, setBusy] = useState(false); + + return ( + <div className="rounded-lg border border-border bg-surface-muted/30 p-3"> + <div className="flex items-start justify-between gap-3"> + <div> + <p className="text-sm font-medium">{title}</p> + <p className="text-[11px] text-foreground-muted">{subtitle}</p> + </div> + {!editing && ( + <div className="flex items-center gap-2"> + {level && ( + <span className="rounded-full border border-border px-2 py-0.5 text-[10px] font-medium capitalize text-foreground-muted"> + {level.mode} + </span> + )} + {canEdit ? ( + <button + type="button" + onClick={() => { + setDaily(level?.daily_tokens ?? 0); + setMode(level?.mode ?? "passive"); + setBuffer(level?.buffer_percent ?? 20); + setEditing(true); + }} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium hover:bg-surface-muted" + > + {level ? "Edit" : "Set budget"} + </button> + ) : ( + <span + className="rounded-md border border-border px-2 py-1 text-[10px] font-medium text-foreground-muted" + title="Only a cluster or org admin can raise inference budgets." + > + <Icon name="lock" size={11} className="inline" /> Admin only + </span> + )} + </div> + )} + </div> + + <div className="mt-2"> + <Meter level={level} used={fallbackUsed} /> + </div> + + {editing && ( + <div className="mt-3 space-y-2 rounded-md border border-border bg-surface p-3"> + <label className="block text-[11px] font-medium text-foreground-muted"> + Daily token cap (0 = no cap) + <input + type="number" + min={0} + value={daily} + onChange={(e) => setDaily(Number(e.target.value))} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm tabular-nums" + /> + </label> + <label className="block text-[11px] font-medium text-foreground-muted"> + Enforcement + <select + value={mode} + onChange={(e) => setMode(e.target.value)} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm" + > + {MODES.map((m) => ( + <option key={m.id} value={m.id}> + {m.label} + </option> + ))} + </select> + </label> + {mode === "buffer" && ( + <label className="block text-[11px] font-medium text-foreground-muted"> + Buffer headroom (%) + <input + type="number" + min={0} + max={1000} + value={buffer} + onChange={(e) => setBuffer(Number(e.target.value))} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm tabular-nums" + /> + </label> + )} + <div className="flex items-center gap-2 pt-1"> + <button + type="button" + disabled={busy} + onClick={async () => { + setBusy(true); + await onSave({ daily_tokens: daily, mode, buffer_percent: buffer }); + setBusy(false); + setEditing(false); + }} + className="rounded-md bg-signal px-3 py-1.5 text-[11px] font-semibold text-signal-fg disabled:opacity-50" + > + {busy ? "Saving…" : "Save"} + </button> + <button + type="button" + disabled={busy} + onClick={() => setEditing(false)} + className="rounded-md border border-border px-3 py-1.5 text-[11px] text-foreground-muted hover:bg-surface-muted" + > + Cancel + </button> + {onRemove && level && ( + <button + type="button" + disabled={busy} + onClick={async () => { + setBusy(true); + await onRemove(); + setBusy(false); + setEditing(false); + }} + className="ml-auto rounded-md border border-border px-3 py-1.5 text-[11px] text-foreground-muted hover:border-danger/40 hover:text-danger" + > + Remove cap + </button> + )} + </div> + </div> + )} + </div> + ); +} + +function AddWorkspace({ + suggestions, + defaultNs, + label = "Workspace (namespace)", + placeholder, + onCancel, + onCreate, +}: { + suggestions: string[]; + defaultNs: string; + label?: string; + placeholder?: string; + onCancel: () => void; + onCreate: (ns: string, body: SaveBody) => Promise<void>; +}) { + const [ns, setNs] = useState(suggestions[0] ?? defaultNs); + const [daily, setDaily] = useState(50000); + const [mode, setMode] = useState("buffer"); + const [buffer, setBuffer] = useState(20); + const [busy, setBusy] = useState(false); + + return ( + <div className="space-y-2 rounded-lg border border-signal/30 bg-signal/[0.03] p-3"> + <label className="block text-[11px] font-medium text-foreground-muted"> + {label} + <input + value={ns} + onChange={(e) => setNs(e.target.value)} + list="ws-suggestions" + placeholder={placeholder} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm font-mono" + /> + <datalist id="ws-suggestions"> + {suggestions.map((s) => ( + <option key={s} value={s} /> + ))} + </datalist> + </label> + <label className="block text-[11px] font-medium text-foreground-muted"> + Daily token cap + <input + type="number" + min={0} + value={daily} + onChange={(e) => setDaily(Number(e.target.value))} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm tabular-nums" + /> + </label> + <label className="block text-[11px] font-medium text-foreground-muted"> + Enforcement + <select + value={mode} + onChange={(e) => setMode(e.target.value)} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm" + > + {MODES.map((m) => ( + <option key={m.id} value={m.id}> + {m.label} + </option> + ))} + </select> + </label> + {mode === "buffer" && ( + <label className="block text-[11px] font-medium text-foreground-muted"> + Buffer headroom (%) + <input + type="number" + min={0} + max={1000} + value={buffer} + onChange={(e) => setBuffer(Number(e.target.value))} + className="mt-1 w-full rounded-md border border-border bg-surface px-2 py-1 text-sm tabular-nums" + /> + </label> + )} + <div className="flex items-center gap-2 pt-1"> + <button + type="button" + disabled={busy || !ns.trim()} + onClick={async () => { + setBusy(true); + await onCreate(ns.trim(), { daily_tokens: daily, mode, buffer_percent: buffer }); + setBusy(false); + }} + className="rounded-md bg-signal px-3 py-1.5 text-[11px] font-semibold text-signal-fg disabled:opacity-50" + > + {busy ? "Adding…" : "Add"} + </button> + <button + type="button" + disabled={busy} + onClick={onCancel} + className="rounded-md border border-border px-3 py-1.5 text-[11px] text-foreground-muted hover:bg-surface-muted" + > + Cancel + </button> + </div> + </div> + ); +} + +/** Inline per-sandbox policy budget editor — PATCHes spec.tokenBudget.dailyTokens + * so an operator can adjust a policy's daily cap without editing raw JSON. */ +export function InferenceBudgetEdit({ name, current }: { name: string; current: number | null }) { + const [editing, setEditing] = useState(false); + const [val, setVal] = useState(current ?? 0); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + + if (!editing) { + return ( + <button + type="button" + onClick={() => { + setVal(current ?? 0); + setEditing(true); + setSaved(false); + }} + className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 tabular-nums hover:bg-surface-muted" + title="Edit daily token budget" + > + {saved ? <span className="text-signal">{(current ?? 0).toLocaleString()} ✓</span> : (current != null ? current.toLocaleString() : "—")} + <span aria-hidden className="text-foreground-muted"><Icon name="pencil" size={10} /></span> + </button> + ); + } + return ( + <span className="inline-flex items-center gap-1"> + <input + type="number" + min={0} + value={val} + onChange={(e) => setVal(Number(e.target.value))} + className="w-24 rounded border border-border bg-surface px-1.5 py-0.5 text-xs tabular-nums" + /> + <button + type="button" + disabled={busy} + onClick={async () => { + setBusy(true); + await fetch(`/api/operator/inferencepolicies/${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ daily_tokens: val }), + }); + setBusy(false); + setEditing(false); + setSaved(true); + }} + className="rounded bg-signal px-1.5 py-0.5 text-[10px] font-semibold text-signal-fg disabled:opacity-50" + > + {busy ? "…" : "Save"} + </button> + <button + type="button" + onClick={() => setEditing(false)} + className="rounded border border-border px-1.5 py-0.5 text-[10px] text-foreground-muted" + > + ✕ + </button> + </span> + ); +} diff --git a/bridge/web/src/components/intent-entry.tsx b/bridge/web/src/components/intent-entry.tsx new file mode 100644 index 000000000..ba502147d --- /dev/null +++ b/bridge/web/src/components/intent-entry.tsx @@ -0,0 +1,150 @@ +"use client"; + +// Unified intent-first intake. One box: describe the outcome. Bridge classifies +// it as a one-off mission or a standing team, shows the recommendation with a +// plain-language reason, and lets you flip it before continuing. Continuing +// routes to the matching composer with the intent prefilled — which then +// auto-composes so you land on the editable package/org chart, not a blank box. +// One intent → smart routing → review → pre-flight → launch: a single flow. + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { classifyIntent, type IntentKind } from "@/lib/classify-intent"; +import { LoopDesigner } from "@/components/loop-designer"; +import { Icon } from "@/components/icon"; + +const EXAMPLES: { label: string; text: string; kind: IntentKind }[] = [ + { + label: "Research report", + text: "Research the current state of open-source agent frameworks and write a report comparing their governance models.", + kind: "mission", + }, + { + label: "Repo health team", + text: "Keep the kars repo healthy: triage new issues, watch open PRs, and report failing checks to me daily.", + kind: "team", + }, + { + label: "Competitor watch", + text: "Monitor our top 3 competitors' product launches continuously and summarize anything material every week.", + kind: "team", + }, +]; + +export function IntentEntry() { + const router = useRouter(); + const [intent, setIntent] = useState(""); + const [override, setOverride] = useState<IntentKind | null>(null); + const [pending, startTransition] = useTransition(); + + const auto = useMemo(() => classifyIntent(intent), [intent]); + const kind: IntentKind = override ?? auto.kind; + const ready = intent.trim().length >= 8; + + function go() { + if (!ready) return; + const q = `intent=${encodeURIComponent(intent.trim())}`; + const href = kind === "team" ? `/workspace/teams/new?${q}` : `/workspace/new?${q}`; + startTransition(() => router.push(href)); + } + + return ( + <div> + <textarea + value={intent} + onChange={(e) => { + setIntent(e.target.value); + setOverride(null); + }} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") go(); + }} + rows={3} + autoFocus + placeholder="e.g. Research the top agentic-AI launches this quarter and write me a briefing — or — keep an eye on our repo's PRs and tell me when checks fail." + className="w-full resize-y rounded-2xl border border-border bg-surface px-4 py-3 text-sm leading-relaxed shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + + {/* Live recommendation — transparent, always overridable. */} + {ready && ( + <div className="mt-3 flex flex-col gap-2 rounded-xl border border-border bg-surface-muted/40 p-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-start gap-2.5"> + <span + className={`grid h-8 w-8 shrink-0 place-items-center rounded-lg text-lg ${ + kind === "team" ? "bg-accent/15 text-accent" : "bg-signal/15 text-signal" + }`} + aria-hidden + > + {kind === "team" ? <Icon name="handshake" size={18} /> : <Icon name="target" size={18} />} + </span> + <div className="min-w-0"> + <p className="text-sm font-semibold"> + {kind === "team" ? "Recommended: a standing team" : "Recommended: a one-off mission"} + {!override && ( + <span className="ml-2 rounded-full bg-surface px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-foreground-muted"> + {auto.confidence} confidence + </span> + )} + </p> + <p className="mt-0.5 text-xs text-foreground-muted"> + {override ? "You chose this manually." : auto.reason} + </p> + </div> + </div> + <div className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface p-1 text-xs"> + <button + type="button" + onClick={() => setOverride("mission")} + className={`rounded-md px-2.5 py-1 font-medium transition ${ + kind === "mission" ? "bg-signal text-signal-fg" : "text-foreground-muted hover:text-foreground" + }`} + > + Mission + </button> + <button + type="button" + onClick={() => setOverride("team")} + className={`rounded-md px-2.5 py-1 font-medium transition ${ + kind === "team" ? "bg-accent text-accent-fg" : "text-foreground-muted hover:text-foreground" + }`} + > + Team + </button> + </div> + </div> + )} + + <div className="mt-3 flex flex-wrap items-center gap-2"> + <button + type="button" + onClick={go} + disabled={!ready || pending} + className="inline-flex items-center gap-2 rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg disabled:opacity-50" + > + {pending ? "Composing…" : kind === "team" ? "Compose the team →" : "Compose the mission →"} + </button> + <span className="text-xs text-foreground-muted"> + ⌘↵ to continue · you review & adjust everything before anything runs + </span> + </div> + + {/* Examples — one-click seeds that also demonstrate the routing. */} + <div className="mt-4 flex flex-wrap gap-2"> + <span className="text-[11px] font-medium uppercase tracking-wide text-foreground-muted">Try</span> + {EXAMPLES.map((ex) => ( + <button + key={ex.label} + type="button" + onClick={() => { + setIntent(ex.text); + setOverride(null); + }} + className="rounded-full border border-border bg-surface px-3 py-1 text-xs text-foreground-muted transition hover:border-signal hover:text-foreground" + > + {ex.label} + </button> + ))} + </div> + </div> + ); +} diff --git a/bridge/web/src/components/journey-rail.tsx b/bridge/web/src/components/journey-rail.tsx new file mode 100644 index 000000000..3540945a8 --- /dev/null +++ b/bridge/web/src/components/journey-rail.tsx @@ -0,0 +1,131 @@ +// kars Bridge — the Journey rail. ONE lifecycle spine, rendered identically +// across the product so every surface tells the same story: a unit of work +// (mission or team) always moves through Describe → Compose → Review → Launch → +// Build → Run → Deliver. Showing the same seven beats everywhere is what makes +// the product legible: wherever you are, you can read where you are and what +// comes next. Purely presentational — `current` is derived by each surface from +// real state (compose step, task phase, execution phase). + +export const JOURNEY_BEATS = [ + { id: "describe", label: "Describe", hint: "State the intent" }, + { id: "compose", label: "Compose", hint: "Envelope assembled" }, + { id: "review", label: "Review", hint: "Edit & verify" }, + { id: "launch", label: "Launch", hint: "You approve the start" }, + { id: "build", label: "Build", hint: "Provision & verify access" }, + { id: "run", label: "Run", hint: "Live work & steering" }, + { id: "deliver", label: "Deliver", hint: "Artifacts & receipt" }, +] as const; + +export type JourneyBeat = (typeof JOURNEY_BEATS)[number]["id"]; + +/** Map a mission's real state to the current journey beat. */ +export function missionBeat(opts: { + launched: boolean; + executionPhase: string | null; + hasResult: boolean; + blocked?: boolean; +}): JourneyBeat { + if (opts.hasResult) return "deliver"; + if (opts.executionPhase === "Running") return "run"; + if (opts.executionPhase === "Launching" || opts.executionPhase === "Pending") return "build"; + if (opts.launched) return "run"; + return "launch"; +} + +/** Map a standing team's state to the current beat (teams live mostly in Run). */ +export function teamBeat(opts: { paused: boolean; everRan: boolean }): JourneyBeat { + // A team paused AFTER it has run is still a Run-phase unit that's simply + // hibernating — surface it on "run" (the caller passes `blocked` to render + // the distinct paused treatment) rather than rewinding it to "review", which + // wrongly implied a team with 20+ delivered runs was still awaiting review. + // Only a team paused BEFORE it ever launched legitimately sits pre-run. + if (opts.paused) return opts.everRan ? "run" : "review"; + return opts.everRan ? "run" : "build"; +} + +export function JourneyRail({ + current, + blocked = false, + paused = false, + compact = false, +}: { + current: JourneyBeat; + blocked?: boolean; + paused?: boolean; + compact?: boolean; +}) { + const idx = JOURNEY_BEATS.findIndex((b) => b.id === current); + return ( + <nav aria-label="Mission journey" className="kb-rise"> + <ol className="flex items-stretch gap-1 overflow-x-auto rounded-xl border border-border bg-surface/70 p-1.5"> + {JOURNEY_BEATS.map((b, i) => { + const state = i < idx ? "done" : i === idx ? "current" : "upcoming"; + const isBlocked = i === idx && blocked; + // Paused is a HEALTHY resting state (a hibernating standing team), not + // an error — render it as a muted "on hold" beat, distinct from the + // red `blocked` attention state. + const isPaused = i === idx && paused && !isBlocked; + return ( + <li key={b.id} className="flex min-w-0 flex-1 items-center gap-1"> + <div + className={[ + "flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 transition", + state === "current" && !isBlocked && !isPaused + ? "bg-signal/10" + : isBlocked + ? "bg-danger/10" + : isPaused + ? "bg-surface-muted" + : "", + ].join(" ")} + > + <span + className={[ + "grid h-5 w-5 shrink-0 place-items-center rounded-full text-[10px] font-semibold", + state === "done" + ? "bg-signal text-signal-fg" + : isBlocked + ? "bg-danger text-white" + : isPaused + ? "bg-surface-muted text-foreground-muted ring-1 ring-border" + : state === "current" + ? "bg-signal/20 text-signal ring-2 ring-signal/40" + : "bg-surface-muted text-foreground-muted", + ].join(" ")} + aria-hidden + > + {state === "done" ? "✓" : isBlocked ? "!" : isPaused ? "‖" : i + 1} + </span> + <span className="min-w-0"> + <span + className={[ + "block whitespace-nowrap text-xs font-medium leading-tight", + state === "upcoming" ? "text-foreground-muted" : "text-foreground", + ].join(" ")} + > + {b.label} + </span> + {!compact && ( + <span className="hidden truncate text-[10px] leading-tight text-foreground-muted lg:block"> + {state === "current" && isBlocked + ? "Needs your attention" + : isPaused + ? "Paused — resumes on Run now" + : b.hint} + </span> + )} + </span> + </div> + {i < JOURNEY_BEATS.length - 1 && ( + <span + aria-hidden + className={`h-px w-3 shrink-0 ${i < idx ? "bg-signal" : "bg-border"}`} + /> + )} + </li> + ); + })} + </ol> + </nav> + ); +} diff --git a/bridge/web/src/components/list-skeleton.tsx b/bridge/web/src/components/list-skeleton.tsx new file mode 100644 index 000000000..cd9cd87ee --- /dev/null +++ b/bridge/web/src/components/list-skeleton.tsx @@ -0,0 +1,35 @@ +import { Skeleton } from "@/components/ui"; + +/// A shared loading scaffold for the workspace list pages (missions, teams, +/// inbox, artifacts, skills). Shown via Next.js `loading.tsx` while the server +/// component fetches from the BFF — so a slow round-trip reads as "loading", +/// never a blank page that looks broken. +export function ListSkeleton({ rows = 4, title = "Loading…" }: { rows?: number; title?: string }) { + return ( + <div className="space-y-6" aria-busy="true" aria-live="polite"> + <div> + <Skeleton className="h-7 w-48" /> + <Skeleton className="mt-2 h-4 w-72" /> + <span className="sr-only">{title}</span> + </div> + <ul className="space-y-3"> + {Array.from({ length: rows }).map((_, i) => ( + <li key={i} className="rounded-xl border border-border bg-surface p-5"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0 flex-1"> + <Skeleton className="h-5 w-1/3" /> + <Skeleton className="mt-2 h-3.5 w-2/3" /> + </div> + <Skeleton className="h-6 w-20 rounded-full" /> + </div> + <div className="mt-4 flex gap-4"> + <Skeleton className="h-3.5 w-24" /> + <Skeleton className="h-3.5 w-24" /> + <Skeleton className="h-3.5 w-24" /> + </div> + </li> + ))} + </ul> + </div> + ); +} diff --git a/bridge/web/src/components/live-activity-view.tsx b/bridge/web/src/components/live-activity-view.tsx new file mode 100644 index 000000000..732d66a9c --- /dev/null +++ b/bridge/web/src/components/live-activity-view.tsx @@ -0,0 +1,72 @@ +"use client"; + +// kars Bridge — the shared live Activity view. Opens ONE SSE connection (via +// useLiveTrace) and feeds both the auto-folding agent graph and the per-round / +// per-tool feed, so the Activity tab never opens two connections to the same +// stream. Missions and team runs both render through this. + +import { useLiveTrace } from "./use-live-trace"; +import { AgentGraph } from "./agent-graph"; +import { ActivityStream } from "./activity-stream"; +import type { ActivityEvent, MissionTelemetry, SubAgent } from "@/lib/types"; + +export function LiveActivityView({ + running, + activity, + telemetry, + ns, + name, + agentLabel, + subAgents = [], + showGraph = true, + identity = null, + envelopeDigest = null, + receipt = null, + activityTitle, + activityDetail, +}: { + running: boolean; + activity: ActivityEvent[]; + telemetry: MissionTelemetry | null; + ns?: string; + name?: string; + agentLabel?: string; + subAgents?: SubAgent[]; + showGraph?: boolean; + identity?: import("@/lib/types").AgentIdentity | null; + envelopeDigest?: string | null; + receipt?: import("@/lib/types").Receipt | null; + activityTitle?: string; + activityDetail?: string; +}) { + // One stream, shared by the graph and the feed. + const events = useLiveTrace(ns, name, running, activity); + return ( + <div className="space-y-4"> + {showGraph && ( + <AgentGraph + running={running} + activity={activity} + events={events} + ns={ns} + name={name} + agentLabel={agentLabel} + subAgents={subAgents} + identity={identity} + envelopeDigest={envelopeDigest} + receipt={receipt} + /> + )} + <ActivityStream + running={running} + activity={activity} + events={events} + telemetry={telemetry} + ns={ns} + name={name} + title={activityTitle} + detail={activityDetail} + /> + </div> + ); +} diff --git a/bridge/web/src/components/live-refresh.tsx b/bridge/web/src/components/live-refresh.tsx new file mode 100644 index 000000000..3f330f161 --- /dev/null +++ b/bridge/web/src/components/live-refresh.tsx @@ -0,0 +1,88 @@ +"use client"; + +// kars Bridge Workspace — live mission canvas. +// +// While a mission is live (its sandbox is running, or a run is in flight) the +// server component is re-fetched on a short interval so the activity trace, +// token telemetry, deliverable, and artifact set appear as the run lands — no +// manual reload. `router.refresh()` re-runs the server render with fresh BFF +// data while preserving client state. Polling stops the moment the mission is +// no longer active or the page is hidden, so it never spins needlessly. + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +export function LiveRefresh({ + active, + intervalMs = 3500, +}: { + active: boolean; + intervalMs?: number; +}) { + const router = useRouter(); + const timer = useRef<ReturnType<typeof setInterval> | null>(null); + // Guard against overlapping refreshes: if the BFF is slower than the interval, + // don't stack a second router.refresh() on top of an in-flight one (which can + // deliver stale-after-fresh out of order). We settle the guard after a short + // window that comfortably exceeds a normal server round-trip. + const refreshing = useRef(false); + + useEffect(() => { + if (!active) return; + const tick = () => { + if (document.visibilityState !== "visible" || refreshing.current) return; + refreshing.current = true; + router.refresh(); + setTimeout(() => { + refreshing.current = false; + }, Math.max(1000, intervalMs - 250)); + }; + // Refresh once immediately on mount so an operator arriving mid-run sees the + // current state without waiting a full interval (or hitting F5), then poll. + if (document.visibilityState === "visible") { + refreshing.current = true; + router.refresh(); + setTimeout(() => { + refreshing.current = false; + }, Math.max(1000, intervalMs - 250)); + } + timer.current = setInterval(tick, intervalMs); + return () => { + if (timer.current) clearInterval(timer.current); + refreshing.current = false; + }; + }, [active, intervalMs, router]); + + return null; +} + +/// A small pulsing "live" indicator — a calm signal that the canvas is +/// auto-updating. Purely presentational. +export function LivePulse({ label = "Live" }: { label?: string }) { + return ( + <span className="inline-flex items-center gap-1.5 rounded-full bg-signal/10 px-2.5 py-1 text-xs font-medium text-signal"> + <span className="relative flex h-2 w-2"> + <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-signal/70" /> + <span className="relative inline-flex h-2 w-2 rounded-full bg-signal" /> + </span> + {label} + </span> + ); +} + +/// Wraps any content and gently fades/slides it in on mount — used so newly +/// arrived activity rows feel alive rather than popping in abruptly. +export function FadeIn({ children }: { children: React.ReactNode }) { + const [shown, setShown] = useState(false); + useEffect(() => { + const id = requestAnimationFrame(() => setShown(true)); + return () => cancelAnimationFrame(id); + }, []); + return ( + <div + className={`transition-all duration-300 ${shown ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"}`} + > + {children} + </div> + ); +} diff --git a/bridge/web/src/components/loop-designer.tsx b/bridge/web/src/components/loop-designer.tsx new file mode 100644 index 000000000..8eaa053fd --- /dev/null +++ b/bridge/web/src/components/loop-designer.tsx @@ -0,0 +1,209 @@ +"use client"; + +// kars Bridge — Loop Designer. The authoring surface for 2026 loop engineering: +// pick a feedback-loop pattern, state the goal + how success is measured, and it +// generates a structured, evaluation-driven objective/charter that ENCODES the +// loop. Because that text is what the harness runs and what a principal hands to +// the sub-agents it spawns, the loop reaches the harness and is inherited down +// the delegation tree. `onApply` writes the generated text into the objective +// (mission) or charter (team) field. + +import { useMemo, useState } from "react"; +import { Icon } from "@/components/icon"; +import { + patternsFor, + scaffoldObjective, + type LoopPattern, + type LoopSurface, +} from "@/lib/loop-patterns"; + +export function LoopDesigner({ + surface, + initialGoal = "", + initialPatternId, + initialCriteria = "", + rationale, + defaultOpen = false, + applyLabel = "Use this loop", + onApply, +}: { + surface: LoopSurface; + initialGoal?: string; + /** Pre-select a pattern (e.g. the orchestrator's proposal) for review. */ + initialPatternId?: string; + /** Pre-fill success criteria (e.g. the orchestrator's draft). */ + initialCriteria?: string; + /** The orchestrator's one-line why-this-pattern note, shown in review mode. */ + rationale?: string; + /** Open expanded immediately (review-step mode) vs the collapsed button. */ + defaultOpen?: boolean; + applyLabel?: string; + /** + * Called with the generated objective/charter text when the user applies it. + * The second argument carries the structured loop fields so a caller can + * re-seed this designer (e.g. after back-navigation) with the user's edits. + */ + onApply: (text: string, parts?: { goal: string; patternId: string; criteria: string }) => void; +}) { + const patterns = useMemo(() => patternsFor(surface), [surface]); + const [open, setOpen] = useState(defaultOpen); + const [selected, setSelected] = useState<LoopPattern | null>( + () => patterns.find((p) => p.id === initialPatternId) ?? null, + ); + const [goal, setGoal] = useState(initialGoal); + // Keep the loop goal in sync with the objective the operator typed in the main + // field: `initialGoal` is the objective, and a useState initializer only runs + // once, so without this the goal stayed empty when the objective was filled + // AFTER the designer mounted — leaving "Use this loop" permanently disabled + // (audit N2). Mirror the objective into the goal until the operator edits the + // goal directly (tracked by whether it still equals the last seen objective). + const [goalTouched, setGoalTouched] = useState(false); + const effectiveGoal = goalTouched ? goal : initialGoal; + const [criteria, setCriteria] = useState(initialCriteria); + const [context, setContext] = useState(""); + + const preview = useMemo( + () => + selected + ? scaffoldObjective(selected, { goal: effectiveGoal, criteria, context }, surface) + : "", + [selected, effectiveGoal, criteria, context, surface], + ); + + if (!open) { + return ( + <button + type="button" + onClick={() => setOpen(true)} + className="inline-flex items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/[0.06] px-3 py-1.5 text-xs font-medium text-accent transition hover:bg-accent/10" + title="Design a feedback loop (2026 loop engineering) that shapes how the agent iterates" + > + <span aria-hidden><Icon name="loop" size={14} /></span> Design a loop + </button> + ); + } + + return ( + <div className="mt-3 space-y-4 rounded-xl border border-accent/25 bg-accent/[0.03] p-4"> + <div className="flex items-start justify-between gap-3"> + <div> + <h3 className="text-sm font-semibold">Loop Designer</h3> + <p className="mt-0.5 text-xs text-foreground-muted"> + Loop engineering (2026): design the feedback cycle, not a one-shot prompt. The loop is + baked into what the harness runs — and inherited by any sub-agents. + </p> + </div> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground"> + Close + </button> + </div> + + {/* Orchestrator's proposal rationale (review mode). */} + {rationale && ( + <div className="flex items-start gap-2 rounded-lg border border-accent/25 bg-accent/[0.05] p-2.5 text-xs"> + <span aria-hidden><Icon name="compass" size={14} /></span> + <p className="text-foreground-muted"><span className="font-medium text-foreground">Orchestrator picked this loop:</span> {rationale} You can change the pattern or details below before running.</p> + </div> + )} + + {/* Pattern catalog */} + <div className="grid gap-2 sm:grid-cols-2"> + {patterns.map((p) => { + const active = selected?.id === p.id; + return ( + <button + key={p.id} + type="button" + onClick={() => setSelected(p)} + className={`rounded-lg border p-3 text-left transition ${ + active + ? "border-accent/50 bg-accent/[0.08] ring-1 ring-accent/30" + : "border-border bg-surface hover:border-accent/40" + }`} + > + <p className="flex items-center gap-1.5 text-sm font-medium"> + <Icon name={p.icon} className="text-accent" /> {p.name} + </p> + <p className="mt-0.5 text-[11px] text-foreground-muted">{p.tagline}</p> + {active && ( + <> + <p className="mt-2 text-[11px] text-foreground-muted"><span className="font-medium text-foreground">Use when:</span> {p.whenToUse}</p> + <ol className="mt-1.5 space-y-0.5 text-[11px] text-foreground-muted"> + {p.steps.map((s, i) => ( + <li key={i}>{i + 1}. {s}</li> + ))} + </ol> + </> + )} + </button> + ); + })} + </div> + + {selected && ( + <div className="space-y-3"> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Goal — the outcome you want</span> + <textarea + value={effectiveGoal} + onChange={(e) => { + setGoalTouched(true); + setGoal(e.target.value); + }} + rows={2} + placeholder="e.g. Keep our API docs in sync with the OpenAPI spec and open a PR when they drift." + className="mt-1 w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" + /> + </label> + <label className="block"> + <span className="text-xs font-medium text-foreground-muted">Success criteria — how “done” is judged (one per line)</span> + <textarea + value={criteria} + onChange={(e) => setCriteria(e.target.value)} + rows={3} + placeholder={"e.g.\nEvery endpoint in the spec has matching docs\nNo doc references a removed field\nA PR is opened only when something actually changed"} + className="mt-1 w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" + /> + <span className="mt-1 block text-[11px] text-foreground-muted"> + Defining the checks first is the point — the agent evaluates against them every cycle. + </span> + </label> + <details className="text-xs"> + <summary className="cursor-pointer text-foreground-muted">Add context / constraints (optional)</summary> + <textarea + value={context} + onChange={(e) => setContext(e.target.value)} + rows={2} + className="mt-1 w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" + /> + </details> + + {/* Live preview of the generated loop objective */} + <div> + <p className="text-xs font-medium text-foreground-muted">Generated {surface === "team" ? "charter" : "objective"} — this runs on the harness:</p> + <pre className="mt-1 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface p-3 font-mono text-[11px] leading-relaxed text-foreground"> + {preview} + </pre> + </div> + + <div className="flex items-center justify-end gap-2"> + {!effectiveGoal.trim() && ( + <span className="text-[11px] text-foreground-muted">Describe the goal above first.</span> + )} + <button + type="button" + disabled={!effectiveGoal.trim()} + onClick={() => { + onApply(preview, { goal: effectiveGoal, patternId: selected.id, criteria }); + setOpen(false); + }} + className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50" + > + {applyLabel} + </button> + </div> + </div> + )} + </div> + ); +} diff --git a/bridge/web/src/components/mermaid-diagram.tsx b/bridge/web/src/components/mermaid-diagram.tsx new file mode 100644 index 000000000..f44f764f7 --- /dev/null +++ b/bridge/web/src/components/mermaid-diagram.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; + +let initialized = false; + +function isFlowchartSource(chart: string): boolean { + for (const line of chart.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("%%")) continue; + return /^(?:flowchart|graph)\b/i.test(trimmed); + } + return false; +} + +function normalizeFlowchartLabels(chart: string): string { + if (!isFlowchartSource(chart)) return chart; + + let changed = false; + const normalized = chart.replace( + /(^|[^[(])\[(?![\[(])([^\]\n]*?)\]/gm, + (match, prefix: string, label: string) => { + if (!/[()]/.test(label)) return match; + const trimmed = label.trimStart(); + if (!trimmed || /^["'`/\\>]/.test(trimmed)) return match; + changed = true; + return `${prefix}[${JSON.stringify(label)}]`; + }, + ); + + return changed ? normalized : chart; +} + +async function preflightMermaidSource( + mermaid: typeof import("mermaid").default, + chart: string, +): Promise<string> { + try { + await mermaid.parse(chart); + return chart; + } catch (rawError) { + const normalized = normalizeFlowchartLabels(chart); + if (normalized !== chart) { + try { + await mermaid.parse(normalized); + return normalized; + } catch { + // Fall through to the original parse error so the fallback keeps the + // readable source and a single concise error message. + } + } + throw rawError; + } +} + +export function MermaidDiagram({ chart }: { chart: string; className?: string }) { + const id = useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const target = useRef<HTMLDivElement>(null); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + let cancelled = false; + void import("mermaid") + .then(async ({ default: mermaid }) => { + if (!initialized) { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "neutral", + fontFamily: "ui-sans-serif, system-ui, sans-serif", + }); + initialized = true; + } + const source = await preflightMermaidSource(mermaid, chart); + const rendered = await mermaid.render(`kars-mermaid-${id}`, source); + if (cancelled || !target.current) return; + target.current.innerHTML = rendered.svg; + rendered.bindFunctions?.(target.current); + setError(null); + }) + .catch((reason: unknown) => { + if (cancelled) return; + setError(reason instanceof Error ? reason.message : "Diagram rendering failed"); + }); + return () => { + cancelled = true; + }; + }, [chart, id]); + + if (error) { + return ( + <figure className="my-4 overflow-hidden rounded-xl border border-warning/30 bg-warning/5"> + <figcaption className="border-b border-warning/20 px-3 py-2 text-xs text-warning"> + Mermaid could not render this diagram: {error} + </figcaption> + <pre className="overflow-x-auto p-3 font-mono text-xs leading-relaxed">{chart}</pre> + </figure> + ); + } + + return ( + <figure className="my-4 overflow-x-auto rounded-xl border border-border bg-white p-4"> + <div + ref={target} + role="img" + aria-label="Rendered Mermaid diagram" + className="min-w-fit [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full" + /> + </figure> + ); +} diff --git a/bridge/web/src/components/mission-scorecard.tsx b/bridge/web/src/components/mission-scorecard.tsx new file mode 100644 index 000000000..b2b299f08 --- /dev/null +++ b/bridge/web/src/components/mission-scorecard.tsx @@ -0,0 +1,85 @@ +// kars Bridge Workspace — mission scorecard (graphical, honest). +// +// The efficiency numbers the plan promises to deliver to USERS. Structural +// facts (decisions, budget, receipt) are real; runtime token/latency render the +// explicit "needs a real run" state rather than fabricated zeros. + +import { HonestState } from "@/components/honest-state"; +import type { Scorecard } from "@/lib/types"; + +export function MissionScorecard({ scorecard }: { scorecard: Scorecard }) { + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Efficiency scorecard</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + How this mission performed and what it was allowed to spend. + </p> + + <dl className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4"> + <Stat label="Decisions you made" value={String(scorecard.decisions_recorded)} /> + <Stat + label="Approved / Denied" + value={`${scorecard.approvals_granted} / ${scorecard.approvals_denied}`} + /> + <Stat + label="Token budget" + value={scorecard.token_budget != null ? scorecard.token_budget.toLocaleString() : "No cap"} + /> + <Stat + label="Receipt" + value={scorecard.receipt_issued ? "Signed ✓" : "—"} + ok={scorecard.receipt_issued} + /> + </dl> + + <div className="mt-5 border-t border-border pt-4"> + <p className="text-xs font-medium uppercase tracking-wide text-foreground-muted"> + Token cost & speed + </p> + <div className="mt-2"> + {scorecard.runtime_metrics_available ? ( + <div className="space-y-2"> + <div className="flex flex-wrap gap-4"> + <Stat label="Total tokens" value={scorecard.run_total_tokens?.toLocaleString() ?? "—"} /> + <Stat + label="In / out" + value={ + scorecard.run_prompt_tokens != null && scorecard.run_completion_tokens != null + ? `${scorecard.run_prompt_tokens.toLocaleString()} / ${scorecard.run_completion_tokens.toLocaleString()}` + : "—" + } + /> + {scorecard.run_model && <Stat label="Model" value={scorecard.run_model} />} + </div> + <p className="text-xs text-foreground-muted"> + Real tokens from the latest governed run. Streaming latency / time-to-first-result is + a named next step. + </p> + </div> + ) : ( + <HonestState + variant="needs_run" + compact + title="No run captured yet" + detail={ + scorecard.runtime_metrics_note ?? + "Run the mission to capture a real governed run with its real token cost." + } + /> + )} + </div> + </div> + </section> + ); +} + +function Stat({ label, value, ok }: { label: string; value: string; ok?: boolean }) { + return ( + <div> + <dt className="text-xs text-foreground-muted">{label}</dt> + <dd className={`mt-1 text-lg font-semibold tabular-nums ${ok ? "text-ok" : "text-foreground"}`}> + {value} + </dd> + </div> + ); +} diff --git a/bridge/web/src/components/mission-status.tsx b/bridge/web/src/components/mission-status.tsx new file mode 100644 index 000000000..7880f2532 --- /dev/null +++ b/bridge/web/src/components/mission-status.tsx @@ -0,0 +1,71 @@ +// kars Bridge Workspace — mission status projection. +// +// Projects operator governance vocabulary (Ready/Degraded/Pending + execution +// phase) into plain user language. The Workspace never shows "Degraded" or +// "Pending" — it shows what the user actually cares about. + +export type MissionStatus = + | "drafting" + | "deploying" + | "running" + | "needs_you" + | "done" + | "failed" + | "blocked"; + +/** Map a governance phase (+ optional execution phase) to user language. + * ONE projection, used by every surface (page badge, execution panel, fleet + * card) so the mission never shows two different states at once. + * `delivered` is the authoritative "work is done" signal (a REAL ok result); + * `failed` is a captured error result (run completed but did not succeed); + * `needsYou` surfaces a pending human decision; `launched` distinguishes a + * deploying mission from an un-launched draft. */ +export function missionStatus( + phase: string | null, + executionPhase?: string | null, + opts?: { delivered?: boolean; needsYou?: boolean; launched?: boolean; failed?: boolean }, +): MissionStatus { + // A captured error result is terminal — it outranks a stale "Running" phase + // so the mission never reads "Running" while its run has already failed. + if (opts?.failed) return "failed"; + if (phase === "Degraded") return "blocked"; + if (executionPhase === "Degraded") return "blocked"; + if (opts?.needsYou) return "needs_you"; + // A captured deliverable is the terminal, authoritative outcome — it must + // outrank "Running" too, exactly like `failed` above. The sandbox pod can + // stay alive after its mesh task-delivery already produced a result (e.g. + // an idle-daemon Hermes/OpenClaw agent waiting for the next inbound), so + // "pod still running" must never mask an already-delivered mission. + if (opts?.delivered) return "done"; + if (executionPhase === "Running") return "running"; + // Launched but not yet Running and nothing delivered → the sandbox is + // materializing. This is "Deploying", NOT "Ready to launch" — the badge, the + // execution panel, and the deploy timeline all agree on this single state. + if (opts?.launched) return "deploying"; + // Governed but idle (the §20 review-then-launch default) reads as "drafting" + // to the user — it's planned, not yet running. + if (phase === "Ready") return "drafting"; + return "drafting"; +} + +const META: Record<MissionStatus, { label: string; cls: string }> = { + drafting: { label: "Ready to launch", cls: "border-border bg-surface-muted text-foreground-muted" }, + deploying: { label: "Deploying", cls: "border-signal/40 bg-signal/10 text-signal" }, + running: { label: "Running", cls: "border-signal/40 bg-signal/10 text-signal" }, + needs_you: { label: "Needs you", cls: "border-warning/40 bg-warning/10 text-warning" }, + done: { label: "Delivered", cls: "border-ok/40 bg-ok/10 text-ok" }, + failed: { label: "Run failed", cls: "border-danger/40 bg-danger/10 text-danger" }, + blocked: { label: "Blocked", cls: "border-danger/40 bg-danger/10 text-danger" }, +}; + +export function MissionStatusBadge({ status }: { status: MissionStatus }) { + const m = META[status]; + return ( + <span + className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${m.cls}`} + > + <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> + {m.label} + </span> + ); +} diff --git a/bridge/web/src/components/orchestration-cube.tsx b/bridge/web/src/components/orchestration-cube.tsx new file mode 100644 index 000000000..00ecf9696 --- /dev/null +++ b/bridge/web/src/components/orchestration-cube.tsx @@ -0,0 +1,99 @@ +"use client"; + +// Orchestration cube — the visual spine of the compose/validate/execute flow. +// A slowly tumbling governed "core" surrounded by tiles that carry the REAL +// orchestration facts (how many models/harnesses/tools the cluster actually +// offers, what the efficiency frontier learned, the objective being composed). +// The cube's motion is pure presentation; every number shown is real data +// passed in by the caller — nothing here is fabricated. When the caller signals +// completion, the cube settles and the active phase advances to the end. + +import { useEffect, useState } from "react"; +import { RubiksCube } from "./rubiks-cube"; +import { Icon, type IconName } from "./icon"; + +export interface OrchestrationPhase { + /** Short label, e.g. "Reading cluster palette". */ + label: string; + /** Real supporting fact, e.g. "12 models · 3 harnesses · 5 tool policies". */ + detail: string; + /** Icon marker (see components/icon.tsx). */ + icon: IconName; +} + +export function OrchestrationCube({ + title, + phases, + active, + done, + doneLabel, +}: { + title: string; + phases: OrchestrationPhase[]; + /** Index of the phase currently lit (caller-driven or auto-advanced). */ + active: number; + /** True when the underlying real work (compose/validate) has completed. */ + done: boolean; + doneLabel?: string; +}) { + // Auto-advance through phases while work is in flight, but never past the + // last "in-progress" phase until the caller reports `done` — so the animation + // can't claim completion the real work hasn't reached. + const [autoIdx, setAutoIdx] = useState(active); + useEffect(() => { + if (done) return; // `lit` already pins to the final phase when done. + const id = setInterval(() => { + setAutoIdx((i) => Math.min(i + 1, Math.max(phases.length - 2, 0))); + }, 1400); + return () => clearInterval(id); + }, [done, phases.length]); + + const lit = done ? phases.length - 1 : Math.max(active, autoIdx); + + return ( + <div className="rounded-2xl border border-border bg-surface p-5"> + <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-foreground-muted"> + <span className={`inline-block h-1.5 w-1.5 rounded-full ${done ? "bg-signal" : "bg-accent"} ${done ? "" : "kb-pulse"}`} /> + {title} + </div> + <div className="mt-4 flex flex-col items-center gap-5 sm:flex-row sm:items-center sm:gap-8"> + <div className="grid shrink-0 place-items-center" style={{ width: 140, height: 140 }}> + <RubiksCube size={104} settled={done} assembling={!done} /> + </div> + <ol className="min-w-0 flex-1 space-y-2"> + {phases.map((p, i) => { + const state = i < lit ? "done" : i === lit ? (done && i === phases.length - 1 ? "done" : "active") : "pending"; + return ( + <li + key={p.label} + className={`flex items-start gap-3 rounded-lg border px-3 py-2 transition ${ + state === "active" + ? "border-accent/50 bg-accent/5" + : state === "done" + ? "border-signal/40 bg-signal/5" + : "border-border bg-surface-muted/30 opacity-60" + }`} + > + <span + className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-md text-sm ${ + state === "done" ? "bg-signal/15 text-signal" : state === "active" ? "bg-accent/15 text-accent" : "bg-surface text-foreground-muted" + }`} + aria-hidden + > + {state === "done" ? "✓" : state === "active" ? <span className="kb-pulse inline-block h-2 w-2 rounded-full bg-accent" /> : <Icon name={p.icon} size={14} />} + </span> + <div className="min-w-0"> + <p className="text-sm font-medium">{p.label}</p> + <p className="truncate text-xs text-foreground-muted">{p.detail}</p> + </div> + </li> + ); + })} + </ol> + </div> + {done && doneLabel && ( + <p className="mt-3 text-center text-xs font-medium text-signal sm:text-left">{doneLabel}</p> + )} + </div> + ); +} diff --git a/bridge/web/src/components/org-tree.tsx b/bridge/web/src/components/org-tree.tsx new file mode 100644 index 000000000..f6b972a32 --- /dev/null +++ b/bridge/web/src/components/org-tree.tsx @@ -0,0 +1,194 @@ +"use client"; + +// kars Bridge — a real org chart. Nodes, drawn reporting-line edges, and live +// per-node state. The reporting line IS the trust boundary, so we actually draw +// it: a principal on top, connectors down to each member, using the classic CSS +// connector technique (pure borders) so the lines never break on wrap — the row +// scrolls horizontally instead of collapsing into a stack. Used for both team +// and mission orgs. + +import Link from "next/link"; +import { useState } from "react"; +import { ViewportPortal } from "@/components/viewport-portal"; + +export type OrgStatus = "principal" | "running" | "verified" | "pending" | "degraded"; + +export type OrgNode = { + id: string; + title: string; + /** Secondary line, e.g. "Tier 2 · Shared" or "Principal · team lead". */ + role?: string; + chips?: { label: string; tone?: "muted" | "signal" | "accent" | "danger" | "ok" }[]; + status?: OrgStatus; + href?: string; + /** Optional short prompt/description shown under the title. */ + detail?: string; +}; + +const STATUS_DOT: Record<OrgStatus, string> = { + principal: "bg-signal", + running: "bg-ok kb-pulse", + verified: "bg-ok", + pending: "bg-foreground-muted", + degraded: "bg-danger", +}; + +const STATUS_LABEL: Record<OrgStatus, string> = { + principal: "Principal", + running: "Running", + verified: "Verified", + pending: "Launch-verified", + degraded: "Rejected", +}; + +const CHIP_TONE: Record<string, string> = { + muted: "border-border bg-surface-muted text-foreground-muted", + signal: "border-signal/30 bg-signal/10 text-signal", + accent: "border-accent/30 bg-accent/10 text-accent", + danger: "border-danger/30 bg-danger/10 text-danger", + ok: "border-ok/30 bg-ok/10 text-ok", +}; + +function Card({ node }: { node: OrgNode }) { + const status = node.status ?? "pending"; + const principal = status === "principal"; + const inner = ( + <div + className={`w-52 rounded-xl border px-3.5 py-2.5 text-left shadow-sm transition sm:w-56 ${ + principal + ? "border-signal/50 bg-signal/[0.07]" + : "border-border bg-surface hover:-translate-y-0.5 hover:border-signal/40 hover:shadow-md" + }`} + > + <div className="flex items-start justify-between gap-2"> + <p className="min-w-0 line-clamp-2 break-words text-sm font-semibold leading-tight" title={node.title}>{node.title}</p> + <span + className={`inline-flex shrink-0 items-center gap-1 self-start whitespace-nowrap rounded-full border px-1.5 py-0.5 text-[9px] font-medium ${ + status === "degraded" + ? "border-danger/30 bg-danger/10 text-danger" + : principal + ? "border-signal/30 bg-signal/10 text-signal" + : status === "verified" || status === "running" + ? "border-ok/30 bg-ok/10 text-ok" + : "border-border bg-surface-muted text-foreground-muted" + }`} + title={STATUS_LABEL[status]} + > + <span className={`h-1.5 w-1.5 rounded-full ${STATUS_DOT[status]}`} aria-hidden /> + {STATUS_LABEL[status]} + </span> + </div> + {node.role && <p className="mt-0.5 text-[11px] text-foreground-muted">{node.role}</p>} + {node.detail && <p className="mt-1 line-clamp-2 text-[11px] text-foreground-muted">{node.detail}</p>} + {node.chips && node.chips.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1"> + {node.chips.map((c, i) => ( + <span key={i} className={`rounded border px-1.5 py-0.5 text-[10px] font-medium ${CHIP_TONE[c.tone ?? "muted"]}`}> + {c.label} + </span> + ))} + </div> + )} + </div> + ); + return node.href ? ( + <Link href={node.href} className="block" title={node.title}> + {inner} + </Link> + ) : ( + inner + ); +} + +export function OrgTree({ + principal, + members, + footer, +}: { + principal: OrgNode; + members: OrgNode[]; + /** Optional trailing node (e.g. an "+ Add member" affordance) drawn as a leaf. */ + footer?: React.ReactNode; +}) { + const [expanded, setExpanded] = useState(false); + return ( + <> + <div className="mb-2 flex justify-end"> + <button + type="button" + onClick={() => setExpanded(true)} + className="rounded-md border border-border bg-surface px-2.5 py-1 text-[11px] font-medium text-foreground-muted hover:border-signal/40 hover:text-foreground" + > + Expand org chart + </button> + </div> + <TreeCanvas principal={principal} members={members} footer={footer} /> + {expanded && ( + <ViewportPortal onClose={() => setExpanded(false)}> + <div + role="dialog" + aria-modal="true" + aria-label="Expanded team org chart" + className="fixed inset-0 z-[100] flex flex-col overflow-hidden bg-surface" + > + <div className="flex items-center justify-between border-b border-border px-5 py-3"> + <div> + <p className="text-sm font-semibold">Team org chart</p> + <p className="text-xs text-foreground-muted">Full-screen reporting and trust-boundary view</p> + </div> + <button + type="button" + onClick={() => setExpanded(false)} + className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted" + > + Close + </button> + </div> + <div className="min-h-0 flex-1 overflow-auto p-6"> + <TreeCanvas principal={principal} members={members} footer={footer} /> + </div> + </div> + </ViewportPortal> + )} + </> + ); +} + +function TreeCanvas({ + principal, + members, + footer, +}: { + principal: OrgNode; + members: OrgNode[]; + footer?: React.ReactNode; +}) { + const leaves = members.length + (footer ? 1 : 0); + return ( + <div className="kb-orgtree overflow-x-auto pb-2"> + <ul> + <li> + <div className="kb-orgtree-node"> + <Card node={{ ...principal, status: "principal" }} /> + </div> + {leaves > 0 && ( + <ul> + {members.map((m) => ( + <li key={m.id}> + <div className="kb-orgtree-node"> + <Card node={m} /> + </div> + </li> + ))} + {footer && ( + <li> + <div className="kb-orgtree-node">{footer}</div> + </li> + )} + </ul> + )} + </li> + </ul> + </div> + ); +} diff --git a/bridge/web/src/components/phase-badge.tsx b/bridge/web/src/components/phase-badge.tsx new file mode 100644 index 000000000..e551caa83 --- /dev/null +++ b/bridge/web/src/components/phase-badge.tsx @@ -0,0 +1,22 @@ +// kars Bridge web — map a KarsTask phase to a status-badge tone. + +import { StatusBadge } from "@/components/status-badge"; + +type Tone = "ok" | "warning" | "danger" | "muted"; + +function phaseTone(phase: string): Tone { + switch (phase) { + case "Ready": + return "ok"; + case "Degraded": + return "danger"; + case "Pending": + return "warning"; + default: + return "muted"; + } +} + +export function PhaseBadge({ phase }: { phase: string }) { + return <StatusBadge tone={phaseTone(phase)} label={phase} />; +} diff --git a/bridge/web/src/components/preflight-check.tsx b/bridge/web/src/components/preflight-check.tsx new file mode 100644 index 000000000..bc26761c1 --- /dev/null +++ b/bridge/web/src/components/preflight-check.tsx @@ -0,0 +1,111 @@ +"use client"; + +// kars Bridge — shared pre-flight check. The SAME honest, cluster-grounded +// validation for both missions and teams: it validates the composed package +// against the live cluster (tools, connected services, model, egress, budget, +// tier) before anything runs, and reports whether the package is launch-ready. +// +// Used by the mission intake and the team composer so the two flows are +// consolidated on one validation surface. + +import { useState, useTransition } from "react"; +import { validatePackageAction } from "@/lib/preflight-actions"; +import type { ValidationResult } from "@/lib/types"; + +function CheckMark({ status }: { status: "pass" | "fail" | "warn" }) { + const map = { + pass: { c: "text-emerald-600", s: "✓" }, + warn: { c: "text-amber-600", s: "!" }, + fail: { c: "text-rose-600", s: "✕" }, + } as const; + const m = map[status]; + return ( + <span + className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-bold ${m.c}`} + aria-hidden + > + {m.s} + </span> + ); +} + +export function PreflightCheck({ + blueprint, + tier, + budgetTokens, + workload = "mission", + onResult, + disabled, +}: { + blueprint: unknown; + tier?: number; + budgetTokens?: number | null; + workload?: "mission" | "team"; + /** Notified with the result so the caller can gate its launch/create button. */ + onResult?: (r: ValidationResult) => void; + disabled?: boolean; +}) { + const [pending, startTransition] = useTransition(); + const [result, setResult] = useState<ValidationResult | null>(null); + const [error, setError] = useState<string | null>(null); + + function run() { + setError(null); + startTransition(async () => { + try { + const r = await validatePackageAction(blueprint, { + tier, + budget_tokens: budgetTokens ?? null, + workload, + }); + setResult(r); + onResult?.(r); + } catch (e) { + setError(e instanceof Error ? e.message : "validation failed"); + } + }); + } + + return ( + <section className="rounded-xl border border-border bg-surface p-5"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Pre-flight check</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Validate the package against the live cluster before anything runs — the governance + policy, requested model, connected services, network access, and any configured limits + are checked for admissibility. It doesn’t execute the mission or test the + agent’s behaviour. + </p> + </div> + <button + type="button" + onClick={run} + disabled={pending || disabled} + className="shrink-0 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted disabled:opacity-50" + > + {pending ? "Checking…" : "Validate package"} + </button> + </div> + {error && <p className="mt-2 text-xs text-rose-600">{error}</p>} + {result && ( + <ul className="mt-3 space-y-1.5"> + {result.checks.map((c) => ( + <li key={c.id} className="flex items-start gap-2 text-sm"> + <CheckMark status={c.status} /> + <span> + <span className="font-medium">{c.label}</span> + <span className="ml-1.5 text-xs text-foreground-muted">{c.detail}</span> + </span> + </li> + ))} + </ul> + )} + {result && !result.ok && ( + <p className="mt-2 text-xs font-medium text-rose-600"> + Fix the failing checks above before launching. + </p> + )} + </section> + ); +} diff --git a/bridge/web/src/components/primary-nav.tsx b/bridge/web/src/components/primary-nav.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/bridge/web/src/components/provenance-overlay.tsx b/bridge/web/src/components/provenance-overlay.tsx new file mode 100644 index 000000000..bd6e20cca --- /dev/null +++ b/bridge/web/src/components/provenance-overlay.tsx @@ -0,0 +1,74 @@ +"use client"; + +// kars Bridge — Provenance Overlay. A standalone, slide-over lineage trail that +// makes a mission's full provenance chain legible in one place: trust envelope → +// signed in-toto predicate → claim matrix → tamper-evidence inclusion entry → +// signed checkpoint (tree head). It reads ONLY the real receipt; it never +// asserts cryptographic validity itself (that is `kars receipt verify`). This is +// the dedicated overlay the design note asks for — distinct from the inline +// receipt panel — surfacing the lineage as a navigable chain, not a form. + +import { useState } from "react"; +import type { ActivityEvent, Receipt } from "@/lib/types"; +import { ProvenanceStory } from "@/components/provenance-story"; + +function Step({ ord, title, value, mono, hint, status }: { ord: number; title: string; value: string; mono?: boolean; hint?: string; status?: "ok" | "partial" | "omitted" }) { + const dot = status === "ok" ? "bg-ok" : status === "partial" ? "bg-warning" : status === "omitted" ? "bg-foreground-muted/40" : "bg-signal"; + return ( + <li className="relative pl-7"> + <span className={`absolute left-1.5 top-1.5 h-2.5 w-2.5 rounded-full ${dot}`} /> + <p className="text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">{ord}. {title}</p> + <p className={`mt-0.5 break-all text-sm ${mono ? "font-mono text-xs" : ""}`}>{value}</p> + {hint && <p className="mt-0.5 text-[11px] text-foreground-muted">{hint}</p>} + </li> + ); +} + +export function ProvenanceOverlay({ receipt, deliverableDid, activity, egress }: { receipt: Receipt; deliverableDid?: string | null; activity?: ActivityEvent[]; egress?: string[] }) { + const [open, setOpen] = useState(false); + return ( + <> + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + View provenance trail + </button> + {open && ( + <div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}> + <aside className="h-full w-full max-w-md overflow-y-auto border-l border-border bg-background p-6 shadow-xl" onClick={(e) => e.stopPropagation()}> + <div className="flex items-center justify-between"> + <h2 className="text-sm font-semibold">Provenance trail</h2> + <button type="button" onClick={() => setOpen(false)} className="text-foreground-muted hover:text-foreground" aria-label="Close">✕</button> + </div> + <p className="mt-1 text-xs text-foreground-muted">One legible chain from the granted envelope to the signed, witnessed log head. Render-only — verify with the command on the receipt.</p> + {activity && activity.length > 0 && ( + <div className="mt-5 rounded-lg border border-border bg-surface p-4"> + <h3 className="text-xs font-semibold">What the agent actually did</h3> + <div className="mt-2"><ProvenanceStory activity={activity} egress={egress} /></div> + </div> + )} + <p className="mt-5 text-[11px] font-semibold uppercase tracking-wide text-foreground-muted">The cryptographic chain that proves the above</p> + <ol className="mt-3 space-y-4 border-l border-border"> + <Step ord={1} title="Trust envelope" value={receipt.envelope_digest} mono hint="The exact authority granted — fingerprinted so it can't be altered after the fact" /> + <Step ord={2} title="Predicate" value={receipt.predicate_type} hint={`The signed statement of what ran (${receipt.scheme})`} /> + {receipt.claims.map((c, i) => ( + <Step key={c.class} ord={3 + i} title={`Claim · ${c.class}`} value={c.status} status={c.status === "PASS" ? "ok" : c.status === "PARTIAL" ? "partial" : "omitted"} /> + ))} + {receipt.inclusion_seq != null && ( + <Step ord={3 + receipt.claims.length} title="Inclusion entry" value={`seq ${receipt.inclusion_seq} · ${receipt.inclusion_entry_hash ?? ""}`} mono hint="Logged so removing or altering it would break the chain" /> + )} + {receipt.checkpoint && ( + <Step ord={4 + receipt.claims.length} title="Signed checkpoint" value={`tree=${receipt.checkpoint.tree_size} root=${receipt.checkpoint.root_hash}`} mono hint={`A second key co-signs the whole log so it can't be forked (key ${receipt.checkpoint.key_id})`} status="ok" /> + )} + {deliverableDid && ( + <Step ord={5 + receipt.claims.length} title="Deliverable identity" value={deliverableDid} mono hint="Content-addressed output" status="ok" /> + )} + </ol> + </aside> + </div> + )} + </> + ); +} diff --git a/bridge/web/src/components/provenance-story.tsx b/bridge/web/src/components/provenance-story.tsx new file mode 100644 index 000000000..be64e3c26 --- /dev/null +++ b/bridge/web/src/components/provenance-story.tsx @@ -0,0 +1,95 @@ +"use client"; + +// kars Bridge — Provenance Story. The detailed, plain-language answer to "how +// did this get made?" built from the REAL trace: model rounds, every tool call +// with its actual parameters + result + duration, files touched, and any +// network destinations. Foldable per step. Nothing is inferred — each row is a +// recorded event. Shared by mission, artifact, and agent views. + +import { useState } from "react"; +import type { ActivityEvent } from "@/lib/types"; +import { Icon, type IconName } from "@/components/icon"; + +type ToolEvent = Extract<ActivityEvent, { kind: "tool" }>; + +function classify(name: string): { icon: IconName; verb: string; cat: string } { + const n = name.toLowerCase(); + if (/(write|create|save|edit|patch|append)/.test(n)) return { icon: "pencil", verb: "wrote a file", cat: "file" }; + if (/(read|cat|open|view|list|glob|grep|find)/.test(n)) return { icon: "file", verb: "read files", cat: "file" }; + if (/(git|commit|push|pull|pr|branch)/.test(n)) return { icon: "branch", verb: "ran git", cat: "git" }; + if (/(http|fetch|web|search|browse|curl|api|crawl|tavily|brave)/.test(n)) return { icon: "globe", verb: "reached the network", cat: "net" }; + if (/(shell|bash|exec|run|command)/.test(n)) return { icon: "terminal", verb: "ran a command", cat: "exec" }; + return { icon: "wrench", verb: "used a tool", cat: "tool" }; +} + +function pathFrom(t: ToolEvent): string | null { + const m = t.result_preview?.match(/\/[\w./-]+\.\w+/); + return m ? m[0].split("/").pop()! : null; +} +function domainFrom(t: ToolEvent): string | null { + const m = (t.args_preview ?? "").match(/https?:\/\/([\w.-]+)/); + return m ? m[1] : null; +} + +export function ProvenanceStory({ activity, egress, artifactName, tokens }: { activity: ActivityEvent[]; egress?: string[]; artifactName?: string; tokens?: number | null }) { + const tools = activity.filter((e): e is ToolEvent => e.kind === "tool"); + const rounds = activity.filter((e) => e.kind === "round").length; + const cats = tools.reduce<Record<string, number>>((m, t) => { const c = classify(t.name).cat; m[c] = (m[c] ?? 0) + 1; return m; }, {}); + const domains = Array.from(new Set([...(tools.map(domainFrom).filter(Boolean) as string[]), ...(egress ?? [])])); + const summary = [rounds && `${rounds} rounds`, cats.file && `${cats.file} file ops`, cats.net && `${cats.net} network`, cats.git && `${cats.git} git`, cats.exec && `${cats.exec} commands`, tokens ? `${tokens.toLocaleString()} tok` : null].filter(Boolean).join(" · "); + + if (activity.length === 0) return <p className="text-xs text-foreground-muted">No execution trace yet — run it to record how the deliverable is produced.</p>; + + const numberedActivity: Array<{ event: ActivityEvent; roundOrdinal: number }> = []; + let roundOrdinal = 0; + for (const event of activity) { + if (event.kind === "round") roundOrdinal += 1; + numberedActivity.push({ event, roundOrdinal }); + } + + return ( + <div className="space-y-3"> + <p className="text-sm">{artifactName ? <><span className="font-mono text-xs">{artifactName}</span> via </> : "Produced via "}<strong>{summary}</strong>. Every step is a recorded action.</p> + {domains.length > 0 && ( + <p className="text-xs text-foreground-muted">Reached: {domains.map((h) => <span key={h} className="mr-1 inline-block rounded bg-surface-muted px-1.5 py-0.5 font-mono">{h}</span>)} — all other egress denied.</p> + )} + <ol className="space-y-1 border-l border-border pl-3"> + {numberedActivity.map(({ event: e, roundOrdinal }, i) => { + if (e.kind === "round") { + return ( + <li key={i} className="flex items-center gap-2 py-0.5 text-xs"> + <Icon name="brain" className="text-foreground-muted" /> + <span className="font-medium">Round {roundOrdinal}</span> + <span className="text-foreground-muted">{e.tool_calls > 0 ? `chose ${e.tool_calls} tool` : "reasoned"} · {e.total_tokens.toLocaleString()} tok · {e.ms}ms</span> + </li> + ); + } + return <ToolRow key={i} t={e} />; + })} + </ol> + </div> + ); +} + +function ToolRow({ t }: { t: ToolEvent }) { + const [open, setOpen] = useState(false); + const c = classify(t.name); + const file = pathFrom(t); + return ( + <li className="text-xs"> + <button type="button" onClick={() => setOpen(!open)} className="flex w-full items-center gap-2 py-0.5 text-left hover:text-foreground"> + <Icon name={c.icon} className="text-foreground-muted" /> + <span>{c.verb}</span> + <span className="font-mono text-foreground-muted">{t.name}{file ? ` · ${file}` : ""}</span> + {!t.ok && <span className="text-danger">failed</span>} + <span className="ml-auto inline-flex items-center gap-1 text-foreground-muted">{t.ms}ms <Icon name="chevron-down" size={12} className={`transition-transform ${open ? "rotate-180" : ""}`} /></span> + </button> + {open && ( + <div className="mb-1 ml-6 space-y-1 rounded border border-border bg-surface-muted/40 p-2 font-mono text-[11px]"> + <div className="break-words"><span className="text-foreground-muted">params:</span> {t.args_preview || "—"}</div> + <div className="break-words"><span className="text-foreground-muted">result:</span> {t.result_preview || "—"}</div> + </div> + )} + </li> + ); +} diff --git a/bridge/web/src/components/receipt-panel.tsx b/bridge/web/src/components/receipt-panel.tsx new file mode 100644 index 000000000..b9a691712 --- /dev/null +++ b/bridge/web/src/components/receipt-panel.tsx @@ -0,0 +1,276 @@ +"use client"; + +// kars Bridge — Governance Receipt evidence panel (the auditor's moment). +// +// This renders the signed receipt the controller emitted: the claim matrix, +// the signing identity, and the signed in-toto predicate. It is deliberately +// HONEST about its own role — it renders evidence, it does not assert +// cryptographic validity. The "Verify" affordance shows the exact +// `kars receipt verify` command, because independent verification (against the +// controller's out-of-band public-key anchor) is a different trust domain than +// this UI. Showing a self-asserted green "verified" checkmark here would be the +// very dashboard-trust the receipt exists to replace. + +import { useState } from "react"; +import type { Receipt, ReceiptClaim } from "@/lib/types"; + +function ClaimBadge({ status }: { status: string }) { + const map: Record<string, { cls: string; label: string }> = { + PASS: { cls: "border-ok/40 bg-ok/10 text-ok", label: "PASS" }, + PARTIAL: { cls: "border-warning/40 bg-warning/10 text-warning", label: "PARTIAL" }, + OMITTED: { + cls: "border-border bg-surface-muted text-foreground-muted", + label: "OMITTED", + }, + FAIL: { cls: "border-danger/40 bg-danger/10 text-danger", label: "FAIL" }, + }; + const s = map[status] ?? { + cls: "border-border bg-surface-muted text-foreground-muted", + label: status, + }; + return ( + <span + className={`inline-flex min-w-[4.5rem] justify-center rounded border px-2 py-0.5 font-mono text-[11px] font-medium tracking-wide ${s.cls}`} + > + {s.label} + </span> + ); +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + <button + type="button" + onClick={async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // insecure context — value remains selectable + } + }} + aria-label={label} + className="shrink-0 border-l border-border px-3 text-xs font-medium text-foreground-muted hover:bg-surface hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {copied ? "Copied" : "Copy"} + </button> + ); +} + +function claimTitle(claim: ReceiptClaim): string { + const t: Record<string, string> = { + integrity: "Integrity", + conformance: "Conformance", + completeness: "Completeness", + regulatory: "Regulatory", + }; + return t[claim.class] ?? claim.class; +} + +export function ReceiptPanel({ receipt }: { receipt: Receipt }) { + const [showPayload, setShowPayload] = useState(false); + const sig = receipt.signatures[0]; + + // The validated launch package recorded at the head of the predicate (§20). + const lp = (() => { + const s = receipt.statement as { predicate?: { launchPackage?: Record<string, unknown> } } | null; + return s?.predicate?.launchPackage ?? null; + })(); + + return ( + <section + aria-labelledby="receipt-heading" + className="rounded-xl border border-border bg-surface p-6" + > + <div className="flex items-start justify-between gap-4"> + <div> + <h2 id="receipt-heading" className="text-sm font-semibold"> + Governance Receipt + </h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + A signed, independently verifiable record that this task was + governed under its trust envelope. + </p> + </div> + <span className="inline-flex items-center gap-1.5 rounded-full border border-signal/40 bg-signal/10 px-2.5 py-1 text-xs font-medium text-signal"> + <svg aria-hidden viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor"> + <path d="M8 1 2.5 3.2v3.5c0 3.2 2.3 6.2 5.5 7.3 3.2-1.1 5.5-4.1 5.5-7.3V3.2L8 1Zm-.9 9.6L4.6 8.1l1-1 1.5 1.5 3-3 1 1-4 4Z" /> + </svg> + Signed · {receipt.scheme} + </span> + </div> + + {/* Launch package — what was reviewed + approved, at the head (§20). */} + {lp && ( + <div className="mt-5 rounded-lg border border-border bg-surface-muted/40 p-4"> + <p className="text-xs font-medium uppercase tracking-wide text-foreground-muted"> + Validated launch package + </p> + <dl className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs"> + {typeof lp.model === "string" && ( + <div className="flex gap-1.5"> + <dt className="text-foreground-muted">Model</dt> + <dd className="font-medium">{lp.model}</dd> + </div> + )} + {typeof lp.runtime === "string" && ( + <div className="flex gap-1.5"> + <dt className="text-foreground-muted">Harness</dt> + <dd className="font-medium">{lp.runtime}</dd> + </div> + )} + {typeof lp.toolPolicy === "string" && ( + <div className="flex gap-1.5"> + <dt className="text-foreground-muted">Tool policy</dt> + <dd className="font-medium">{lp.toolPolicy}</dd> + </div> + )} + {typeof lp.isolation === "string" && ( + <div className="flex gap-1.5"> + <dt className="text-foreground-muted">Isolation</dt> + <dd className="font-medium">{lp.isolation}</dd> + </div> + )} + </dl> + {typeof lp.digest === "string" && ( + <p className="mt-2 font-mono text-[10px] text-foreground-muted">{lp.digest}</p> + )} + </div> + )} + + + {/* Claim matrix — the honest §24b posture, surfaced verbatim. */} + <div className="mt-5"> + <p className="text-xs font-medium uppercase tracking-wide text-foreground-muted"> + Claim matrix + </p> + <ul className="mt-2 divide-y divide-border border-t border-border"> + {receipt.claims.map((c) => ( + <li key={c.class} className="flex items-start gap-3 py-3"> + <ClaimBadge status={c.status} /> + <div className="min-w-0"> + <p className="text-sm font-medium">{claimTitle(c)}</p> + <p className="mt-0.5 text-xs text-foreground-muted">{c.detail}</p> + </div> + </li> + ))} + </ul> + </div> + + {/* Signing identity. */} + <dl className="mt-5 space-y-3"> + <div> + <dt className="text-xs text-foreground-muted">Signed by (key id)</dt> + <dd className="mt-1 flex items-stretch overflow-hidden rounded-lg border border-border bg-surface-muted"> + <code className="min-w-0 flex-1 break-all px-3 py-2 font-mono text-xs leading-relaxed"> + {receipt.key_id} + </code> + <CopyButton value={receipt.key_id} label="Copy key id" /> + </dd> + </div> + {sig && ( + <div> + <dt className="text-xs text-foreground-muted"> + Signature ({receipt.payload_type}) + </dt> + <dd className="mt-1 flex items-stretch overflow-hidden rounded-lg border border-border bg-surface-muted"> + <code className="min-w-0 flex-1 break-all px-3 py-2 font-mono text-xs leading-relaxed"> + {sig.sig} + </code> + <CopyButton value={sig.sig} label="Copy signature" /> + </dd> + </div> + )} + {receipt.inclusion_state === "Failed" && ( + <div className="rounded-lg border border-danger/40 bg-danger/5 p-3"> + <dt className="text-xs font-medium text-danger"> + Transparency inclusion failed + </dt> + <dd className="mt-1 text-xs text-foreground-muted"> + This receipt is signed, but it is not currently checkpointed and + independently witnessed.{" "} + {receipt.inclusion_error ?? "The controller reported an inclusion failure."} + </dd> + </div> + )} + {receipt.inclusion_state !== "Failed" && receipt.inclusion_seq != null && ( + <div> + <dt className="text-xs text-foreground-muted"> + Inclusion log (cross-receipt tamper-evidence) + </dt> + <dd className="mt-1 flex items-center gap-2 text-sm"> + <span className="inline-flex items-center rounded-full border border-border bg-surface-muted px-2 py-0.5 font-mono text-xs"> + seq {receipt.inclusion_seq} + </span> + {receipt.log_segment && ( + <span className="font-mono text-xs text-foreground-muted"> + {receipt.log_segment} + </span> + )} + {receipt.inclusion_entry_hash && ( + <code className="truncate font-mono text-xs text-foreground-muted"> + {receipt.inclusion_entry_hash.slice(0, 24)}… + </code> + )} + </dd> + <p className="mt-1 text-xs text-foreground-muted"> + Recorded in the segmented hash-chained receipt log. + {receipt.checkpoint + ? " A signed checkpoint is available for independent verification." + : ""} + {receipt.witnessed + ? " A witness co-signature is recorded; it is shown, not independently verified here." + : ""} + </p> + {receipt.checkpoint && ( + <p className="mt-1.5 text-xs text-foreground-muted"> + <span className="font-medium text-foreground">Signed checkpoint</span>{" "} + over {receipt.checkpoint.tree_size} entries · root{" "} + <code className="font-mono"> + {receipt.checkpoint.root_hash.slice(0, 16)}… + </code>{" "} + — pin this to detect a later history rewrite ( + <code className="font-mono">kars receipt checkpoint</code>). + </p> + )} + </div> + )} + </dl> + + {/* Independent verification — the whole point. */} + <div className="mt-5 rounded-lg border border-signal/30 bg-signal/5 p-4"> + <p className="text-sm font-medium">Verify independently</p> + <p className="mt-1 text-xs text-foreground-muted"> + Don't trust this screen. Verify the signature against the + controller's published public key — on a plain kars cluster, no + Bridge required: + </p> + <div className="mt-2 flex items-stretch overflow-hidden rounded-lg border border-border bg-surface"> + <code className="min-w-0 flex-1 break-all px-3 py-2 font-mono text-xs leading-relaxed"> + {receipt.verify_command} + </code> + <CopyButton value={receipt.verify_command} label="Copy verify command" /> + </div> + </div> + + {/* The signed payload — the exact bytes the signature covers. */} + <div className="mt-4"> + <button + type="button" + onClick={() => setShowPayload((v) => !v)} + aria-expanded={showPayload} + className="rounded text-xs font-medium text-signal hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {showPayload ? "Hide" : "Show"} signed in-toto predicate + </button> + {showPayload && ( + <pre className="mt-2 max-h-80 overflow-auto rounded-lg border border-border bg-surface-muted p-3 font-mono text-[11px] leading-relaxed"> + {JSON.stringify(receipt.statement, null, 2)} + </pre> + )} + </div> + </section> + ); +} diff --git a/bridge/web/src/components/receipt-verify.tsx b/bridge/web/src/components/receipt-verify.tsx new file mode 100644 index 000000000..06e04e417 --- /dev/null +++ b/bridge/web/src/components/receipt-verify.tsx @@ -0,0 +1,113 @@ +"use client"; + +// kars Bridge — in-browser receipt verification for the mission surface. The +// receipt panel shows the `kars receipt verify` CLI as the expert option; this +// lets an ordinary user verify independently right here (audit f40): it calls +// the same backend verify endpoint (which re-checks the Ed25519 signature, the +// trust-envelope binding, the signing key against the cluster's published +// anchor, and the inclusion-log entry) and renders every recomputed check — so +// the user SEES the proof, not just a green tick. + +import { useState } from "react"; +import type { VerifyResult } from "@/lib/types"; + +export function ReceiptVerifyButton({ ns, task }: { ns: string; task: string }) { + const [verifying, setVerifying] = useState(false); + const [result, setResult] = useState<VerifyResult | null>(null); + const [error, setError] = useState<string | null>(null); + + async function run() { + setVerifying(true); + setError(null); + setResult(null); + try { + const res = await fetch( + `/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/receipt/verify`, + { method: "POST", headers: { accept: "application/json" } }, + ); + if (!res.ok) throw new Error(`verify failed: ${res.status}`); + setResult((await res.json()) as VerifyResult); + } catch { + setError("Verification couldn't be completed — the audit backend may be unreachable."); + } finally { + setVerifying(false); + } + } + + return ( + <section className="rounded-xl border border-border bg-surface p-5"> + <div className="flex flex-wrap items-center justify-between gap-2"> + <div> + <h3 className="text-sm font-semibold">Verify this receipt</h3> + <p className="mt-0.5 text-[11px] text-foreground-muted"> + Re-checks the signature, the trust-envelope binding, and the signing key against the + cluster’s published anchor — live, in your browser. No tooling to install. + </p> + </div> + <button + type="button" + onClick={run} + disabled={verifying} + className="shrink-0 rounded-lg bg-signal px-3.5 py-2 text-xs font-semibold text-signal-fg hover:opacity-90 disabled:opacity-50" + > + {verifying ? "Verifying…" : result ? "Re-verify" : "Verify now"} + </button> + </div> + + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + + {result && ( + <div className="mt-3 space-y-3 rounded-lg border border-border bg-surface-muted/30 p-3"> + <span + className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold ${ + result.verified + ? "border-ok/30 bg-ok/10 text-ok" + : "border-danger/30 bg-danger/10 text-danger" + }`} + > + {result.verified ? "✓ Verified" : "✗ Not verified"} + </span> + <ul className="space-y-2"> + {result.checks.map((c) => ( + <li key={c.name} className="text-xs"> + <div className="flex gap-2"> + <span + aria-hidden + className={c.advisory ? "text-foreground-muted" : c.passed ? "text-ok" : "text-danger"} + > + {c.advisory ? "ℹ" : c.passed ? "✓" : "✗"} + </span> + <span className="min-w-0"> + <span className="font-medium">{c.name}</span> + {c.advisory && ( + <span className="ml-1 rounded bg-surface-muted px-1 py-0.5 text-[10px] font-medium text-foreground-muted"> + shown, not verified + </span> + )} + <span className="text-foreground-muted"> — {c.detail}</span> + </span> + </div> + {(c.expected || c.computed) && ( + <dl className="ml-5 mt-1 space-y-0.5 font-mono text-[10px] text-foreground-muted"> + {c.expected && ( + <div className="flex gap-1.5"> + <dt className="w-20 shrink-0">{c.advisory ? "recorded key" : "recorded"}</dt> + <dd className="truncate" title={c.expected}>{c.expected}</dd> + </div> + )} + {c.computed && ( + <div className="flex gap-1.5"> + <dt className="w-20 shrink-0">recomputed</dt> + <dd className={`truncate ${c.passed && c.expected === c.computed ? "text-ok" : ""}`} title={c.computed}>{c.computed}</dd> + </div> + )} + </dl> + )} + </li> + ))} + </ul> + </div> + )} + </section> + ); +} diff --git a/bridge/web/src/components/repo-access.tsx b/bridge/web/src/components/repo-access.tsx new file mode 100644 index 000000000..99da454a7 --- /dev/null +++ b/bridge/web/src/components/repo-access.tsx @@ -0,0 +1,132 @@ +"use client"; + +// kars Bridge — Repository access (keyless git write) for a mission or team. +// +// Each principal connects repos through the shared GitHub App. Here the user +// grants a subset of only their own connected repositories +// to THIS mission/team so its agents can open pull requests — without ever holding +// a credential (the router mints + injects a scoped token at run time). The +// selection is written to a hidden `git_write_repos` field the create action reads; +// the controller clamps it to declared ∩ connection-granted. + +import { useEffect, useState } from "react"; +import { defaultNamespace } from "@/lib/config"; + +type Connection = { connected: boolean; account: string | null; repos: string[] }; + +export function RepoAccess({ + ns = defaultNamespace(), + initialSelected = [], + onSelectionChange, +}: { + ns?: string; + initialSelected?: string[]; + onSelectionChange?: (repos: string[]) => void; +}) { + const [conn, setConn] = useState<Connection | null>(null); + const [error, setError] = useState(false); + const [selected, setSelected] = useState<Set<string>>( + () => new Set(initialSelected), + ); + + useEffect(() => { + let live = true; + fetch(`/api/namespaces/${ns}/github/connection`) + .then((r) => r.json()) + .then((c: Connection) => { + if (live) setConn(c); + }) + .catch(() => { + if (live) setError(true); + }); + return () => { + live = false; + }; + }, [ns]); + + const selectedHas = (repos: Set<string>, repo: string) => + Array.from(repos).some((entry) => entry.toLowerCase() === repo.toLowerCase()); + + const toggle = (repo: string) => + setSelected((prev) => { + const next = new Set(prev); + const selectedEntry = Array.from(next).find( + (entry) => entry.toLowerCase() === repo.toLowerCase(), + ); + if (selectedEntry) next.delete(selectedEntry); + else next.add(repo); + onSelectionChange?.(Array.from(next)); + return next; + }); + + const value = Array.from(selected).join(","); + const availableRepos = conn?.repos ?? []; + const repoRows = [...availableRepos, ...Array.from(selected)].filter( + (repo, index, rows) => + rows.findIndex((entry) => entry.toLowerCase() === repo.toLowerCase()) === index, + ); + const isAvailable = (repo: string) => + availableRepos.some((entry) => entry.toLowerCase() === repo.toLowerCase()); + + return ( + <div className="rounded-xl border border-border bg-surface-muted/40 px-4 py-3"> + <input type="hidden" name="git_write_repos" value={value} /> + <div className="flex items-center justify-between gap-2"> + <div> + <p className="text-sm font-medium">Pull request access</p> + <p className="mt-0.5 text-xs text-foreground-muted"> + Let this {`work`} open pull requests on connected repos. Agents never hold a + credential — a scoped token is injected at run time. + </p> + </div> + </div> + + {error ? ( + <p className="mt-2 text-xs text-warning">Couldn’t load the GitHub connection.</p> + ) : conn == null ? ( + <p className="mt-2 text-xs text-foreground-muted">Loading connected repos…</p> + ) : repoRows.length === 0 ? ( + <p className="mt-2 text-xs text-foreground-muted"> + No repository is connected for your user yet. Connect GitHub on the{" "} + <span className="font-medium text-foreground">Configuration</span> console to grant + pull-request access. Leaving this empty means no git write — the mission can still + read/clone public repos. + </p> + ) : ( + <div className="mt-2 space-y-1.5"> + {conn.connected && conn.account && ( + <p className="text-[11px] text-foreground-muted"> + Connected as <span className="font-medium text-foreground">{conn.account}</span> + </p> + )} + {!conn.connected && selected.size > 0 && ( + <p className="text-[11px] text-warning"> + GitHub is disconnected. Existing grants remain listed so you can revoke them. + </p> + )} + {repoRows.map((repo) => ( + <label key={repo} className="flex cursor-pointer items-center gap-2 text-sm"> + <input + type="checkbox" + checked={selectedHas(selected, repo)} + onChange={() => toggle(repo)} + className="h-3.5 w-3.5 rounded border-border accent-signal" + /> + <span className="font-mono text-xs">{repo}</span> + {!isAvailable(repo) && ( + <span className="text-[11px] text-warning"> + no longer connected — uncheck to revoke + </span> + )} + </label> + ))} + {selected.size === 0 && ( + <p className="text-[11px] text-foreground-muted"> + None selected — no git write. Tick a repo to allow opening PRs. + </p> + )} + </div> + )} + </div> + ); +} diff --git a/bridge/web/src/components/retention-policy.tsx b/bridge/web/src/components/retention-policy.tsx new file mode 100644 index 000000000..b2e09f847 --- /dev/null +++ b/bridge/web/src/components/retention-policy.tsx @@ -0,0 +1,140 @@ +"use client"; + +// kars Bridge Operator Console — mission/team-run retention policy. +// +// Kars keeps mission/team-run records (deliverable, receipt, activity) after +// delivery by design — only the sandbox (live compute) auto-tears-down. Left +// unmanaged, records accumulate forever. This control mirrors Kubernetes' +// Job.spec.ttlSecondsAfterFinished: set a cluster-wide default TTL and the +// controller auto-deletes a delivered mission/team-run once it elapses. `0` +// (the default) disables auto-delete — nothing changes unless an admin opts +// in. A mission or team may still set its OWN override at creation, +// independent of this cluster-wide default. + +import { useCallback, useEffect, useState } from "react"; +import type { RetentionPolicy } from "@/lib/types"; + +const PRESETS = [ + { label: "Never (default)", seconds: 0 }, + { label: "1 hour", seconds: 3600 }, + { label: "24 hours", seconds: 86400 }, + { label: "7 days", seconds: 604800 }, + { label: "30 days", seconds: 2592000 }, +]; + +export function RetentionPolicyPanel({ isAdmin = true }: { isAdmin?: boolean }) { + const [data, setData] = useState<RetentionPolicy | null>(null); + const [error, setError] = useState<string | null>(null); + const [saving, setSaving] = useState(false); + const [customHours, setCustomHours] = useState(""); + + const load = useCallback(async () => { + try { + const r = await fetch("/api/operator/retention-policy", { cache: "no-store" }); + if (!r.ok) throw new Error(); + setData(await r.json()); + setError(null); + } catch { + setError("Couldn't load the retention policy."); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(timer); + }, [load]); + + const apply = useCallback(async (seconds: number) => { + setSaving(true); + try { + const r = await fetch("/api/operator/retention-policy", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ default_ttl_seconds: seconds }), + }); + if (!r.ok) { + const body = await r.json().catch(() => null); + throw new Error(body?.error?.message || "Save failed"); + } + setData(await r.json()); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }, []); + + if (error && !data) { + return ( + <div className="rounded-xl border border-border bg-surface-muted/50 p-4 text-sm text-danger"> + {error} + </div> + ); + } + if (!data) { + return <div className="text-sm text-foreground-muted">Loading retention policy…</div>; + } + + const active = data.default_ttl_seconds; + + return ( + <div className="kb-card p-5 sm:p-6"> + <div className="flex items-center justify-between"> + <h2 className="text-sm font-semibold">Retention — delivered mission/team-run cleanup</h2> + </div> + <p className="mt-1 text-xs text-foreground-muted">{data.summary}</p> + {error && <p className="mt-2 text-xs text-danger">{error}</p>} + <div className="mt-4 flex flex-wrap gap-2"> + {PRESETS.map((p) => ( + <button + key={p.seconds} + type="button" + disabled={!isAdmin || saving} + onClick={() => apply(p.seconds)} + className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition ${ + active === p.seconds + ? "border-signal bg-signal/10 text-signal" + : "border-border bg-surface text-foreground-muted hover:text-foreground" + } ${!isAdmin ? "cursor-not-allowed opacity-60" : ""}`} + > + {p.label} + </button> + ))} + </div> + <div className="mt-3 flex items-center gap-2"> + <label className="text-xs text-foreground-muted"> + Custom (hours) + <input + type="number" + min={0} + value={customHours} + onChange={(e) => setCustomHours(e.target.value)} + disabled={!isAdmin || saving} + placeholder="e.g. 72" + className="mt-1 block w-28 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs disabled:opacity-60" + /> + </label> + <button + type="button" + disabled={!isAdmin || saving || customHours.trim() === ""} + onClick={() => apply(Math.max(0, Math.round(Number(customHours) * 3600)))} + className="mt-4 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-foreground-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" + > + Apply + </button> + </div> + {!isAdmin && ( + <p className="mt-3 text-[11px] text-foreground-muted"> + Read-only — switch to the Admin role to change the retention default. + </p> + )} + <p className="mt-3 text-[11px] text-foreground-muted"> + This is the cluster-wide default. A mission or standing team may set its own + retention when created, which takes precedence over this default. A team’s + principal and roster members are never auto-deleted — only individual missions + and task-force run records are eligible. + </p> + </div> + ); +} diff --git a/bridge/web/src/components/role-switcher.tsx b/bridge/web/src/components/role-switcher.tsx new file mode 100644 index 000000000..50c4ee5c0 --- /dev/null +++ b/bridge/web/src/components/role-switcher.tsx @@ -0,0 +1,148 @@ +"use client"; + +// kars Bridge — the multi-user surface. Shows the current principal + primary +// role, and (no SSO yet) lets a developer switch which role they act as, so the +// four differentiated permission sets — admin / operator / workspace user / +// auditor — can be exercised and verified. The dropdown is honest about being a +// dev identity switch, not a logged-in session. + +import { useState } from "react"; +import { switchRole } from "@/app/role-actions"; +import { ALL_ROLES, ROLE_META, type Role } from "@/lib/config"; +import { Icon } from "@/components/icon"; + +export function RoleSwitcher({ + principal, + primary, + roles, + simulated, + ssoSignedIn = false, + ssoAvailable = false, +}: { + principal: string; + primary: Role; + roles: Role[]; + simulated: boolean; + /** True when the CURRENT session is a real, signed, SSO-verified login. */ + ssoSignedIn?: boolean; + /** True when an operator has configured a real OIDC IdP (BRIDGE_OIDC_*), + * regardless of whether THIS request is signed in yet. */ + ssoAvailable?: boolean; +}) { + const [open, setOpen] = useState(false); + const meta = ROLE_META[primary]; + + return ( + <div className="relative"> + <button + type="button" + onClick={() => setOpen((o) => !o)} + className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-2.5 py-1 text-xs font-medium text-foreground-muted transition hover:bg-surface-muted hover:text-foreground" + title={ + ssoSignedIn + ? `${principal} — acting as ${meta.label} (signed in via SSO)` + : `Acting as ${meta.label}${simulated ? " (simulated — no SSO)" : ""}` + } + > + <Icon name={meta.glyph} size={14} /> + {ssoSignedIn ? ( + // Real login: show WHO you are (the identity), with the role conveyed + // by the glyph + the dropdown. A signed-in user must see their own + // identity in the header, not just their role. + <span className="hidden max-w-[12rem] truncate sm:inline">{principal}</span> + ) : ( + <span className="hidden sm:inline">{meta.label}</span> + )} + <svg viewBox="0 0 12 12" className="h-2.5 w-2.5" fill="currentColor" aria-hidden> + <path d="M6 8 2 4h8L6 8Z" /> + </svg> + </button> + {open && ( + <> + <button + type="button" + aria-hidden + className="fixed inset-0 z-10 cursor-default" + onClick={() => setOpen(false)} + /> + <div className="absolute right-0 z-20 mt-1.5 w-72 rounded-xl border border-border bg-surface p-2 shadow-lg"> + <div className="px-2 py-1.5"> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Signed in as</p> + <p className="truncate font-mono text-xs">{principal}</p> + <p className="mt-0.5 text-[10px] text-foreground-muted"> + {ssoSignedIn + ? "Real SSO session — roles from your identity provider's group claims." + : simulated + ? "Simulated role — there is no SSO session." + : "Roles from BRIDGE_ROLES env."}{" "} + The real boundary is the Bridge’s Kubernetes ServiceAccount. + </p> + </div> + <div className="my-1 border-t border-border" /> + {ssoSignedIn ? ( + <form action="/auth/logout" method="post"> + <button + type="submit" + className="w-full rounded-lg border border-border px-2 py-1.5 text-left text-[11px] font-medium text-foreground-muted hover:bg-surface-muted" + > + Sign out + </button> + </form> + ) : ( + <> + {ssoAvailable && ( + <a + href="/auth/login" + className="mb-1 block rounded-lg bg-signal px-2 py-1.5 text-center text-[11px] font-semibold text-signal-fg hover:opacity-90" + > + Sign in with SSO + </a> + )} + <p className="px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-foreground-muted"> + Act as {ssoAvailable && "(dev preview — no session)"} + </p> + {ALL_ROLES.map((r) => { + const m = ROLE_META[r]; + const isPrimary = r === primary; + return ( + <form key={r} action={switchRole.bind(null, r)}> + <button + type="submit" + className={`flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition hover:bg-surface-muted ${ + isPrimary ? "bg-surface-muted/60" : "" + }`} + > + <span aria-hidden className="mt-0.5"><Icon name={m.glyph} size={15} /></span> + <span className="min-w-0"> + <span className="flex items-center gap-1.5 text-xs font-medium"> + {m.label} + {isPrimary && ( + <span className="rounded-full border border-signal/40 bg-signal/10 px-1.5 py-0 text-[9px] text-signal"> + current + </span> + )} + </span> + <span className="block text-[10px] leading-snug text-foreground-muted">{m.blurb}</span> + </span> + </button> + </form> + ); + })} + {simulated && ( + <form action={switchRole.bind(null, "reset")}> + <button + type="submit" + className="mt-1 w-full rounded-lg border border-border px-2 py-1.5 text-[11px] text-foreground-muted hover:bg-surface-muted" + > + Reset to env default + </button> + </form> + )} + </> + )} + </div> + </> + )} + </div> + ); +} diff --git a/bridge/web/src/components/rubiks-cube.tsx b/bridge/web/src/components/rubiks-cube.tsx new file mode 100644 index 000000000..8120a1a74 --- /dev/null +++ b/bridge/web/src/components/rubiks-cube.tsx @@ -0,0 +1,56 @@ +"use client"; + +// A real Rubik's cube rendered in CSS 3D — six faces, each a 3×3 grid of +// classic-colored stickers on a dark plastic body, tumbling smoothly. Pure +// presentation for the orchestration flow; no external deps. Deterministic +// sticker colors (the solved cube) so it reads instantly as a Rubik's cube. + +const FACE_COLORS: Record<string, string> = { + U: "#f8fafc", // up — white + D: "#facc15", // down — yellow + F: "#22c55e", // front — green + B: "#3b82f6", // back — blue + R: "#ef4444", // right — red + L: "#f97316", // left — orange +}; + +// Face transform for a cube of edge `s` (px): position + orient each face. +function faceTransform(face: string, s: number): string { + const h = s / 2; + switch (face) { + case "F": return `translateZ(${h}px)`; + case "B": return `rotateY(180deg) translateZ(${h}px)`; + case "R": return `rotateY(90deg) translateZ(${h}px)`; + case "L": return `rotateY(-90deg) translateZ(${h}px)`; + case "U": return `rotateX(90deg) translateZ(${h}px)`; + case "D": return `rotateX(-90deg) translateZ(${h}px)`; + default: return ""; + } +} + +export function RubiksCube({ size = 104, settled = false, assembling = false }: { size?: number; settled?: boolean; assembling?: boolean }) { + const faces = ["F", "B", "R", "L", "U", "D"]; + return ( + <div className="kb-rubik-scene" style={{ width: size, height: size }}> + <div className={`kb-rubik ${settled ? "kb-rubik-settle" : ""} ${assembling ? "kb-rubik-assembling" : ""}`} style={{ width: size, height: size }}> + {faces.map((f, fi) => ( + <div key={f} className="kb-rubik-face" style={{ width: size, height: size, transform: faceTransform(f, size) }}> + {Array.from({ length: 9 }).map((_, i) => ( + <span + key={i} + className="kb-rubik-sticker" + style={{ + background: FACE_COLORS[f], + // Self-assembly: stickers cascade in (staggered by position) + // while the package is being computed, so the cube visibly + // builds itself during the loading calculation. + ...(assembling ? { animationDelay: `${(fi * 9 + i) * 26}ms` } : {}), + }} + /> + ))} + </div> + ))} + </div> + </div> + ); +} diff --git a/bridge/web/src/components/segmented-tier.tsx b/bridge/web/src/components/segmented-tier.tsx new file mode 100644 index 000000000..900151da2 --- /dev/null +++ b/bridge/web/src/components/segmented-tier.tsx @@ -0,0 +1,90 @@ +"use client"; + +// kars Bridge — segmented autonomy-tier control. +// +// A precise 1–5 selector that mirrors the tier-scale visualization on the +// task detail page, so the *input* and the *evidence* speak the same visual +// language. Far clearer than a bare range slider for a 5-value authority +// choice, and it can render a "ceiling" marker for the authority relationship. + +import { TIER_LABELS } from "@/lib/types"; + +export function SegmentedTier({ + name, + value, + onChange, + ceiling, + invalidAbove, +}: { + name: string; + value: number; + onChange: (v: number) => void; + /** When set, draws a ceiling ring on this tier (authority ceiling). */ + ceiling?: number; + /** When set, tiers strictly above this are marked invalid (ceiling > tier). */ + invalidAbove?: number; +}) { + return ( + <div> + <input type="hidden" name={name} value={value} /> + <div + role="radiogroup" + aria-label={name} + className="grid grid-cols-5 gap-2" + > + {[1, 2, 3, 4, 5].map((t) => { + const active = t === value; + const within = t <= value; + const isCeiling = ceiling === t; + const invalid = invalidAbove != null && t > invalidAbove; + return ( + <button + key={t} + type="button" + role="radio" + aria-checked={active} + onClick={() => onChange(t)} + className={[ + "flex flex-col items-center gap-1 rounded-lg border px-2 py-2.5 text-center transition", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal", + active + ? "border-signal bg-signal/10" + : within + ? "border-signal/30 bg-signal/5" + : "border-border bg-surface hover:border-foreground-muted/40", + invalid ? "opacity-40" : "", + isCeiling ? "ring-2 ring-warning ring-offset-1 ring-offset-surface" : "", + ].join(" ")} + > + <span + className={[ + "relative grid h-6 w-6 place-items-center rounded text-xs font-semibold", + active + ? "bg-signal text-signal-fg" + : within + ? "text-signal" + : "text-foreground-muted", + ].join(" ")} + > + {t} + </span> + <span className="text-[11px] leading-tight text-foreground-muted"> + {TIER_LABELS[t]} + </span> + {active && ( + <span className="text-[10px] font-medium leading-none text-signal"> + selected + </span> + )} + {isCeiling && !active && ( + <span className="text-[10px] font-medium leading-none text-warning"> + ceiling + </span> + )} + </button> + ); + })} + </div> + </div> + ); +} diff --git a/bridge/web/src/components/skill-composer.tsx b/bridge/web/src/components/skill-composer.tsx new file mode 100644 index 000000000..0a0960501 --- /dev/null +++ b/bridge/web/src/components/skill-composer.tsx @@ -0,0 +1,272 @@ +"use client"; + +// kars Bridge — shared skill-package composer. A guided visual form (name, +// version, summary, recipe, bounding tool policy, per-file editor with a +// SKILL.md starter template) that both the Workspace (user submits, lands +// PENDING review) and the Operator Console (operator authors the same way — +// this is the ONE creation path; editing an existing skill's raw spec is a +// separate, deliberate JSON escape hatch via AuthorResource) render +// identically. Previously the console had a bare file-picker requiring a +// pre-authored skill.json — this makes both surfaces the same experience. + +import { useState, useTransition } from "react"; +import { Icon } from "@/components/icon"; +import type { RefOption } from "@/lib/types"; + +export interface SkillComposerInput { + name: string; + display_name: string; + version: string; + summary: string; + bounding_policy: string; + recipe?: string; + files: { name: string; content: string }[]; +} + +export type SkillComposerResult = { ok: true } | { ok: false; error: string }; + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +export function SkillComposer({ + toolPolicies, + submit, +}: { + toolPolicies: RefOption[]; + submit: (input: SkillComposerInput) => Promise<SkillComposerResult>; +}) { + const [open, setOpen] = useState(false); + const [displayName, setDisplayName] = useState(""); + const [version, setVersion] = useState("1.0.0"); + const [summary, setSummary] = useState(""); + const [recipe, setRecipe] = useState(""); + const [boundingPolicy, setBoundingPolicy] = useState(toolPolicies[0]?.name ?? "kars-default"); + const [files, setFiles] = useState<{ name: string; content: string }[]>([]); + const [error, setError] = useState<string | null>(null); + const [done, setDone] = useState(false); + const [pending, start] = useTransition(); + + const valid = displayName.trim().length >= 2 && summary.trim().length >= 8 && version.trim().length > 0 && boundingPolicy.trim().length > 0; + + function onSubmit() { + setError(null); + start(async () => { + const res = await submit({ + name: slugify(displayName), + display_name: displayName.trim(), + version: version.trim(), + summary: summary.trim(), + bounding_policy: boundingPolicy, + recipe: recipe.trim() || undefined, + files: files.filter((f) => f.name.trim() && f.content.trim()), + }); + if (res.ok) { + setDone(true); + setDisplayName(""); + setSummary(""); + setRecipe(""); + setFiles([]); + setVersion("1.0.0"); + setTimeout(() => setDone(false), 4000); + setOpen(false); + } else { + setError(res.error); + } + }); + } + + if (!open) { + return ( + <div className="kb-card flex flex-wrap items-center justify-between gap-3 p-4"> + <div> + <p className="text-sm font-medium">Upload a skill</p> + <p className="text-xs text-foreground-muted"> + Propose a capability package. It goes to an operator to scan, review, and sign before it's usable. + </p> + </div> + <div className="flex items-center gap-2"> + {done && <span className="text-xs font-medium text-ok">Submitted — pending review</span>} + <button + type="button" + onClick={() => setOpen(true)} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg hover:opacity-90" + > + Upload a skill + </button> + </div> + </div> + ); + } + + return ( + <div className="kb-card space-y-4 p-5"> + <div className="flex items-center justify-between"> + <h2 className="text-sm font-semibold">Upload a skill</h2> + <button type="button" onClick={() => setOpen(false)} className="text-xs text-foreground-muted hover:text-foreground"> + Cancel + </button> + </div> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="bolt" size={13} /> Package details</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <Field label="Name"> + <input + value={displayName} + onChange={(e) => setDisplayName(e.target.value)} + placeholder="e.g. Repo triage" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + {displayName && <p className="mt-1 font-mono text-[10px] text-foreground-muted">id: {slugify(displayName) || "—"}</p>} + </Field> + <Field label="Version"> + <input + value={version} + onChange={(e) => setVersion(e.target.value)} + placeholder="1.0.0" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-signal" + /> + </Field> + </div> + <div className="mt-3"> + <Field label="Summary"> + <input + value={summary} + onChange={(e) => setSummary(e.target.value)} + placeholder="What the skill does, in one or two plain sentences." + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + </Field> + </div> + <div className="mt-3"> + <Field label="Recipe — standing instructions (optional)"> + <textarea + value={recipe} + onChange={(e) => setRecipe(e.target.value)} + rows={3} + placeholder="How the agent should use this capability, e.g. Label issues by area; close duplicates; flag regressions." + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + </Field> + </div> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <div className="flex items-center justify-between"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="file" size={13} /> Package files — SKILL.md + scripts (optional)</legend> + <div className="flex gap-2"> + {!files.some((f) => f.name === "SKILL.md") && ( + <button + type="button" + onClick={() => + setFiles((prev) => [ + { + name: "SKILL.md", + content: + "---\nname: " + + (slugify(displayName) || "my-skill") + + "\ndescription: One clear sentence on WHAT this does and WHEN to use it — the agent reads this to decide.\n---\n\n# " + + (displayName || "My skill") + + "\n\nHow to use this capability. Reference scripts by name, e.g. run `bash hello.sh`.\n", + }, + ...prev, + ]) + } + className="rounded-md border border-signal/40 bg-signal/10 px-2 py-1 text-[11px] font-medium text-signal hover:bg-signal/15" + > + + SKILL.md template + </button> + )} + <button + type="button" + onClick={() => setFiles((prev) => [...prev, { name: "", content: "" }])} + className="rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground-muted hover:text-foreground" + > + + File + </button> + </div> + </div> + <p className="mt-1 text-[11px] text-foreground-muted"> + A real package the agent installs and runs. It must include a <span className="font-mono">SKILL.md</span> with a + frontmatter <span className="font-mono">description</span> — that's how the agent discovers and decides to use it. + Flat filenames only (no folders). Installed on OpenClaw sandboxes. + </p> + <div className="mt-2 space-y-2"> + {files.map((f, i) => ( + <div key={i} className="rounded-md border border-border bg-surface p-2"> + <div className="flex items-center gap-2"> + <input + value={f.name} + onChange={(e) => setFiles((prev) => prev.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} + placeholder="filename (e.g. SKILL.md, hello.sh)" + className="flex-1 rounded border border-border bg-surface px-2 py-1 font-mono text-xs outline-none focus:border-signal" + /> + <button + type="button" + onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))} + className="shrink-0 text-foreground-muted hover:text-danger" + > + <Icon name="cross" size={13} /> + </button> + </div> + <textarea + value={f.content} + onChange={(e) => setFiles((prev) => prev.map((x, j) => (j === i ? { ...x, content: e.target.value } : x)))} + rows={4} + placeholder="file content" + className="mt-1 w-full resize-y rounded border border-border bg-surface px-2 py-1 font-mono text-[11px] outline-none focus:border-signal" + /> + </div> + ))} + </div> + </fieldset> + + <fieldset className="rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="shield" size={13} /> Bounding tool policy</legend> + <select + value={boundingPolicy} + onChange={(e) => setBoundingPolicy(e.target.value)} + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + > + {(toolPolicies.length ? toolPolicies : [{ name: "kars-default", summary: null }]).map((p) => ( + <option key={p.name} value={p.name}> + {p.name} + {p.summary ? ` — ${p.summary}` : ""} + </option> + ))} + </select> + <p className="mt-1.5 text-[11px] text-foreground-muted"> + The cap on what this skill may do — chosen from the policies your operator vetted. + </p> + </fieldset> + + {error && <p className="rounded-lg border border-danger/30 bg-danger/[0.06] px-3 py-2 text-xs text-danger">{error}</p>} + + <div className="flex items-center gap-2"> + <button + type="button" + disabled={!valid || pending} + onClick={onSubmit} + className="rounded-lg bg-signal px-4 py-2 text-sm font-semibold text-signal-fg hover:opacity-90 disabled:opacity-50" + > + {pending ? "Submitting…" : "Submit for review"} + </button> + <p className="text-[11px] text-foreground-muted">Lands pending — an operator scans + signs before it's usable.</p> + </div> + </div> + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <label className="block text-xs text-foreground-muted"> + {label} + <div className="mt-1">{children}</div> + </label> + ); +} diff --git a/bridge/web/src/components/stat-card.tsx b/bridge/web/src/components/stat-card.tsx new file mode 100644 index 000000000..2c6ca3d77 --- /dev/null +++ b/bridge/web/src/components/stat-card.tsx @@ -0,0 +1,55 @@ +// kars Bridge — compact metric tile for the command-center + console overviews. +// Shares the premium value-first treatment of `ui.tsx`'s <Stat/> so KPI tiles +// look identical in the Workspace and the Operator Console (one visual +// language). API is unchanged — every existing caller upgrades for free. + +const VALUE_TONE = { + default: "text-foreground", + ok: "text-ok", + warning: "text-warning", + danger: "text-danger", +} as const; + +const GLOW_TONE = { + default: "bg-signal/10", + ok: "bg-ok/15", + warning: "bg-warning/15", + danger: "bg-danger/15", +} as const; + +export function StatCard({ + label, + value, + tone = "default", + hint, +}: { + label: string; + value: string | number; + tone?: "default" | "ok" | "warning" | "danger"; + hint?: string; +}) { + const emphasized = tone !== "default"; + return ( + <div + className={`group relative overflow-hidden rounded-xl border p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md ${ + emphasized + ? "border-current/20 bg-gradient-to-br from-surface to-surface-muted/40" + : "border-border bg-surface" + }`} + > + <span + aria-hidden + className={`pointer-events-none absolute -right-6 -top-6 h-16 w-16 rounded-full blur-2xl transition-opacity ${GLOW_TONE[tone]} ${ + emphasized ? "opacity-100" : "opacity-0 group-hover:opacity-100" + }`} + /> + <p + className={`text-[1.7rem] font-semibold leading-none tracking-tight tabular-nums ${VALUE_TONE[tone]}`} + > + {value} + </p> + <p className="mt-1.5 text-xs font-medium text-foreground-muted">{label}</p> + {hint && <p className="mt-1 text-[11px] text-foreground-muted/80">{hint}</p>} + </div> + ); +} diff --git a/bridge/web/src/components/status-badge.tsx b/bridge/web/src/components/status-badge.tsx new file mode 100644 index 000000000..cfc4fb2b8 --- /dev/null +++ b/bridge/web/src/components/status-badge.tsx @@ -0,0 +1,31 @@ +// kars Bridge web — status badge. A small, accessible indicator used across +// the trust/verification surfaces. + +type Tone = "ok" | "warning" | "danger" | "muted"; + +const TONE_CLASS: Record<Tone, string> = { + ok: "bg-ok/15 text-ok border-ok/30", + warning: "bg-warning/15 text-warning border-warning/30", + danger: "bg-danger/15 text-danger border-danger/30", + muted: "bg-surface-muted text-foreground-muted border-border", +}; + +export function StatusBadge({ + tone, + label, +}: { + tone: Tone; + label: string; +}) { + return ( + <span + className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${TONE_CLASS[tone]}`} + > + <span + aria-hidden + className="h-1.5 w-1.5 rounded-full bg-current" + /> + {label} + </span> + ); +} diff --git a/bridge/web/src/components/surface-switcher.tsx b/bridge/web/src/components/surface-switcher.tsx new file mode 100644 index 000000000..6f51e9e27 --- /dev/null +++ b/bridge/web/src/components/surface-switcher.tsx @@ -0,0 +1,50 @@ +"use client"; + +// kars Bridge — surface switcher. +// +// The deliberate, infrequent act of crossing between the THREE products: the +// employee Workspace, the operator Console, and the read-only Auditor surface. +// It lives in the header (account area), never auto-linked from content. +// Entitlement-gating lands with auth; today the role flags come from config. + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +type Target = { href: string; label: string }; + +export function SurfaceSwitcher({ + canOperate = true, + canAudit = false, +}: { + canOperate?: boolean; + canAudit?: boolean; +}) { + const pathname = usePathname(); + const inConsole = pathname.startsWith("/console"); + const inAudit = pathname.startsWith("/audit"); + + const targets: Target[] = []; + if (inConsole || inAudit) targets.push({ href: "/workspace", label: "Workspace" }); + if (!inConsole && canOperate) targets.push({ href: "/console", label: "Operator Console" }); + if (!inAudit && canAudit) targets.push({ href: "/audit", label: "Auditor" }); + + if (targets.length === 0) return null; + + return ( + <div className="flex items-center gap-1.5"> + {targets.map((t) => ( + <Link + key={t.href} + href={t.href} + prefetch={false} + className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-2.5 py-1 text-xs font-medium text-foreground-muted transition hover:bg-surface-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor" aria-hidden> + <path d="M5 3 1.5 6.5 5 10V7.5h6V5.5H5V3Zm6 3v2.5H5v2L8.5 13 12 9.5 11 8.5V6h-1Z" /> + </svg> + {t.label} + </Link> + ))} + </div> + ); +} diff --git a/bridge/web/src/components/task-checkpoint.tsx b/bridge/web/src/components/task-checkpoint.tsx new file mode 100644 index 000000000..67e9e68d8 --- /dev/null +++ b/bridge/web/src/components/task-checkpoint.tsx @@ -0,0 +1,51 @@ +import type { TaskCheckpoint } from "@/lib/types"; + +const TONE: Record<TaskCheckpoint["status"], string> = { + pending: "border-border bg-surface-muted/30", + in_progress: "border-sky-500/40 bg-sky-500/[0.06]", + completed: "border-emerald-500/40 bg-emerald-500/[0.06]", + blocked: "border-warning/50 bg-warning/[0.08]", +}; + +export function TaskCheckpointPanel({ checkpoint }: { checkpoint: TaskCheckpoint }) { + return ( + <section className={`rounded-xl border p-5 ${TONE[checkpoint.status]}`}> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <p className="text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + Durable milestone checkpoint + </p> + <h2 className="mt-1 text-sm font-semibold"> + {checkpoint.milestone_id.replaceAll("-", " ")} + </h2> + </div> + <span className="rounded-full border border-current/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"> + {checkpoint.status.replaceAll("_", " ")} + </span> + </div> + <p className="mt-2 text-sm text-foreground-muted">{checkpoint.summary}</p> + {(checkpoint.acceptance_criteria?.length ?? 0) > 0 && ( + <div className="mt-3"> + <p className="text-xs font-semibold">Acceptance criteria</p> + <ul className="mt-1 space-y-1 text-xs text-foreground-muted"> + {checkpoint.acceptance_criteria!.map((criterion) => ( + <li key={criterion}>• {criterion}</li> + ))} + </ul> + </div> + )} + {(checkpoint.artifacts?.length ?? 0) > 0 && ( + <p className="mt-3 text-xs text-foreground-muted"> + <span className="font-semibold text-foreground">Owned artifacts:</span>{" "} + {checkpoint.artifacts!.join(", ")} + </p> + )} + {(checkpoint.next_steps?.length ?? 0) > 0 && ( + <p className="mt-2 text-xs text-foreground-muted"> + <span className="font-semibold text-foreground">Next:</span>{" "} + {checkpoint.next_steps!.join(" · ")} + </p> + )} + </section> + ); +} diff --git a/bridge/web/src/components/team-run-activity.tsx b/bridge/web/src/components/team-run-activity.tsx new file mode 100644 index 000000000..81071cbdc --- /dev/null +++ b/bridge/web/src/components/team-run-activity.tsx @@ -0,0 +1,159 @@ +import type { TeamRunEvidence } from "@/lib/team-run-evidence"; + +function label(value: string): string { + return value.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase()); +} + +export function TeamRunActivity({ evidence }: { evidence: TeamRunEvidence }) { + const events = [ + ...evidence.collaboration.map((event) => ({ + at: event.at, + kind: "collaboration" as const, + title: event.event === "mcp_tool_call" ? "MCP tool call" : label(event.event), + actor: event.member ?? event.agent, + detail: event.preview, + outcome: event.outcome, + href: null, + meta: event.source === "ledger" + ? "core-ledger" + : event.source === "router" + ? "router-authoritative" + : event.source === "agent-reported" + ? "agent-reported" + : "recovered", + })), + ...evidence.research.map((event) => ({ + at: event.at, + kind: "research" as const, + title: event.outcome === "success" ? "External source reached" : "External source blocked or failed", + actor: event.agent, + detail: event.host, + outcome: event.outcome, + href: event.url, + meta: event.source === "router" + ? event.status ? `router · HTTP ${event.status}` : "router · governed egress" + : "agent-reported", + })), + ].sort((a, b) => (a.at ?? "").localeCompare(b.at ?? "")); + const delivered = evidence.roles.filter((role) => role.state === "delivered").length; + const working = evidence.roles.filter((role) => role.state === "working").length; + + return ( + <section className="rounded-xl border border-border bg-surface p-5"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Live team storyline</h2> + <p className="mt-1 text-xs text-foreground-muted"> + Purposeful delegation and handbacks toward the run outcome. Infrastructure events are + retained below as evidence, not presented as the work itself. + </p> + </div> + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-1 text-xs font-medium text-foreground-muted"> + {working > 0 + ? `${working} working · ${delivered} delivered` + : `${delivered}/${evidence.roles.length} roles delivered`} + </span> + </div> + <ol className="mt-4 grid gap-3 md:grid-cols-3"> + {evidence.roles.map((role) => { + const normalize = (value: string) => + value.toLowerCase().replace(/[^a-z0-9]+/g, ""); + const handback = + role.handbacks.at(-1) ?? + evidence.collaboration + .filter( + (event) => + event.preview && + event.member && + normalize(event.member) === normalize(role.role.name), + ) + .at(-1); + const tone = + role.state === "delivered" + ? "border-emerald-500/30 bg-emerald-500/[0.06]" + : role.state === "working" + ? "border-sky-500/30 bg-sky-500/[0.06]" + : role.state === "failed" || role.state === "missing" + ? "border-rose-500/30 bg-rose-500/[0.06]" + : "border-border bg-surface-muted/25"; + return ( + <li key={role.role.name} className={`rounded-xl border p-4 ${tone}`}> + <div className="flex items-center justify-between gap-2"> + <span className="font-mono text-xs font-semibold">{role.role.name}</span> + <span className="rounded-full border border-current/20 px-2 py-0.5 text-[10px] font-medium capitalize"> + {role.state} + </span> + </div> + <p className="mt-2 line-clamp-4 text-xs leading-relaxed text-foreground-muted"> + {handback?.preview ?? + (role.state === "working" + ? "Working on the assigned packet." + : role.state === "skipped" + ? "Not needed for this run." + : "No structured handback is available.")} + </p> + <p className="mt-3 text-[10px] text-foreground-muted"> + {role.artifacts.length} evidence file{role.artifacts.length === 1 ? "" : "s"} + {handback?.at ? ` · ${new Date(handback.at).toLocaleTimeString()}` : ""} + </p> + </li> + ); + })} + </ol> + {evidence.research.length > 0 && ( + <p className="mt-4 rounded-lg border border-border bg-surface-muted/25 px-3 py-2 text-xs text-foreground-muted"> + Governed external evidence: {evidence.research.length} request + {evidence.research.length === 1 ? "" : "s"} across{" "} + {new Set(evidence.research.map((event) => event.host).filter(Boolean)).size} host + {new Set(evidence.research.map((event) => event.host).filter(Boolean)).size === 1 + ? "" + : "s"} + . + </p> + )} + <details className="mt-4 rounded-lg border border-border bg-surface-muted/20"> + <summary className="cursor-pointer px-4 py-3 text-xs font-medium"> + Evidence timeline + <span className="ml-2 font-normal text-foreground-muted"> + {events.length} ledger, agent, and router event{events.length === 1 ? "" : "s"} + </span> + </summary> + {events.length === 0 ? ( + <p className="border-t border-border px-4 py-3 text-xs text-foreground-muted"> + This run predates structured evidence or has not emitted an event yet. + </p> + ) : ( + <ol className="space-y-2 border-t border-border p-3"> + {events.map((event, index) => ( + <li + key={`${event.kind}-${event.at ?? "unknown"}-${index}`} + className="min-w-0 overflow-hidden rounded-lg border border-border bg-surface px-3 py-2" + > + <div className="flex min-w-0 flex-wrap items-center gap-2 text-xs"> + <span + className={`h-1.5 w-1.5 rounded-full ${event.kind === "research" ? "bg-sky-500" : "bg-signal"}`} + aria-hidden + /> + <span className="font-medium">{event.title}</span> + {event.actor && ( + <span className="max-w-64 truncate font-mono text-[10px] text-foreground-muted"> + {event.actor} + </span> + )} + <span className="ml-auto text-[9px] uppercase tracking-wide text-foreground-muted"> + {event.meta} + </span> + </div> + {event.detail && ( + <p className="mt-1 line-clamp-2 break-words text-[11px] text-foreground-muted"> + {event.detail} + </p> + )} + </li> + ))} + </ol> + )} + </details> + </section> + ); +} diff --git a/bridge/web/src/components/team-run-flow.tsx b/bridge/web/src/components/team-run-flow.tsx new file mode 100644 index 000000000..1947d2ba7 --- /dev/null +++ b/bridge/web/src/components/team-run-flow.tsx @@ -0,0 +1,372 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { TaskDetail } from "@/lib/types"; +import type { TeamRunEvidence } from "@/lib/team-run-evidence"; + +type FlowCategory = "team" | "tool" | "reasoning" | "outcome" | "system"; + +type FlowEvent = { + id: string; + at: string | null; + category: FlowCategory; + actor: string; + title: string; + detail: string | null; + status: "ok" | "working" | "error" | "neutral"; + count?: number; + tokens?: number; +}; + +function assignmentTitle(event: TaskDetail["assignment_events"][number]): { + title: string; + actor: string; + status: FlowEvent["status"]; +} { + const actor = event.child_role ?? (event.worker_did ? "principal" : "controller"); + if (event.event_type === "acknowledged") { + return { title: "Principal acknowledged assignment", actor, status: "working" }; + } + switch (event.stage ?? event.event_type) { + case "assigned": + return { title: "Principal assigned", actor, status: "neutral" }; + case "worker_replaced": + return { title: "Worker restarted; assignment rerouted", actor, status: "working" }; + case "child_assigned": + return { title: `${event.child_role ?? "Role"} assigned`, actor, status: "neutral" }; + case "child_progress": + return { title: `${event.child_role ?? "Role"} working`, actor, status: "working" }; + case "child_handback": + return { + title: `${event.child_role ?? "Role"} handback received`, + actor, + status: event.outcome === "success" ? "ok" : "error", + }; + case "completed": + return { + title: "Principal handback recorded", + actor, + status: event.outcome === "success" ? "ok" : "error", + }; + default: + return { + title: (event.stage ?? event.event_type).replaceAll("_", " "), + actor, + status: event.state === "Completed" ? "ok" : "neutral", + }; + } +} + +function buildEvents(task: TaskDetail, evidence: TeamRunEvidence): FlowEvent[] { + const events: FlowEvent[] = task.assignment_events.map((event) => { + const mapped = assignmentTitle(event); + return { + id: `assignment-${event.sequence}`, + at: event.at, + category: event.child_role ? "team" : "system", + actor: mapped.actor, + title: mapped.title, + detail: event.message, + status: mapped.status, + }; + }); + + const toolGroups = new Map<string, FlowEvent>(); + for (const event of task.activity) { + if (event.kind !== "tool") continue; + const key = [ + event.agent ?? "principal", + event.name, + event.ok ? "ok" : "error", + event.result_preview, + ].join("|"); + const current = toolGroups.get(key); + if (current) { + current.count = (current.count ?? 1) + 1; + if ((event.ts ?? "") > (current.at ?? "")) current.at = event.ts; + continue; + } + toolGroups.set(key, { + id: `tool-${toolGroups.size}-${event.round}`, + at: event.ts, + category: "tool", + actor: event.agent ?? "principal", + title: event.name, + detail: [event.args_preview, event.result_preview].filter(Boolean).join(" → "), + status: event.ok ? "ok" : "error", + count: 1, + }); + } + events.push(...toolGroups.values()); + + const rounds = task.activity.filter((event) => event.kind === "round"); + if (rounds.length > 0) { + events.push({ + id: "reasoning-summary", + at: rounds.at(-1)?.ts ?? null, + category: "reasoning", + actor: "model", + title: `${rounds.length} model reasoning round${rounds.length === 1 ? "" : "s"}`, + detail: `${rounds.reduce((sum, event) => sum + event.prompt_tokens, 0).toLocaleString()} prompt · ${rounds.reduce((sum, event) => sum + event.completion_tokens, 0).toLocaleString()} completion tokens`, + status: "neutral", + tokens: rounds.reduce((sum, event) => sum + event.total_tokens, 0), + }); + } + + if (task.result) { + events.push({ + id: "final-outcome", + at: task.result.finished_at, + category: "outcome", + actor: "truthfulness gate", + title: + task.result.status === "error" + ? "Run rejected as failed" + : evidence.outcome === "delivered" + ? "Outcome delivered" + : "Run completed with issues", + detail: + task.result.status === "error" + ? `${evidence.roles.filter((role) => role.state === "delivered").length}/${evidence.roles.filter((role) => role.state !== "skipped").length} selected roles returned durable handbacks.` + : evidence.issues[0] ?? null, + status: task.result.status === "error" ? "error" : "ok", + tokens: task.result.total_tokens ?? undefined, + }); + } + + return events.sort((left, right) => (left.at ?? "").localeCompare(right.at ?? "")); +} + +const FILTERS: Array<{ value: "all" | FlowCategory | "errors"; label: string }> = [ + { value: "all", label: "All flow" }, + { value: "team", label: "Team handoffs" }, + { value: "tool", label: "Tools" }, + { value: "errors", label: "Errors" }, + { value: "reasoning", label: "Reasoning cost" }, +]; + +function eventTime(value: string | null): string { + if (!value) return "time unavailable"; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) + ? "time unavailable" + : `${parsed.toISOString().slice(11, 19)} UTC`; +} + +function SignalPath({ + task, + evidence, +}: { + task: TaskDetail; + evidence: TeamRunEvidence; +}) { + const roles = evidence.roles + .filter((role) => role.state !== "skipped") + .map((role) => role.role.name.replaceAll("-", " ")); + const toolCount = task.activity.filter((event) => event.kind === "tool").length; + const failedTools = task.activity.filter((event) => event.kind === "tool" && !event.ok).length; + const resultStatus = task.result?.status ?? null; + const running = resultStatus == null; + const nodes = [ + { label: "Controller", detail: "assignment", tone: "border-slate-400/40 bg-slate-500/[0.05]" }, + { label: "Principal", detail: "orchestration", tone: "border-sky-500/40 bg-sky-500/[0.06]" }, + { + label: + roles.length === 0 + ? "Principal only" + : roles.length === 1 + ? roles[0] + : `${roles.length} specialist roles`, + detail: + roles.length > 1 + ? roles.join(" · ") + : roles.length === 1 + ? "delegated work" + : "no delegation selected", + tone: "border-violet-500/40 bg-violet-500/[0.06]", + }, + { + label: `${toolCount} tool call${toolCount === 1 ? "" : "s"}`, + detail: + failedTools > 0 + ? `${failedTools} failed${running ? " · running" : ""}` + : running + ? "calls so far" + : "all returned", + tone: failedTools > 0 + ? "border-danger/40 bg-danger/[0.05]" + : running + ? "border-sky-500/40 bg-sky-500/[0.06]" + : "border-emerald-500/40 bg-emerald-500/[0.06]", + }, + { + label: "Truthfulness gate", + detail: running + ? "pending" + : resultStatus === "error" + ? "rejected" + : "verified", + tone: running + ? "border-border bg-surface-muted/30" + : resultStatus === "error" + ? "border-danger/40 bg-danger/[0.05]" + : "border-amber-500/40 bg-amber-500/[0.06]", + }, + ]; + + return ( + <div className="mt-4 rounded-lg border border-border bg-surface-muted/20 p-3"> + <div className="mb-2 flex items-center justify-between gap-2"> + <p className="text-[11px] font-semibold uppercase tracking-wide text-foreground-muted"> + Signal path + </p> + <p className="text-[10px] text-foreground-muted"> + assign → delegate → call → hand back → verify + </p> + </div> + <div className="flex items-stretch gap-2 overflow-x-auto pb-1"> + {nodes.map((node, index) => ( + <div key={node.label} className="flex min-w-0 items-center gap-2"> + {index > 0 && ( + <span className="shrink-0 text-base text-foreground-muted" aria-hidden> + → + </span> + )} + <div className={`min-w-32 rounded-lg border px-3 py-2 ${node.tone}`}> + <p className="truncate text-xs font-semibold">{node.label}</p> + <p className="mt-0.5 max-w-48 truncate text-[10px] text-foreground-muted" title={node.detail}> + {node.detail} + </p> + </div> + </div> + ))} + </div> + </div> + ); +} + +export function TeamRunFlow({ + task, + evidence, +}: { + task: TaskDetail; + evidence: TeamRunEvidence; +}) { + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState<(typeof FILTERS)[number]["value"]>("all"); + const events = useMemo(() => buildEvents(task, evidence), [task, evidence]); + const normalized = query.trim().toLowerCase(); + const visible = events.filter((event) => { + if (filter === "errors" && event.status !== "error") return false; + if (filter !== "all" && filter !== "errors" && event.category !== filter) return false; + if (!normalized) return true; + return [event.actor, event.title, event.detail, event.category] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(normalized)); + }); + const failedTools = task.activity.filter((event) => event.kind === "tool" && !event.ok).length; + const rounds = task.activity.filter((event) => event.kind === "round").length; + + return ( + <section className="rounded-xl border border-border bg-surface p-5"> + <div className="flex flex-wrap items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Execution flow</h2> + <p className="mt-1 text-xs text-foreground-muted"> + Searchable chronology of assignment, agent handoffs, grouped tool calls, reasoning cost, + and the final truthfulness decision. + </p> + </div> + <div className="flex flex-wrap gap-2 text-[11px] text-foreground-muted"> + <span>{task.assignment_events.length} lifecycle events</span> + <span>{task.activity.filter((event) => event.kind === "tool").length} tool calls</span> + <span className={failedTools > 0 ? "text-danger" : ""}>{failedTools} failed</span> + <span>{rounds} rounds</span> + <span>{(task.result?.total_tokens ?? 0).toLocaleString()} tokens</span> + </div> + </div> + + <SignalPath task={task} evidence={evidence} /> + + <div className="mt-4 flex flex-wrap gap-2"> + <input + type="search" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="Search role, tool, error, URL, or stage" + className="min-w-64 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + <select + value={filter} + onChange={(event) => + setFilter(event.target.value as (typeof FILTERS)[number]["value"]) + } + aria-label="Filter execution flow" + className="rounded-lg border border-border bg-surface px-3 py-2 text-xs" + > + {FILTERS.map((option) => ( + <option key={option.value} value={option.value}> + {option.label} + </option> + ))} + </select> + </div> + + <ol className="relative mt-5 space-y-3 before:absolute before:bottom-3 before:left-[7.1rem] before:top-3 before:w-px before:bg-border"> + {visible.map((event) => { + const tone = + event.status === "error" + ? "border-danger/40 bg-danger/[0.05]" + : event.status === "ok" + ? "border-emerald-500/30 bg-emerald-500/[0.04]" + : event.status === "working" + ? "border-sky-500/30 bg-sky-500/[0.04]" + : "border-border bg-surface-muted/20"; + return ( + <li key={event.id} className="relative grid grid-cols-[6.25rem_1fr] gap-6"> + <time className="pt-3 text-right text-[10px] text-foreground-muted"> + {eventTime(event.at)} + </time> + <span + className={`absolute left-[6.86rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-surface ${ + event.status === "error" + ? "bg-danger" + : event.status === "ok" + ? "bg-emerald-500" + : event.status === "working" + ? "bg-sky-500" + : "bg-foreground-muted" + }`} + aria-hidden + /> + <div className={`rounded-lg border px-3 py-2.5 ${tone}`}> + <div className="flex flex-wrap items-center gap-2"> + <span className="rounded bg-surface-muted px-1.5 py-0.5 font-mono text-[10px]"> + {event.actor} + </span> + <span className="text-xs font-semibold">{event.title}</span> + {(event.count ?? 1) > 1 && ( + <span className="rounded-full border border-border px-1.5 py-0.5 text-[9px]"> + ×{event.count} + </span> + )} + <span className="ml-auto text-[9px] uppercase tracking-wide text-foreground-muted"> + {event.category} + </span> + </div> + {event.detail && ( + <p className="mt-1 break-words text-[11px] leading-relaxed text-foreground-muted"> + {event.detail} + </p> + )} + </div> + </li> + ); + })} + </ol> + {visible.length === 0 && ( + <p className="mt-5 text-xs text-foreground-muted">No flow events match this search.</p> + )} + </section> + ); +} diff --git a/bridge/web/src/components/team-timing.tsx b/bridge/web/src/components/team-timing.tsx new file mode 100644 index 000000000..6c8b06bc7 --- /dev/null +++ b/bridge/web/src/components/team-timing.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; + +function distance(ms: number): string { + const seconds = Math.max(0, Math.floor(Math.abs(ms) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ${minutes % 60}m`; + return `${Math.floor(hours / 24)}d ${hours % 24}h`; +} + +export function TeamTiming({ + lastActivityAt, + nextActivityAt, + paused = false, + compact = false, +}: { + lastActivityAt: string | null; + nextActivityAt: string | null; + paused?: boolean; + compact?: boolean; +}) { + const [now, setNow] = useState<number | null>(null); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const last = lastActivityAt ? new Date(lastActivityAt).getTime() : null; + const next = nextActivityAt ? new Date(nextActivityAt).getTime() : null; + const className = compact + ? "mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-foreground-muted" + : "flex flex-wrap gap-x-5 gap-y-1 rounded-xl border border-border bg-surface px-4 py-3 text-xs text-foreground-muted"; + + return ( + <div className={className}> + <span title={lastActivityAt ?? undefined}> + Last activity: {last == null ? "none yet" : now == null ? "recorded" : `${distance(now - last)} ago`} + </span> + <span title={nextActivityAt ?? undefined}> + Next scheduled activity:{" "} + {paused + ? "paused" + : next == null + ? "on demand" + : now == null + ? "scheduled" + : next <= now + ? "due now" + : `in ${distance(next - now)}`} + </span> + </div> + ); +} diff --git a/bridge/web/src/components/theme-toggle.tsx b/bridge/web/src/components/theme-toggle.tsx new file mode 100644 index 000000000..958fba9e0 --- /dev/null +++ b/bridge/web/src/components/theme-toggle.tsx @@ -0,0 +1,83 @@ +"use client"; + +// kars Bridge — theme toggle. The palette already ships light + dark token sets +// (globals.css); this lets an operator explicitly pick one instead of being +// locked to the OS preference. The choice is persisted in localStorage and +// applied by adding `light` / `dark` (or neither = follow system) to <html>. +// A tiny inline script in the root layout applies the saved choice before +// first paint so there is no flash of the wrong theme. + +import { useEffect, useState } from "react"; +import { Icon } from "@/components/icon"; + +type Choice = "light" | "dark" | "system"; + +const STORAGE_KEY = "kb-theme"; + +function apply(choice: Choice): void { + const root = document.documentElement; + root.classList.remove("light", "dark"); + if (choice === "light") root.classList.add("light"); + else if (choice === "dark") root.classList.add("dark"); + try { + if (choice === "system") localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, choice); + } catch { + /* private mode — non-fatal, the in-memory choice still applies */ + } +} + +function current(): Choice { + if (typeof document === "undefined") return "system"; + const root = document.documentElement; + if (root.classList.contains("dark")) return "dark"; + if (root.classList.contains("light")) return "light"; + return "system"; +} + +export function ThemeToggle() { + // Cycle light -> dark -> system so all three are reachable from one control. + const [choice, setChoice] = useState<Choice>("system"); + + useEffect(() => { + const frame = window.requestAnimationFrame(() => setChoice(current())); + return () => window.cancelAnimationFrame(frame); + }, []); + + const next: Record<Choice, Choice> = { light: "dark", dark: "system", system: "light" }; + // What the CURRENT theme is (for the icon + the accessible "current" hint). + const label: Record<Choice, string> = { + light: "Light", + dark: "Dark", + system: "System", + }; + // What CLICKING does — the button describes the ACTION, not the current state, + // so "Use dark theme" actually switches to dark (audit N3). + const actionLabel: Record<Choice, string> = { + light: "Use dark theme", + dark: "Use system theme", + system: "Use light theme", + }; + const iconFor: Record<Choice, import("@/components/icon").IconName> = { + light: "target", + dark: "shield", + system: "gear", + }; + + return ( + <button + type="button" + onClick={() => { + const c = next[choice]; + setChoice(c); + apply(c); + }} + title={`${label[choice]} theme active — click to ${actionLabel[choice].toLowerCase()}`} + aria-label={`${label[choice]} theme active. ${actionLabel[choice]}.`} + className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-muted transition hover:bg-surface-muted hover:text-foreground" + > + <Icon name={iconFor[choice]} className="h-3.5 w-3.5" aria-hidden /> + <span className="hidden sm:inline">{actionLabel[choice]}</span> + </button> + ); +} diff --git a/bridge/web/src/components/ui.tsx b/bridge/web/src/components/ui.tsx new file mode 100644 index 000000000..ebb558bf0 --- /dev/null +++ b/bridge/web/src/components/ui.tsx @@ -0,0 +1,80 @@ +// kars Bridge — shared UI primitives. One consistent visual language so every +// page stops being an undifferentiated stack of gray boxes. Hierarchy comes +// from these: PageHeader (eyebrow + title + lead), Section (titled block), +// Card (hoverable surface), Stat (KPI), Badge (status), Skeleton (loading). + +import type { ReactNode } from "react"; + +export function PageHeader({ eyebrow, title, lead, action }: { eyebrow?: string; title: string; lead?: string; action?: ReactNode }) { + return ( + <div className="flex flex-wrap items-end justify-between gap-4 border-b border-border/70 pb-4"> + <div className="max-w-2xl"> + {eyebrow && ( + <p className="mb-1.5 inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-signal"> + <span aria-hidden className="inline-block h-1 w-1 rounded-full bg-signal" /> + {eyebrow} + </p> + )} + <h1 className="text-2xl font-semibold tracking-tight sm:text-[1.7rem]">{title}</h1> + {lead && <p className="mt-1.5 text-sm leading-relaxed text-foreground-muted">{lead}</p>} + </div> + {action && <div className="shrink-0">{action}</div>} + </div> + ); +} + +export function Section({ title, subtitle, action, children, className = "" }: { title?: string; subtitle?: string; action?: ReactNode; children: ReactNode; className?: string }) { + return ( + <section className={`kb-card p-5 sm:p-6 ${className}`}> + {(title || action) && ( + <div className="mb-4 flex items-start justify-between gap-3"> + <div> + {title && <h2 className="text-sm font-semibold">{title}</h2>} + {subtitle && <p className="mt-0.5 text-xs text-foreground-muted">{subtitle}</p>} + </div> + {action} + </div> + )} + {children} + </section> + ); +} + +export function Card({ children, className = "", hover = false, accent = false }: { children: ReactNode; className?: string; hover?: boolean; accent?: boolean }) { + return ( + <div className={`kb-card ${hover ? "kb-card-hover" : ""} ${accent ? "border-signal/30 bg-signal/[0.04]" : ""} p-4 ${className}`}>{children}</div> + ); +} + +export function Stat({ label, value, hint, accent }: { label: string; value: ReactNode; hint?: string; accent?: boolean }) { + return ( + <div className={`group relative overflow-hidden rounded-xl border p-4 shadow-sm transition hover:shadow-md ${accent ? "border-signal/30 bg-gradient-to-br from-signal/[0.08] to-transparent" : "border-border bg-surface"}`}> + {accent && <span aria-hidden className="pointer-events-none absolute -right-6 -top-6 h-16 w-16 rounded-full bg-signal/10 blur-2xl" />} + <p className={`text-[1.7rem] font-semibold tabular-nums leading-none tracking-tight ${accent ? "text-signal" : "text-foreground"}`}>{value}</p> + <p className="mt-1.5 text-xs font-medium text-foreground-muted">{label}</p> + {hint && <p className="mt-1 text-[11px] text-foreground-muted/80">{hint}</p>} + </div> + ); +} + +type Tone = "ok" | "warn" | "danger" | "info" | "muted" | "accent"; +const TONE: Record<Tone, string> = { + ok: "border-ok/30 bg-ok/10 text-ok", + warn: "border-warning/30 bg-warning/10 text-warning", + danger: "border-danger/30 bg-danger/10 text-danger", + info: "border-signal/30 bg-signal/10 text-signal", + accent: "border-accent/30 bg-accent/10 text-accent", + muted: "border-border bg-surface-muted text-foreground-muted", +}; +export function Badge({ tone = "muted", children, dot = false }: { tone?: Tone; children: ReactNode; dot?: boolean }) { + return ( + <span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${TONE[tone]}`}> + {dot && <span className="h-1.5 w-1.5 rounded-full bg-current" />} + {children} + </span> + ); +} + +export function Skeleton({ className = "" }: { className?: string }) { + return <div className={`kb-skeleton ${className}`} />; +} diff --git a/bridge/web/src/components/use-live-trace.ts b/bridge/web/src/components/use-live-trace.ts new file mode 100644 index 000000000..fdee0a431 --- /dev/null +++ b/bridge/web/src/components/use-live-trace.ts @@ -0,0 +1,97 @@ +"use client"; + +// kars Bridge — shared live-activity stream hook. +// +// One EventSource per mission/team run, consumed by BOTH the agent graph and the +// activity feed so a single Activity tab never opens two connections. It: +// - resets when the task `name` changes (no cross-run event contamination), +// - de-duplicates by a composite key (agent+kind+seq+round) so a server-side +// seed that already contains live events, plus the SSE tail, never double- +// count the same event, +// - does NOT close on a transient error (EventSource auto-reconnects); it only +// closes on the terminal `done` event and on unmount. + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ActivityEvent } from "@/lib/types"; + +function eventKey(e: ActivityEvent, principal: string, idx: number): string { + const anyE = e as unknown as Record<string, unknown>; + const seq = anyE.seq; + // When the router stamped a `seq`, it uniquely identifies the event, and the + // SAME event appears once from the seed (agent normalized to the principal — + // the seed IS the principal's trace) and once from the live tail (agent = the + // emitting sandbox). Key on (agent, seq) so those de-dupe while running. + if (typeof seq === "number") { + const rawInstance = anyE.agentInstance; + const rawAgent = anyE.agent; + const agent = + typeof rawInstance === "string" && rawInstance + ? rawInstance + : typeof rawAgent === "string" && rawAgent + ? rawAgent + : principal; + return `${agent}:${e.kind}:${seq}`; + } + // No router `seq` — this is the persisted, agent-self-reported trace read on a + // DELIVERED run (no live tail to collide with). Its events are already unique, + // so key on the source index to keep every round/tool distinct. (Dropping this + // and keying only on agent:kind collapsed a 6-round run to a single row.) + const round = "round" in anyE ? anyE.round : ""; + return `seed:${e.kind}:${round}:${idx}`; +} + +/** + * Returns the de-duplicated union of the server-provided `seed` activity and the + * live SSE tail. One connection, shared by every consumer that passes the result + * down as `events`. + */ +export function useLiveTrace( + ns: string | undefined, + name: string | undefined, + running: boolean, + seed: ActivityEvent[], +): ActivityEvent[] { + const [live, setLive] = useState<ActivityEvent[]>([]); + // Gate live events behind a mount flag so the FIRST client render is byte-for- + // byte identical to the server's (both derive purely from `seed`). Without + // this, an EventSource frame that lands between hydration scheduling and commit + // can slip a live event into the first client render, diverging from the SSR + // HTML and tripping a hydration mismatch in every consumer (graph + feed). + const [mounted, setMounted] = useState(false); + useEffect(() => { + const frame = requestAnimationFrame(() => setMounted(true)); + return () => cancelAnimationFrame(frame); + }, []); + // Track the current stream key so a `name` change resets accumulated events. + const streamKey = `${ns ?? ""}/${name ?? ""}`; + const prevKey = useRef(streamKey); + + useEffect(() => { + // New task → drop any events accumulated for the previous one. + if (prevKey.current !== streamKey) { + prevKey.current = streamKey; + setLive([]); + } + if (!running || !ns || !name) return; + const es = new EventSource(`/api/namespaces/${ns}/tasks/${name}/stream`); + es.onmessage = (m) => { + try { + setLive((prev) => [...prev, JSON.parse(m.data) as ActivityEvent]); + } catch { + /* ignore malformed frame */ + } + }; + es.addEventListener("done", () => es.close()); + // Intentionally NOT closing on error: the browser auto-reconnects an + // EventSource, so a transient blip resumes instead of stranding the stream. + return () => es.close(); + }, [running, ns, name, streamKey]); + + return useMemo(() => { + const map = new Map<string, ActivityEvent>(); + const principal = name ?? ""; + seed.forEach((e, i) => map.set(eventKey(e, principal, i), e)); + if (mounted) live.forEach((e, i) => map.set(eventKey(e, principal, seed.length + i), e)); + return [...map.values()]; + }, [seed, live, mounted, name]); +} diff --git a/bridge/web/src/components/viewport-portal.tsx b/bridge/web/src/components/viewport-portal.tsx new file mode 100644 index 000000000..bd02c1d9c --- /dev/null +++ b/bridge/web/src/components/viewport-portal.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +export function ViewportPortal({ + children, + onClose, +}: { + children: ReactNode; + onClose: () => void; +}) { + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("keydown", closeOnEscape); + document.body.style.overflow = previousOverflow; + }; + }, [onClose]); + + return createPortal(children, document.body); +} diff --git a/bridge/web/src/components/wiring-badge.tsx b/bridge/web/src/components/wiring-badge.tsx new file mode 100644 index 000000000..5ddb49c30 --- /dev/null +++ b/bridge/web/src/components/wiring-badge.tsx @@ -0,0 +1,29 @@ +// kars Bridge web — wiring-status badge. Communicates honest implementation +// status: live (green), partial (amber), not wired (neutral/dashed). + +import type { WiringStatus } from "@/lib/types"; +import { WIRING_LABELS } from "@/lib/types"; + +const STYLE: Record<WiringStatus, string> = { + live: "border-ok/30 bg-ok/15 text-ok", + partial: "border-warning/30 bg-warning/15 text-warning", + not_wired: "border-border bg-surface-muted text-foreground-muted", +}; + +export function WiringBadge({ status }: { status: WiringStatus }) { + return ( + <span + className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${STYLE[status]}`} + > + <span + aria-hidden + className={ + status === "not_wired" + ? "h-1.5 w-1.5 rounded-full border border-current" + : "h-1.5 w-1.5 rounded-full bg-current" + } + /> + {WIRING_LABELS[status]} + </span> + ); +} diff --git a/bridge/web/src/components/workspace-nav.tsx b/bridge/web/src/components/workspace-nav.tsx new file mode 100644 index 000000000..67009271c --- /dev/null +++ b/bridge/web/src/components/workspace-nav.tsx @@ -0,0 +1,80 @@ +"use client"; + +// kars Bridge Workspace — primary navigation (employee surface). +// Plain-language, mission-first. No Kubernetes vocabulary ever. + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +const NAV = [ + { href: "/workspace", label: "Home", exact: true, icon: "home", hint: "Start here" }, + { href: "/workspace/missions", label: "Missions", exact: false, icon: "grid", hint: "One-off tasks" }, + { href: "/workspace/agents", label: "Active agents", exact: false, icon: "pulse", hint: "Working right now" }, + { href: "/workspace/teams", label: "Teams", exact: false, icon: "people", hint: "Standing, long-running work" }, + { href: "/workspace/inbox", label: "Inbox", exact: false, icon: "inbox", hint: "Decisions waiting on you" }, + { href: "/workspace/artifacts", label: "Artifacts", exact: false, icon: "doc", hint: "What was produced" }, + { href: "/workspace/skills", label: "Skills", exact: false, icon: "doc", hint: "Upload & assign capabilities" }, + { href: "/workspace/connections", label: "Connections", exact: false, icon: "link", hint: "Connect your GitHub repos" }, +] as const; + +const ICON: Record<string, React.ReactNode> = { + home: <path d="M3 9.5 10 4l7 5.5V17a1 1 0 0 1-1 1h-3v-5H7v5H4a1 1 0 0 1-1-1V9.5Z" />, + plus: <path d="M10 4v12M4 10h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" />, + grid: <path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />, + people: <path d="M7 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm6 0a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm-6 1.5c-2.5 0-4.5 1.4-4.5 3.2V16h9v-2.3c0-1.8-2-3.2-4.5-3.2Zm6 0c-.6 0-1.2.08-1.7.23 1 .8 1.7 1.9 1.7 3v2.27h4.5V13.7c0-1.8-2-3.2-4.5-3.2Z" />, + inbox: <path d="M3 4h14v9a1 1 0 0 1-1 1h-3l-1 2H8l-1-2H4a1 1 0 0 1-1-1V4Zm2 2v5h2l1 2h4l1-2h2V6H5Z" />, + doc: <path d="M5 2h7l3 3v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm6 1.5V6h2.5L11 3.5Z" />, + chart: <path d="M3 17h14M6 13v2M10 8v7M14 11v4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" />, + pulse: <path d="M2 10h4l2-5 4 10 2-5h4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" fill="none" />, + link: <path d="M8 12a3 3 0 0 0 4 0l2-2a3 3 0 0 0-4-4l-1 1M12 8a3 3 0 0 0-4 0l-2 2a3 3 0 0 0 4 4l1-1" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" fill="none" />, +}; + +export function WorkspaceNav({ pendingAsks = 0 }: { pendingAsks?: number }) { + const pathname = usePathname(); + return ( + <nav aria-label="Workspace" className="hidden w-52 shrink-0 md:block"> + <ul className="space-y-1"> + {NAV.map((item) => { + const active = item.exact + ? pathname === item.href + : pathname.startsWith(item.href); + const badge = item.href === "/workspace/inbox" && pendingAsks > 0 ? pendingAsks : null; + return ( + <li key={item.href}> + <Link + href={item.href} + prefetch={false} + aria-current={active ? "page" : undefined} + aria-label={`${item.label} — ${item.hint}`} + className={[ + "relative flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal", + active + ? "bg-signal/10 font-medium text-foreground before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-signal" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground", + ].join(" ")} + > + <svg viewBox="0 0 20 20" className="mt-0.5 h-4 w-4 shrink-0 self-start" fill="currentColor" aria-hidden> + {ICON[item.icon]} + </svg> + <span className="flex flex-1 flex-col leading-tight"> + <span className="flex items-center gap-1.5"> + {item.label} + {badge != null && ( + <span + className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-warning px-1 text-[10px] font-semibold text-white kb-pulse" + title={`${badge} decision${badge === 1 ? "" : "s"} waiting on you`} + > + {badge} + </span> + )} + </span> + <span className="text-[11px] text-foreground-muted">{item.hint}</span> + </span> + </Link> + </li> + ); + })} + </ul> + </nav> + ); +} diff --git a/bridge/web/src/lib/auth-return.ts b/bridge/web/src/lib/auth-return.ts new file mode 100644 index 000000000..8b5f84843 --- /dev/null +++ b/bridge/web/src/lib/auth-return.ts @@ -0,0 +1,17 @@ +export function safeReturnTo(value: string | null | undefined, fallback = "/workspace"): string { + const candidate = value?.trim(); + if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//") || candidate.includes("\\")) { + return fallback; + } + try { + const parsed = new URL(candidate, "http://bridge.local"); + if (parsed.origin !== "http://bridge.local") return fallback; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return fallback; + } +} + +export function loginPath(returnTo: string): string { + return `/auth/login?returnTo=${encodeURIComponent(safeReturnTo(returnTo))}`; +} diff --git a/bridge/web/src/lib/bff.ts b/bridge/web/src/lib/bff.ts new file mode 100644 index 000000000..a9596475e --- /dev/null +++ b/bridge/web/src/lib/bff.ts @@ -0,0 +1,894 @@ +// kars Bridge web — server-side BFF client. +// +// This module runs only on the Next.js server. It is the single place the +// web app reaches the BFF, so auth-cookie forwarding and error normalization +// live here once. Importing this from a Client Component is a build error by +// design (it has no "use client"). + +import { bffBaseUrl } from "./config"; +import { cookies } from "next/headers"; +import { SESSION_COOKIE, verifySession } from "./session-token"; +import { ssoConfigured } from "./oidc-config"; +import { parseCredentialContinuation, parseCredentialReview, + type CredentialContinuation, type CredentialInput, type CredentialReview } from "./credential-review"; +import type { + Approval, + CreateTaskRequest, + Options, + Receipt, + SystemStatus, + TaskDetail, + TaskSummary, +} from "./types"; + +/** Liveness shape returned by the BFF `/healthz`. */ +export interface BffHealth { + status: string; + service: string; + version: string; +} + +/** Readiness shape returned by the BFF `/readyz`. */ +export interface BffReadiness { + status: string; + cluster_configured: boolean; +} + +/** Result of probing the BFF — either reachable with a payload, or not. */ +export type BffProbe<T> = + | { reachable: true; data: T } + | { reachable: false; error: string }; + +async function getJson<T>(path: string): Promise<BffProbe<T>> { + const url = `${bffBaseUrl()}${path}`; + try { + const res = await fetch(url, { + // BFF readiness must never be cached — it reflects live state. + cache: "no-store", + headers: { accept: "application/json" }, + }); + if (!res.ok) { + return { reachable: false, error: `BFF responded ${res.status}` }; + } + const data = (await res.json()) as T; + return { reachable: true, data }; + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error"; + return { reachable: false, error: message }; + } +} + +/** Probe BFF readiness (includes cluster-wiring status). */ +export function probeReadiness(): Promise<BffProbe<BffReadiness>> { + return getJson<BffReadiness>("/readyz"); +} + +/** Probe BFF liveness + version. */ +export function probeHealth(): Promise<BffProbe<BffHealth>> { + return getJson<BffHealth>("/healthz"); +} + +// ─── KarsTask API ────────────────────────────────────────────────────────── +// Server-only client functions for the task surface. All run in Server +// Components / Route Handlers; the browser never calls the cluster directly. + +async function requestJson<T>( + path: string, + init?: RequestInit, +): Promise<T> { + const res = await authenticatedBffFetch(path, init); + if (!res.ok) { + let code = `http_${res.status}`; + let message = ""; + let credentialContinuation: CredentialContinuation | undefined; + try { + const body = await res.json(); + if (body?.error?.code) code = body.error.code; + if (body?.error?.message) message = body.error.message; + if (res.status === 409 && code === "conflict") + credentialContinuation = parseCredentialContinuation(body?.error?.credentialContinuation); + } catch { + // non-JSON error body; keep the status-derived code + } + throw new BffError(code, res.status, message, credentialContinuation); + } + return (await res.json()) as T; +} + +/** Authenticated raw BFF request for server actions that need non-JSON bodies + * or custom response handling. This is the ONLY direct BFF fetch path. */ +export async function authenticatedBffFetch( + path: string, + init?: RequestInit, +): Promise<Response> { + const headers = new Headers(init?.headers); + headers.set("accept", "application/json"); + if (init?.body != null && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + if (ssoConfigured()) { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + if (!token || !(await verifySession(token))) { + throw new BffError("unauthorized", 401, "A signed Bridge session is required."); + } + headers.set("x-kars-principal-token", token); + } + return fetch(`${bffBaseUrl()}${path}`, { + cache: "no-store", + ...init, + headers, + }); +} + +/** Error carrying the BFF's stable error code + HTTP status + message. */ +export class BffError extends Error { + #credentialContinuation?: CredentialContinuation; + + constructor( + public readonly code: string, + public readonly status: number, + message = "", + credentialContinuation?: CredentialContinuation, + ) { + super(message || code); + this.name = "BffError"; + this.#credentialContinuation = credentialContinuation; + } + + get credentialContinuation(): CredentialContinuation | undefined { return this.#credentialContinuation; } +} + +/** List tasks in a namespace. */ +export function listTasks(namespace: string): Promise<TaskSummary[]> { + return requestJson<TaskSummary[]>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks`, + ); +} + +/** Fetch one task by name. */ +export function getTask( + namespace: string, + name: string, +): Promise<TaskDetail> { + return requestJson<TaskDetail>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}`, + ); +} + +/** Create a task. */ +export function createTask( + namespace: string, + body: CreateTaskRequest, +): Promise<TaskDetail> { + return requestJson<TaskDetail>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks`, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +// ─── KarsTeam API (standing orgs) ──────────────────────────────────────────── + +/** List standing teams in a namespace. */ +export function listTeams( + namespace: string, +): Promise<import("./types").TeamSummary[]> { + return requestJson<import("./types").TeamSummary[]>( + `/api/namespaces/${encodeURIComponent(namespace)}/teams`, + ); +} + +/** Fetch one team by name (charter, org chart, watching status, history). */ +export function getTeam( + namespace: string, + name: string, +): Promise<import("./types").TeamDetail> { + return requestJson<import("./types").TeamDetail>( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(name)}`, + ); +} + +export function getEngineeringSource( + namespace: string, + team: string, +): Promise<import("./types").EngineeringSource> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(team)}/engineering-source`, + ); +} + +export function putEngineeringSource( + namespace: string, + team: string, + body: { + enabled: boolean; + auto_run: boolean; + repos: string[]; + signals: import("./types").EngineeringSignal[]; + poll_interval_seconds: number; + }, +): Promise<import("./types").EngineeringSource> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(team)}/engineering-source`, + { method: "PUT", body: JSON.stringify(body) }, + ); +} + +export function syncEngineeringSource( + namespace: string, + team: string, +): Promise<import("./types").EngineeringSource> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(team)}/engineering-source/sync`, + { method: "POST" }, + ); +} + +export function deleteEngineeringSource( + namespace: string, + team: string, +): Promise<import("./types").EngineeringSource> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(team)}/engineering-source`, + { method: "DELETE" }, + ); +} + +export function getGithubConnection( + namespace: string, +): Promise<import("./types").GithubConnection> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/github/connection`, + ); +} + +/** Fetch a team's knowledge commons (shared, provenance-tracked memory). */ +export function getTeamCommons( + namespace: string, + name: string, +): Promise<import("./types").CommonsResponse> { + return requestJson<import("./types").CommonsResponse>( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(name)}/commons`, + ); +} + +export function getTeamChannels( + namespace: string, + name: string, +): Promise<import("./types").TeamChannelsState> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(name)}/channels`, + ); +} + +export function getArchivedTeamRun( + namespace: string, + team: string, + run: string, +): Promise<import("./types").CommonsEntry> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(team)}/runs/${encodeURIComponent(run)}/archive`, + ); +} + +// ─── Artifact review (§16) ─────────────────────────────────────────────────── + +/** Fetch the review state for a mission's deliverable. */ +export function getReview( + namespace: string, + name: string, +): Promise<import("./types").ReviewState> { + return requestJson<import("./types").ReviewState>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/review`, + ); +} + +/** Record a review decision; request_changes re-drives the producing task. */ +export function postReview( + namespace: string, + name: string, + body: { decision: "approve" | "request_changes"; comment?: string }, +): Promise<import("./types").ReviewState> { + return requestJson<import("./types").ReviewState>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/review`, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +/** Request a temporary, scoped egress grant for a mission's sandbox. Files an + * EgressApproval; widens nothing until a human approves it. */ +export function requestEgress( + namespace: string, + name: string, + body: { host: string; port?: number | null; reason: string; ttl?: string }, +): Promise<{ requested: boolean; name: string | null; note: string }> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/egress`, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +/** The cross-team digest stream (§20), newest first. */ +export function getDigests(): Promise<import("./types").Digest[]> { + return requestJson<import("./types").Digest[]>("/api/digests"); +} + +/** The cross-harness efficiency frontier (§3B). */ +export function getEfficiency(): Promise<import("./types").Efficiency> { + return requestJson<import("./types").Efficiency>("/api/efficiency"); +} + +/** The hierarchical inference-budget config + live measured daily usage. */ +export function getInferenceBudgets(): Promise<import("./types").InferenceBudgets> { + return requestJson<import("./types").InferenceBudgets>("/api/operator/inference-budgets"); +} + +/** Cluster-wide mission/team-run retention default (auto-delete delivered + * records after a TTL — mirrors Kubernetes' Job.ttlSecondsAfterFinished). */ +export function getRetentionPolicy(): Promise<import("./types").RetentionPolicy> { + return requestJson<import("./types").RetentionPolicy>("/api/operator/retention-policy"); +} + +/** Set the cluster-wide retention default (admin-only, enforced server-side). */ +export function setRetentionPolicy( + defaultTtlSeconds: number, +): Promise<import("./types").RetentionPolicy> { + return requestJson<import("./types").RetentionPolicy>("/api/operator/retention-policy", { + method: "PUT", + body: JSON.stringify({ default_ttl_seconds: defaultTtlSeconds }), + }); +} + +/** The team's continuous ledger (§14), newest first. */ +export function getTeamLedger( + namespace: string, + name: string, +): Promise<import("./types").LedgerEvent[]> { + return requestJson<import("./types").LedgerEvent[]>( + `/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(name)}/ledger`, + ); +} + +/** Fetch the composable launch-package options from live cluster state. */ +export function getOptions(): Promise<Options> { + return requestJson<Options>("/api/options"); +} + +/** Validate a launch package against live cluster state (the §20 gate). */ +export function validatePackage( + namespace: string, + blueprint: unknown, + envelope?: { tier?: number; budget_tokens?: number | null; workload?: "mission" | "team" }, +): Promise<import("./types").ValidationResult> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/validate`, + { method: "POST", body: JSON.stringify({ + blueprint, + tier: envelope?.tier, + budget_tokens: envelope?.budget_tokens ?? undefined, + workload: envelope?.workload, + }) }, + ); +} + +/** Ask the orchestrator to compose a launch package from a plain objective. */ +export function composePackage( + namespace: string, + objective: string, +): Promise<import("./types").ComposeResponse> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/compose`, + { + method: "POST", + body: JSON.stringify({ objective }), + signal: AbortSignal.timeout(120_000), + }, + ); +} + +/** Ask the orchestrator to propose a 2026 loop (pattern + goal + criteria) from + * a raw intent, for the user to review in the Loop Designer before executing. */ +export function proposeLoop( + namespace: string, + intent: string, + surface: "mission" | "team", +): Promise<import("./types").LoopProposal> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/propose-loop`, + { + method: "POST", + body: JSON.stringify({ intent, surface }), + signal: AbortSignal.timeout(120_000), + }, + ); +} + +/** Ask the orchestrator to compose an org chart from a team charter. */ +export function composeTeam( + namespace: string, + charter: string, +): Promise<import("./types").ComposeTeamResponse> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/compose-team`, + { + method: "POST", + body: JSON.stringify({ charter }), + // Team composition can perform one structural repair and one qualification + // repair after the initial proposal. Keep the browser alive for that bounded + // server workflow instead of aborting a valid final repair mid-flight. + signal: AbortSignal.timeout(300_000), + }, + ); +} + +/** Independently verify a receipt's DSSE/Ed25519 signature server-side against + * the controller's published public-key anchor — a real cryptographic verdict + * in the browser, no CLI required. */ +export function verifyReceipt( + namespace: string, + task: string, +): Promise<import("./types").VerifyResult> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(task)}/receipt/verify`, + { method: "POST" }, + ); +} + +/** The cross-mission deliverable index — real captured artifacts. */ +export function getArtifacts(): Promise<import("./types").ArtifactsIndex> { + return requestJson<import("./types").ArtifactsIndex>("/api/artifacts"); +} + +/** Fetch the Governance Receipt for a task, or null if it has none. */ +export async function getReceipt( + namespace: string, + name: string, +): Promise<Receipt | null> { + try { + return await requestJson<Receipt>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/receipt`, + ); + } catch (e) { + if (e instanceof BffError && e.status === 404) return null; + throw e; + } +} + +/** Fetch the honest system / wiring status. */ +export function getSystem(): Promise<SystemStatus> { + return requestJson<SystemStatus>("/api/system"); +} + +/** List KarsEval safety/conformance evals with their latest verdicts. */ +export function listEvals(): Promise<import("./types").Eval[]> { + return requestJson<import("./types").Eval[]>("/api/operator/evals"); +} + +/** Fetch the compliance evidence pack (EU AI Act / NIST AI RMF) derived from a + * mission's signed receipt, or null if it has no receipt yet. */ +export async function getCompliancePack( + namespace: string, + name: string, +): Promise<import("./types").CompliancePack | null> { + try { + return await requestJson<import("./types").CompliancePack>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/compliance`, + ); + } catch (e) { + if (e instanceof BffError && e.status === 404) return null; + throw e; + } +} + +export function getDiagnostics(): Promise<import("./types").Diagnostics> { + return requestJson("/api/operator/diagnostics"); +} + +export function getOrchestrator(): Promise<import("./types").Orchestrator> { + return requestJson("/api/operator/orchestrator"); +} + +export function getIntegrations(): Promise<import("./types").Integrations> { + return requestJson("/api/operator/integrations"); +} + +export function getGithubApp(): Promise<import("./types").GithubApp> { + return requestJson("/api/github/app"); +} + +// ─── kars-SRE self-remediation proposals ──────────────────────────────────── + +export function listSreActions(): Promise<import("./types").SreAction[]> { + return requestJson("/api/operator/sre-actions"); +} + +export function decideSreAction( + namespace: string, + name: string, + body: { verdict: "approve" | "reject"; note?: string }, +): Promise<import("./types").SreAction> { + return requestJson( + `/api/operator/sre-actions/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/decision`, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +// ─── Steering / HITL approvals ─────────────────────────────────────────────── + +/** Fleet-wide steering inbox. Pass `pending` to show only undecided. */ +export function listApprovals( + namespace: string, + opts?: { pending?: boolean; scopeAll?: boolean }, +): Promise<Approval[]> { + const params = new URLSearchParams(); + if (opts?.pending) params.set("pending", "true"); + if (opts?.scopeAll) params.set("scope_all", "true"); + const q = params.size > 0 ? `?${params}` : ""; + return requestJson<Approval[]>( + `/api/namespaces/${encodeURIComponent(namespace)}/approvals${q}`, + ); +} + +/** Approvals gating a single task. */ +export function listTaskApprovals( + namespace: string, + name: string, +): Promise<Approval[]> { + return requestJson<Approval[]>( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/approvals`, + ); +} + +/** Record a human decision (approve/deny) on an approval. */ +export function decideApproval( + namespace: string, + name: string, + body: { + verdict: "approve" | "deny"; + reason?: string; + resource_version: string; + bound_envelope_digest: string | null; + }, +): Promise<Approval> { + return requestJson<Approval>( + `/api/namespaces/${encodeURIComponent(namespace)}/approvals/${encodeURIComponent(name)}/decision`, + { method: "POST", body: JSON.stringify(body) }, + ); +} + +// ─── Operator Console + Insights (real cluster reads) ─────────────────────── + +export function listSandboxes(): Promise<import("./types").Sandbox[]> { + return requestJson("/api/operator/sandboxes"); +} +export function getClusterCapacity(): Promise<import("./types").ClusterCapacity> { + return requestJson("/api/operator/capacity"); +} +export function listMcpServers(): Promise<import("./types").McpServer[]> { + return requestJson("/api/operator/mcpservers"); +} +export function listToolPolicies(): Promise<import("./types").ToolPolicy[]> { + return requestJson("/api/operator/toolpolicies"); +} +export function listInferencePolicies(): Promise<import("./types").InferencePolicy[]> { + return requestJson("/api/operator/inferencepolicies"); +} +export function listEgress(): Promise<import("./types").EgressApproval[]> { + return requestJson("/api/operator/egress"); +} +export function getDatapathWitness(): Promise<import("./types").DatapathWitness> { + return requestJson("/api/operator/datapath-witness"); +} +export function getInsights(): Promise<import("./types").Insights> { + return requestJson("/api/insights"); +} +export function getScorecard( + namespace: string, + name: string, +): Promise<import("./types").Scorecard> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/scorecard`, + ); +} + +/** Live, cluster-backed troubleshooting for a failed run: real pod/container + * status + the agent's own log tail + an evidence-derived diagnosis. */ +export function getTroubleshoot( + namespace: string, + name: string, +): Promise<import("./types").Troubleshoot> { + return requestJson( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/troubleshoot`, + ); +} + +export function getAudit(): Promise<import("./types").Audit> { + return requestJson("/api/operator/audit"); +} + +export function listSkills(): Promise<import("./types").SkillSummary[]> { + return requestJson("/api/operator/skills"); +} +/** User-side: skills visible to the user (same list, user framing). */ +export function listUserSkills(): Promise<import("./types").SkillSummary[]> { + return requestJson("/api/skills"); +} +/** Fleet-wide live telemetry: aggregate live metrics + merged activity feed. */ +export function getFleetTelemetry(): Promise<import("./types").FleetTelemetry> { + return requestJson("/api/agents/fleet"); +} +export interface SubmitSkillInput { + name: string; + display_name?: string; + version: string; + summary: string; + bounding_policy: string; + recipe?: string; + mcp_servers?: string[]; + uploaded_by?: string; + /** Package files — flat filenames (SKILL.md + scripts) the agent installs. */ + files?: { name: string; content: string }[]; +} +/** User-side: submit a skill package. It lands PENDING operator review. */ +export function submitSkill( + input: SubmitSkillInput, +): Promise<import("./types").SkillSummary> { + return requestJson("/api/skills", { + method: "POST", + body: JSON.stringify(input), + }); +} +export function listProfiles(): Promise<import("./types").ProfileSummary[]> { + return requestJson("/api/operator/profiles"); +} +export function putCredential(body: CredentialInput & { value: string; review?: string }): Promise<{ stored: boolean; source: { name: string; uid: string }; namespace: string; phase: string; note: string }> { + return requestJson("/api/operator/credentials", { method: "POST", body: JSON.stringify(body) }); +} + +export async function reviewCredential(body: CredentialInput & { continuation?: string }): Promise<CredentialReview> { + const result = await requestJson<unknown>("/api/operator/credentials/review", { method: "POST", body: JSON.stringify(body) }); + const review = parseCredentialReview(result); + if (!review) throw new BffError("invalid_credential_review", 502, "Credential review metadata was malformed."); + return review; +} + +/** Configure the ONE shared kars GitHub App (operator self-service — replaces + * the manual `kubectl create secret` step). Verified against the real + * GitHub API before the credential is stored. */ +export function putGithubApp(body: { app_id: string; private_key: string }): Promise<{ configured: boolean; slug: string | null; name: string | null; note: string }> { + return requestJson("/api/operator/github-app", { method: "PUT", body: JSON.stringify(body) }); +} + +export function deleteGithubApp(): Promise<{ configured: boolean }> { + return requestJson("/api/operator/github-app", { method: "DELETE" }); +} + +/** Author or edit a governance CRD (ToolPolicy / McpServer / KarsSkill) via the + * BFF's Server-Side Apply endpoint — create on first apply, edit on re-apply. */ +export function applyGovernance( + plural: "toolpolicies" | "mcpservers" | "skills" | "profiles" | "inferencepolicies", + body: { name: string; spec: unknown; namespace?: string; force?: boolean }, +): Promise<{ applied: boolean; kind: string; name: string; namespace: string; note: string }> { + return requestJson(`/api/operator/${plural}`, { method: "PUT", body: JSON.stringify(body) }); +} + +/** Delete an operator-authored governance object (or revoke an egress grant). */ +export function deleteGovernance( + plural: "toolpolicies" | "mcpservers" | "skills" | "profiles" | "egress" | "inferencepolicies", + name: string, +): Promise<{ deleted: boolean; kind: string; name: string; namespace: string; note: string }> { + return requestJson(`/api/operator/${plural}/${encodeURIComponent(name)}`, { method: "DELETE" }); +} + +/** Approve + version-lock a skill (operator trust gate). Users only see + * approved+locked skills. */ +export function approveSkill( + name: string, + approved_by?: string, +): Promise<import("./types").SkillSummary> { + return requestJson(`/api/operator/skills/${encodeURIComponent(name)}/approve`, { + method: "POST", + body: JSON.stringify({ approved_by: approved_by ?? null }), + }); +} + +/** Revoke a skill's approval, returning it to review. */ +export function revokeSkill(name: string): Promise<import("./types").SkillSummary> { + return requestJson(`/api/operator/skills/${encodeURIComponent(name)}/revoke`, { method: "POST" }); +} + +/** Replicate a mission's exact package k times to measure pass^k reliability. */ +/** Request a per-mission tier promotion (§12). */ +export function promoteMission( + namespace: string, + name: string, + tier: number, +): Promise<{ requested: boolean; tier: number; note: string }> { + return requestJson(`/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/promote`, { + method: "POST", + body: JSON.stringify({ tier }), + }); +} + +export function replicateMission( + namespace: string, + name: string, + count: number, +): Promise<{ replicated: string; count: number; runs: string[]; note: string }> { + return requestJson(`/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/replicate`, { + method: "POST", + body: JSON.stringify({ count, launch: true }), + }); +} + +/** Operator: list/upsert/delete MCP profiles (vetted McpServer bundles). */ +export function putMcpProfile(body: { name: string; summary?: string | null; servers: string[] }): Promise<import("./types").McpProfileOption[]> { + return requestJson("/api/operator/mcp-profiles", { method: "PUT", body: JSON.stringify(body) }); +} +export function deleteMcpProfile(name: string): Promise<import("./types").McpProfileOption[]> { + return requestJson(`/api/operator/mcp-profiles/${encodeURIComponent(name)}`, { method: "DELETE" }); +} + +/** Operator: Foundry connection status/onboarding. */ +export interface FoundryStatus { + connected: boolean; + project_endpoint: string | null; + inference_endpoint: string | null; + memory_store_id: string | null; + auth: string | null; + has_api_key: boolean; +} +export interface FoundryCheck { label: string; status: string; detail: string } +export interface FoundryConnection { name: string; category: string | null } +export interface FoundryDiscovered { + models: string[]; + connections: FoundryConnection[]; + memory_store_found: boolean | null; +} +export interface FoundryVerifyResult { checks: FoundryCheck[]; discovered: FoundryDiscovered } + +export function getFoundry(): Promise<FoundryStatus> { + return requestJson("/api/operator/foundry"); +} +export function connectFoundry(body: { + project_endpoint: string; + inference_endpoint?: string; + memory_store_id?: string; + auth: "api" | "managed-identity"; + api_key?: string; +}): Promise<{ connected: boolean; note: string }> { + return requestJson("/api/operator/foundry", { method: "POST", body: JSON.stringify(body) }); +} +export function disconnectFoundry(): Promise<{ connected: boolean; note: string }> { + return requestJson("/api/operator/foundry", { method: "DELETE" }); +} +export function verifyFoundry(): Promise<FoundryVerifyResult> { + return requestJson("/api/operator/foundry/verify", { method: "POST" }); +} + +export function listAgents(): Promise<import("./types").AgentLifecycle[]> { + return requestJson("/api/agents"); +} + +export interface CreateRole { name: string; system_prompt?: string; runtime?: string; model?: string; skills?: string[] } +export function createTeam(namespace: string, body: { name: string; display_name?: string; charter: string; tier?: number; authority_ceiling?: number; delegation_depth?: number; reporting_to?: string; knowledge_commons?: string; memory?: string; tool_policy?: string; runtime?: string; model?: string; model_fallbacks?: string[]; mcp_servers?: string[]; egress?: { host: string; port?: number }[]; egress_mode?: "learning" | "strict"; cadence_minutes?: number; lifecycle_mode?: import("./types").TeamLifecycleMode; warm_idle_seconds?: number; launch?: boolean; roles?: CreateRole[]; execution_plan?: import("./types").ExecutionPlan; git_write_repos?: string[]; created_by?: string }): Promise<{ created: boolean; name: string }> { + return requestJson(`/api/namespaces/${encodeURIComponent(namespace)}/teams`, { method: "POST", body: JSON.stringify(body) }); +} +export function updateTeam(namespace: string, name: string, body: { charter?: string; paused?: boolean; cadence_minutes?: number; reporting_to?: string; lifecycle_mode?: import("./types").TeamLifecycleMode; warm_idle_seconds?: number; runtime?: string; model?: string; model_fallbacks?: string[]; memory?: string; mcp_servers?: string[]; execution_plan?: import("./types").ExecutionPlan }): Promise<{ updated: boolean }> { + return requestJson(`/api/namespaces/${encodeURIComponent(namespace)}/teams/${encodeURIComponent(name)}`, { method: "PATCH", body: JSON.stringify(body) }); +} + +export function putProvider(body: { kind: string; auth: string; endpoint?: string; models: string; key?: string }): Promise<{ onboarded: boolean; note: string }> { + return requestJson("/api/operator/providers", { method: "POST", body: JSON.stringify(body) }); +} + +/** Live model discovery so the operator never hand-types a deployment id. + * Throws (BffError) when the kind has no live catalog to query (e.g. GitHub + * Copilot) or the round-trip to the provider fails. */ +export function discoverModels(body: { kind: string; endpoint?: string; key?: string }): Promise<import("./types").DiscoveredModel[]> { + return requestJson("/api/operator/providers/discover", { method: "POST", body: JSON.stringify(body) }); +} + +// ─── GitHub Copilot device-flow sign-in ───────────────────────────────────── +// Mints a Copilot-authorized token via GitHub's device flow (a stock `gh` +// token 404s on the Copilot exchange). The token is stored server-side; the +// browser only ever sees the user code + the discovered models. +export interface CopilotLoginStart { device_code: string; user_code: string; verification_uri: string; interval: number; expires_in: number } +export function copilotLoginStart(): Promise<CopilotLoginStart> { + return requestJson("/api/operator/providers/copilot/login/start", { method: "POST" }); +} +export interface CopilotLoginPoll { status: "pending" | "authorized"; models?: import("./types").DiscoveredModel[] } +export function copilotLoginPoll(device_code: string): Promise<CopilotLoginPoll> { + return requestJson("/api/operator/providers/copilot/login/poll", { method: "POST", body: JSON.stringify({ device_code }) }); +} + +// ─── Additional providers (§ inference-provider-wizard) ───────────────────── +// Multiple providers can be configured at once (e.g. GitHub Copilot as the +// default, Azure AI Foundry also connected) — InferencePolicy decides which +// one a given sandbox's calls actually use, per request. + +export function listAdditionalProviders(): Promise<import("./types").AdditionalProvider[]> { + return requestJson("/api/operator/providers/additional"); +} +export function putAdditionalProvider(body: { tag: string; endpoint?: string; api_key?: string; models: string }): Promise<{ configured: boolean; tag: string; note: string }> { + return requestJson("/api/operator/providers/additional", { method: "PUT", body: JSON.stringify(body) }); +} +export function deleteAdditionalProvider(tag: string): Promise<{ removed: boolean; tag: string }> { + return requestJson(`/api/operator/providers/additional/${encodeURIComponent(tag)}`, { method: "DELETE" }); +} +export function promoteAdditionalProvider(tag: string): Promise<{ promoted: boolean; tag: string; note: string }> { + return requestJson(`/api/operator/providers/additional/${encodeURIComponent(tag)}/promote`, { method: "POST" }); +} +export function setDefaultModel(deployment: string, provider: string): Promise<{ ok: boolean; default: string; provider: string }> { + return requestJson("/api/operator/models/default", { method: "POST", body: JSON.stringify({ deployment, provider }) }); +} + + +// ─── Local (in-cluster) inference (§ local-inference) ──────────────────────── +// A model running entirely inside the cluster — no external API, no egress +// dependency. Built on AI Runway's ModelDeployment CRD, which kars does not +// install itself (see docs/local-inference.md in the kars core repo) — an +// operator installs AI Runway + KAITO once, the same tier as the GitHub App. + +export interface LocalInferenceStatus { + available: boolean; + gpu_node_count: number; + gpu_products: string[]; +} +export function getLocalInferenceStatus(): Promise<LocalInferenceStatus> { + return requestJson("/api/operator/local-inference/status"); +} + +export interface CuratedLocalModel { + id: string; + label: string; + tier: "cpu" | "gpu"; + params: string; +} +export function getLocalInferenceCatalog(): Promise<CuratedLocalModel[]> { + return requestJson("/api/operator/local-inference/catalog"); +} + +export interface LocalModelDeployment { + name: string; + namespace: string; + managed: boolean; + model_id: string | null; + engine: string | null; + provider: string | null; + phase: string | null; + message: string | null; + endpoint: string | null; + created_at: string | null; +} +export function listLocalModelDeployments(): Promise<LocalModelDeployment[]> { + return requestJson("/api/operator/local-inference/deployments"); +} +export function createLocalModelDeployment(body: { name: string; model_id: string; tier: "cpu" | "gpu"; image?: string; gpu_count?: number }): Promise<LocalModelDeployment> { + return requestJson("/api/operator/local-inference/deployments", { method: "POST", body: JSON.stringify(body) }); +} +export function deleteLocalModelDeployment(name: string): Promise<{ deleted: boolean; name: string }> { + return requestJson(`/api/operator/local-inference/deployments/${encodeURIComponent(name)}`, { method: "DELETE" }); +} + +export interface DeployCondition { type: string; status: string; reason: string; message: string } +export interface DeployPodState { name: string; phase: string; ready: boolean; running: boolean; waiting_reason: string | null; waiting_message: string | null } +export interface DeployActivity { time: string | null; reason: string; message: string; type: string; count: number } +export interface LocalDeployLiveStatus { + name: string; + found: boolean; + phase: string | null; + message: string | null; + percent: number; + ready: boolean; + failed: boolean; + failure_reason: string | null; + failure_message: string | null; + replicas_desired: number; + replicas_ready: number; + conditions: DeployCondition[]; + pods: DeployPodState[]; + activities: DeployActivity[]; +} +export function getLocalDeploymentLiveStatus(name: string): Promise<LocalDeployLiveStatus> { + return requestJson(`/api/operator/local-inference/deployments/${encodeURIComponent(name)}/status`); +} diff --git a/bridge/web/src/lib/classify-intent.ts b/bridge/web/src/lib/classify-intent.ts new file mode 100644 index 000000000..a4279dfdc --- /dev/null +++ b/bridge/web/src/lib/classify-intent.ts @@ -0,0 +1,79 @@ +// Intent classifier for the unified intent-first intake. +// +// One intent box decides whether the work is a MISSION (a focused, one-off task +// that composes → runs → delivers → done) or a standing TEAM (a continuous org +// that works under a charter, often with cadence and multiple roles). This is a +// transparent client-side heuristic that produces a *recommendation* the user +// can always override — it never silently decides for them. The authoritative +// composition still happens server-side via the orchestrator once routed. + +export type IntentKind = "mission" | "team"; + +export interface IntentClassification { + kind: IntentKind; + confidence: "low" | "medium" | "high"; + reason: string; +} + +// Signals that the work is ongoing/standing rather than a single deliverable. +const TEAM_PATTERNS: RegExp[] = [ + /\bteams?\b/, + /\bmonitor(ing|s)?\b/, + /\bwatch(ing|es)?\b/, + /\bcontinuous(ly)?\b/, + /\bongoing\b/, + /\bstanding\b/, + /\bkeep an eye\b/, + /\bover time\b/, + /\bevery (day|week|hour|morning|month)\b/, + /\b(daily|weekly|hourly|nightly|monthly)\b/, + /\bcadence\b/, + /\bon a schedule\b/, + /\bregularly\b/, + /\bkeep .* (healthy|up to date|updated|current)\b/, + /\btrack .* (over time|continuously)\b/, + /\b(recurring|recurrent)\b/, + /\bmultiple (roles|agents|members)\b/, + /\borg chart\b/, + /\b(finance|marketing|support|ops|sales) team\b/, +]; + +// Signals that the work is a discrete, bounded deliverable. +const MISSION_PATTERNS: RegExp[] = [ + /\b(write|draft|create|build|make|generate|produce)\b/, + /\b(analy[sz]e|research|investigate|summari[sz]e|review|audit)\b/, + /\b(fix|debug|refactor|implement)\b/, + /\b(find|look up|gather)\b/, + /\bonce\b/, + /\bone[- ]off\b/, + /\bright now\b/, + /\ba report on\b/, +]; + +export function classifyIntent(raw: string): IntentClassification { + const text = (raw ?? "").toLowerCase(); + if (text.trim().length === 0) { + return { kind: "mission", confidence: "low", reason: "Describe the work to get a recommendation." }; + } + + const teamHits = TEAM_PATTERNS.filter((re) => re.test(text)).length; + const missionHits = MISSION_PATTERNS.filter((re) => re.test(text)).length; + + if (teamHits > 0 && teamHits >= missionHits) { + const confidence = teamHits >= 2 ? "high" : missionHits === 0 ? "medium" : "low"; + return { + kind: "team", + confidence, + reason: + "This reads as continuous, standing work (monitoring, a cadence, or multiple roles) — a team keeps running under a charter.", + }; + } + + const confidence = missionHits >= 2 ? "high" : missionHits === 1 ? "medium" : "low"; + return { + kind: "mission", + confidence, + reason: + "This reads as a focused, one-off deliverable — a single mission composes, runs, and delivers, then it's done.", + }; +} diff --git a/bridge/web/src/lib/config.ts b/bridge/web/src/lib/config.ts new file mode 100644 index 000000000..8262285a0 --- /dev/null +++ b/bridge/web/src/lib/config.ts @@ -0,0 +1,144 @@ +// kars Bridge web — runtime configuration. +// +// All values are read server-side. The browser never receives cluster +// credentials or signing keys; it only ever talks to the BFF through the +// Next.js server (see lib/bff.ts). + +import { ssoConfigured } from "./oidc-config"; +import type { IconName } from "@/components/icon"; + +/** Base URL of the kars Bridge BFF, reachable from the Next.js server. */ +export function bffBaseUrl(): string { + return process.env.BRIDGE_BFF_URL ?? "http://localhost:8081"; +} + +/** Default namespace the UI scopes task views to. */ +export function defaultNamespace(): string { + return process.env.BRIDGE_DEFAULT_NAMESPACE ?? "kars-system"; +} + +/** Deployment environment label shown in the header (local/staging/prod). */ +export function environment(): string { + return process.env.BRIDGE_ENV ?? "local"; +} + +/** + * Identity recorded as the decider on an approval. + * + * HONEST GAP: the Bridge does not yet have an authenticated session, so a + * decision made through the UI is attributed to this configured operator + * identity rather than a logged-in user. The UI surfaces this plainly next to + * the decision controls — we never imply a per-user identity we cannot prove. + * Wiring real auth (and binding the decider to it) is a follow-up. + */ +export function operatorIdentity(): string { + return process.env.BRIDGE_OPERATOR ?? "bridge-operator@local"; +} + +/** Whether real per-user auth is wired (drives the honest decider caveat). + * True once an operator sets BRIDGE_AUTH_WIRED=true directly, OR + * automatically once real OIDC SSO is configured (lib/oidc-config.ts) — + * configuring a working IdP connection IS wiring real auth, no separate + * flag to remember to flip. */ +export function authWired(): boolean { + if (process.env.BRIDGE_AUTH_WIRED === "true") return true; + return ssoConfigured(); +} + +/** Optional deep-link to a Headlamp (or any K8s dashboard) instance for this + * cluster. When set, the operator console surfaces a launch link; when unset, + * it shows how to enable it rather than a dead button. */ +export function headlampUrl(): string | null { + const u = process.env.BRIDGE_HEADLAMP_URL; + return u && u.trim().length > 0 ? u : null; +} + +/** + * The FOUR role sets that partition kars Bridge, in privilege order: + * - `user` — the employee Workspace: start missions/teams, review, connections. + * - `auditor` — read-only Auditor surface: receipts, evidence. Changes nothing. + * - `operator` — the Operator Console: providers, policies, skills, fleet, evals, + * egress approvals. Manages governance for everyone's work. + * - `admin` — cluster/org administrator: everything an operator can do PLUS the + * highest-privilege actions — raising cluster/workspace inference + * budgets, cluster/provider configuration, and the roles surface. + * They are SEPARATE permission sets and (with SSO) map to distinct groups. `admin` + * implies `operator`, `auditor`, and `user`; `operator` implies `user`. + */ +export type Role = "user" | "operator" | "auditor" | "admin"; + +export const ALL_ROLES: Role[] = ["user", "auditor", "operator", "admin"]; + +/** Human labels + one-line capability summaries for the roles surface. */ +export const ROLE_META: Record<Role, { label: string; blurb: string; glyph: IconName }> = { + user: { + label: "Workspace user", + blurb: "Start & review missions and teams, connect GitHub/channels. The employee surface.", + glyph: "person", + }, + auditor: { + label: "Auditor", + blurb: "Read-only receipts, evidence, and the tamper-evident log. Changes nothing.", + glyph: "search", + }, + operator: { + label: "Operator", + blurb: "Govern the fleet: tool/inference policies, skills, MCP, evals, egress approvals.", + glyph: "wrench", + }, + admin: { + label: "Admin", + blurb: "Cluster/org administration: raise inference budgets, provider config, manage roles.", + glyph: "crown", + }, +}; + +/** Expand a set of granted roles to include everything they imply (admin ⊇ + * operator ⊇ user; admin ⊇ auditor). Auditor is a separate read-only persona + * and does not imply employee Workspace access. */ +export function expandRoles(granted: Role[]): Role[] { + const set = new Set<Role>(granted); + if (set.has("admin")) { + set.add("operator"); + set.add("auditor"); + } + if (set.has("operator")) set.add("user"); + return ALL_ROLES.filter((r) => set.has(r)); +} + +/** Parse a comma-separated role string into a valid, implication-expanded set. */ +export function parseRoles(raw: string | undefined | null): Role[] | null { + if (!raw) return null; + const roles = raw + .split(",") + .map((r) => r.trim().toLowerCase()) + .filter((r): r is Role => (ALL_ROLES as string[]).includes(r)); + return roles.length ? expandRoles(roles) : null; +} + +/** The highest role held, for a single primary badge. */ +export function primaryRole(roles: Role[]): Role { + if (roles.includes("admin")) return "admin"; + if (roles.includes("operator")) return "operator"; + if (roles.includes("auditor")) return "auditor"; + return "user"; +} + +/** + * The current principal's roles from the STATIC env (`BRIDGE_ROLES`), expanded by + * implication; defaults to ALL in dev so a single developer sees everything. The + * cookie-aware, switchable session lives in `lib/session.ts` (server-only) and + * layers on top of this — use that in server components; this is the env floor. + */ +export function envRoles(): Role[] { + return parseRoles(process.env.BRIDGE_ROLES) ?? ["admin", "operator", "auditor", "user"]; +} + +/** @deprecated in server components use `sessionRoles()` from lib/session.ts. */ +export function sessionRoles(): Role[] { + return envRoles(); +} + +export function hasRole(role: Role): boolean { + return sessionRoles().includes(role); +} diff --git a/bridge/web/src/lib/credential-review.ts b/bridge/web/src/lib/credential-review.ts new file mode 100644 index 000000000..1a3e97b53 --- /dev/null +++ b/bridge/web/src/lib/credential-review.ts @@ -0,0 +1,197 @@ +export type CredentialKind = "KarsSandbox" | "KarsTask" | "KarsTeam"; +export interface CredentialInput { + kind: CredentialKind; + namespace: string; + target: string; + targetUid?: string; + key: string; +} +export interface StoredCredentialSource { + name: string; uid: string; version: string; metadataDigest: string; +} +export interface CredentialContinuation { + token: string; + outcome: "source-stored" | "no-write-attempted"; + source: StoredCredentialSource | null; +} +export interface CredentialReview { + token: string; + expiresAt: number; + submission: number; + continuation: boolean; + bindingOnly: boolean; + metadata: { + target: { kind: CredentialKind; namespace: string; name: string; uid: string | null; + generation: number | null; version: string | null; intent: string | null }; + grant: { uid: string; generation: number; version: string; intent: string; + workspaceUid: string; legacyInventory: string }; + source: { name: string; uid: string | null; version: string | null; metadataDigest: string | null; keys: string[] }; + key: string; + }; +} +export interface CredentialFormState { + error: string | null; + ok: string | null; + review: CredentialReview | null; + pending: { receipt: CredentialContinuation; input: CredentialInput; origin: CredentialReview } | null; +} +export interface CredentialFailure { + status: number; code: string; message: string; continuation?: CredentialContinuation; +} + +const object = (value: unknown): Record<string, unknown> | null => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; +const text = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256; +const hash = (value: unknown): value is string => typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value); +const token = (value: unknown): value is string => + typeof value === "string" && value.length <= 32768 && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value); +const kind = (value: unknown): value is CredentialKind => + value === "KarsSandbox" || value === "KarsTask" || value === "KarsTeam"; +const generation = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0; + +export function parseCredentialContinuation(value: unknown): CredentialContinuation | undefined { + const record = object(value), source = object(record?.source); + if (!record || !token(record.token)) return undefined; + if (record.outcome === "no-write-attempted" && record.source === null) + return { token: record.token, outcome: "no-write-attempted", source: null }; + if (record.outcome !== "source-stored" || !source || !text(source.name) || !text(source.uid) + || !text(source.version) || !hash(source.metadataDigest)) return undefined; + return { token: record.token, outcome: "source-stored", source: { name: source.name, uid: source.uid, + version: source.version, metadataDigest: source.metadataDigest } }; +} + +export function parseCredentialReview(value: unknown): CredentialReview | undefined { + const record = object(value), metadata = object(record?.metadata); + const target = object(metadata?.target), grant = object(metadata?.grant), source = object(metadata?.source); + if (!record || !metadata || !target || !grant || !source || !token(record.token) + || typeof record.expiresAt !== "number" || !Number.isSafeInteger(record.expiresAt) + || !generation(record.submission) || record.submission > 3 || typeof record.continuation !== "boolean" + || typeof record.bindingOnly !== "boolean" + || !kind(target.kind) || !text(target.namespace) || !text(target.name) + || !(target.uid === null ? target.generation === null && target.version === null && target.intent === null + : text(target.uid) && generation(target.generation) && text(target.version) && hash(target.intent)) + || !text(grant.uid) || !generation(grant.generation) || !text(grant.version) || !hash(grant.intent) + || !text(grant.workspaceUid) || !hash(grant.legacyInventory) || !text(source.name) + || !(source.uid === null ? source.version === null && source.metadataDigest === null + : text(source.uid) && text(source.version) && hash(source.metadataDigest)) + || !Array.isArray(source.keys) || !source.keys.every(text) || !text(metadata.key)) return undefined; + return { + token: record.token, expiresAt: record.expiresAt, submission: record.submission, + continuation: record.continuation, bindingOnly: record.bindingOnly, + metadata: { + target: { kind: target.kind, namespace: target.namespace, name: target.name, + uid: target.uid as string | null, generation: target.generation as number | null, + version: target.version as string | null, intent: target.intent as string | null }, + grant: { uid: grant.uid, generation: grant.generation, version: grant.version, intent: grant.intent, + workspaceUid: grant.workspaceUid, legacyInventory: grant.legacyInventory }, + source: { name: source.name, uid: source.uid as string | null, version: source.version as string | null, + metadataDigest: source.metadataDigest as string | null, keys: [...source.keys] }, + key: metadata.key, + }, + }; +} + +export function credentialReviewMatches(review: CredentialReview, input: CredentialInput): boolean { + const target = review.metadata.target; + return target.kind === input.kind && target.namespace === input.namespace && target.name === input.target + && review.metadata.key === input.key && (!input.targetUid || target.uid === input.targetUid); +} + +function parseInput(value: unknown): CredentialInput | undefined { + const input = object(value); + if (!input) return undefined; + const { target, namespace, targetUid, key, kind: targetKind } = input; + const dns = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + if (!kind(targetKind) || typeof namespace !== "string" || typeof target !== "string" + || typeof key !== "string" || !dns.test(namespace) || !dns.test(target) + || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || (targetUid !== undefined && !text(targetUid))) return undefined; + return { kind: targetKind, namespace, target, ...(targetUid ? { targetUid } : {}), key }; +} + +export function credentialContinuationMatches(origin: CredentialReview, current: CredentialReview, + receipt: CredentialContinuation): boolean { + const target = (review: CredentialReview) => ({ ...review.metadata.target, version: null }); + const grant = (review: CredentialReview) => ({ ...review.metadata.grant, version: null }); + const keys = [...new Set([...origin.metadata.source.keys, origin.metadata.key])].sort(); + const sameAuthority = current.continuation && current.submission === origin.submission + 1 && current.expiresAt === origin.expiresAt + && JSON.stringify(target(origin)) === JSON.stringify(target(current)) + && JSON.stringify(grant(origin)) === JSON.stringify(grant(current)) + && current.metadata.key === origin.metadata.key; + if (!sameAuthority) return false; + if (!receipt.source) + return receipt.outcome === "no-write-attempted" && !current.bindingOnly + && JSON.stringify(origin.metadata.source) === JSON.stringify(current.metadata.source); + return receipt.outcome === "source-stored" && current.bindingOnly + && current.metadata.source.name === receipt.source.name && current.metadata.source.uid === receipt.source.uid + && current.metadata.source.version === receipt.source.version + && current.metadata.source.metadataDigest === receipt.source.metadataDigest + && JSON.stringify([...current.metadata.source.keys].sort()) === JSON.stringify(keys); +} + +export async function credentialFormTransition( + previous: CredentialFormState, + form: FormData, + api: { + review: (input: CredentialInput & { continuation?: string }) => Promise<CredentialReview>; + write: (input: CredentialInput & { value: string; review: string }) => Promise<{ stored: boolean; note: string }>; + failure: (error: unknown) => CredentialFailure | undefined; + now?: () => number; + }, +): Promise<CredentialFormState> { + const empty: CredentialFormState = { error: null, ok: null, review: null, pending: null }; + const pendingRecord = object(previous?.pending); + const receipt = parseCredentialContinuation(pendingRecord?.receipt); + const pendingInput = parseInput(pendingRecord?.input); + const origin = parseCredentialReview(pendingRecord?.origin); + previous = { ...empty, review: parseCredentialReview(previous?.review) ?? null, + pending: receipt && pendingInput && origin ? { receipt, input: pendingInput, origin } : null }; + const operation = form.get("operation"); + if (operation === "reset") return empty; + const targetUid = String(form.get("targetUid") ?? "").trim(); + const input = parseInput({ target: String(form.get("target") ?? "").trim().toLowerCase(), + namespace: String(form.get("namespace") ?? "").trim(), key: String(form.get("key") ?? "").trim(), + kind: form.get("kind"), ...(targetUid ? { targetUid } : {}) }); + if (!input) return { ...previous, ok: null, error: "Select a valid workspace, target kind/name and credential key." }; + try { + if (operation === "review") { + const pending = previous.pending; + if (pending && (pending.input.namespace !== input.namespace || pending.input.kind !== input.kind + || pending.input.target !== input.target || pending.input.key !== input.key + || (input.targetUid && input.targetUid !== pending.input.targetUid))) { + return { ...previous, error: "Restore the original target and key, or explicitly start a new change.", ok: null }; + } + const reviewed = parseCredentialReview(await api.review({ ...input, + ...(pending ? { targetUid: pending.input.targetUid, continuation: pending.receipt.token } : {}) })); + if (!reviewed || !credentialReviewMatches(reviewed, input) + || (pending ? !credentialContinuationMatches(pending.origin, reviewed, pending.receipt) + : reviewed.continuation || reviewed.bindingOnly || reviewed.submission !== 1)) { + return { ...previous, review: null, ok: null, error: "Credential review did not match the selected authority." }; + } + return { ...empty, review: reviewed }; + } + const reviewed = previous.review; + if (operation !== "store" || previous.pending || !reviewed || !credentialReviewMatches(reviewed, input) + || reviewed.expiresAt <= (api.now?.() ?? Math.floor(Date.now() / 1000)) || form.get("confirmed") !== "on") { + return { ...previous, ok: null, error: "Refresh and explicitly confirm the current metadata review before storing." }; + } + const value = String(form.get("value") ?? ""); + if (!value) return { ...previous, ok: null, error: "Enter the credential value; it is never read back." }; + const result = await api.write({ ...input, targetUid: reviewed.metadata.target.uid ?? undefined, + value, review: reviewed.token }); + if (!result.stored) return { ...empty, error: "Credential storage was not confirmed." }; + return { ...empty, ok: result.note }; + } catch (error) { + const failure = api.failure(error); + const receipt = parseCredentialContinuation(failure?.continuation); + if (operation === "store" && failure?.status === 409 && failure.code === "conflict" && receipt + && previous.review) { + return { ...empty, error: receipt.source + ? "The source was stored but binding conflicted. Explicitly refresh/review its acknowledgement, then re-enter the same value to resume." + : "The review changed before any source write was attempted. Explicitly refresh/review the same authority before resubmitting.", + pending: { receipt, origin: previous.review, input: { ...input, + targetUid: previous.review.metadata.target.uid ?? undefined } } }; + } + return { ...empty, ...(operation === "review" ? { pending: previous.pending } : {}), + error: failure?.message || "Credential operation failed; no automatic resubmission was attempted." }; + } +} diff --git a/bridge/web/src/lib/format.ts b/bridge/web/src/lib/format.ts new file mode 100644 index 000000000..496a13fea --- /dev/null +++ b/bridge/web/src/lib/format.ts @@ -0,0 +1,72 @@ +// kars Bridge web — value formatting helpers. Disciplined, audit-friendly +// rendering of machine values (counts, budgets, money). + +/** Thousands-separated integer, or an em-dash placeholder when null. */ +export function formatInt(n: number | null | undefined): string { + if (n == null) return "—"; + return n.toLocaleString(); +} + +export function formatWarmIdle(seconds: number | null | undefined): string { + const value = seconds ?? 900; + if (value <= 0) return "immediately"; + if (value < 60) return `${value}s`; + const minutes = Math.floor(value / 60); + const remainder = value % 60; + if (minutes < 60) return remainder ? `${minutes}m ${remainder}s` : `${minutes} min`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +/** Micro-USD (1e-6 USD) rendered as currency, or null when unset. */ +export function formatUsdMicros(micros: number | null | undefined): string | null { + if (micros == null) return null; + return `$${(micros / 1_000_000).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; +} + +/** Classify an egress host as an internal (in-cluster / private) destination or + * an external (public internet) one. Purely presentational — the engine treats + * every entry identically as an allowed egress; this only helps the operator + * read the list. A host is internal if it targets a Kubernetes service domain, + * a private/loopback address, or an explicitly internal suffix. */ +export function egressScope(host: string): "internal" | "external" { + const h = host.trim().toLowerCase().split(":")[0]; + const internalSuffix = [ + ".svc", + ".svc.cluster.local", + ".cluster.local", + ".local", + ".internal", + ".in-addr.arpa", + ]; + if (h === "localhost" || h.endsWith(".localhost")) return "internal"; + if (internalSuffix.some((s) => h.endsWith(s))) return "internal"; + // Bare single-label hostnames (no dot) resolve in-cluster. + if (!h.includes(".")) return "internal"; + // RFC1918 / loopback / link-local IP ranges. + if ( + /^10\./.test(h) || + /^127\./.test(h) || + /^192\.168\./.test(h) || + /^169\.254\./.test(h) || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(h) + ) + return "internal"; + return "external"; +} + +// End users shouldn't see raw in-cluster DNS for MCP servers. Turn e.g. +// "https://github-mcp.default.svc.cluster.local:8080" into "github-mcp". +// Non-cluster identifiers (already human names) are returned unchanged. +export function humanizeMcp(server: string): string { + let s = server.trim().replace(/^[a-z]+:\/\//i, ""); // strip scheme + s = s.split("/")[0].split(":")[0]; // host only, drop path + port + if (s.includes(".svc.cluster.local") || s.endsWith(".local")) { + s = s.split(".")[0]; // keep the service label + } + return s || server; +} diff --git a/bridge/web/src/lib/loop-patterns.ts b/bridge/web/src/lib/loop-patterns.ts new file mode 100644 index 000000000..80184a38a --- /dev/null +++ b/bridge/web/src/lib/loop-patterns.ts @@ -0,0 +1,164 @@ +// kars Bridge — Loop engineering catalog (2026). +// +// "Loop engineering" is the 2026 discipline that supersedes one-shot prompt +// engineering: you design the feedback-driven cycle an agent runs (observe → +// reason → act → evaluate → repeat), define how success is MEASURED first +// (evaluation-driven authoring), and let the loop — not a static prompt — carry +// the work. In kars a loop is not a separate resource: it is the STRUCTURE of the +// objective (a mission) or charter (a team) the harness actually runs. Because +// the objective/charter is what the runtime executes and what a principal hands +// to the sub-agents it spawns, a loop authored here reaches the harness AND is +// inherited down the delegation tree — every pattern's scaffold ends with an +// explicit sub-agent-inheritance clause so the loop propagates, not just the goal. + +export type LoopSurface = "mission" | "team"; + +export interface LoopPattern { + id: string; + name: string; + /** One-line essence. */ + tagline: string; + /** When this loop is the right choice. */ + whenToUse: string; + /** The cycle steps, shown as the loop's shape. */ + steps: string[]; + /** Which surfaces this pattern suits (mission = single run, team = standing). */ + surfaces: LoopSurface[]; + /** Icon name (see components/icon.tsx) for the card. */ + icon: import("@/components/icon").IconName; +} + +export interface LoopInputs { + /** The outcome the user wants. */ + goal: string; + /** How success is judged — the evaluation criteria (may be blank). */ + criteria: string; + /** Optional extra context / constraints. */ + context?: string; +} + +/** The 2026 loop-engineering catalog. Ordered from most common to specialised. */ +export const LOOP_PATTERNS: LoopPattern[] = [ + { + id: "react", + name: "ReAct — Reason + Act", + tagline: "Think, use a tool, observe, repeat — the workhorse for tool-using work.", + whenToUse: + "Research, investigation, or anything that needs tools/web/repos where each step depends on what the last one returned.", + steps: ["Reason about the next step", "Act (call a tool)", "Observe the result", "Repeat until the goal is met"], + surfaces: ["mission", "team"], + icon: "loop", + }, + { + id: "reflect", + name: "Reflect-Refine — self-critique", + tagline: "Draft, critique your own draft against the bar, revise — until it clears.", + whenToUse: "Quality-critical output: reports, code, analysis where the first pass is rarely good enough.", + steps: ["Produce a draft", "Critique it against the success criteria", "Revise the weak parts", "Repeat until it passes"], + surfaces: ["mission", "team"], + icon: "mirror", + }, + { + id: "plan-execute", + name: "Plan-Execute — plan then do", + tagline: "Make a concrete plan, execute step by step, re-plan when a step fails.", + whenToUse: "Multi-step tasks with clear sub-goals — migrations, build-outs, structured deliverables.", + steps: ["Write a concrete step-by-step plan", "Execute the next step", "Check the outcome", "Re-plan on failure; continue on success"], + surfaces: ["mission", "team"], + icon: "map", + }, + { + id: "eval-iterate", + name: "Evaluate-Iterate — tests first", + tagline: "Define acceptance checks up front, then loop until every check passes.", + whenToUse: + "When 'done' must be objective and verifiable. The evaluation-driven pattern — write the checks before the work.", + steps: ["Turn the goal into explicit acceptance checks", "Attempt the work", "Run each check", "Fix failures and re-check until all pass"], + surfaces: ["mission", "team"], + icon: "check-cycle", + }, + { + id: "explore-branch", + name: "Explore-Branch — tree of thoughts", + tagline: "Generate several candidate approaches, evaluate, keep the best, prune the rest.", + whenToUse: "Hard or open-ended problems where the first idea is unlikely to be the best.", + steps: ["Generate 2–3 distinct candidate approaches", "Evaluate each against the criteria", "Prune the weak ones", "Deepen the best; repeat"], + surfaces: ["mission"], + icon: "branch", + }, + { + id: "standing-watch", + name: "Standing Watch — cadence loop", + tagline: "Periodically observe, detect what changed since last time, act, report — with memory.", + whenToUse: "Standing teams that monitor something over time (a repo, a market, a system) and act on change.", + steps: ["Observe the current state", "Compare with memory of last run", "Act only on meaningful change", "Report and record what you learned"], + surfaces: ["team"], + icon: "eye", + }, +]; + +export function patternsFor(surface: LoopSurface): LoopPattern[] { + return LOOP_PATTERNS.filter((p) => p.surfaces.includes(surface)); +} + +/** + * Scaffold a structured, evaluation-driven objective/charter that ENCODES the + * loop — so the harness runs the loop, not a bare instruction. The output always + * carries: the goal, the loop cycle, explicit success criteria, a stop + * condition, and a sub-agent-inheritance clause so any spawned sub-agent runs + * the same loop. `surface` tunes the wording (a mission runs once to a + * deliverable; a team runs the loop on every cadence tick). + */ +export function scaffoldObjective( + pattern: LoopPattern, + inputs: LoopInputs, + surface: LoopSurface, +): string { + const goal = inputs.goal.trim() || "<describe the outcome you want>"; + const criteria = inputs.criteria.trim(); + const context = (inputs.context ?? "").trim(); + + const cycle = pattern.steps.map((s, i) => ` ${i + 1}. ${s}.`).join("\n"); + + const criteriaBlock = criteria + ? `SUCCESS CRITERIA (how this is judged — evaluate against these every cycle):\n${criteria + .split(/\n|;/) + .map((c) => c.trim()) + .filter(Boolean) + .map((c) => ` • ${c}`) + .join("\n")}` + : `SUCCESS CRITERIA: State the checks you'll judge yourself against before you start, then evaluate against them every cycle.`; + + const stop = + surface === "team" + ? "STOP CONDITION: End the run once the success criteria are met for this cycle; if nothing meaningful changed since last run, say so briefly and stop (don't invent work)." + : "STOP CONDITION: Stop as soon as the success criteria are all met — don't loop further once you're done."; + + const inheritance = + "SUB-AGENT INHERITANCE: If you delegate to sub-agents, give EACH the same loop — the cycle above and these success criteria — so the whole tree works the same way, not just you."; + + const contextBlock = context ? `\nCONTEXT / CONSTRAINTS:\n${context}\n` : ""; + + const header = + surface === "team" + ? `LOOP: ${pattern.name} (run this loop on every cadence tick).` + : `LOOP: ${pattern.name}.`; + + return [ + header, + ``, + `GOAL: ${goal}`, + contextBlock ? contextBlock.trimEnd() : ``, + `CYCLE — repeat until done:`, + cycle, + ``, + criteriaBlock, + ``, + stop, + inheritance, + ] + .filter((l) => l !== null && l !== undefined) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} diff --git a/bridge/web/src/lib/member-archetypes.ts b/bridge/web/src/lib/member-archetypes.ts new file mode 100644 index 000000000..2fb1f7ab1 --- /dev/null +++ b/bridge/web/src/lib/member-archetypes.ts @@ -0,0 +1,97 @@ +// kars Bridge — reusable team MEMBER archetypes. +// +// Pre-defined role templates ("a Rust engineer", "a Financial analyst") an +// operator/user can drop into any team instead of writing every member's charge +// from scratch. Distinct from a KarsProfile (a whole-team template): an archetype +// is ONE member you compose into a team's roster. Each carries a battle-tested +// system prompt and sensible skill hints; runtime/model stay unset so they +// inherit the team default unless overridden. + +export interface MemberArchetype { + /** dns-safe role name used in the roster. */ + id: string; + /** Human title for the picker. */ + title: string; + /** One-line description. */ + blurb: string; + icon: string; + /** The role's system prompt (its standing charge). */ + system_prompt: string; + /** Skill names this archetype benefits from (hints; only applied if they exist). */ + suggestedSkills: string[]; +} + +export const MEMBER_ARCHETYPES: MemberArchetype[] = [ + { + id: "rust-engineer", + title: "Rust Engineer", + blurb: "Writes, reviews, and hardens Rust — ownership, safety, performance, tests.", + icon: "🦀", + system_prompt: + "You are a senior Rust engineer. Implement and review Rust changes with an eye for ownership/borrow correctness, error handling (no unwrap in library paths), performance, and idiomatic APIs. Always add or update tests for behaviour you change, run the smallest relevant `cargo test`/`clippy`, and explain any unsafe or non-obvious decision. Prefer minimal, surgical diffs.", + suggestedSkills: ["repo-triage"], + }, + { + id: "financial-analyst", + title: "Financial Analyst", + blurb: "Models numbers, checks assumptions, and writes decision-ready analysis.", + icon: "📊", + system_prompt: + "You are a rigorous financial analyst. Build and sanity-check quantitative analysis (unit economics, forecasts, sensitivities), state every assumption explicitly, and separate facts from estimates. Show the math, flag data you couldn't verify, and end with a crisp, decision-ready recommendation and the key risks. Never fabricate figures — mark unknowns as unknown.", + suggestedSkills: [], + }, + { + id: "security-reviewer", + title: "Security Reviewer", + blurb: "Finds real vulnerabilities with high signal — injection, authz, secrets, crypto.", + icon: "🔐", + system_prompt: + "You are a security reviewer. Analyze changes for high-confidence, exploitable issues (injection, broken authz, secret exposure, unsafe deserialization, weak crypto, SSRF). Report only findings you can justify, each with severity, the exploit path, and a concrete fix. Do not raise style nits or low-confidence speculation. Prefer false negatives over false positives.", + suggestedSkills: [], + }, + { + id: "data-analyst", + title: "Data Analyst", + blurb: "Turns raw data into clear, sourced findings and simple visuals.", + icon: "📈", + system_prompt: + "You are a data analyst. Explore the data, validate its shape and quality first, then answer the question with clearly-labelled findings. Show your method, quantify uncertainty, and call out confounders. Prefer a few sharp, well-captioned tables/figures over dumping everything. Never overstate what the data supports.", + suggestedSkills: [], + }, + { + id: "technical-writer", + title: "Technical Writer", + blurb: "Produces clear, accurate docs from code and specs — no fluff.", + icon: "✍️", + system_prompt: + "You are a technical writer. Produce accurate, concise documentation grounded in the actual code/spec — never invent behaviour. Lead with what the reader needs, use runnable examples, keep terminology consistent, and flag anything ambiguous for the owner rather than guessing. Match the repo's existing docs style.", + suggestedSkills: [], + }, + { + id: "qa-engineer", + title: "QA Engineer", + blurb: "Designs tests, reproduces bugs, and guards regressions.", + icon: "🧪", + system_prompt: + "You are a QA engineer. Turn requirements into concrete test cases (happy path, edges, failure modes), reproduce reported bugs with a minimal case, and write regression tests that would have caught them. Run the smallest relevant test target and report pass/fail plainly with the exact command. Prioritise the tests that catch the most risk per effort.", + suggestedSkills: [], + }, + { + id: "devops-engineer", + title: "DevOps Engineer", + blurb: "Automates build/deploy/observability with safe, reversible changes.", + icon: "⚙️", + system_prompt: + "You are a DevOps engineer. Improve CI/CD, infrastructure, and observability with changes that are safe, reversible, and least-privilege. Prefer existing tooling and conventions, make configuration explicit, and never widen access or disable a control without saying so. Validate with the smallest real run and describe the rollback.", + suggestedSkills: [], + }, + { + id: "product-researcher", + title: "Product Researcher", + blurb: "Investigates a topic and delivers a sourced, decision-ready briefing.", + icon: "🔎", + system_prompt: + "You are a product researcher. Investigate the assigned question, gather evidence from the sources available to you, and synthesize a concise briefing: what's true, what's uncertain, and the implication. Cite where each claim comes from, separate signal from noise, and end with a clear recommendation. Say so plainly when you couldn't verify something.", + suggestedSkills: [], + }, +]; diff --git a/bridge/web/src/lib/oidc-config.ts b/bridge/web/src/lib/oidc-config.ts new file mode 100644 index 000000000..0ad731af1 --- /dev/null +++ b/bridge/web/src/lib/oidc-config.ts @@ -0,0 +1,75 @@ +// kars Bridge — OIDC SSO configuration (server-only). +// +// Generic, config-only OIDC Authorization Code + PKCE client: point it at any +// standards-compliant IdP (Entra ID, Okta, Auth0, Keycloak, Dex, ...) via env +// vars. HONEST GAP: no IdP is registered in this environment, so +// `oidcConfig()` returns `null` and the whole flow stays inert — /auth/login +// shows an explicit "SSO not configured" state instead of faking a login. +// Wiring a real IdP is a config change only; no code changes are needed. + +import type { Role } from "./config"; + +export interface OidcConfig { + /** IdP issuer URL. Discovery doc is fetched from `${issuer}/.well-known/openid-configuration`. */ + issuer: string; + clientId: string; + clientSecret: string; + /** Where the IdP redirects back to. Defaults to `{request origin}/auth/callback`. */ + redirectUri: string | null; + /** OAuth scopes requested. Always includes `openid`. */ + scopes: string[]; + /** The ID-token claim carrying group/role membership (e.g. `groups`, `roles`). */ + roleClaim: string; + /** Maps a claim value (an IdP group/role name) to a Bridge `Role`. Unmapped + * claim values are ignored — a user with no mapped claim gets no roles + * (fail-closed), never a default grant. */ + roleMap: Record<string, Role>; + /** HS256 secret signing the Bridge's own session cookie (not the IdP's keys). */ + sessionSecret: string; +} + +/** Parse `BRIDGE_OIDC_ROLE_MAP` — a JSON object of `{"idp-group-name": "role"}`. */ +function parseRoleMap(raw: string | undefined): Record<string, Role> { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as Record<string, unknown>; + const out: Record<string, Role> = {}; + const valid = new Set(["user", "operator", "auditor", "admin"]); + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string" && valid.has(v)) out[k] = v as Role; + } + return out; + } catch { + return {}; + } +} + +/** The active OIDC config, or `null` when SSO isn't configured (the honest + * default — every field required for a working flow must be present). */ +export function oidcConfig(): OidcConfig | null { + const issuer = process.env.BRIDGE_OIDC_ISSUER?.trim(); + const clientId = process.env.BRIDGE_OIDC_CLIENT_ID?.trim(); + const clientSecret = process.env.BRIDGE_OIDC_CLIENT_SECRET?.trim(); + const sessionSecret = process.env.BRIDGE_SESSION_SECRET?.trim(); + if (!issuer || !clientId || !clientSecret || !sessionSecret) return null; + + const extraScopes = (process.env.BRIDGE_OIDC_SCOPES ?? "profile email") + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean); + + return { + issuer: issuer.replace(/\/$/, ""), + clientId, + clientSecret, + redirectUri: process.env.BRIDGE_OIDC_REDIRECT_URI?.trim() || null, + scopes: Array.from(new Set(["openid", ...extraScopes])), + roleClaim: process.env.BRIDGE_OIDC_ROLE_CLAIM?.trim() || "roles", + roleMap: parseRoleMap(process.env.BRIDGE_OIDC_ROLE_MAP), + sessionSecret, + }; +} + +export function ssoConfigured(): boolean { + return oidcConfig() != null; +} diff --git a/bridge/web/src/lib/oidc.ts b/bridge/web/src/lib/oidc.ts new file mode 100644 index 000000000..a7a3efa1f --- /dev/null +++ b/bridge/web/src/lib/oidc.ts @@ -0,0 +1,182 @@ +// kars Bridge — OIDC Authorization Code + PKCE client (server-only). +// +// Standards-compliant against any OIDC-conformant IdP: discovery document, +// PKCE (S256), state + nonce anti-CSRF/replay, JWKS-verified ID token. Uses +// `jose` (audited, dependency-free JWT/JWK library) rather than hand-rolled +// signature verification. + +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import { webcrypto } from "node:crypto"; +import type { OidcConfig } from "./oidc-config"; + +interface DiscoveryDoc { + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + end_session_endpoint?: string; + issuer: string; +} + +// Discovery docs and JWKS are cached per issuer for the process lifetime — +// they change essentially never; refetching on every login would just add +// latency and give the IdP unnecessary load. +const discoveryCache = new Map<string, DiscoveryDoc>(); +const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>(); + +async function discover(issuer: string): Promise<DiscoveryDoc> { + const cached = discoveryCache.get(issuer); + if (cached) return cached; + const res = await fetch(`${issuer}/.well-known/openid-configuration`); + if (!res.ok) { + throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${res.status}`); + } + const doc = (await res.json()) as DiscoveryDoc; + if (!doc.authorization_endpoint || !doc.token_endpoint || !doc.jwks_uri) { + throw new Error(`OIDC discovery document from ${issuer} is missing required fields`); + } + discoveryCache.set(issuer, doc); + return doc; +} + +function jwks(jwksUri: string) { + let set = jwksCache.get(jwksUri); + if (!set) { + set = createRemoteJWKSet(new URL(jwksUri)); + jwksCache.set(jwksUri, set); + } + return set; +} + +function base64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64url"); +} + +/** Random URL-safe token for `state`/`nonce`/the PKCE code_verifier. */ +function randomToken(bytes = 32): string { + return base64url(webcrypto.getRandomValues(new Uint8Array(bytes))); +} + +async function codeChallengeS256(verifier: string): Promise<string> { + const digest = await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return base64url(new Uint8Array(digest)); +} + +export interface AuthRequest { + url: string; + state: string; + nonce: string; + codeVerifier: string; +} + +/** Build the IdP authorization-endpoint redirect URL + the PKCE/anti-replay + * values the caller must stash (in a short-lived signed cookie) to validate + * the callback. */ +export async function buildAuthorizationRequest( + cfg: OidcConfig, + redirectUri: string, +): Promise<AuthRequest> { + const doc = await discover(cfg.issuer); + const state = randomToken(16); + const nonce = randomToken(16); + const codeVerifier = randomToken(32); + const codeChallenge = await codeChallengeS256(codeVerifier); + + const params = new URLSearchParams({ + response_type: "code", + client_id: cfg.clientId, + redirect_uri: redirectUri, + scope: cfg.scopes.join(" "), + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + return { url: `${doc.authorization_endpoint}?${params.toString()}`, state, nonce, codeVerifier }; +} + +export interface OidcIdentity { + sub: string; + name: string | null; + email: string | null; + claims: JWTPayload; +} + +/** Exchange the authorization code for tokens and verify the ID token's + * signature (against the IdP's live JWKS), issuer, audience, expiry, and + * nonce. Throws on any verification failure — callers must not treat a + * thrown error as "signed in with no claims". */ +export async function exchangeCodeForIdentity( + cfg: OidcConfig, + code: string, + redirectUri: string, + codeVerifier: string, + expectedNonce: string, +): Promise<OidcIdentity> { + const doc = await discover(cfg.issuer); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + code_verifier: codeVerifier, + }); + const res = await fetch(doc.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + if (!res.ok) { + throw new Error(`OIDC token exchange failed: HTTP ${res.status} ${await res.text().catch(() => "")}`); + } + const tokens = (await res.json()) as { id_token?: string }; + if (!tokens.id_token) { + throw new Error("OIDC token response carried no id_token"); + } + + const { payload } = await jwtVerify(tokens.id_token, jwks(doc.jwks_uri), { + issuer: doc.issuer, + audience: cfg.clientId, + }); + + if (payload.nonce !== expectedNonce) { + throw new Error("OIDC id_token nonce mismatch — possible replay"); + } + if (!payload.sub) { + throw new Error("OIDC id_token carried no sub claim"); + } + + return { + sub: payload.sub, + name: (payload.name as string | undefined) ?? (payload.preferred_username as string | undefined) ?? null, + email: (payload.email as string | undefined) ?? null, + claims: payload, + }; +} + +/** Map the configured role claim (string or string[]) through `roleMap`. + * Fail-closed: an unrecognized or absent claim yields no roles, never a + * default grant — the operator must explicitly map IdP groups to roles. */ +export function rolesFromClaims(cfg: OidcConfig, claims: JWTPayload): import("./config").Role[] { + const raw = claims[cfg.roleClaim]; + const values = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []; + const roles = new Set<import("./config").Role>(); + for (const v of values) { + const mapped = cfg.roleMap[String(v)]; + if (mapped) roles.add(mapped); + } + return Array.from(roles); +} + +/** End-session (RP-initiated logout) URL, when the IdP advertises one. */ +export async function endSessionUrl(cfg: OidcConfig, postLogoutRedirectUri: string): Promise<string | null> { + const doc = await discover(cfg.issuer); + if (!doc.end_session_endpoint) return null; + const params = new URLSearchParams({ + client_id: cfg.clientId, + post_logout_redirect_uri: postLogoutRedirectUri, + }); + return `${doc.end_session_endpoint}?${params.toString()}`; +} diff --git a/bridge/web/src/lib/preflight-actions.ts b/bridge/web/src/lib/preflight-actions.ts new file mode 100644 index 000000000..eb34777bb --- /dev/null +++ b/bridge/web/src/lib/preflight-actions.ts @@ -0,0 +1,20 @@ +// kars Bridge — shared pre-flight validation action. Validates a launch package +// (mission OR team) against the live cluster: model served, tool policy compiled, +// MCP servers reconciled + endpoints resolve, egress hosts resolve, budget/tier +// sane. One action so mission and team flows run the IDENTICAL pre-flight. +"use server"; + +import { defaultNamespace } from "@/lib/config"; +import type { ValidationResult } from "@/lib/types"; + +export async function validatePackageAction( + blueprint: unknown, + envelope?: { + tier?: number; + budget_tokens?: number | null; + workload?: "mission" | "team"; + }, +): Promise<ValidationResult> { + const { validatePackage } = await import("@/lib/bff"); + return validatePackage(defaultNamespace(), blueprint, envelope); +} diff --git a/bridge/web/src/lib/run-mission-client.ts b/bridge/web/src/lib/run-mission-client.ts new file mode 100644 index 000000000..ee1c6a9b3 --- /dev/null +++ b/bridge/web/src/lib/run-mission-client.ts @@ -0,0 +1,25 @@ +export async function runMissionClient( + namespace: string, + name: string, +): Promise<{ ok: boolean; error: string | null }> { + try { + const res = await fetch( + `/api/namespaces/${encodeURIComponent(namespace)}/tasks/${encodeURIComponent(name)}/run`, + { + method: "POST", + cache: "no-store", + headers: { accept: "application/json" }, + }, + ); + const body = await res.json().catch(() => null); + if (!res.ok || !body?.ok) { + return { ok: false, error: body?.error ?? `Run failed (${res.status}).` }; + } + return { ok: true, error: null }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "unknown error", + }; + } +} diff --git a/bridge/web/src/lib/session-token.ts b/bridge/web/src/lib/session-token.ts new file mode 100644 index 000000000..19a944cf0 --- /dev/null +++ b/bridge/web/src/lib/session-token.ts @@ -0,0 +1,100 @@ +// kars Bridge — signed session cookie (server-only). +// +// A real OIDC login mints one of these: a compact, HS256-signed JWT (never +// the IdP's own tokens — those aren't the Bridge's to hold onto) carrying just +// what the Bridge needs: subject, display name, and the roles resolved from +// the IdP's group/role claim at login time. Roles are snapshotted at login, +// not re-derived per-request — matches standard session semantics (a role +// change at the IdP takes effect on next login, not instantly). + +import { SignJWT, jwtVerify } from "jose"; +import type { Role } from "./config"; +import { oidcConfig } from "./oidc-config"; + +export interface BridgeSession { + sub: string; + name: string; + roles: Role[]; +} + +const ALG = "HS256"; +const MAX_AGE_SECONDS = 60 * 60 * 8; // 8h — a real login session, not indefinite. + +function secretKey(secret: string): Uint8Array { + return new TextEncoder().encode(secret); +} + +/** Sign a session for a just-authenticated user. */ +export async function signSession(session: BridgeSession): Promise<string> { + const cfg = oidcConfig(); + if (!cfg) throw new Error("cannot sign a session: SSO is not configured"); + return new SignJWT({ name: session.name, roles: session.roles }) + .setProtectedHeader({ alg: ALG }) + .setSubject(session.sub) + .setIssuedAt() + .setExpirationTime(`${MAX_AGE_SECONDS}s`) + .sign(secretKey(cfg.sessionSecret)); +} + +/** Verify + decode a session cookie. Returns `null` on any failure (expired, + * tampered, wrong key, or SSO no longer configured) — callers must fall back + * to the existing dev-role-switch/env-floor path, never treat a failure as + * "signed in with no roles". */ +export async function verifySession(token: string): Promise<BridgeSession | null> { + const cfg = oidcConfig(); + if (!cfg) return null; + try { + const { payload } = await jwtVerify(token, secretKey(cfg.sessionSecret), { algorithms: [ALG] }); + if (!payload.sub) return null; + const roles = Array.isArray(payload.roles) ? (payload.roles as Role[]) : []; + return { sub: payload.sub, name: (payload.name as string | undefined) ?? payload.sub, roles }; + } catch { + return null; + } +} + +export const SESSION_COOKIE = "bridge-session"; +export const OIDC_STATE_COOKIE = "bridge-oidc-state"; +export const SESSION_MAX_AGE_SECONDS = MAX_AGE_SECONDS; + +/** The transient PKCE/anti-replay values stashed between /auth/login and + * /auth/callback, signed the same way as a session but with a short TTL. */ +export interface OidcFlowState { + state: string; + nonce: string; + codeVerifier: string; + redirectUri: string; + returnTo?: string; +} + +const FLOW_MAX_AGE_SECONDS = 10 * 60; // 10min — long enough for a real login, short enough to bound replay. + +export async function signFlowState(flow: OidcFlowState): Promise<string> { + const cfg = oidcConfig(); + if (!cfg) throw new Error("cannot sign OIDC flow state: SSO is not configured"); + return new SignJWT({ ...flow }) + .setProtectedHeader({ alg: ALG }) + .setIssuedAt() + .setExpirationTime(`${FLOW_MAX_AGE_SECONDS}s`) + .sign(secretKey(cfg.sessionSecret)); +} + +export async function verifyFlowState(token: string): Promise<OidcFlowState | null> { + const cfg = oidcConfig(); + if (!cfg) return null; + try { + const { payload } = await jwtVerify(token, secretKey(cfg.sessionSecret), { algorithms: [ALG] }); + const { state, nonce, codeVerifier, redirectUri, returnTo } = + payload as Partial<OidcFlowState>; + if (!state || !nonce || !codeVerifier || !redirectUri) return null; + return { + state, + nonce, + codeVerifier, + redirectUri, + returnTo: typeof returnTo === "string" ? returnTo : undefined, + }; + } catch { + return null; + } +} diff --git a/bridge/web/src/lib/session.ts b/bridge/web/src/lib/session.ts new file mode 100644 index 000000000..6828efc9b --- /dev/null +++ b/bridge/web/src/lib/session.ts @@ -0,0 +1,116 @@ +// kars Bridge — server-side session/RBAC resolution. +// +// Roles come from, in priority order: +// 1. a REAL signed OIDC session (`bridge-session` cookie) — only present +// once an operator has configured SSO (BRIDGE_OIDC_ISSUER + friends, +// see lib/oidc-config.ts) AND a user has completed /auth/login; +// 2. the `bridge-role` cookie — a DEV identity switch so one developer can +// view the Bridge as each role (admin / operator / user / auditor) and +// verify the gates hold, WITHOUT standing up an IdP; +// 3. the `BRIDGE_ROLES` env floor (`lib/config.ts::envRoles`); +// 4. default: all roles (single-developer dev convenience). +// +// Whether or not SSO is configured, the REAL authorization boundary remains +// the Bridge's Kubernetes ServiceAccount RBAC (deploy/rbac.yaml) — the roles +// resolved here only drive which UI affordances render, never what the BFF +// is actually allowed to do against the cluster. + +import { cookies } from "next/headers"; +import { + type Role, + envRoles, + expandRoles, + parseRoles, + primaryRole, + ROLE_META, +} from "./config"; +import { ssoConfigured } from "./oidc-config"; +import { verifySession, SESSION_COOKIE } from "./session-token"; + +const COOKIE = "bridge-role"; + +async function oidcSession() { + if (!ssoConfigured()) return null; + const jar = await cookies(); + const token = jar.get(SESSION_COOKIE)?.value; + if (!token) return null; + return verifySession(token); +} + +/** The active roles for this request (real SSO session → dev cookie → env floor). */ +export async function sessionRoles(): Promise<Role[]> { + const session = await oidcSession(); + if (session) return expandRoles(session.roles); + + // When SSO is configured, there is NO dev-cookie / env-floor fallback. A + // missing or invalid session means "not signed in" → ZERO roles, and the + // layout guards (+ the /workspace guard) redirect to /auth/login. This closes + // the hole where an unauthenticated request would otherwise inherit the + // `bridge-role` dev cookie or the `BRIDGE_ROLES` floor (which defaults to ALL + // roles) — i.e. full access with no login. Dev fallbacks apply ONLY when no + // IdP is configured (single-developer local mode). + if (ssoConfigured()) return []; + + const jar = await cookies(); + const fromCookie = parseRoles(jar.get(COOKIE)?.value); + if (fromCookie) return fromCookie; + return envRoles(); +} + +export async function hasRole(role: Role): Promise<boolean> { + return (await sessionRoles()).includes(role); +} + +/** True when the principal can take cluster/org-admin actions. */ +export async function canAdminister(): Promise<boolean> { + return (await sessionRoles()).includes("admin"); +} + +/** The current principal: identity + active roles + a primary badge + whether the + * roles are simulated (dev cookie) vs a real signed-in SSO session vs the env floor. */ +export async function currentPrincipal(): Promise<{ + name: string; + roles: Role[]; + primary: Role; + primaryLabel: string; + simulated: boolean; + ssoSignedIn: boolean; +}> { + const session = await oidcSession(); + if (session) { + const roles = expandRoles(session.roles); + const primary = primaryRole(roles); + return { + name: session.name, + roles, + primary, + primaryLabel: ROLE_META[primary].label, + simulated: false, + ssoSignedIn: true, + }; + } + + const jar = await cookies(); + const cookieVal = jar.get(COOKIE)?.value; + // Under SSO, no valid session ⇒ NOT signed in: zero roles, no dev-cookie / + // env fallback (mirrors sessionRoles). The layout guards redirect to + // /auth/login. Without SSO (local dev) the dev/env fallback below applies. + if (ssoConfigured()) { + return { + name: "", + roles: [], + primary: primaryRole([]), + primaryLabel: ROLE_META[primaryRole([])].label, + simulated: false, + ssoSignedIn: false, + }; + } + const simulated = !!parseRoles(cookieVal); + const roles = simulated ? expandRoles(parseRoles(cookieVal)!) : envRoles(); + const primary = primaryRole(roles); + const name = + (simulated ? `${ROLE_META[primary].label.toLowerCase().replace(/\s+/g, "-")}@local` : undefined) ?? + process.env.BRIDGE_OPERATOR ?? + "bridge-operator@local"; + return { name, roles, primary, primaryLabel: ROLE_META[primary].label, simulated, ssoSignedIn: false }; +} diff --git a/bridge/web/src/lib/team-run-evidence.ts b/bridge/web/src/lib/team-run-evidence.ts new file mode 100644 index 000000000..a644f82a6 --- /dev/null +++ b/bridge/web/src/lib/team-run-evidence.ts @@ -0,0 +1,407 @@ +import type { MissionArtifact, TaskDetail, TeamDetail, TeamRole } from "./types"; + +export interface CollaborationEvent { + at: string | null; + event: string; + agent: string | null; + member: string | null; + outcome: string | null; + message_id: string | null; + preview: string | null; + source: "ledger" | "router" | "harness" | "governance" | "agent-reported" | "artifact-derived"; +} + +export interface ResearchEvent { + at: string | null; + agent: string | null; + url: string; + host: string | null; + outcome: string | null; + status: number | null; + digest: string | null; + source: "router" | "agent-reported"; +} + +export interface RoleEvidence { + role: TeamRole; + state: "delivered" | "skipped" | "working" | "missing" | "failed"; + artifacts: MissionArtifact[]; + handbacks: CollaborationEvent[]; + artifactAttribution: "recorded" | "inferred" | "none"; +} + +export interface TeamRunEvidence { + outcome: "paused" | "running" | "delivered" | "delivered_with_issues" | "incomplete" | "failed"; + roles: RoleEvidence[]; + collaboration: CollaborationEvent[]; + research: ResearchEvent[]; + issues: string[]; + evidenceMode: "ledger" | "agent-reported" | "artifact-derived"; + unattributedArtifacts: MissionArtifact[]; +} + +export type TeamEvidenceInput = Pick<TeamDetail, "roster" | "paused">; +export type TaskEvidenceInput = Pick< + TaskDetail, + | "artifacts" + | "activity" + | "result" + | "launched" + | "execution_phase" + | "assignment" + | "assignment_events" + | "current_run_nonce" + | "role_plan" + | "collaboration_events" +>; + +function parseJsonLines<T>(artifact: MissionArtifact | undefined): T[] { + if (!artifact?.content || artifact.content_truncated) return []; + return artifact.content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as T]; + } catch { + return []; + } + }); +} + +function humanEvidencePreview(value: unknown): string | null { + if (typeof value !== "string" || !value.trim()) return null; + const normalized = value.replace(/\\"/g, "\""); + if (normalized.includes("\"type\":\"file_transfer\"")) { + const file = normalized.match(/"file_name"\s*:\s*"([^"]+)"/)?.[1] ?? "artifact"; + const size = normalized.match(/"size_bytes"\s*:\s*(\d+)/)?.[1]; + return `Transferred ${file}${size ? ` (${Number(size).toLocaleString()} bytes)` : ""} into the recipient workspace.`; + } + try { + const parsed = JSON.parse(normalized) as Record<string, unknown>; + if (parsed.type === "file_transfer") { + const file = typeof parsed.file_name === "string" ? parsed.file_name : "artifact"; + const size = typeof parsed.size_bytes === "number" + ? ` (${parsed.size_bytes.toLocaleString()} bytes)` + : ""; + return `Transferred ${file}${size} into the recipient workspace.`; + } + if (typeof parsed.message === "string") return parsed.message; + } catch { + // Plain-text evidence is already suitable for display. + } + return value.replace(/\s+/g, " ").trim(); +} + +function roleScore(role: TeamRole, artifact: MissionArtifact): number { + const normalize = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, ""); + const roleId = normalize(role.name); + if (!roleId) return 0; + const provenance = [ + artifact.name, + artifact.source_agent ?? "", + artifact.source_path ?? "", + ].map(normalize); + if (normalize(artifact.source_agent ?? "") === roleId) return 110; + if (provenance.some((value) => value.includes(roleId))) return 100; + const roleText = `${role.name} ${role.system_prompt ?? ""}`.toLowerCase(); + const artifactText = `${artifact.name} ${artifact.source_path ?? ""}`.toLowerCase(); + if ( + /(test|quality|verification|qa)/.test(roleText) && + /(test|spec|verification|validation|test_report)/.test(artifactText) + ) { + return 80; + } + if ( + /(ux|browser|design|usability|visual)/.test(roleText) && + /(screenshot|ux|playwright|browser|\.(png|jpg|jpeg|svg|pdf)$)/.test(artifactText) + ) { + return 80; + } + if ( + /(application|implement|build|developer|engineer|source)/.test(roleText) && + /(readme|server|app|style|index|stock|requirement|source|dashboard|\.(py|js|mjs|ts|tsx|jsx|html|css|json|zip|tgz|tar\.gz)$)/.test(artifactText) + ) { + return 60; + } + return 0; +} + +function rolePlan(task: TaskEvidenceInput): { + selected: Set<string>; + skipped: Set<string>; +} { + return { + selected: new Set(task.role_plan.selected_roles), + skipped: new Set(task.role_plan.skipped_roles), + }; +} + +export function analyzeTeamRun(team: TeamEvidenceInput, task: TaskEvidenceInput): TeamRunEvidence { + const currentTaskId = task.current_run_nonce ?? task.assignment?.task_id ?? null; + const currentAssignment = + task.assignment != null + && (currentTaskId == null || task.assignment.task_id === currentTaskId) + ? task.assignment + : null; + const currentAssignmentEvents = task.assignment_events.filter( + (event) => currentTaskId == null || event.task_id === currentTaskId, + ); + const collaborationArtifact = task.artifacts.find( + (a) => + a.name === "collaboration.jsonl" || + a.source_path?.endsWith("/collaboration.jsonl"), + ); + const rawCollaboration: Record<string, unknown>[] = task.collaboration_events.length > 0 + ? task.collaboration_events.map((event) => ({ ...event })) + : parseJsonLines<Record<string, unknown>>(collaborationArtifact); + const ledgerCollaboration: CollaborationEvent[] = currentAssignmentEvents.map((event) => ({ + at: event.at, + event: event.stage ?? event.event_type, + agent: event.worker_did, + member: event.child_role, + outcome: event.outcome ?? event.state, + message_id: event.child_task_id ?? event.task_id, + preview: event.message, + source: "ledger", + })); + const collaboration: CollaborationEvent[] = [ + ...ledgerCollaboration, + ...rawCollaboration.map((e) => ({ + at: typeof e.at === "string" ? e.at : null, + event: typeof e.event === "string" ? e.event : "event", + agent: typeof e.agent === "string" ? e.agent : null, + member: typeof e.member === "string" + ? e.member + : typeof e.from_agent === "string" + ? e.from_agent + : typeof e.to_agent === "string" + ? e.to_agent + : null, + outcome: typeof e.outcome === "string" ? e.outcome : null, + message_id: typeof e.message_id === "string" ? e.message_id : null, + preview: humanEvidencePreview( + typeof e.reply_preview === "string" + ? e.reply_preview + : typeof e.content_preview === "string" + ? e.content_preview + : null, + ), + source: "agent-reported" as const, + })), + ]; + collaboration.push( + ...task.activity.flatMap((event) => { + if ( + event.kind !== "tool" || + !["router", "harness", "governance"].includes(event.source ?? "") || + event.name === "http_fetch" + ) { + return []; + } + return [{ + at: event.ts || null, + event: "mcp_tool_call", + agent: event.agent ?? null, + member: null, + outcome: event.ok ? "success" : "failed", + message_id: null, + preview: `${event.name}${event.args_preview ? ` · ${event.args_preview}` : ""} · ${event.result_preview || "no result preview"}`, + source: (event.source ?? "router") as "router" | "harness" | "governance", + }]; + }), + ); + + const research: ResearchEvent[] = task.activity.flatMap((event) => { + if (event.kind !== "tool" || event.name !== "http_fetch" || event.source !== "router") return []; + const statusMatch = event.result_preview.match(/HTTP\s+(\d{3})/i); + let host: string | null = null; + try { + host = new URL(event.args_preview).host; + } catch { + host = null; + } + return [{ + at: event.ts || null, + agent: event.agent ?? null, + url: event.args_preview, + host, + outcome: event.ok ? "success" : "failed", + status: statusMatch ? Number(statusMatch[1]) : null, + digest: null, + source: "router", + }]; + }); + + const evidenceArtifacts = task.artifacts.filter( + (a) => + !a.name.endsWith("collaboration.jsonl") && + !a.name.endsWith("research-evidence.jsonl"), + ); + const assigned = new Map<string, { role: string; inferred: boolean }>(); + for (const artifact of evidenceArtifacts) { + const ranked = team.roster + .map((role) => ({ role: role.name, score: roleScore(role, artifact) })) + .filter((r) => r.score > 0) + .sort((a, b) => b.score - a.score); + if (ranked[0]) { + assigned.set(artifact.name, { + role: ranked[0].role, + inferred: ranked[0].score < 100, + }); + } + } + const plan = rolePlan(task); + const hasExplicitPlan = plan.selected.size > 0 || plan.skipped.size > 0; + const hasSelectedSet = plan.selected.size > 0; + const normalizeRole = (value: string) => value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const memberMatchesRole = (member: string, role: string) => { + const normalizedMember = normalizeRole(member); + const normalizedRole = normalizeRole(role); + return normalizedMember === normalizedRole + || normalizedMember.endsWith(`-${normalizedRole}`); + }; + const roles: RoleEvidence[] = team.roster.map((role) => { + const artifacts = evidenceArtifacts.filter((a) => assigned.get(a.name)?.role === role.name); + const artifactAttribution = artifacts.length === 0 + ? "none" + : artifacts.some((artifact) => assigned.get(artifact.name)?.inferred) + ? "inferred" + : "recorded"; + const roleEvents = ledgerCollaboration.filter( + (event) => + event.member != null && + memberMatchesRole(event.member, role.name), + ); + const failedOutcome = (event: CollaborationEvent) => + event.outcome === "failed" || event.outcome === "Failed"; + const handbacks = roleEvents.filter( + (event) => + (event.event === "child_handback" && !failedOutcome(event)) || + event.outcome === "success" || + event.outcome === "Completed", + ); + const failed = handbacks.length === 0 && roleEvents.some( + (event) => + event.event === "child_lease_expired" || + failedOutcome(event), + ); + const lifecycleAssigned = roleEvents.some( + (event) => + event.event === "child_assigned" || + event.event === "child_progress" || + event.event === "child_handback" || + event.event === "child_lease_expired", + ); + return { + role, + state: plan.skipped.has(role.name) || (hasSelectedSet && !plan.selected.has(role.name)) + ? "skipped" + : handbacks.length > 0 + ? "delivered" + : failed + ? "failed" + : lifecycleAssigned && task.result == null + ? "working" + : "missing", + artifacts, + handbacks, + artifactAttribution, + }; + }); + + if (collaboration.length === 0) { + for (const role of roles.filter((r) => r.artifacts.length > 0)) { + collaboration.push({ + at: task.result?.finished_at ?? null, + event: "role_artifact_recovered", + agent: null, + member: role.role.name, + outcome: "delivered", + message_id: null, + preview: `${role.artifacts.length} retained artifact${role.artifacts.length === 1 ? "" : "s"}`, + source: "artifact-derived", + }); + } + } + + const issues: string[] = []; + if (task.result?.artifact_persistence === "partial") { + issues.unshift( + `Only ${task.result.artifact_count ?? 0} of ${task.result.declared_artifact_count ?? "the declared"} artifacts were durably persisted.`, + ); + } + const missingRoles = roles.some( + (role) => + (role.state === "missing" || role.state === "failed") && + (!hasExplicitPlan || plan.selected.has(role.role.name)), + ); + const partialArtifacts = task.result?.artifact_persistence === "partial"; + const awaitingAssignment = Boolean( + task.current_run_nonce + && task.assignment?.task_id !== task.current_run_nonce, + ); + const resultMatchesCurrentRun = + task.result == null + || task.current_run_nonce == null + || task.result.assignment_nonce == null + || task.result.assignment_nonce === task.current_run_nonce; + const latestRootEvent = [...currentAssignmentEvents] + .filter( + (event) => + event.child_task_id == null + && (currentTaskId == null || event.task_id === currentTaskId), + ) + .sort((left, right) => right.sequence - left.sequence)[0]; + const assignmentState = + currentAssignment?.state?.toLowerCase() + ?? latestRootEvent?.state.toLowerCase() + ?? null; + const hasAssignmentLedger = + currentAssignment != null || currentAssignmentEvents.length > 0; + const rootCompleted = assignmentState === "completed"; + const rootFailed = assignmentState === "failed"; + const running = + task.launched && + (task.result == null || !resultMatchesCurrentRun) && + !rootFailed && + (awaitingAssignment || + assignmentState === "assigned" || + assignmentState === "acknowledged" || + assignmentState === "running" || + task.execution_phase === "Running"); + const failed = + rootFailed || (resultMatchesCurrentRun && task.result?.status === "error"); + const blocked = resultMatchesCurrentRun && Boolean(task.result?.blocked); + const outcome = team.paused && running + ? "paused" + : running + ? "running" + : failed + ? "failed" + : blocked || !hasAssignmentLedger || !rootCompleted || missingRoles + ? "incomplete" + : partialArtifacts + ? "delivered_with_issues" + : "delivered"; + + return { + outcome, + roles, + collaboration, + research, + issues, + evidenceMode: hasAssignmentLedger + ? "ledger" + : rawCollaboration.length > 0 + ? "agent-reported" + : collaboration.some((event) => event.source === "router") + ? "agent-reported" + : "artifact-derived", + unattributedArtifacts: evidenceArtifacts.filter((a) => !assigned.has(a.name)), + }; +} diff --git a/bridge/web/src/lib/types.ts b/bridge/web/src/lib/types.ts new file mode 100644 index 000000000..1f7baedf5 --- /dev/null +++ b/bridge/web/src/lib/types.ts @@ -0,0 +1,1611 @@ +// kars Bridge web — shared types mirroring the BFF API DTOs. +// The BFF (Rust) owns these shapes; keep field names in sync with +// bff/src/routes/tasks.rs. + +export interface Budget { + scope?: "GovernedInference"; + tokens: number | null; + usd_micros: number | null; +} + +export interface Envelope { + tier: number; + authority_ceiling: number; + delegation_depth: number; + budget: Budget | null; + tool_policy: string | null; + egress_allowlist: string | null; +} + +export interface TaskSummary { + name: string; + namespace: string; + objective: string; + display_name: string | null; + created_at: string | null; + tier: number; + phase: string; + envelope_digest: string | null; + team: string | null; + delivered: boolean; + failed: boolean; + launched: boolean; + execution_phase: string | null; +} + +export interface TaskDetail { + name: string; + namespace: string; + objective: string; + display_name: string | null; + created_at: string | null; + envelope: Envelope; + phase: string; + envelope_digest: string | null; + observed_generation: number | null; + lineage: string[]; + parent: string | null; + /** The standing team that owns this task (from kars.azure.com/team). */ + team: string | null; + status_message: string | null; + children: TaskSummary[]; + launched: boolean; + execution_phase: string | null; + sandbox: string | null; + egress_mode: string | null; + execution_detail: string | null; + assignment: TaskAssignmentStatus | null; + assignment_events: TaskAssignmentEvent[]; + assignment_sequence: number | null; + composition: Composition | null; + sub_agents: SubAgent[]; + result: MissionResult | null; + artifacts: MissionArtifact[]; + role_plan: TeamRolePlan; + collaboration_events: TeamCollaborationEvent[]; + /** Pull requests the mission opened — first-class deliverables shown on the + * Artifacts tab (a PR is a delivery type). Empty when none. */ + pull_requests?: PullRequestRef[]; + activity: ActivityEvent[]; + telemetry: MissionTelemetry | null; + checkpoint: TaskCheckpoint | null; + agent_identity: AgentIdentity | null; + /** A governed capability-routing correction recorded at creation (e.g. a + * chat-gateway harness swapped to an autonomous one for a one-shot mission). + * Null when no correction was needed. */ + harness_corrected: string | null; + /** A governed emergency-stop decision (operator/reason/at) when the mission + * was halted. Null when never halted. */ + halted: string | null; + /** Whether a run has ever been requested (the run-requested annotation is set). + * Gates the client auto-kickoff so the first run fires exactly once. */ + run_requested: boolean; + /** Exact latest requested run nonce, available before assignment acknowledgement. */ + current_run_nonce: string | null; +} + +export interface TeamRolePlan { + selected_roles: string[]; + skipped_roles: string[]; +} + +export interface TeamCollaborationEvent { + at: string | null; + event: string; + agent: string | null; + member: string | null; + outcome: string | null; + message_id: string | null; + reply_preview: string | null; + content_preview: string | null; +} + +export interface TaskCheckpoint { + schema: string; + milestone_id: string; + status: "pending" | "in_progress" | "completed" | "blocked"; + summary: string; + acceptance_criteria?: string[]; + artifacts?: string[]; + next_steps?: string[]; + updated_at?: string; + agent?: string; +} + +export interface TaskAssignmentStatus { + task_id: string; + state: string; + worker_did: string | null; + stage: string | null; + child_task_id: string | null; + child_role: string | null; + last_progress_at: string | null; + completed_at: string | null; + error: string | null; +} + +export interface TaskAssignmentEvent { + sequence: number; + event_id: string; + task_id: string; + event_type: string; + state: string; + at: string; + worker_did: string | null; + stage: string | null; + child_task_id: string | null; + child_role: string | null; + outcome: string | null; + message: string | null; +} + +/** Loop-shape telemetry for a mission run (token totals are on MissionResult). */ +export interface MissionTelemetry { + rounds: number | null; + tool_calls: number | null; +} + +/** One event in the agent's live execution trace. A `round` event records the + * model call (real token usage); a `tool` event records one tool invocation + * with a sanitized args/result preview. */ +export type ActivityEvent = + | { + kind: "round"; + round: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + finish_reason: string; + tool_calls: number; + ms: number; + ts: string; + /** The agent (sandbox) that emitted this event, and its role in the tree. + * Present when the stream aggregates the whole agent tree; absent for a + * single-agent trace read from the persisted ConfigMap. */ + agent?: string; + agentInstance?: string; + agentRole?: "principal" | "subagent"; + seq?: number; + } + | { + kind: "tool"; + round: number; + name: string; + args_preview: string; + result_preview: string; + ms: number; + ok: boolean; + ts: string; + agent?: string; + agentInstance?: string; + agentRole?: "principal" | "subagent"; + seq?: number; + /** Present on tools authoritatively executed and recorded by the router. */ + source?: "router" | "harness" | "governance"; + }; + +/** One artifact file in a mission's deliverable set. `content` is present for + * text artifacts (markdown/json/csv/…) and null for binary ones. */ +export interface MissionArtifact { + name: string; + size_bytes: number | null; + content: string | null; + content_bytes: number | null; + content_truncated: boolean; + source_agent: string | null; + source_path: string | null; + digest: string | null; +} + +/** A running agent's real mesh identity, discovered from the AGT registry. */ +export interface AgentIdentity { + did: string; + capabilities: string[]; + last_seen: string | null; + reputation_score: number | null; +} + +/** A captured mission run result — a real deliverable + real token cost. */ +export interface MissionResult { + output: string; + status: string | null; + model: string | null; + total_tokens: number | null; + prompt_tokens: number | null; + completion_tokens: number | null; + finished_at: string | null; + assignment_nonce: string | null; + /** How the deliverable was produced. "single_turn" = one model turn (no + * tools/sub-agents) because the mesh agent loop was unavailable; absent for + * a full agent-loop run. */ + source: string | null; + /** Set when this run's ok-output is actually a capability/limit STOP (today the + * daily token budget), not a deliverable — rendered as an actionable state. */ + blocked: RunBlocked | null; + artifact_persistence: "complete" | "partial" | null; + artifact_count: number | null; + declared_artifact_count: number | null; +} + +export interface RunBlocked { + /** Machine reason. Today: "budget". */ + reason: string; + detail: string; + spent: number | null; + limit: number | null; +} + +/** A sub-agent the mission's agent spawned at run time (a labelled sandbox). */ +export interface SubAgent { + name: string; + namespace: string; + phase: string | null; + runtime: string | null; + role: string | null; + parent: string | null; + logical_agent_id: string | null; + model: string | null; +} + +/** The composed run — what a mission actually runs with (from the blueprint). */ +export interface Composition { + runtime: string | null; + model: string | null; + instructions: string | null; + tool_policy: string | null; + mcp_servers: string[]; + egress: string[]; + isolation: string | null; + memory: string | null; +} + +export interface CreateTaskRequest { + name: string; + objective: string; + display_name: string | null; + envelope: Envelope; + parent?: string | null; + blueprint?: Blueprint | null; + delegation?: MissionDelegation | null; + launch?: boolean; + /** Repos (owner/name) from this principal's GitHub connection. The BFF + * validates the complete set and derives the connection reference. */ + git_write_repos?: string[] | null; + /** The creating principal, for per-user budget attribution. */ + created_by?: string | null; +} + +/** A model route — provider tag + deployment, lands on InferencePolicy. */ +export interface BlueprintModel { + provider: string; + deployment: string; +} + +/** A network destination the mission may reach. */ +export interface BlueprintEgress { + host: string; + port?: number | null; +} + +/** + * The editable run composition reviewed on the launch package. Every field maps + * to a real field on the materialized InferencePolicy / KarsSandbox; the + * controller compiles it. Mirrors bff/src/kars/task.rs::TaskBlueprint. + */ +export interface Blueprint { + runtime?: string | null; + model?: BlueprintModel | null; + model_fallbacks?: BlueprintModel[]; + instructions?: string | null; + tool_policy?: string | null; + mcp_servers?: string[]; + egress?: BlueprintEgress[]; + egress_mode?: "strict" | "learning"; + isolation?: string | null; + memory?: string | null; + skills?: string[]; + execution_plan?: ExecutionPlan | null; +} + +export interface ExecutionPlan { + schema: "kars.execution-plan/v1"; + roles: ExecutionRole[]; + max_parallel: number; + synthesis: ExecutionSynthesis; + deliverables: ExecutionDeliverable[]; +} + +export interface ExecutionRole { + name: string; + objective: string; + depends_on: string[]; + phases: ExecutionPhase[]; + budget_tokens?: number | null; +} + +export interface ExecutionPhase { + name: string; + objective: string; + capabilities: ExecutionCapability[]; + required_tool_calls?: ExecutionRequiredToolCall[]; + min_tool_calls?: number; + max_tool_calls: number; + fresh_context: boolean; +} + +export interface ExecutionRequiredToolCall { + name: "github_actions_job_logs"; + arguments: Record<string, string>; +} + +export type ExecutionCapability = + | "filesystem-read" + | "filesystem-write" + | "shell" + | "network" + | "mcp" + | "memory"; + +export interface ExecutionSynthesis { + objective: string; + capabilities: ExecutionCapability[]; + max_tool_calls: number; +} + +export interface ExecutionDeliverable { + name: string; + media_type?: string | null; +} + +// ─── Launch-package options (from /api/options) ────────────────────────────── + +export interface ModelOption { + provider: string; + deployment: string; + is_default: boolean; + /** Short human detail (e.g. "Anthropic · 1.0M ctx · powerful"), when known. */ + detail?: string | null; +} +export interface RuntimeOption { + kind: string; + label: string; + wired: boolean; + status: "ready" | "needs_image" | "unavailable" | "validated" | "available"; + note: string; +} +export interface ProviderInfo { + id: string; + label: string; + note: string; +} +/** An additional inference provider configured alongside the single + * default — e.g. GitHub Copilot as the default plus Azure AI Foundry also + * connected. `has_key` only reports whether a dev-mode key is stored, never + * the value. `models` are the deployment ids this provider serves, feeding + * the shared model catalog tagged with this provider's own tag. */ +export interface AdditionalProvider { + tag: string; + endpoint: string | null; + has_key: boolean; + models: string[]; +} +export interface RefOption { + name: string; + namespace: string; + summary: string | null; + mode?: string | null; + discovered_tools?: string[]; + tool_schema_digest?: string | null; + compiled_digest?: string | null; + backend?: string | null; + readiness?: string | null; + version?: string | null; + recipe?: string | null; + version_digest?: string | null; + qualified_routes?: string[]; +} +export interface IsolationOption { + value: string; + label: string; + note: string; +} +export interface Options { + models: ModelOption[]; + default_model: string | null; + provider: ProviderInfo | null; + runtimes: RuntimeOption[]; + isolation: IsolationOption[]; + tool_policies: RefOption[]; + mcp_servers: RefOption[]; + mcp_profiles: McpProfileOption[]; + memories: RefOption[]; + skills: RefOption[]; +} + +/** Operator-curated MCP bundle (a vetted set of McpServers). */ +export interface McpProfileOption { + name: string; + summary: string | null; + servers: string[]; +} + +// ─── Orchestrator: intent → composed launch package (§20) ──────────────────── + +export interface ComposeProposal { + tier: number; + model: BlueprintModel | null; + model_fallbacks: BlueprintModel[]; + model_basis: string | null; + runtime: string; + instructions: string; + tool_policy: string | null; + mcp_servers: string[]; + skills: string[]; + egress: BlueprintEgress[]; + isolation: string; + memory: string | null; + budget_tokens: number | null; + execution_plan: ExecutionPlan | null; + delegation: MissionDelegation; +} + +export interface MissionDelegationRole { + name: string; + objective: string; +} + +export interface MissionDelegation { + mode: "single-agent" | "principal-specialists"; + roles: MissionDelegationRole[]; + max_parallel: number; +} +export interface ComposeResponse { + available: boolean; + reason: string | null; + proposal: ComposeProposal | null; + rationale: string | null; + source: string | null; +} + +// ─── Team orchestrator: charter → org chart ────────────────────────────────── + +export interface ComposeTeamRole { + name: string; + system_prompt: string; + runtime: string; + model: string; + skills: string[]; +} + +export interface ComposeTeamProposal { + tier: number; + cadence_minutes: number; + instructions: string; + model: string; + model_fallbacks: string[]; + model_basis: string | null; + expected_tokens_per_outcome: number | null; + efficiency_sample_runs: number; + mcp_servers: string[]; + memory: string | null; + egress: BlueprintEgress[]; + egress_mode: "learning" | "strict"; + engineering_enabled: boolean; + engineering_signals: EngineeringSignal[]; + engineering_poll_interval_seconds: number; + engineering_auto_run: boolean; + roles: ComposeTeamRole[]; + execution_plan: ExecutionPlan | null; + milestones: ComposeTeamMilestone[]; +} + +export interface TeamChannelStatus { + channel: string; + enabled: boolean; + qualified?: boolean | null; + detail?: string | null; +} + +export interface TeamChannelsState { + enabled: string[]; + statuses: TeamChannelStatus[]; +} + +export interface ComposeTeamMilestone { + id: string; + title: string; + description: string; + owner_role: string | null; + depends_on: string[]; + acceptance_criteria: string[]; + review_required: boolean; +} + +export interface ComposeTeamResponse { + available: boolean; + reason: string | null; + proposal: ComposeTeamProposal | null; + rationale: string | null; + source: string | null; +} + +// ─── Artifacts index (cross-mission deliverables, §16) ─────────────────────── + +export interface ArtifactFile { + name: string; + size_bytes: number | null; + has_content: boolean; + content_address: string | null; + did: string | null; +} +export interface MissionArtifacts { + task: string; + evidence_key: string | null; + team: string | null; + archived: boolean; + display_name: string | null; + objective: string | null; + model: string | null; + finished_at: string | null; + status: string | null; + review_status: string; + review_revision: number; + files: ArtifactFile[]; + summary: string | null; + excerpt: string | null; + pull_requests: PullRequestRef[]; + deliverable_did: string | null; +} +export interface PullRequestRef { + repo: string; + number: number; + url: string; +} +export interface ArtifactsIndex { + missions: MissionArtifacts[]; +} + +/** One level of the hierarchical inference token budget (cluster / workspace), + * with the live measured daily usage and computed enforcement status. Mirrors + * bff/src/routes/budgets.rs::BudgetLevelDto. */ +export interface BudgetLevel { + scope: string; + label: string; + daily_tokens: number; + mode: "passive" | "buffer" | "strict"; + buffer_percent: number; + used_today: number; + status: "ok" | "alert" | "over_buffer_headroom" | "blocking"; + percent: number; + hard_cap: number; +} +export interface InferenceBudgets { + cluster: BudgetLevel | null; + cluster_used_today: number; + workspaces: BudgetLevel[]; + users: BudgetLevel[]; + default_namespace: string; + unbudgeted_namespaces: string[]; + unbudgeted_users: string[]; + alerts?: BudgetAlert[]; +} +export interface BudgetAlert { + scope: string; + label: string; + severity: "alert" | "over_buffer" | "blocking"; + message: string; +} + +// ─── Retention policy (mission/team-run auto-cleanup) ─────────────────────── + +export interface RetentionPolicy { + default_ttl_seconds: number; + summary: string; +} + +// ─── Pre-flight validation (§20) ───────────────────────────────────────────── + +export type CheckStatus = "pass" | "fail" | "warn"; +export interface ValidationCheck { + id: string; + label: string; + status: CheckStatus; + detail: string; +} +export interface ValidationResult { + ok: boolean; + checks: ValidationCheck[]; +} + +/** Autonomy tier labels (1..5), aligned with the kars taxonomy. */ +export const TIER_LABELS: Record<number, string> = { + 1: "Manual", + 2: "Shared", + 3: "Conditional", + 4: "Supervised", + 5: "Full", +}; + +// ─── Teams (standing orgs) ─────────────────────────────────────────────────── +// A Team is the durability-axis primitive: a standing org with a charter and a +// cadence loop that mints task-force work autonomously. Distinct from a Mission +// (a finite task force). Mirrors the BFF Teams DTOs. + +export interface TeamSummary { + name: string; + display_name: string | null; + charter: string; + phase: string; + reporting_to: string | null; + tier: number; + member_count: number; + generated_task_count: number; + every_minutes: number | null; + lifecycle_mode: TeamLifecycleMode; + warm_idle_seconds: number | null; + runtime_state: TeamRuntimeState | null; + current_assignment_task: string | null; + idle_deadline_at: string | null; + paused: boolean; + created_at: string | null; + last_run_at: string | null; + last_success_at: string | null; + last_activity_at: string | null; + next_run_at: string | null; + health: string | null; + detail: string | null; + runs_succeeded: number; + retained_delivered: number; + retained_no_action: number; + retained_failed: number; +} + +export interface TeamRole { + name: string; + system_prompt: string | null; + tier: number | null; + member_task: string | null; + skills: string[]; + runtime: string | null; + model: string | null; +} + +export interface LedgerEvent { + at: string; + kind: string; + summary: string; + task: string | null; + tokens: number | null; +} + +export interface TeamDetail { + name: string; + display_name: string | null; + charter: string; + phase: string; + reporting_to: string | null; + knowledge_commons: string | null; + tier: number; + authority_ceiling: number; + delegation_depth: number; + paused: boolean; + every_minutes: number | null; + lifecycle_mode: TeamLifecycleMode; + warm_idle_seconds: number | null; + runtime_state: TeamRuntimeState | null; + current_assignment_nonce: string | null; + current_assignment_task: string | null; + idle_deadline_at: string | null; + envelope_digest: string | null; + principal_task: string | null; + roster: TeamRole[]; + member_count: number; + generated_task_count: number; + last_generated_task: string | null; + last_run_at: string | null; + next_run_at: string | null; + detail: string | null; + health: string | null; + runs_succeeded: number; + tokens_spent_total: number; + commons_entry_count: number; + last_success_at: string | null; + created_at: string | null; + last_activity_at: string | null; + generated_tasks: string[]; + recent_outcomes: TeamOutcome[]; + recent_outcome_summary: TeamOutcomeSummary; + tool_policy: string | null; + tool_policy_default: boolean; + mcp_servers: string[]; + git_write_repos: string[]; + egress: string[]; + egress_mode: string | null; + /** Domains the team's agents have actually reached (live, from running runs). */ + learned_egress: string[]; + network_posture: string; + model: string | null; + model_fallbacks: string[]; + model_default: boolean; + memory: string | null; + runtime: string | null; + runtime_default: boolean; + isolation: string | null; + execution_plan: ExecutionPlan | null; + tasks: TeamTask[]; + channels: string[]; +} + +export type TeamLifecycleMode = "ephemeral" | "resourceOptimized" | "persistent"; +export type TeamRuntimeState = "Working" | "Warm" | "Hibernating" | "Idle"; + +export type TeamOutcomeDisposition = + | "change_proposed" + | "no_action_needed" + | "completed" + | "failed"; + +export interface TeamOutcome { + run: string; + disposition: TeamOutcomeDisposition; + headline: string; + detail: string; + objective: string; + finished_at: string | null; + duration_seconds: number | null; + tokens: number | null; + model: string | null; + pull_requests: PullRequestRef[]; + artifact_count: number; +} + +export interface TeamOutcomeSummary { + change_proposed: number; + no_action_needed: number; + completed: number; + failed: number; +} + +/** A backlog task assigned to a standing team. */ +export interface TeamTask { + id: string; + title: string; + description: string; + depends_on: string[]; + acceptance_criteria: string[]; + review_required: boolean; + status: string; // pending | active | done + run: string | null; + created_at: string | null; + done_at: string | null; + stuck_since?: string | null; + assignment_nonce?: string | null; +} + +export type EngineeringSignal = + | "dependabot_pr" + | "dependabot_alert" + | "code_scanning_alert" + | "secret_scanning_alert"; +export type EngineeringSignalSyncState = + | "ok" + | "unavailable" + | "forbidden" + | "truncated" + | "error"; +export interface EngineeringSignalResult { + repo: string; + signal: EngineeringSignal; + state: EngineeringSignalSyncState; + discovered: number; + detail: string; +} +export type EngineeringSyncState = + | "disabled" + | "idle" + | "syncing" + | "ok" + | "partial" + | "error"; +export type EngineeringReviewState = + | "ready_for_review" + | "waiting_for_ci" + | "ci_failed" + | "blocked" + | "unknown"; + +export interface EngineeringReviewItem { + repo: string; + pr_number: number; + pr_url: string; + title: string; + run: string; + source_id: string; + work_id: string; + task_status: string; + run_state: string | null; + selected_roles: string[]; + delivered_roles: string[]; + artifact_count: number | null; + head_sha: string; + state: EngineeringReviewState; + detail: string; + checks_total: number; + checks_passed: number; + observed_at: string; +} + +export interface EngineeringSourceStatus { + state: EngineeringSyncState; + last_sync_at: string | null; + last_success_at: string | null; + last_error: string | null; + items_discovered: number; + items_queued: number; + total_items_queued: number; + next_poll_at: string | null; + review_items: EngineeringReviewItem[]; + ready_for_review: number; + waiting_for_ci: number; + ci_failed: number; + signal_results: EngineeringSignalResult[]; +} + +export interface EngineeringSource { + configured: boolean; + enabled: boolean; + auto_run: boolean; + repos: string[]; + signals: EngineeringSignal[]; + poll_interval_seconds: number; + status: EngineeringSourceStatus; +} + +export interface GithubConnection { + connected: boolean; + account: string | null; + repos: string[]; +} + +export interface CommonsEntry { + id: string; + title: string; + author: string; + source_task: string; + created_at: string; + digest: string; + size_bytes: number; + content: string | null; +} + +export interface CommonsResponse { + commons: string; + count: number; + entries: CommonsEntry[]; +} + +// ─── Artifact review (§16) ─────────────────────────────────────────────────── + +export interface ReviewEntry { + decision: string; + comment: string | null; + reviewer: string; + decided_at: string; + revision: number; + /** Whether the reviewer identity is server-attested. A self-reported + * (client-supplied) name in V0 is unverified → shown as such. */ + attested?: boolean; + assignment_nonce?: string | null; +} + +export interface ReviewState { + status: string; + revision: number; + history: ReviewEntry[]; + redrive_pending: boolean; + assignment_nonce: string | null; +} + +// ─── Team digests (§20) ────────────────────────────────────────────────────── + +export interface Digest { + team: string; + at: string; + reporting_to: string | null; + health: string; + summary: string; + runs_generated: number; + runs_delivered: number; + tokens_spent: number; + knowledge_entries: number; + channel: string | null; + gated: boolean; +} + + +// ─── Governance Receipt ────────────────────────────────────────────────────── + +export type ClaimStatus = "PASS" | "PARTIAL" | "FAIL" | "OMITTED"; +export interface ReceiptClaim { + class: string; + status: string; + detail: string; +} + +export interface ReceiptSignature { + keyid: string; + sig: string; +} + +export interface Receipt { + name: string; + namespace: string; + task: string; + envelope_digest: string; + predicate_type: string; + scheme: string; + key_id: string; + payload_type: string; + signatures: ReceiptSignature[]; + claims: ReceiptClaim[]; + /** The decoded in-toto Statement — the exact bytes the signature covers. */ + statement: unknown; + issued_at: string | null; + /** Inclusion-log sequence (cross-receipt tamper-evidence chain). */ + inclusion_seq: number | null; + /** Inclusion-log entry hash. */ + inclusion_entry_hash: string | null; + inclusion_state: "Included" | "Failed" | null; + inclusion_error: string | null; + log_segment: string | null; + checkpoint_tree_size: number | null; + witnessed: boolean | null; + /** The log's signed checkpoint (signed tree head), when published. */ + checkpoint: ReceiptCheckpoint | null; + /** The exact command an auditor runs to verify independently. */ + verify_command: string; +} + +/** A KarsEval safety/conformance eval and its latest verdict. */ +export interface EvalResult { + total: number; + passed: number; + failed: number; + errored: number; + corpus_name: string | null; + corpus_digest: string | null; + completed_at: string | null; +} + +export interface Eval { + name: string; + namespace: string; + display_name: string | null; + target_sandbox: string | null; + corpus: string | null; + phase: string | null; + schedule: string | null; + last_run_at: string | null; + last_result: EvalResult | null; + created: string | null; +} + +/** A single eval case: what it probes + its latest verdict. */ +export interface EvalCase { + id: string; + tags: string[]; + probe: string | null; + expected: string | null; + actual: string | null; + actual_reason: string | null; + pass: boolean | null; + /** True when the case couldn't be evaluated (target unreachable) — inconclusive, not a policy fail. */ + errored: boolean; +} + +/** The detailed eval report — corpus cases merged with per-case verdicts. */ +export interface EvalReport { + name: string; + corpus: string | null; + total: number; + passed: number; + failed: number; + /** Cases the runner couldn't evaluate (target unreachable) — inconclusive, shown separately. */ + errored: number; + completed_at: string | null; + per_case_available: boolean; + cases: EvalCase[]; +} + +export interface ReceiptCheckpoint { + tree_size: number; + root_hash: string; + key_id: string; + published_at: string | null; +} + +/** A signed receipt claim mapped to an external regulatory obligation. */ +export interface ComplianceControl { + control_id: string; + framework: string; + reference: string; + receipt_class: string; + status: string; + evidence: string; + /** True for the `regulatory` claim class — a named V0 limitation (external + * transparency anchor lands in V1), so it reads PARTIAL on every receipt + * this product issues today, not a gap specific to this task. */ + advisory?: boolean; +} + +/** A compliance evidence pack derived from a mission's signed receipt. */ +export interface CompliancePack { + task: string; + namespace: string; + generated_at: string; + predicate_type: string; + envelope_digest: string; + signature_scheme: string; + key_id: string; + inclusion_seq: number | null; + issued_at: string | null; + verify_command: string; + controls: ComplianceControl[]; + satisfied: number; + partial: number; + /** Count of `advisory` controls — excluded from `partial`. */ + advisory?: number; +} + +// ─── Steering / HITL approvals ─────────────────────────────────────────────── + +export interface Approval { + name: string; + namespace: string; + task: string; + team: string | null; + milestone: string | null; + action_kind: string; + summary: string; + detail: string | null; + requested_tier: number | null; + phase: string; + decider: string | null; + requested_at: string | null; + decided_at: string | null; + expires_at: string | null; + bound_envelope_digest: string | null; + run_nonce: string | null; + resource_version: string; + generation: number; + /** Whether a human can still act on this (only a Pending approval). */ + actionable: boolean; +} + +// ─── System / wiring ───────────────────────────────────────────────────────── + +export type WiringStatus = "live" | "partial" | "not_wired"; + +export interface PipelineStage { + id: string; + name: string; + description: string; + status: WiringStatus; + detail: string; +} + +export interface CrdStatus { + name: string; + installed: boolean; +} + +export interface SystemCounts { + tasks: number; + ready_tasks: number; + degraded_tasks: number; + digested_tasks: number; + sandboxes: number | null; +} + +export interface SystemStatus { + namespace: string; + controller_reachable: boolean; + crds: CrdStatus[]; + counts: SystemCounts; + pipeline: PipelineStage[]; +} + +/** One concrete, act-on-it problem the live diagnostics scan found. */ +export interface DiagnosticIssue { + severity: "critical" | "warning"; + kind: string; + subject: string; + reason: string; + detail: string | null; + remedy: string; +} + +export interface Diagnostics { + issues: DiagnosticIssue[]; + scanned_pods: number; + scanned_sandboxes: number; + healthy: boolean; +} + +/** The orchestrator's proposed loop for an intent, shown in the Loop Designer. */ +export interface LoopProposal { + pattern: string; + goal: string; + criteria: string; + rationale: string; + source: "orchestrator" | "heuristic"; +} + +/** kars-SRE agent + Headlamp plugin integration status. */ +export interface Integrations { + sre_present: boolean; + sre_phase: string | null; + sre_ready: string | null; + sre_activate_cmd: string; + headlamp_deployed: boolean; + headlamp_url: string | null; + headlamp_paths: { label: string; path: string }[]; + headlamp_install_hint: string; +} + +/** Orchestrator (compose engine) health + the active inference path. */ +export interface Orchestrator { + mode: "direct" | "sandbox" | "none"; + direct_configured: boolean; + sandbox_present: boolean; + sandbox_phase: string | null; + sandbox_ready: string | null; + sandbox_restarts: number | null; + sandbox_waiting_reason: string | null; + router_candidates: number; + recommend_direct: boolean; + note: string; +} + +export const WIRING_LABELS: Record<WiringStatus, string> = { + live: "Live", + partial: "Partial", + not_wired: "Not wired", +}; + +// ─── Operator Console projections (real CRD reads) ────────────────────────── + +export interface Sandbox { + name: string; + namespace: string; + runtime_namespace: string | null; + phase: string | null; + runtime: string | null; + isolation: string | null; + tool_policy: string | null; + inference_policy: string | null; + governed: boolean; + team: string | null; + parent: string | null; + message: string | null; + created: string | null; + working: boolean | null; + /** Currently executing a task (Running AND not yet delivered) — distinct + * from `working` (has ever produced activity). See operator.rs SandboxDto. */ + executing: boolean | null; + cpu_millicores: number | null; + memory_bytes: number | null; + conditions: Array<{ + type_: string; + status: string; + reason: string | null; + message: string | null; + }>; +} + +export interface NodeCapacity { + name: string; + cpu_usage_millicores: number | null; + cpu_allocatable_millicores: number | null; + memory_usage_bytes: number | null; + memory_allocatable_bytes: number | null; + cpu_percent: number | null; + memory_percent: number | null; +} + +export interface ClusterCapacity { + metrics_available: boolean; + metrics_error: string | null; + team_max_concurrent_runs: number; + global_active_runs_limit: number; + active_team_runs: number; + pod_metrics_available: boolean; + pod_metrics_error: string | null; + nodes: NodeCapacity[]; +} + +export interface McpServer { + name: string; + namespace: string; + url: string | null; + phase: string | null; + mode: "Managed" | "External" | null; + endpoint: string | null; + workload_ref: string | null; + discovered_tools: string[]; + tool_schema_digest: string | null; + production: boolean | null; + allowed_tools: string[]; + created: string | null; + spec: Record<string, unknown>; +} + +export interface ToolPolicy { + name: string; + namespace: string; + phase: string | null; + version_hash: string | null; + applies_to: string | null; + has_governance_profile: boolean; + allowed: string[]; + created: string | null; + spec: Record<string, unknown>; +} + +export interface InferencePolicy { + name: string; + namespace: string; + phase: string | null; + version_hash: string | null; + sandbox: string | null; + daily_token_budget: number | null; + content_safety: boolean; + created: string | null; + spec: Record<string, unknown>; +} + +export interface EgressApproval { + name: string; + namespace: string; + sandbox: string | null; + phase: string | null; + reason: string | null; + hosts: string[]; + expires_at: string | null; + created: string | null; +} + +// ─── GitHub App (platform identity) ───────────────────────────────────────── + +export interface GithubApp { + configured: boolean; + slug: string | null; + install_url: string | null; +} + +export interface DiscoveredModel { + id: string; + label: string | null; + /** True for the one starred/pre-selected pick — currently only populated + * for GitHub Copilot's curated catalog (mirrors `kars dev`'s picker). */ + recommended?: boolean; +} + +// ─── kars-SRE self-remediation proposals ──────────────────────────────────── + +export interface SreAction { + name: string; + namespace: string; + action_type: string; + target_namespace: string | null; + target_name: string | null; + params: Record<string, unknown>; + rationale: string | null; + diagnosis: string | null; + approval_state: string; + approval_note: string | null; + phase: string; + applied_at: string | null; + ttl_minutes: number | null; + created_at: string | null; + actionable: boolean; +} + +// ─── Insights / scorecard (real + honest) ─────────────────────────────────── + +export interface CountPair { + label: string; + count: number; +} + +export interface Insights { + missions_by_phase: CountPair[]; + missions_by_tier: CountPair[]; + decisions: CountPair[]; + launched: number; + receipts_issued: number; + inclusion_log_size: number; + amplification_rejections: number; + runtime_metrics_available: boolean; + runtime_metrics_note: string | null; +} + +export interface Scorecard { + task: string; + namespace: string; + tier: number | null; + launched: boolean; + execution_phase: string | null; + token_budget: number | null; + decisions_recorded: number; + approvals_granted: number; + approvals_denied: number; + receipt_issued: boolean; + run_total_tokens: number | null; + run_prompt_tokens: number | null; + run_completion_tokens: number | null; + run_model: string | null; + runtime_metrics_available: boolean; + runtime_metrics_note: string | null; +} + +export interface ReceiptSummary { + name: string; + namespace: string; + task: string | null; + envelope_digest: string | null; + key_id: string | null; + inclusion_seq: number | null; + created: string | null; + verdict: "verified" | "failed" | "partial" | "none"; +} + +export interface Audit { + receipts: ReceiptSummary[]; + inclusion_log_size: number; + checkpoint: { + tree_size: number; + root_hash: string; + key_id: string; + published_at: string | null; + } | null; + /** Real cryptographic integrity verdict computed server-side: the whole hash + * chain recomputed + the signed checkpoint verified against the anchor. */ + integrity: { + chain_consistent: boolean; + tree_size: number; + checkpoint_verified: boolean; + witness_present: boolean; + anchor_pinned: boolean; + }; +} + +/** One independent verification check performed server-side by the BFF. */ +export interface VerifyCheck { + name: string; + passed: boolean; + detail: string; + /** Displayed but not cryptographically re-verified here (e.g. the V0 witness + * whose public key isn't published) — rendered as "shown, not verified", + * never a green ✓. */ + advisory?: boolean; + /** The recorded value the proof expected (e.g. a logged hash), when shown. */ + expected?: string | null; + /** The value the BFF independently recomputed — visibly matches `expected`. */ + computed?: string | null; +} + +export interface InclusionEvidence { + seq: number; + receipt: string; + payload_sha256: string; + prev_hash: string; + entry_hash: string; + recomputed_entry_hash: string; + chain_head: string; + chain_consistent: boolean; + tree_size: number; +} + +export interface CheckpointEvidence { + tree_size: number; + root_hash: string; + signed_note: string; + signature_b64: string; + signature_valid: boolean; + witness_key_id?: string | null; + witness_signature_b64?: string | null; +} + +export interface Evidence { + signed_statement?: unknown; + signature_b64?: string | null; + scheme?: string | null; + anchor_key_id?: string | null; + anchor_public_key_b64?: string | null; + inclusion?: InclusionEvidence | null; + checkpoint?: CheckpointEvidence | null; +} + +/** The result of in-browser (BFF-side) cryptographic receipt verification. */ +export interface VerifyResult { + verified: boolean; + checks: VerifyCheck[]; + evidence: Evidence; +} + +// ─── Cross-harness efficiency frontier (§3B, Pillar B) ─────────────────────── + +export interface RouteEfficiency { + route: string; + harness: string; + runs: number; + delivered: number; + success_rate: number; + accepted: number; + acceptance_rate: number; + avg_tokens: number; + tokens_per_outcome: number; + avg_rounds: number; + avg_tool_calls: number; + // 2026 enrichments. + avg_prompt_tokens: number; + avg_completion_tokens: number; + tool_fail_rate: number; + avg_wall_ms: number; + p95_wall_ms: number; + avg_ttfa_ms: number; + reliability_rate: number | null; + reliability_k: number | null; + reliability_samples: number; + usd_per_outcome: number | null; + cache_hit_rate: number; + top_fault: string; +} + +export interface Efficiency { + routes: RouteEfficiency[]; + recommended: string | null; + recommended_harness: string | null; + recommended_basis: string | null; + recommended_low_confidence: boolean; + total_runs: number; + priced: boolean; +} + +export interface SkillSummary { + name: string; + namespace: string; + version: string | null; + summary: string | null; + bounding_policy: string | null; + phase: string | null; + version_digest: string | null; + attestation_verified: boolean | null; + // Operator trust gate. + review: string; + locked_digest: string | null; + approved_by: string | null; + approved_at: string | null; + usable: boolean; + spec: Record<string, unknown>; +} + +export interface ProfileRole { + name: string; + system_prompt: string | null; + skills: string[]; +} + +export interface ProfileSummary { + name: string; + namespace: string; + domain: string | null; + phase: string | null; + template_digest: string | null; + display_name: string | null; + charter_template: string | null; + tier: number | null; + tool_policy: string | null; + knowledge_commons: string | null; + roles: ProfileRole[]; + spec: Record<string, unknown>; +} + +export interface AgentLifecycle { + sandbox: string; + namespace: string; + phase: string | null; + parent: string | null; + task: string | null; + objective: string | null; + tier: number | null; + rounds: number; + tool_calls: number; + last_action: string | null; + live: boolean; + tokens: number | null; + /** The run's token budget ceiling (envelope), when set — for spend-vs-limit. */ + budget_tokens: number | null; + status: string | null; + finished_at: string | null; + team: string | null; + display_name: string | null; + health: PodHealth | null; +} + +/** Honest pod-level health of a live agent (no CPU/mem — status-derived). */ +export interface PodHealth { + ready_containers: number; + total_containers: number; + restarts: number; + uptime_seconds: number | null; + node: string | null; + waiting_reason: string | null; +} + +/** Datapath-completeness witness — the optional eBPF (Inspektor Gadget) witness + * cross-checks kernel-observed egress against each sandbox's declared allowlist. + * `enabled: false` => the witness isn't installed (show enable instructions). */ +export interface DatapathWitnessSandbox { + namespace: string; + sandbox: string; + declared_hosts: string[]; + observed_dns: string[]; + observed_connects: number; + beyond_declared: string[]; + unused_declared: string[]; + verdict: "COMPLIANT" | "BEYOND-DECLARED" | "LEARN" | string; +} +export interface DatapathWitness { + enabled: boolean; + generated_at: string | null; + window_seconds: number | null; + sandboxes: DatapathWitnessSandbox[]; + install_hint: string; +} + +/** A single cross-agent activity event in the fleet live feed. */ +export interface FleetActivityItem { + agent: string; + display_name: string | null; + team: string | null; + kind: "tool" | "round" | string; + label: string; + detail: string | null; + failed: boolean; + round: number; + seq: number; + ms: number | null; +} +/** Fleet-wide live telemetry — aggregate metrics + merged activity feed. */ +export interface FleetTelemetry { + working: number; + teams_active: number; + sub_agents: number; + tokens_in_flight: number; + tool_calls: number; + rounds: number; + feed: FleetActivityItem[]; +} + +/** Live troubleshooting evidence for a failed run (from GET …/troubleshoot). */ +export interface TroubleshootContainer { + name: string; + ready: boolean; + restarts: number; + state: string; + reason: string | null; +} +export interface Troubleshoot { + pod_found: boolean; + pod_summary: string | null; + containers: TroubleshootContainer[]; + agent_log_tail: string[]; + evidence: string[]; + cause: string; + remedy: string; + harness_issue: boolean; + result_status: string | null; + result_reason: string | null; +} diff --git a/bridge/web/src/proxy.ts b/bridge/web/src/proxy.ts new file mode 100644 index 000000000..428bc9d82 --- /dev/null +++ b/bridge/web/src/proxy.ts @@ -0,0 +1,64 @@ +// kars Bridge — server-side RBAC enforcement at the edge. +// +// The Operator Console UI disables admin-only controls, but that is cosmetic: a +// browser can call the BFF directly through the same-origin /api proxy. This +// middleware runs on the WEB SERVER before the request reaches the proxy route +// handler, so it enforces admin-only mutations for real — the role comes from the +// httpOnly `bridge-role` cookie the browser cannot forge, falling back to the +// BRIDGE_ROLES env floor, exactly like lib/session.ts. +// +// The actual /api/* -> BFF proxying is done at RUNTIME by the catch-all route +// handler app/api/[...path]/route.ts (it reads BRIDGE_BFF_URL per request, so the +// one image works on kind/AKS/EKS/GKE). A next.config `rewrites` would freeze the +// destination at build time. + +import { NextResponse, type NextRequest } from "next/server"; +import { parseRoles, envRoles } from "@/lib/config"; + +// (pathPrefix, methods) tuples that require the `admin` role. +const ADMIN_ONLY: { prefix: string; methods: string[] }[] = [ + { prefix: "/api/operator/inference-budgets", methods: ["PUT", "POST", "DELETE"] }, + { prefix: "/api/operator/retention-policy", methods: ["PUT", "POST", "DELETE"] }, +]; + +function isAdmin(req: NextRequest): boolean { + const cookie = req.cookies.get("bridge-role")?.value; + const roles = parseRoles(cookie) ?? envRoles(); + return roles.includes("admin"); +} + +export function proxy(req: NextRequest) { + const { pathname } = req.nextUrl; + const method = req.method.toUpperCase(); + const gated = ADMIN_ONLY.find( + (g) => pathname.startsWith(g.prefix) && g.methods.includes(method), + ); + if (gated && !isAdmin(req)) { + return NextResponse.json( + { + error: { + code: "forbidden", + message: + "Only a cluster or org admin can change this setting. Switch to the Admin role (or ask an admin).", + }, + }, + { status: 403 }, + ); + } + const requestHeaders = new Headers(req.headers); + requestHeaders.set( + "x-bridge-return-to", + `${req.nextUrl.pathname}${req.nextUrl.search}`, + ); + return NextResponse.next({ request: { headers: requestHeaders } }); +} + +export const config = { + matcher: [ + "/workspace/:path*", + "/console/:path*", + "/audit/:path*", + "/api/operator/inference-budgets/:path*", + "/api/operator/retention-policy", + ], +}; diff --git a/bridge/web/tests/credential-review.test.mjs b/bridge/web/tests/credential-review.test.mjs new file mode 100644 index 000000000..053037a4a --- /dev/null +++ b/bridge/web/tests/credential-review.test.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + credentialFormTransition, parseCredentialReview, parseCredentialContinuation, +} from "../src/lib/credential-review.ts"; + +const PRIVATE = "PRIVATE_VALUE_NEVER_RETURNED"; +const digest = `sha256:${"a".repeat(64)}`; +const empty = () => ({ error: null, ok: null, review: null, pending: null }); +const metadata = () => ({ + target: { kind: "KarsSandbox", namespace: "work", name: "target", uid: "target-uid", + generation: 1, version: "1", intent: digest }, + grant: { uid: "grant-uid", generation: 1, version: "1", intent: digest, + workspaceUid: "workspace-uid", legacyInventory: digest }, + source: { name: "kars-credential-input-sandbox-target", uid: null, version: null, metadataDigest: null, keys: [] }, + key: "SLACK_BOT_TOKEN", +}); +const initial = () => ({ token: "header.payload.signature", expiresAt: 5000, submission: 1, + continuation: false, bindingOnly: false, metadata: metadata() }); +const receipt = stored => ({ token: "continuation.payload.signature", + outcome: stored ? "source-stored" : "no-write-attempted", + source: stored ? { name: metadata().source.name, uid: "source-uid", version: "1", metadataDigest: digest } : null }); +function refreshed(stored) { + const review = initial(); + review.token = "refreshed.payload.signature"; + review.submission = 2; + review.continuation = true; + review.bindingOnly = stored; + review.metadata.target.version = "2"; + review.metadata.grant.version = "2"; + if (stored) review.metadata.source = { ...receipt(true).source, keys: ["SLACK_BOT_TOKEN"] }; + return review; +} +function form(operation, options = {}) { + const data = new FormData(); + for (const [key, value] of Object.entries({ + namespace: "work", kind: "KarsSandbox", target: "target", targetUid: "target-uid", + key: "SLACK_BOT_TOKEN", operation, ...options, + })) data.set(key, value); + return data; +} +function api(stored = true) { + const calls = []; + let writes = 0; + return { + calls, now: () => 100, + review: async input => { + calls.push(["review", input]); + return input.continuation ? refreshed(stored) : initial(); + }, + write: async input => { + calls.push(["write", input]); + if (++writes === 1) throw { status: 409, code: "conflict", message: "fixed conflict", continuation: receipt(stored) }; + return { stored: true, note: "confirmed" }; + }, + failure: error => error && typeof error === "object" ? error : undefined, + }; +} + +test("review has no value, confirmation gates the write, and no conflict is automatically resubmitted", async () => { + const service = api(); + let state = await credentialFormTransition(empty(), form("review", { value: PRIVATE }), service); + assert.equal(service.calls.length, 1); + assert.equal("value" in service.calls[0][1], false); + state = await credentialFormTransition(state, form("store", { value: PRIVATE }), service); + assert.equal(service.calls.length, 1); + state = await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + assert.equal(service.calls.length, 2); + assert.ok(state.pending); + assert.equal(state.review, null); + assert.equal(JSON.stringify(state).includes(PRIVATE), false); + await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + assert.equal(service.calls.length, 2); +}); + +for (const stored of [false, true]) { + test(`explicit ${stored ? "acknowledged bind-only" : "confirmed no-write"} refresh requires another confirmation`, async () => { + const service = api(stored); + let state = await credentialFormTransition(empty(), form("review"), service); + state = await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + state = await credentialFormTransition(state, form("review", { value: PRIVATE }), service); + assert.equal(state.review.bindingOnly, stored); + assert.equal(service.calls.length, 3); + assert.equal("value" in service.calls[2][1], false); + await credentialFormTransition(state, form("store", { value: PRIVATE }), service); + assert.equal(service.calls.length, 3); + state = await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + assert.equal(state.ok, "confirmed"); + assert.deepEqual(service.calls.map(([kind]) => kind), ["review", "write", "review", "write"]); + assert.equal(service.calls[3][1].review, "refreshed.payload.signature"); + assert.equal(JSON.stringify(state).includes(PRIVATE), false); + }); +} + +for (const change of ["uid", "generation", "intent", "grant", "grant-policy", "workspace", "source", "version", "source-intent", "expiry", "key-scope"]) { + test(`refresh rejects changed ${change} before a second write`, async () => { + const service = api(); + let state = await credentialFormTransition(empty(), form("review"), service); + state = await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + service.review = async input => { + service.calls.push(["review", input]); + const value = refreshed(true); + if (change === "uid") value.metadata.target.uid = "replacement"; + if (change === "generation") value.metadata.target.generation++; + if (change === "intent") value.metadata.target.intent = `sha256:${"b".repeat(64)}`; + if (change === "grant") value.metadata.grant.uid = "replacement"; + if (change === "grant-policy") value.metadata.grant.intent = `sha256:${"b".repeat(64)}`; + if (change === "workspace") value.metadata.grant.workspaceUid = "replacement"; + if (change === "source") value.metadata.source.uid = "replacement"; + if (change === "version") value.metadata.source.version = "2"; + if (change === "source-intent") value.metadata.source.metadataDigest = `sha256:${"b".repeat(64)}`; + if (change === "expiry") value.expiresAt++; + if (change === "key-scope") value.metadata.source.keys.push("OTHER_KEY"); + return value; + }; + state = await credentialFormTransition(state, form("review"), service); + assert.equal(state.review, null); + assert.ok(state.error); + assert.equal(service.calls.filter(([kind]) => kind === "write").length, 1); + }); +} + +for (const status of [403, 409, 422, 502]) { + test(`HTTP ${status} without a valid owned continuation cannot resume`, async () => { + const service = api(); + service.write = async input => { + service.calls.push(["write", input]); + throw { status, code: status === 409 ? "conflict" : "upstream_error", message: "fixed failure" }; + }; + let state = await credentialFormTransition(empty(), form("review"), service); + state = await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + assert.equal(state.pending, null); + assert.equal(state.review, null); + assert.equal(service.calls.length, 2); + }); +} + +test("metadata and previous-state projection never echoes extra secret fields", async () => { + const untrusted = initial(); + untrusted.value = PRIVATE; + untrusted.metadata.source.data = { token: PRIVATE }; + assert.equal(JSON.stringify(parseCredentialReview(untrusted)).includes(PRIVATE), false); + const continuation = receipt(true); + continuation.value = PRIVATE; + continuation.source.data = PRIVATE; + assert.equal(JSON.stringify(parseCredentialContinuation(continuation)).includes(PRIVATE), false); + const state = await credentialFormTransition({ ...empty(), value: PRIVATE }, form("store"), api()); + assert.equal(JSON.stringify(state).includes(PRIVATE), false); +}); + +test("editing identity or submitting an expired review performs no write", async () => { + const service = api(); + const state = { ...empty(), review: initial() }; + await credentialFormTransition(state, form("store", { target: "other", value: PRIVATE, confirmed: "on" }), service); + assert.equal(service.calls.length, 0); + service.now = () => 5000; + await credentialFormTransition(state, form("store", { value: PRIVATE, confirmed: "on" }), service); + assert.equal(service.calls.length, 0); +}); diff --git a/bridge/web/tsconfig.json b/bridge/web/tsconfig.json new file mode 100644 index 000000000..cf9c65d3e --- /dev/null +++ b/bridge/web/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/ci/no-custom-crypto.sh b/ci/no-custom-crypto.sh index e9f1f8c13..c0572adb1 100755 --- a/ci/no-custom-crypto.sh +++ b/ci/no-custom-crypto.sh @@ -74,6 +74,9 @@ PROD_PATHS=( 'runtimes/openclaw/src/' 'sandbox-images/' 'cli/profiles/' + 'bridge/bff/src/' + 'bridge/web/src/' + 'bridge/teams-gateway/src/' ) # Patterns — each is a canonical import / invocation we never want written by us. diff --git a/ci/no-null-provider-prod.sh b/ci/no-null-provider-prod.sh index c0cc36f27..38167e3ce 100755 --- a/ci/no-null-provider-prod.sh +++ b/ci/no-null-provider-prod.sh @@ -21,6 +21,7 @@ SCAN_PATHS=( 'cli/src/commands/' 'docs/' 'tests/compat/fixtures/' + 'bridge/deploy/' ) # Detect suspect manifests. diff --git a/ci/no-stubs.sh b/ci/no-stubs.sh index 12839ca3d..94e49036e 100755 --- a/ci/no-stubs.sh +++ b/ci/no-stubs.sh @@ -26,6 +26,9 @@ PROD_PATHS=( 'runtimes/openclaw/src/' 'sandbox-images/' 'cli/profiles/' + 'bridge/bff/src/' + 'bridge/web/src/' + 'bridge/teams-gateway/src/' ) # Patterns that indicate an unfinished production code path. diff --git a/ci/security-audit-required.sh b/ci/security-audit-required.sh index e2aa54480..5658e5842 100755 --- a/ci/security-audit-required.sh +++ b/ci/security-audit-required.sh @@ -17,7 +17,7 @@ REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" # Capability-introducing paths — mirrors §4.4 of the plan. -CAP_RE='^(controller/src/(crd|reconcilers|admission)|inference-router/src/(mcp|a2a|providers|routes)|cli/src/(commands|migrate|adapters)|runtimes/openclaw/src/(core|index\.ts)|sandbox-images/[^/]+/(Dockerfile|entrypoint\.sh)|cli/profiles/|deploy/seccomp/|deploy/helm/kars/files/|shared/.*\.rs$)' +CAP_RE='^(controller/src/(crd|reconcilers|admission)|inference-router/src/(mcp|a2a|providers|routes)|cli/src/(commands|migrate|adapters)|runtimes/openclaw/src/(core|index\.ts)|sandbox-images/[^/]+/(Dockerfile|entrypoint\.sh)|cli/profiles/|deploy/seccomp/|deploy/helm/kars/files/|shared/.*\.rs$|bridge/(bff/src/|web/src/|teams-gateway/src/|deploy/|[^/]+/Dockerfile|start-bff\.sh))' changed=$(git diff --name-only "${BASE_REF}...HEAD" 2>/dev/null || git diff --name-only HEAD) # Exclude test files — they exercise capabilities but don't introduce diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md new file mode 100644 index 000000000..c8f2add63 --- /dev/null +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -0,0 +1,60 @@ +# Kars Bridge application publication record + +Status: **draft assembly; no source sign-off or release approval claimed**. + +## Scope + +The complete application snapshot is imported into `bridge/`: Rust BFF, Next.js +Workspace/Console/Audit, optional Teams gateway, additive Helm chart, development +entrypoints, documentation and acceptance fixtures. The existing Kars CLI +remains a core component; no new mandatory Bridge CLI dependency is invented. + +The snapshot source tree is `0a10472ed7e2940235b714276e8def3c0d9190f4`. +Only selected tracked product files were copied. Private Git history, runtime +configuration, cluster-specific deployment overlays and private image-release +workflows were not imported. + +## Additive boundary + +Core has no dependency on Bridge. The BFF keeps its independent manifest and +lockfile outside the core Cargo workspace. Web and gateway retain separate npm +packages. Root Bridge make targets are opt-in. The separate chart retains its +namespace-ownership and uninstall-retention controls; no core resource is adopted +or deleted by this source move. + +Bridge component and native workflows run in Azure/kars. Native qualification +checks out core and Bridge from the same immutable commit, with contents-read +permissions and no image publication. The required source/UID, real admission, +TLS, CNI and cleanup assertions are not replaced by successful compilation. + +## Publication checks and limitations + +An offline Gitleaks 8.30.1 directory scan of the imported product snapshot found +no leaks. The session-local scanner's official release checksum was verified +before execution. This is not a comprehensive security approval. + +The historical preview image defaults are not a claim that public images have +been published. Operators must build and select their own repositories. +Development identity examples are not production authentication defaults. +The foreground BFF launcher does not kill an unrelated listener or silently +leave a detached process. + +The monorepo adaptation still needs component builds, static/security review, +add-on install/removal evidence and same-candidate native acceptance. Core +credential and evaluator prerequisites remain separate reviewed PRs; full +governed Team execution is not declared qualified by this import. + +The existing capability-audit, crypto, stub and null-provider gates now include +Bridge's relevant production paths. CodeQL retains repository-wide analysis +with no path exclusions. Importing source does not exempt it from these gates. + +The imported application predates the core repository's file-size and copyright +header conventions. Several files exceed the unchanged 800-line new-file cap, +and the header gate reports missing Microsoft headers on imported files. +Existing author copyright notices are preserved, not reassigned by the import. +Neither a LOC exception nor an attribution exception is granted by this draft. +These gate failures and any crypto/stub findings must be resolved explicitly +before merge, along with genuine source-review sign-off. + +No main-branch promotion, deployment, registry publication or change to the +original private repository's visibility follows from this draft assembly. From cf7e0ed1b821a10cd0b24515a579429410954bfd Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 15:48:53 +0200 Subject: [PATCH 002/111] Preserve standalone lockfile and artifact routes in Bridge checkout Override only the inherited Cargo.lock and artifact-directory ignore rules. Restore exact imported blobs and require critical application inputs to be tracked, not merely present in a developer worktree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/.gitignore | 3 + bridge/bff/Cargo.lock | 2949 +++++++++++++++++ bridge/teams-gateway/tests/monorepo.test.ts | 6 + .../src/app/workspace/artifacts/loading.tsx | 5 + .../web/src/app/workspace/artifacts/page.tsx | 340 ++ 5 files changed, 3303 insertions(+) create mode 100644 bridge/bff/Cargo.lock create mode 100644 bridge/web/src/app/workspace/artifacts/loading.tsx create mode 100644 bridge/web/src/app/workspace/artifacts/page.tsx diff --git a/bridge/.gitignore b/bridge/.gitignore index ae947306f..9823b0267 100644 --- a/bridge/.gitignore +++ b/bridge/.gitignore @@ -1,12 +1,15 @@ # Rust (BFF) /bff/target/ **/*.rs.bk +!/bff/Cargo.lock # Node / Next.js (web) node_modules /web/.next/ /web/out/ /web/next-env.d.ts +!/web/src/app/workspace/artifacts/ +!/web/src/app/workspace/artifacts/** # Env & secrets — never commit .env diff --git a/bridge/bff/Cargo.lock b/bridge/bff/Cargo.lock new file mode 100644 index 000000000..be1790d23 --- /dev/null +++ b/bridge/bff/Cargo.lock @@ -0,0 +1,2949 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-http-proxy" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8021e0ae20c08eadc94d0bdafdeda66d4f0858541c146ae6e46b219bfe58497e" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kars-bridge-bff" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "axum", + "base64", + "chrono", + "ed25519-dalek", + "hex", + "http", + "json-patch", + "jsonwebtoken", + "k8s-openapi", + "kube", + "reqwest", + "rustls", + "schemars", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "kube" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4eb20010536b48abe97fec37d23d43069bcbe9686adcf9932202327bc5ca6e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc2ed952042df20d15ac2fe9614d0ec14b6118eab89633985d4b36e688dccf1" +dependencies = [ + "base64", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff0d0793db58e70ca6d689489183816cb3aa481673e7433dc618cf7e8007c675" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "json-patch", + "k8s-openapi", + "schemars", + "serde", + "serde-value", + "serde_json", + "thiserror", +] + +[[package]] +name = "kube-derive" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c562f58dc9f7ca5feac8a6ee5850ca221edd6f04ce0dd2ee873202a88cd494c9" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "kube-runtime" +version = "0.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f34cfab9b4bd8633062e0e85edb81df23cb09f159f2e31c60b069ae826ffdc" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "async-trait", + "backon", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "base64", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/bridge/teams-gateway/tests/monorepo.test.ts b/bridge/teams-gateway/tests/monorepo.test.ts index 3a48b12d8..450d79e8f 100644 --- a/bridge/teams-gateway/tests/monorepo.test.ts +++ b/bridge/teams-gateway/tests/monorepo.test.ts @@ -10,12 +10,18 @@ const read = (path: string) => readFileSync(new URL(path, repository), "utf8"); describe("optional Bridge monorepo boundary", () => { it("includes the whole application without making it a core Cargo member", () => { + const tracked = execFileSync("git", ["ls-files", "bridge/"], { + cwd: repository, encoding: "utf8", + }).split("\n"); for (const path of [ "bridge/bff/Cargo.toml", "bridge/bff/Cargo.lock", "bridge/web/package.json", "bridge/teams-gateway/package.json", "bridge/deploy/helm/kars-bridge/Chart.yaml", "bridge/docs/README.md", + "bridge/web/src/app/workspace/artifacts/page.tsx", + "bridge/web/src/app/workspace/artifacts/loading.tsx", ]) { expect(existsSync(new URL(path, repository)), path).toBe(true); + expect(tracked, `${path} must be in the checkout, not only the working tree`).toContain(path); } const cargo = read("Cargo.toml"); expect(cargo.match(/members\s*=\s*\[([\s\S]*?)\]/)?.[1]).not.toContain("bridge/"); diff --git a/bridge/web/src/app/workspace/artifacts/loading.tsx b/bridge/web/src/app/workspace/artifacts/loading.tsx new file mode 100644 index 000000000..2a4a12d65 --- /dev/null +++ b/bridge/web/src/app/workspace/artifacts/loading.tsx @@ -0,0 +1,5 @@ +import { ListSkeleton } from "@/components/list-skeleton"; + +export default function Loading() { + return <ListSkeleton />; +} diff --git a/bridge/web/src/app/workspace/artifacts/page.tsx b/bridge/web/src/app/workspace/artifacts/page.tsx new file mode 100644 index 000000000..61b95d7bd --- /dev/null +++ b/bridge/web/src/app/workspace/artifacts/page.tsx @@ -0,0 +1,340 @@ +// kars Bridge Workspace — Artifacts. The cross-mission deliverable index. +// +// Lists the REAL deliverables missions have produced — the files captured by +// the controller from the agent loop over the mesh, persisted as durable +// cluster records (kars-mission-output / kars-mission-artifacts ConfigMaps). +// Each mission links to its page, where the full artifact set is reviewed in +// place alongside its Governance Receipt. Honestly empty until a mission +// produces a deliverable — never fabricated. + +import Link from "next/link"; +import { HonestState } from "@/components/honest-state"; +import { Icon, type IconName } from "@/components/icon"; +import { BffError, getArtifacts } from "@/lib/bff"; +import type { MissionArtifacts } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +function fmtSize(n: number | null): string { + if (n == null) return ""; + if (n < 1024) return `${n} B`; + return `${(n / 1024).toFixed(1)} KB`; +} + +/** Collapse duplicate artifact entries by name (the BFF can surface the same + * file from both the output manifest and the artifacts ConfigMap). Keeps the + * first (richest) occurrence, so React keys stay unique and the count is true. */ +function dedupeByName<T extends { name: string }>(files: T[]): T[] { + const seen = new Set<string>(); + const out: T[] = []; + for (const f of files) { + if (seen.has(f.name)) continue; + seen.add(f.name); + out.push(f); + } + + return out; +} + +function isInternalArtifact(name: string): boolean { + const normalized = name.toLowerCase(); + return ( + normalized.includes("collaboration.jsonl") || + normalized.endsWith("role-plan.json") || + normalized.endsWith("research-evidence.jsonl") || + normalized.endsWith("activity.jsonl") + ); +} + +/** Map a filename to a glyph + human kind, so a deliverable index reads as a + * gallery of recognisable things rather than a wall of monospace. */ +function fileMeta(name: string): { glyph: IconName; kind: string } { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + if (["md", "mdx", "txt", "rst", "adoc"].includes(ext)) return { glyph: "file", kind: "Document" }; + if (["ts", "tsx", "js", "jsx", "rs", "py", "go", "java", "rb", "c", "cpp", "h", "sh"].includes(ext)) + return { glyph: "puzzle", kind: "Code" }; + if (["json", "yaml", "yml", "toml", "xml"].includes(ext)) return { glyph: "gear", kind: "Config" }; + if (["csv", "tsv", "parquet", "xlsx"].includes(ext)) return { glyph: "chart", kind: "Data" }; + if (["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext)) return { glyph: "layers", kind: "Image" }; + if (["pdf"].includes(ext)) return { glyph: "note", kind: "PDF" }; + if (["html", "htm"].includes(ext)) return { glyph: "globe", kind: "Web" }; + return { glyph: "box", kind: "File" }; +} + +export default async function ArtifactsPage({ + searchParams, +}: { + searchParams: Promise<{ q?: string; view?: string; limit?: string }>; +}) { + const { q = "", view = "deliverables", limit = "recent" } = await searchParams; + let index; + let clusterDown = false; + try { + index = await getArtifacts(); + } catch (err) { + if (err instanceof BffError && err.code === "cluster_unavailable") { + clusterDown = true; + } else { + throw err; + } + } + + const allMissions = index?.missions ?? []; + const query = q.trim().toLowerCase(); + const filteredMissions = allMissions.filter((mission) => { + if (view !== "all" && mission.status === "error") return false; + if (!query) return true; + return [ + mission.display_name, + mission.objective, + mission.excerpt, + mission.team, + ...mission.pull_requests.map((pr) => `${pr.repo} #${pr.number}`), + ].some((value) => value?.toLowerCase().includes(query)); + }); + const missions = limit === "all" ? filteredMissions : filteredMissions.slice(0, 10); + + return ( + <div className="space-y-6"> + <div> + <h1 className="text-2xl font-semibold tracking-tight">Deliverables</h1> + <p className="mt-1 text-sm text-foreground-muted"> + Customer-facing outcomes: pull requests, reports, documents, and other reviewable work. + Internal collaboration files and failed-run diagnostics remain available in All records. + </p> + </div> + + <form className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface p-3"> + <input + type="search" + name="q" + defaultValue={q} + placeholder="Search deliverables, teams, repositories, or PRs" + className="min-w-64 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-signal" + /> + <input type="hidden" name="view" value={view} /> + <input type="hidden" name="limit" value={limit} /> + <button className="rounded-lg bg-signal px-3 py-2 text-xs font-semibold text-signal-fg"> + Search + </button> + <Link + href={`/workspace/artifacts${q ? `?q=${encodeURIComponent(q)}&` : "?"}view=${view === "all" ? "deliverables" : "all"}`} + className="rounded-lg border border-border px-3 py-2 text-xs font-medium" + > + {view === "all" ? "Show deliverables only" : "Show all records"} + </Link> + {filteredMissions.length > 10 && ( + <Link + href={`/workspace/artifacts?view=${encodeURIComponent(view)}&limit=${limit === "all" ? "recent" : "all"}${q ? `&q=${encodeURIComponent(q)}` : ""}`} + className="rounded-lg border border-border px-3 py-2 text-xs font-medium" + > + {limit === "all" ? "Show latest 10" : `Show all ${filteredMissions.length}`} + </Link> + )} + </form> + + {clusterDown ? ( + <HonestState + variant="not_wired" + title="The run environment isn't connected" + detail="Artifacts are read from live cluster records, but the cluster isn't reachable right now — so nothing can be listed. This is an environment state, not a faked screen." + /> + ) : missions.length === 0 ? ( + <HonestState + variant={query ? "empty" : "needs_run"} + title={query ? "No matching deliverables" : "No deliverables captured yet"} + detail={ + query + ? "Try a different team, repository, PR number, or outcome phrase." + : view === "all" + ? "No retained run records are available." + : "No customer-facing outcome is available yet. Failed and internal records remain under All records." + } + /> + ) : ( + <> + <div className="kb-rise"> + <p className="mb-2 text-[11px] uppercase tracking-wide text-foreground-muted"> + Latest deliverable + </p> + <ul className="space-y-4"> + <MissionArtifactsCard key={missions[0].task} m={missions[0]} hero showInternal={view === "all"} /> + </ul> + </div> + {missions.length > 1 && ( + <ul className="space-y-4"> + {missions.slice(1).map((m) => ( + <MissionArtifactsCard key={m.evidence_key ?? m.task} m={m} showInternal={view === "all"} /> + ))} + </ul> + )} + </> + )} + </div> + ); +} + +function MissionArtifactsCard({ + m, + hero = false, + showInternal = false, +}: { + m: MissionArtifacts; + hero?: boolean; + showInternal?: boolean; +}) { + const ok = m.status !== "error"; + const historicalNonTeam = Boolean( + !m.team && m.evidence_key && m.evidence_key !== m.task, + ); + const detailHref = m.team + ? m.evidence_key && m.evidence_key !== m.task + ? `/workspace/teams/${encodeURIComponent(m.team)}?tab=runs` + : `/workspace/teams/${encodeURIComponent(m.team)}/runs/${encodeURIComponent(m.task)}` + : `/workspace/missions/${encodeURIComponent(m.task)}`; + const allFiles = dedupeByName(m.files); + const internalCount = allFiles.filter((file) => isInternalArtifact(file.name)).length; + const files = showInternal ? allFiles : allFiles.filter((file) => !isInternalArtifact(file.name)); + const fileCount = files.length; + // Deliverable kind (audit f14): the dominant file type, or a text deliverable + // when the run produced prose only — so the index reads as a typed gallery. + const kind = fileCount > 0 ? fileMeta(files[0].name).kind : "Text deliverable"; + return ( + <li className={`rounded-xl border bg-surface p-5 ${hero ? "border-signal/40 bg-signal/5 kb-card-hover" : "border-border"}`}> + <div className="flex items-start justify-between gap-4"> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">{kind}</span> + {hero && <span className="text-[10px] font-semibold uppercase tracking-wide text-signal">Latest</span>} + </div> + {historicalNonTeam ? ( + <p className="mt-1 text-sm font-semibold">{m.display_name ?? m.task}</p> + ) : ( + <Link + href={detailHref} + className="mt-1 block text-sm font-semibold text-signal hover:underline" + > + {m.display_name ?? m.task} + </Link> + )} + {m.team && ( + <p className="mt-0.5 text-[11px] text-foreground-muted"> + Team: {m.team}{m.archived ? " · archived delivery" : ""} + </p> + )} + {m.excerpt ? ( + <p className="mt-0.5 line-clamp-2 text-xs text-foreground-muted">{m.excerpt}</p> + ) : m.objective ? ( + <p className="mt-0.5 line-clamp-2 text-xs text-foreground-muted">{m.objective}</p> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-2"> + {!ok && ( + <span className="rounded-full bg-rose-500/10 px-2 py-0.5 text-xs font-medium text-rose-500"> + error + </span> + )} + {m.archived && ( + <span className="rounded-full border border-border bg-surface-muted px-2.5 py-0.5 text-xs font-medium text-foreground-muted"> + Archived + </span> + )} + {m.review_status === "approved" && ( + <span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-600"> + Approved + </span> + )} + {m.review_status === "changes_requested" && ( + <span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600"> + Changes requested{m.review_revision > 0 ? ` · rev ${m.review_revision}` : ""} + </span> + )} + <span className="rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium"> + {fileCount > 0 ? `${fileCount} file${fileCount === 1 ? "" : "s"}` : "Text only"} + </span> + {!showInternal && internalCount > 0 && ( + <span className="text-[10px] text-foreground-muted"> + {internalCount} internal hidden + </span> + )} + </div> + </div> + + {fileCount > 0 && ( + <ul className="mt-3 flex flex-wrap gap-2"> + {files.map((f, idx) => { + const meta = fileMeta(f.name); + return ( + <li + key={`${f.name}-${idx}`} + className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-muted/40 px-2.5 py-1.5" + title={meta.kind} + > + <span aria-hidden className="text-sm leading-none"><Icon name={meta.glyph} size={14} /></span> + <span className="font-mono text-xs">{f.name}</span> + {f.size_bytes != null && ( + <span className="text-[11px] text-foreground-muted">· {fmtSize(f.size_bytes)}</span> + )} + </li> + ); + })} + </ul> + )} + + {m.pull_requests.length > 0 && ( + <ul className="mt-3 flex flex-wrap gap-2"> + {m.pull_requests.map((pr) => ( + <li key={pr.url}> + <a + href={pr.url} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-1.5 hover:bg-signal/10" + title={`Pull request on ${pr.repo}`} + > + <span aria-hidden className="text-sm leading-none"><Icon name="branch" size={14} /></span> + <span className="text-xs font-medium text-signal">PR #{pr.number}</span> + <span className="font-mono text-[11px] text-foreground-muted">{pr.repo}</span> + <span aria-hidden className="text-[11px] text-foreground-muted">↗</span> + </a> + </li> + ))} + </ul> + )} + + <dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-foreground-muted"> + {m.model && ( + <div className="flex gap-1.5"> + <dt>Model</dt> + <dd className="font-medium text-foreground">{m.model}</dd> + </div> + )} + {m.finished_at && ( + <div className="flex gap-1.5"> + <dt>Produced</dt> + <dd className="font-medium text-foreground"> + {new Date(m.finished_at).toLocaleString()} + </dd> + </div> + )} + {m.deliverable_did && ( + <div className="flex min-w-0 gap-1.5"> + <dt>Identity</dt> + <dd className="truncate font-mono text-[11px]" title={m.deliverable_did}>{m.deliverable_did}</dd> + </div> + )} + <div className="flex gap-1.5"> + <dt>Review</dt> + <dd> + <Link + href={detailHref} + className="text-signal hover:underline" + > + {historicalNonTeam ? "Open current mission →" : "Open in place →"} + </Link> + </dd> + </div> + </dl> + </li> + ); +} From 61a7f3fa91a38610270f7db381f0dd5e46e11af5 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 15:57:25 +0200 Subject: [PATCH 003/111] Fix Bridge monorepo installation guide paths Resolve both core install commands from bridge/ and distinguish historical preview evidence from public qualification. Guard chart path resolution in the monorepo regression suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/deploy/helm/kars-bridge/README.md | 25 ++++++++++++--------- bridge/teams-gateway/tests/monorepo.test.ts | 13 +++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/bridge/deploy/helm/kars-bridge/README.md b/bridge/deploy/helm/kars-bridge/README.md index 860939543..e790f7b7d 100644 --- a/bridge/deploy/helm/kars-bridge/README.md +++ b/bridge/deploy/helm/kars-bridge/README.md @@ -4,20 +4,25 @@ kars Bridge is an **additive** layer on top of [kars](https://github.com/Azure/k kars runs on its own; the Bridge deploys the operator console + workspace (BFF + web) and the least-privilege RBAC the BFF needs — it never replaces any kars component. -**Private preview:** there is no public Bridge image/release matrix yet. The -[compatibility document](../../../docs/compatibility.md) records the full private -Kars candidate and historical qualification separately. The public Kars -foundation PRs alone do not provide the full runtime required by Bridge. - -The chart uses standard Kubernetes workloads, but only **AKS** and **local -kind** are live-qualified today. EKS and GKE require environment-specific -identity, registry, ingress, CNI, inference, and compatibility validation. +**Public source integration:** Bridge is published in **Azure/kars** through +the **`kars-bridge`** integration branch, but is **not yet release-qualified**. +There is no public Bridge image/release matrix yet. The +[compatibility document](../../../docs/compatibility.md) separates the current +integration candidate from historical preview qualification; source publication +alone does not qualify the full runtime required by Bridge. + +The chart uses standard Kubernetes workloads. Historical **AKS** and **local +kind** preview results do not qualify the current public candidate. EKS and GKE +also require environment-specific identity, registry, ingress, CNI, inference, +and compatibility validation. ## Prerequisites +Run the commands below from `bridge/`, entered from the repository root (`cd bridge`). + - A Kubernetes cluster (new or existing) with the **kars CRDs + controller** installed: ```bash - helm install kars ../kars/deploy/helm/kars -n kars-system --create-namespace + helm install kars ../deploy/helm/kars -n kars-system --create-namespace ``` - The Bridge images, pushed to a registry your cluster can pull (or loaded into kind). @@ -64,7 +69,7 @@ helm install kars-bridge deploy/helm/kars-bridge -n kars-system \ ### kars + Bridge together, on a NEW cluster ```bash -helm install kars ../kars/deploy/helm/kars -n kars-system --create-namespace +helm install kars ../deploy/helm/kars -n kars-system --create-namespace helm install kars-bridge deploy/helm/kars-bridge -n kars-system # additive ``` (or `make helm-install` — see the Makefile.) diff --git a/bridge/teams-gateway/tests/monorepo.test.ts b/bridge/teams-gateway/tests/monorepo.test.ts index 450d79e8f..cdc4e532e 100644 --- a/bridge/teams-gateway/tests/monorepo.test.ts +++ b/bridge/teams-gateway/tests/monorepo.test.ts @@ -67,6 +67,19 @@ describe("optional Bridge monorepo boundary", () => { expect(read("bridge/docs/deployment.md")).toContain("repository root (`cd bridge`)"); }); + it("resolves the documented installation charts from the Bridge working directory", () => { + const guide = read("bridge/deploy/helm/kars-bridge/README.md"); + const commands = [...guide.matchAll(/helm install (kars|kars-bridge)\s+(\S+)/g)]; + expect(commands.filter((match) => match[1] === "kars")).toHaveLength(2); + expect(commands.filter((match) => match[1] === "kars-bridge").length).toBeGreaterThan(0); + for (const [, release, path] of commands) { + expect(existsSync(new URL(`${path}/Chart.yaml`, new URL("bridge/", repository))), + `${release} chart ${path} must resolve from bridge/`).toBe(true); + } + expect(guide).toContain("repository root (`cd bridge`)"); + expect(guide).toContain("not yet release-qualified"); + }); + it("does not include operator state or a cluster-specific deployment overlay", () => { expect(existsSync(new URL("bridge/.openclaw/", repository))).toBe(false); expect(existsSync(new URL("bridge/deploy/helm/kars-bridge/values-aks-airunway.yaml", repository))) From b3c1183915bc90d3f6423b2c56fdbd4f446b57c8 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 16:19:40 +0200 Subject: [PATCH 004/111] Keep source-stub gate linear for complete application imports Filter each file once without changing any marker, scope, exception, exit status or diagnostic. Regression fixtures preserve all canonical matches and cap grep invocations at three per file; actual public130 findings remain byte-identical and failing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci-gates.yml | 4 ++ ci/no-stubs.sh | 15 ++--- ci/tests/no_stubs_test.py | 120 +++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 ci/tests/no_stubs_test.py diff --git a/.github/workflows/ci-gates.yml b/.github/workflows/ci-gates.yml index 9b53c2463..dc6a2d6cd 100644 --- a/.github/workflows/ci-gates.yml +++ b/.github/workflows/ci-gates.yml @@ -47,6 +47,10 @@ jobs: - name: Make scripts executable run: chmod +x ci/*.sh + - name: Verify source-gate regression fixtures + if: matrix.gate == 'no-stubs' + run: python3 -m unittest discover -s ci/tests -p '*_test.py' + - name: Run gate ${{ matrix.gate }} shell: bash env: diff --git a/ci/no-stubs.sh b/ci/no-stubs.sh index 94e49036e..8367d2c31 100755 --- a/ci/no-stubs.sh +++ b/ci/no-stubs.sh @@ -58,18 +58,13 @@ for f in "${changed[@]}"; do esac [ -f "$f" ] || continue - # For each ADDED line in the diff, check pattern. + # Filter once per file; full product imports must not fork twice per source line. while IFS= read -r line; do stripped="${line#+}" - # Override-aware - if printf '%s' "$stripped" | grep -qE 'ci:stub-ok:'; then - continue - fi - if printf '%s' "$stripped" | grep -qE "$PATTERNS"; then - echo "fail: $f: new stub/placeholder introduced: ${stripped:0:160}" >&2 - fail=1 - fi - done < <(git diff "${BASE_REF}...HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]') + echo "fail: $f: new stub/placeholder introduced: ${stripped:0:160}" >&2 + fail=1 + done < <(git diff "${BASE_REF}...HEAD" -- "$f" 2>/dev/null | + grep -E '^\+[^+]' | grep -E "$PATTERNS" | grep -vE 'ci:stub-ok:') done exit $fail diff --git a/ci/tests/no_stubs_test.py b/ci/tests/no_stubs_test.py new file mode 100644 index 000000000..1bed99ab6 --- /dev/null +++ b/ci/tests/no_stubs_test.py @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Exercise the actual diff gate in disposable local Git repositories.""" + +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile +import unittest + + +GATE = Path(__file__).resolve().parents[1] / "no-stubs.sh" + + +class NoStubsTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="kars-stub-gate-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.git("init", "-q") + self.git("config", "user.name", "Gate Fixture") + self.git("config", "user.email", "gate@example.invalid") + self.git("config", "commit.gpgsign", "false") + self.git("config", "core.hooksPath", str(self.root / "empty-hooks")) + self.git("commit", "--allow-empty", "-qm", "base") + self.base = self.git("rev-parse", "HEAD").strip() + + def git(self, *args): + return subprocess.check_output(["git", *args], cwd=self.root, text=True, + stderr=subprocess.PIPE) + + def write(self, name, text): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + def commit(self): + self.git("add", ".") + self.git("commit", "-qm", "fixture") + + def gate(self, extra_env=None): + return subprocess.run(["bash", str(GATE)], cwd=self.root, text=True, + capture_output=True, timeout=30, + env={**os.environ, "BASE_REF": self.base, **(extra_env or {})}) + + def test_rejects_all_canonical_markers_in_added_lines(self): + markers = ["// TODO work", "// FIXME work", "// XXX work", "// HACK work", + "unimplemented!()", "todo!()", 'panic!("not implemented")', + "// placeholder", "value.stub()", "value.mock()", + "return None; // placeholder", "return Ok(()); // stub"] + self.write("bridge/bff/src/fixture.rs", "\n".join(markers) + "\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.stderr.splitlines(), [ + "fail: bridge/bff/src/fixture.rs: new stub/placeholder introduced: " + marker + for marker in markers]) + + def test_keeps_existing_inline_override_semantics(self): + self.write("bridge/web/src/fixture.ts", "// TODO reviewed // ci:stub-ok: fixture\n") + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_only_checks_production_paths_and_not_test_files(self): + for path in ("docs/example.ts", "bridge/bff/src/tests/example.rs", + "bridge/bff/src/test/example.rs", "bridge/bff/src/tests.rs", + "bridge/bff/src/example_test.rs", "bridge/web/src/example.test.ts", + "bridge/web/src/example.spec.ts"): + self.write(path, "// TODO test-only fixture\n") + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_retains_all_original_production_path_prefixes(self): + paths = ["shared/", "controller/src/", "inference-router/src/", "cli/src/", + "runtimes/openclaw/src/", "sandbox-images/", "cli/profiles/", + "bridge/bff/src/", "bridge/web/src/", "bridge/teams-gateway/src/"] + for path in paths: + self.write(path + "fixture.rs", "// TODO unfinished\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(len(result.stderr.splitlines()), len(paths)) + + def test_does_not_report_unchanged_or_removed_lines(self): + self.write("controller/src/fixture.rs", "// TODO pre-existing\n") + self.write("controller/src/removed.rs", "// TODO removed\n") + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + self.write("controller/src/fixture.rs", "// TODO pre-existing\nfn complete() {}\n") + (self.root / "controller/src/removed.rs").unlink() + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_filters_once_per_file_instead_of_spawning_processes_per_line(self): + self.write("bridge/bff/src/fixture.rs", "fn complete() {}\n" * 250 + "// TODO work\n") + self.commit() + calls = self.root / "grep-calls" + binary = self.root / "bin" + binary.mkdir() + grep = shutil.which("grep") + self.assertIsNotNone(grep) + wrapper = binary / "grep" + wrapper.write_text("#!/usr/bin/env bash\nprintf . >> \"$GREP_CALLS\"\n" + f"exec {shlex.quote(grep)} \"$@\"\n") + wrapper.chmod(0o700) + result = self.gate({"PATH": f"{binary}{os.pathsep}{os.environ['PATH']}", + "GREP_CALLS": str(calls)}) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertLessEqual(len(calls.read_text()), 3) + + +if __name__ == "__main__": + unittest.main() From 69e4a7345f919f1081233de63c4572f9ff24c70f Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 16:29:24 +0200 Subject: [PATCH 005/111] Distinguish real UI field syntax from unfinished implementation markers Use the existing locked TypeScript parser to recognize form fields and Tailwind variants without suppressing comments, string values or unfinished declarations. Preserve source coverage and fail closed on parsing/tool errors. Add eleven scope, marker and syntax regressions; keep the application audit explicitly unsigned. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci-gates.yml | 12 ++ .../web/src/app/console/mcp-catalog-data.ts | 2 +- .../app/workspace/teams/new/team-composer.tsx | 2 +- bridge/web/src/lib/format.ts | 2 +- ci/no-stubs-ts.mjs | 103 ++++++++++++++++++ ci/no-stubs.sh | 11 ++ ci/tests/no_stubs_test.py | 64 +++++++++++ .../2026-09-11-bridge-application.md | 24 +++- 8 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 ci/no-stubs-ts.mjs diff --git a/.github/workflows/ci-gates.yml b/.github/workflows/ci-gates.yml index dc6a2d6cd..229746f66 100644 --- a/.github/workflows/ci-gates.yml +++ b/.github/workflows/ci-gates.yml @@ -47,6 +47,18 @@ jobs: - name: Make scripts executable run: chmod +x ci/*.sh + - name: Set up the locked source parser + if: matrix.gate == 'no-stubs' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: cli/package-lock.json + + - name: Install locked source parser + if: matrix.gate == 'no-stubs' + run: npm ci --prefix cli --ignore-scripts --no-audit --no-fund + - name: Verify source-gate regression fixtures if: matrix.gate == 'no-stubs' run: python3 -m unittest discover -s ci/tests -p '*_test.py' diff --git a/bridge/web/src/app/console/mcp-catalog-data.ts b/bridge/web/src/app/console/mcp-catalog-data.ts index 404d5ff3d..d4281f947 100644 --- a/bridge/web/src/app/console/mcp-catalog-data.ts +++ b/bridge/web/src/app/console/mcp-catalog-data.ts @@ -4,7 +4,7 @@ // Each entry pre-fills the friendly add form (name + endpoint URL + allowed // tools). URLs are the vendors' documented hosted MCP endpoints where one exists // (the operator confirms/edits before creating); self-hosted reference servers -// carry a placeholder URL + a docs link so the operator points it at their own +// carry an example URL + a docs link so the operator points it at their own // deployment. Nothing is created until the operator reviews and submits. export type McpHosting = "hosted" | "managed" | "external"; diff --git a/bridge/web/src/app/workspace/teams/new/team-composer.tsx b/bridge/web/src/app/workspace/teams/new/team-composer.tsx index 22940b09c..69d5b1026 100644 --- a/bridge/web/src/app/workspace/teams/new/team-composer.tsx +++ b/bridge/web/src/app/workspace/teams/new/team-composer.tsx @@ -269,7 +269,7 @@ export function TeamComposer({ options, profile, initialCharter }: { options: Op const a = MEMBER_ARCHETYPES.find((x) => x.id === id); if (!a) return; // B2: dedup — the archetype dropdown gives no confirmation and resets to - // its placeholder, so users spam-click it thinking nothing happened and + // its initial prompt, so users spam-click it thinking nothing happened and // spray duplicate roles. Adding an archetype already in the roster is a // no-op (a role can still be added manually via "+ Add role" if a second // instance is genuinely wanted). Also flash a visible note so the add isn't diff --git a/bridge/web/src/lib/format.ts b/bridge/web/src/lib/format.ts index 496a13fea..89bdf5f37 100644 --- a/bridge/web/src/lib/format.ts +++ b/bridge/web/src/lib/format.ts @@ -1,7 +1,7 @@ // kars Bridge web — value formatting helpers. Disciplined, audit-friendly // rendering of machine values (counts, budgets, money). -/** Thousands-separated integer, or an em-dash placeholder when null. */ +/** Thousands-separated integer, or an em dash when null. */ export function formatInt(n: number | null | undefined): string { if (n == null) return "—"; return n.toLocaleString(); diff --git a/ci/no-stubs-ts.mjs b/ci/no-stubs-ts.mjs new file mode 100644 index 000000000..6f57fa99c --- /dev/null +++ b/ci/no-stubs-ts.mjs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(new URL("../cli/package.json", import.meta.url)); +const ts = require("typescript"); +const [base, file, patterns] = process.argv.slice(2); +if (!base || !file || !patterns) { + throw new Error("fail: source gate requires a base revision, file and marker patterns"); +} + +const source = execFileSync("git", ["show", `HEAD:${file}`], { encoding: "utf8" }); +const diff = execFileSync("git", ["diff", "--unified=0", `${base}...HEAD`, "--", file], + { encoding: "utf8" }); +const kind = file.endsWith(".tsx") ? ts.ScriptKind.TSX + : /\.(?:jsx?|mjs|cjs)$/.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TS; +const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); +if (tree.parseDiagnostics.length) { + const diagnostic = tree.parseDiagnostics[0]; + const position = tree.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + throw new Error(`fail: ${file}:${position.line + 1}: cannot parse source for marker analysis: ${ + ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`); +} + +const ranges = []; +function isPlaceholderField(node) { + const parent = node.parent; + if (!parent) return false; + if ((ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent) + || ts.isPropertyDeclaration(parent) || ts.isJsxAttribute(parent) + || ts.isShorthandPropertyAssignment(parent) || ts.isPropertyAccessExpression(parent)) + && parent.name === node) return true; + if (ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node)) { + return true; + } + if (!ts.isIdentifier(node)) return false; + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) { + if (ts.isFunctionLike(ancestor) || ts.isClassLike(ancestor) + || ts.isVariableDeclaration(ancestor)) return false; + if (ts.isJsxAttribute(ancestor)) return ancestor.name.getText(tree) === "placeholder"; + if (ts.isPropertyAssignment(ancestor)) { + return (ts.isIdentifier(ancestor.name) || ts.isStringLiteral(ancestor.name)) + && ancestor.name.text === "placeholder"; + } + if (ts.isStatement(ancestor)) return false; + } + return false; +} + +function visit(node) { + if ((ts.isIdentifier(node) || ts.isStringLiteral(node)) + && node.text === "placeholder" && isPlaceholderField(node)) { + ranges.push([node.getStart(tree), node.getEnd()]); + } + if (ts.isStringLiteral(node) && ts.isJsxAttribute(node.parent) + && node.parent.name.getText(tree) === "className") { + const start = node.getStart(tree) + 1; + const text = source.slice(start, node.getEnd() - 1); + for (const match of text.matchAll(/(^|[:\s])placeholder(?=:(?:-?[A-Za-z]|\[))/g)) { + const offset = start + match.index + match[1].length; + ranges.push([offset, offset + "placeholder".length]); + } + } + ts.forEachChild(node, visit); +} +visit(tree); + +// Ignore field syntax, not whole lines: comments and string values still +// carry unfinished-implementation markers even alongside a legitimate UI prop. +const parts = []; +let cursor = 0; +for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) { + if (start < cursor) throw new Error(`fail: ${file}: overlapping syntax ranges`); + parts.push(source.slice(cursor, start), " ".repeat(end - start)); + cursor = end; +} +parts.push(source.slice(cursor)); +const original = source.split("\n"); +const masked = parts.join("").split("\n"); +const markers = new RegExp(patterns); +let lineNumber; +for (const line of diff.split("\n")) { + const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (hunk) { + lineNumber = Number(hunk[1]) - 1; + } else if (lineNumber !== undefined && line.startsWith("+")) { + const added = line.slice(1); + if (original[lineNumber] !== added) { + throw new Error(`fail: ${file}: diff does not match committed source at line ${lineNumber + 1}`); + } + if (line.length > 1 && line[1] !== "+" && !added.includes("ci:stub-ok:") + && markers.test(masked[lineNumber])) { + console.error(`fail: ${file}: new stub/placeholder introduced: ${ + Array.from(added).slice(0, 160).join("")}`); + process.exitCode = 1; + } + lineNumber += 1; + } else if (lineNumber !== undefined && line.startsWith(" ")) { + lineNumber += 1; + } +} diff --git a/ci/no-stubs.sh b/ci/no-stubs.sh index 8367d2c31..9c4f13902 100755 --- a/ci/no-stubs.sh +++ b/ci/no-stubs.sh @@ -12,9 +12,12 @@ # on the same line. Reviewer must sign off in the security-audit doc. # # Scope: production code only. +# JS/TS field syntax uses the CLI's locked TypeScript parser. Comment/value +# markers remain checked, including on lines with legitimate UI fields. set -euo pipefail BASE_REF="${BASE_REF:-origin/main}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" @@ -58,6 +61,14 @@ for f in "${changed[@]}"; do esac [ -f "$f" ] || continue + case "$f" in + *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) + if ! node "$SCRIPT_DIR/no-stubs-ts.mjs" "$BASE_REF" "$f" "$PATTERNS"; then + fail=1 + fi + continue;; + esac + # Filter once per file; full product imports must not fork twice per source line. while IFS= read -r line; do stripped="${line#+}" diff --git a/ci/tests/no_stubs_test.py b/ci/tests/no_stubs_test.py index 1bed99ab6..b3ff64c91 100644 --- a/ci/tests/no_stubs_test.py +++ b/ci/tests/no_stubs_test.py @@ -65,6 +65,70 @@ def test_keeps_existing_inline_override_semantics(self): result = self.gate() self.assertEqual((result.returncode, result.stderr), (0, "")) + def test_javascript_placeholder_identifiers_are_not_unfinished_implementations(self): + self.write("bridge/web/src/fixture.tsx", """ +type Props = { placeholder?: string }; +export function Field({ placeholder }: Props) { + return <input placeholder={placeholder} />; +} +export const card = { placeholder: "Describe the requested changes" }; +export const quoted = { "placeholder": "Search" }; +export const styled = <input className="placeholder:text-muted focus:placeholder:text-white" />; +""") + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_javascript_identifiers_do_not_hide_real_markers_on_the_same_line(self): + lines = [ + 'export const a = { placeholder: "TODO implement" };', + 'export const b = { placeholder: "Search" }; // FIXME validation', + 'export const c = { placeholder: "placeholder" };', + 'export const d = "placeholder";', + 'export function placeholder() { return null; }', + 'export const placeholder = null;', + 'export const e = { placeholder: function placeholder() { return null; } };', + '// placeholder implementation', + ] + self.write("bridge/web/src/fixture.ts", "\n".join(lines) + "\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.stderr.splitlines(), [ + "fail: bridge/web/src/fixture.ts: new stub/placeholder introduced: " + line + for line in lines]) + + def test_javascript_diff_positions_do_not_scan_unchanged_markers(self): + self.write("bridge/web/src/fixture.ts", '// TODO pre-existing\nexport const old = 1;\n') + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + self.write("bridge/web/src/fixture.ts", + '// TODO pre-existing\nexport const replacement = { placeholder: "Search" };\n') + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_javascript_parser_failure_never_becomes_a_clean_scan(self): + self.write("bridge/web/src/fixture.ts", "const placeholder = ;\n") + self.commit() + result = self.gate() + self.assertNotEqual(result.returncode, 0) + self.assertIn("fail:", result.stderr) + + def test_css_variant_recognition_keeps_other_markers_and_non_css_strings(self): + lines = [ + 'export const a = <input className="placeholder:text-muted TODO" />;', + 'export const b = <input className="placeholder" />;', + 'export const c = "placeholder:text-muted";', + ] + self.write("bridge/web/src/fixture.tsx", "\n".join(lines) + "\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.stderr.splitlines(), [ + "fail: bridge/web/src/fixture.tsx: new stub/placeholder introduced: " + line + for line in lines]) + def test_only_checks_production_paths_and_not_test_files(self): for path in ("docs/example.ts", "bridge/bff/src/tests/example.rs", "bridge/bff/src/test/example.rs", "bridge/bff/src/tests.rs", diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index c8f2add63..b782c6455 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -39,15 +39,31 @@ Development identity examples are not production authentication defaults. The foreground BFF launcher does not kill an unrelated listener or silently leave a detached process. -The monorepo adaptation still needs component builds, static/security review, -add-on install/removal evidence and same-candidate native acceptance. Core -credential and evaluator prerequisites remain separate reviewed PRs; full -governed Team execution is not declared qualified by this import. +At public candidate `cf7e0ed1b821a10cd0b24515a579429410954bfd`, Bridge CI +34606482468 passed all ten component/audit/add-on jobs. Native run 34606482569 +failed: the API lane reported undeclared `params` in credential-source-writes, +and the runtime lane's initial grant was denied by private-consumption-grant. +Lifecycle and TLS/CNI acceptance were not reached. Subsequent changes require +fresh same-candidate qualification. Core credential and evaluator prerequisites +remain separate reviewed PRs; full governed Team execution is not declared +qualified by this import. The existing capability-audit, crypto, stub and null-provider gates now include Bridge's relevant production paths. CodeQL retains repository-wide analysis with no path exclusions. Importing source does not exempt it from these gates. +The stub gate now filters once per file instead of forking per source line. +That performance-only step reproduced all 130 prior public findings exactly. +The subsequent syntax-aware correction distinguishes actual JS/TS fields and +JSX/Tailwind form syntax from unfinished-code markers using the CLI's existing +locked TypeScript parser. Comments, string values and standalone unfinished +declarations remain checked, including on the same line as a form attribute; +parse or tool failures fail the gate. No production path or marker pattern was +removed. Eleven regression fixtures cover scope, genuine markers, diff position, +CSS variants and fail-closed parsing. Three comments describing example URLs +and input/number presentation were clarified without changing runtime code. +This is a scanner-correctness change, not application source sign-off. + The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, and the header gate reports missing Microsoft headers on imported files. From 5d78f11ea9ca852ee5976d3e90a0d6d8ca50e69f Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 16:51:53 +0200 Subject: [PATCH 006/111] Split Bridge task routes into cohesive bounded modules Preserve all64 route exports and32 tests through mechanical extraction. Every resulting task source file is at most800lines; rustfmt and normalized source parity were checked. Actual Rust compilation remains pending hosted Azure CI, not inferred from syntax checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/tasks.rs | 5187 +------------------ bridge/bff/src/routes/tasks/artifacts.rs | 159 + bridge/bff/src/routes/tasks/creation.rs | 596 +++ bridge/bff/src/routes/tasks/diagnostics.rs | 233 + bridge/bff/src/routes/tasks/egress.rs | 297 ++ bridge/bff/src/routes/tasks/evidence.rs | 346 ++ bridge/bff/src/routes/tasks/fleet.rs | 413 ++ bridge/bff/src/routes/tasks/history.rs | 212 + bridge/bff/src/routes/tasks/lifecycle.rs | 349 ++ bridge/bff/src/routes/tasks/mapping.rs | 321 ++ bridge/bff/src/routes/tasks/models.rs | 633 +++ bridge/bff/src/routes/tasks/presentation.rs | 606 +++ bridge/bff/src/routes/tasks/queries.rs | 419 ++ bridge/bff/src/routes/tasks/tests.rs | 658 +++ 14 files changed, 5293 insertions(+), 5136 deletions(-) create mode 100644 bridge/bff/src/routes/tasks/artifacts.rs create mode 100644 bridge/bff/src/routes/tasks/creation.rs create mode 100644 bridge/bff/src/routes/tasks/diagnostics.rs create mode 100644 bridge/bff/src/routes/tasks/egress.rs create mode 100644 bridge/bff/src/routes/tasks/evidence.rs create mode 100644 bridge/bff/src/routes/tasks/fleet.rs create mode 100644 bridge/bff/src/routes/tasks/history.rs create mode 100644 bridge/bff/src/routes/tasks/lifecycle.rs create mode 100644 bridge/bff/src/routes/tasks/mapping.rs create mode 100644 bridge/bff/src/routes/tasks/models.rs create mode 100644 bridge/bff/src/routes/tasks/presentation.rs create mode 100644 bridge/bff/src/routes/tasks/queries.rs create mode 100644 bridge/bff/src/routes/tasks/tests.rs diff --git a/bridge/bff/src/routes/tasks.rs b/bridge/bff/src/routes/tasks.rs index dc2837324..d073118ac 100644 --- a/bridge/bff/src/routes/tasks.rs +++ b/bridge/bff/src/routes/tasks.rs @@ -4,1473 +4,57 @@ // They map the typed CRD (kars::task) to stable, browser-facing JSON shapes, // so the UI never depends on raw Kubernetes object envelopes. -use axum::Json; -use axum::extract::{Extension, Path, State}; -use serde::{Deserialize, Serialize}; +mod artifacts; +mod creation; +mod diagnostics; +mod egress; +mod evidence; +mod fleet; +mod history; +mod lifecycle; +mod mapping; +mod models; +mod presentation; +mod queries; -use crate::auth::Principal; -use crate::error::{AppError, AppResult}; -use crate::kars::task::{KarsTask, KarsTaskSpec, LocalObjectRef, TaskBudget, TaskEnvelope}; -use crate::routes::ownership::{ - require_owned_task, require_owned_task_or_output, task_is_owned_by, -}; -use crate::state::AppState; -use kube::ResourceExt; -use kube::api::{Api, ListParams, PostParams}; - -/// Map a `kube::Error` to the right client-facing error. An API rejection with -/// a 4xx status (admission/CEL/validation) is the user's invalid input — a 422 -/// carrying the API server's own message — not a gateway failure. -fn map_kube_err(e: kube::Error) -> AppError { - if let kube::Error::Api(resp) = &e - && (400..500).contains(&resp.code) - { - return AppError::Rejected(resp.message.clone()); - } - AppError::Upstream(e.to_string()) -} - -/// Browser-facing budget shape. -#[derive(Debug, Serialize, Deserialize)] -pub struct BudgetDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scope: Option<crate::kars::task::BudgetScope>, - pub tokens: Option<i64>, - pub usd_micros: Option<i64>, -} - -/// Browser-facing envelope shape. -#[derive(Debug, Serialize, Deserialize)] -pub struct EnvelopeDto { - pub tier: i32, - pub authority_ceiling: i32, - pub delegation_depth: i32, - pub budget: Option<BudgetDto>, - pub tool_policy: Option<String>, - pub egress_allowlist: Option<String>, -} - -/// Browser-facing blueprint shape. The request layer is **snake_case** (like -/// every other DTO here and the web's TS types); it maps to the camelCase CRD -/// `TaskBlueprint` on write. Keeping the wire contract consistent here is what -/// prevents silent field-drop on multi-word fields (`tool_policy`, -/// `mcp_servers`). -#[derive(Debug, Deserialize, Default)] -pub struct BlueprintDto { - #[serde(default)] - pub runtime: Option<String>, - #[serde(default)] - pub model: Option<ModelDto>, - #[serde(default)] - pub model_fallbacks: Vec<ModelDto>, - #[serde(default)] - pub instructions: Option<String>, - #[serde(default)] - pub tool_policy: Option<String>, - #[serde(default)] - pub mcp_servers: Vec<String>, - #[serde(default)] - pub egress: Vec<EgressDto>, - #[serde(default)] - pub egress_mode: Option<String>, - #[serde(default)] - pub isolation: Option<String>, - #[serde(default)] - pub memory: Option<String>, - #[serde(default)] - pub skills: Vec<String>, - #[serde(default)] - pub execution_plan: Option<ExecutionPlanDto>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionPlanDto { - pub schema: String, - pub roles: Vec<ExecutionRoleDto>, - pub max_parallel: i32, - pub synthesis: ExecutionSynthesisDto, - #[serde(default)] - pub deliverables: Vec<ExecutionDeliverableDto>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionRoleDto { - pub name: String, - pub objective: String, - #[serde(default)] - pub depends_on: Vec<String>, - pub phases: Vec<ExecutionPhaseDto>, - #[serde(default)] - pub budget_tokens: Option<i64>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionPhaseDto { - pub name: String, - pub objective: String, - #[serde(default)] - pub capabilities: Vec<String>, - #[serde(default)] - pub required_tool_calls: Vec<ExecutionRequiredToolCallDto>, - #[serde(default)] - pub min_tool_calls: i32, - pub max_tool_calls: i32, - #[serde(default)] - pub fresh_context: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionRequiredToolCallDto { - pub name: String, - #[serde(default)] - pub arguments: std::collections::BTreeMap<String, String>, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionSynthesisDto { - pub objective: String, - #[serde(default)] - pub capabilities: Vec<String>, - pub max_tool_calls: i32, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ExecutionDeliverableDto { - pub name: String, - #[serde(default)] - pub media_type: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ModelDto { - pub provider: String, - pub deployment: String, -} - -#[derive(Debug, Deserialize)] -pub struct EgressDto { - pub host: String, - #[serde(default)] - pub port: Option<i32>, -} - -impl BlueprintDto { - fn into_crd(self) -> crate::kars::task::TaskBlueprint { - use crate::kars::task::{TaskBlueprint, TaskEgress, TaskModel}; - TaskBlueprint { - runtime: self.runtime, - model: self.model.map(|m| TaskModel { - provider: m.provider, - deployment: m.deployment, - }), - model_fallbacks: self - .model_fallbacks - .into_iter() - .map(|m| TaskModel { - provider: m.provider, - deployment: m.deployment, - }) - .collect(), - instructions: self.instructions, - tool_policy: self.tool_policy, - mcp_servers: self.mcp_servers, - egress: self - .egress - .into_iter() - .map(|e| TaskEgress { - host: e.host, - port: e.port, - }) - .collect(), - egress_mode: self.egress_mode, - isolation: self.isolation, - memory: self.memory, - skills: self.skills, - git_write: None, - credential_bindings: None, - github_binding: None, - execution_plan: self.execution_plan.map(ExecutionPlanDto::into_crd), - } - } -} - -impl ExecutionPlanDto { - pub(crate) fn from_crd(plan: &crate::kars::task::ExecutionPlan) -> Self { - Self { - schema: plan.schema.clone(), - roles: plan - .roles - .iter() - .map(|role| ExecutionRoleDto { - name: role.name.clone(), - objective: role.objective.clone(), - depends_on: role.depends_on.clone(), - phases: role - .phases - .iter() - .map(|phase| ExecutionPhaseDto { - name: phase.name.clone(), - objective: phase.objective.clone(), - capabilities: phase.capabilities.clone(), - required_tool_calls: phase - .required_tool_calls - .iter() - .map(|call| ExecutionRequiredToolCallDto { - name: call.name.clone(), - arguments: call.arguments.clone(), - }) - .collect(), - min_tool_calls: phase.min_tool_calls, - max_tool_calls: phase.max_tool_calls, - fresh_context: phase.fresh_context, - }) - .collect(), - budget_tokens: role.budget_tokens, - }) - .collect(), - max_parallel: plan.max_parallel, - synthesis: ExecutionSynthesisDto { - objective: plan.synthesis.objective.clone(), - capabilities: plan.synthesis.capabilities.clone(), - max_tool_calls: plan.synthesis.max_tool_calls, - }, - deliverables: plan - .deliverables - .iter() - .map(|deliverable| ExecutionDeliverableDto { - name: deliverable.name.clone(), - media_type: deliverable.media_type.clone(), - }) - .collect(), - } - } - - pub(crate) fn into_crd(self) -> crate::kars::task::ExecutionPlan { - use crate::kars::task::{ - ExecutionDeliverable, ExecutionPhase, ExecutionPlan, ExecutionRequiredToolCall, - ExecutionRole, ExecutionSynthesis, - }; - ExecutionPlan { - schema: self.schema, - roles: self - .roles - .into_iter() - .map(|role| ExecutionRole { - name: role.name, - objective: role.objective, - depends_on: role.depends_on, - phases: role - .phases - .into_iter() - .map(|phase| ExecutionPhase { - name: phase.name, - objective: phase.objective, - capabilities: phase.capabilities, - required_tool_calls: phase - .required_tool_calls - .into_iter() - .map(|call| ExecutionRequiredToolCall { - name: call.name, - arguments: call.arguments, - }) - .collect(), - min_tool_calls: phase.min_tool_calls, - max_tool_calls: phase.max_tool_calls, - fresh_context: phase.fresh_context, - }) - .collect(), - budget_tokens: role.budget_tokens, - }) - .collect(), - max_parallel: self.max_parallel, - synthesis: ExecutionSynthesis { - objective: self.synthesis.objective, - capabilities: self.synthesis.capabilities, - max_tool_calls: self.synthesis.max_tool_calls, - }, - deliverables: self - .deliverables - .into_iter() - .map(|deliverable| ExecutionDeliverable { - name: deliverable.name, - media_type: deliverable.media_type, - }) - .collect(), - } - } -} - -/// Browser-facing task summary (list view). -#[derive(Debug, Serialize)] -pub struct TaskSummaryDto { - pub name: String, - pub namespace: String, - pub objective: String, - pub display_name: Option<String>, - pub created_at: Option<String>, - pub tier: i32, - pub phase: String, - pub envelope_digest: Option<String>, - /// The standing team that owns this task (from the kars.azure.com/team - /// label), when it is team machinery rather than a standalone mission. The - /// Missions surface hides team-owned tasks — they belong to the Team view. - pub team: Option<String>, - /// Whether this mission has captured a delivered result (an `ok` run output - /// exists). The authoritative "done" signal — execution phase returns to - /// Idle after delivery, so phase alone cannot tell delivered from drafting. - pub delivered: bool, - /// Whether this mission's run captured an `error` output — a run that - /// completed but did NOT succeed. Lets the list badge read "Run failed" - /// instead of a misleading "Ready to launch" (audit f6). - pub failed: bool, - /// Whether the task has been launched (execution gate opened). Without this - /// the list cannot tell a launched-and-running mission from an un-launched - /// draft, so a live mission wrongly reads "Ready to launch". - pub launched: bool, - /// The controller's execution phase (Running/Idle/Degraded/…), so the list - /// badge agrees with the detail page — "Running" while the agent works, not - /// a stale "Ready to launch". - pub execution_phase: Option<String>, -} - -/// Browser-facing task detail (single view). -#[derive(Debug, Serialize)] -pub struct TaskDetailDto { - pub name: String, - pub namespace: String, - pub objective: String, - pub display_name: Option<String>, - pub created_at: Option<String>, - pub envelope: EnvelopeDto, - pub phase: String, - pub envelope_digest: Option<String>, - pub observed_generation: Option<i64>, - pub lineage: Vec<String>, - /// Parent task name when this task is a delegated child. - pub parent: Option<String>, - /// The standing team that owns this run. Team-owned runs stay inside the - /// team-native UX rather than leaking into the generic Missions surface. - pub team: Option<String>, - /// The `Ready` condition message — surfaces *why* a task is Degraded - /// (e.g. an amplification rejection), so the UI can explain it. - pub status_message: Option<String>, - /// Names of tasks that delegate from this one (its direct children). - pub children: Vec<TaskSummaryDto>, - /// Whether the task is launched (execution gate). - pub launched: bool, - /// Execution phase: `Idle` | `Launching` | `Running` | `Degraded`. - pub execution_phase: Option<String>, - /// Name of the materialized sandbox, when launched. - pub sandbox: Option<String>, - /// The live egress enforcement mode the sandbox is running under, read from - /// the materialized `KarsSandbox`: `"Learn"` (observe + record every domain - /// the agent reaches, the default) or `"Strict"` (deny anything outside the - /// allowlist). `None` until a sandbox exists. This is the monitoring→enforced - /// surface: a customer watches in Learn, then promotes to Strict when - /// confident the agent's reach is what it should be. - pub egress_mode: Option<String>, - /// Human-readable execution detail (e.g. the kind/Foundry caveat). - pub execution_detail: Option<String>, - /// Authoritative durable root-assignment snapshot from Kars core. - pub assignment: Option<TaskAssignmentStatusDto>, - /// Ordered durable root and child assignment transitions. - pub assignment_events: Vec<TaskAssignmentEventDto>, - /// Highest durable assignment event sequence observed by the controller. - pub assignment_sequence: Option<i64>, - /// The composed run — what model/harness/tools/services/egress/prompt this - /// mission actually runs with, projected from the blueprint. Lets a - /// task-giver review exactly what they launched. `None` when no blueprint - /// was set (the mission uses controller defaults). - pub composition: Option<CompositionDto>, - /// The agents the mission spawned at run time (the running agent/sub-agent - /// tree, distinct from the governed delegation roles in `children`). - pub sub_agents: Vec<SubAgentDto>, - /// The mission's captured run result — a real deliverable produced by a - /// governed model run, with its real token cost. `None` until the mission - /// has been run. - pub result: Option<MissionResultDto>, - /// The full set of artifact files the mission produced through the agent - /// loop over the mesh (research report, data files, decision matrix, …), - /// read from the persisted artifacts ConfigMap. Empty until a mesh run - /// produces files. - pub artifacts: Vec<MissionArtifactDto>, - /// Bounded, server-parsed orchestration plan evidence. This remains complete - /// even when the source artifact preview is truncated or omitted. - pub role_plan: TeamRolePlanDto, - /// Bounded, server-parsed collaboration evidence. Parsing full artifact - /// contents in the BFF prevents preview limits from changing run truth. - pub collaboration_events: Vec<TeamCollaborationEventDto>, - /// Pull requests the mission opened, extracted from its output — surfaced as - /// first-class deliverables (a PR is a delivery type) on the mission's - /// Artifacts tab, not just buried in the prose. Empty when none were opened. - #[serde(default)] - pub pull_requests: Vec<PullRequestRef>, - /// The mission's live execution activity — the real per-round and per-tool - /// trace the agent emitted (token usage, tool names, sanitized arg/result - /// previews, durations), read from the persisted trace ConfigMap. Empty - /// until a mesh run produces a trace. This is the source of the Activity - /// timeline and the clean per-tool audit path. - pub activity: Vec<serde_json::Value>, - /// Run telemetry rollup (rounds, tool calls) parsed from the mission output. - /// Token totals live on `result`; this carries the loop-shape counts. - pub telemetry: Option<MissionTelemetryDto>, - /// Latest durable milestone checkpoint emitted by the running harness. - pub checkpoint: Option<serde_json::Value>, - /// The running agent's real mesh identity (DID), discovered from the AGT - /// registry — proof the agent is a live, harness-neutral mesh participant. - /// `None` when not launched / not yet registered / registry unreachable. - pub agent_identity: Option<crate::kars::cluster::AgentIdentity>, - /// A governed capability-routing decision recorded at creation: set when the - /// requested harness could not run this mission (a chat-gateway harness on a - /// one-shot mission) and was corrected. Surfaced so the swap is attested, not - /// silent. `None` when no correction was needed. - pub harness_corrected: Option<String>, - /// A governed emergency-stop decision: set when an operator halted this - /// mission (agent torn down, record retained). Carries the operator/reason/at - /// string. `None` when the mission was never halted. - pub halted: Option<String>, - /// Whether a run has EVER been requested for this mission (the - /// `kars.azure.com/run-requested` annotation is set). Used by the client - /// auto-kickoff to fire the first run exactly once — gating on this instead - /// of "no activity yet" avoids a race where the agent's startup telemetry - /// (MCP init / tool list) makes the mission look already-active and the - /// first run is never triggered, leaving it silently idle. - pub run_requested: bool, - /// The exact latest requested run nonce. This appears before assignment - /// acknowledgement and is the authoritative scope for run-bound approvals. - pub current_run_nonce: Option<String>, -} - -#[derive(Debug, Serialize)] -pub struct TaskAssignmentStatusDto { - pub task_id: String, - pub state: String, - pub worker_did: Option<String>, - pub stage: Option<String>, - pub child_task_id: Option<String>, - pub child_role: Option<String>, - pub last_progress_at: Option<String>, - pub completed_at: Option<String>, - pub error: Option<String>, -} - -impl From<&crate::kars::task::TaskAssignmentStatus> for TaskAssignmentStatusDto { - fn from(value: &crate::kars::task::TaskAssignmentStatus) -> Self { - Self { - task_id: value.task_id.clone(), - state: value.state.clone(), - worker_did: value.worker_did.clone(), - stage: value.stage.clone(), - child_task_id: value.child_task_id.clone(), - child_role: value.child_role.clone(), - last_progress_at: value.last_progress_at.clone(), - completed_at: value.completed_at.clone(), - error: value.error.clone(), - } - } -} - -#[derive(Debug, Serialize)] -pub struct TaskAssignmentEventDto { - pub sequence: i64, - pub event_id: String, - pub task_id: String, - pub event_type: String, - pub state: String, - pub at: String, - pub worker_did: Option<String>, - pub stage: Option<String>, - pub child_task_id: Option<String>, - pub child_role: Option<String>, - pub outcome: Option<String>, - pub message: Option<String>, -} - -impl From<&crate::kars::task::TaskAssignmentEvent> for TaskAssignmentEventDto { - fn from(value: &crate::kars::task::TaskAssignmentEvent) -> Self { - Self { - sequence: value.sequence, - event_id: value.event_id.clone(), - task_id: value.task_id.clone(), - event_type: value.event_type.clone(), - state: value.state.clone(), - at: value.at.clone(), - worker_did: value.worker_did.clone(), - stage: value.stage.clone(), - child_task_id: value.child_task_id.clone(), - child_role: value.child_role.clone(), - outcome: value.outcome.clone(), - message: value.message.clone(), - } - } -} - -/// Loop-shape telemetry for a mission run (token totals are on the result DTO). -#[derive(Debug, Serialize)] -pub struct MissionTelemetryDto { - pub rounds: Option<i64>, - pub tool_calls: Option<i64>, -} - -/// A captured mission run result (read from the persisted output ConfigMap). -#[derive(Debug, Serialize)] -pub struct MissionResultDto { - pub output: String, - /// Run status the output reflects: `ok` (a real deliverable) or `error` - /// (e.g. a delivery timeout). The UI must not present an `error` output as - /// the mission's deliverable. - pub status: Option<String>, - pub model: Option<String>, - pub total_tokens: Option<i64>, - pub prompt_tokens: Option<i64>, - pub completion_tokens: Option<i64>, - pub finished_at: Option<String>, - /// Assignment nonce that produced this output. Used to hide stale results - /// while a newer run is materializing. - pub assignment_nonce: Option<String>, - /// How the deliverable was produced: `"single_turn"` when the mesh agent - /// loop was unavailable and this is one model turn (no tools/sub-agents). - /// Absent (`None`) for a full agent-loop run — the normal case. - pub source: Option<String>, - /// Set when the run's `ok` output is actually a capability/limit STOP rather - /// than a real deliverable — today the daily token budget (enforced by the - /// sandbox InferencePolicy / router). The UI renders this as an actionable - /// state ("raise the budget / narrow the objective"), never as the answer. - pub blocked: Option<RunBlockedDto>, - /// Whether every artifact declared by the agent was durably persisted. - /// Older runs may not carry this field. - pub artifact_persistence: Option<String>, - pub artifact_count: Option<i64>, - pub declared_artifact_count: Option<i64>, -} - -/// A run that returned transport-`ok` but whose body is a capability/limit stop, -/// not a deliverable. Surfaced so the operator gets an honest, actionable state -/// instead of a non-answer dressed up as the mission's output. -#[derive(Debug, Serialize, Clone)] -pub struct RunBlockedDto { - /// Machine reason. Today: `"budget"`. - pub reason: String, - /// One-line, plain-language explanation. - pub detail: String, - /// Tokens spent / the enforced limit, parsed from the router's message when - /// present (the limit ideally originates from the sandbox InferencePolicy). - pub spent: Option<i64>, - pub limit: Option<i64>, -} - -/// Classify a transport-`ok` run whose body is really a STOP condition (not a -/// deliverable). Today this recognises the daily token-budget block the router -/// enforces from the sandbox InferencePolicy — its message reads -/// "Daily token budget exceeded (23131/20000 tokens)". Returns `None` for a -/// genuine deliverable (or an already-`error` run, handled separately). -pub(crate) fn classify_blocked(status: Option<&str>, output: &str) -> Option<RunBlockedDto> { - if status == Some("error") { - return None; - } - let low = output.to_ascii_lowercase(); - let budget_hit = low.contains("token budget") - && (low.contains("exceeded") || low.contains("429") || low.contains("budget at")); - if budget_hit { - let (spent, limit) = parse_budget_pair(output); - return Some(RunBlockedDto { - reason: "budget".into(), - detail: "The run reached its daily token budget and stopped before finishing.".into(), - spent, - limit, - }); - } - None -} - -/// Extract the `spent/limit` pair from a budget message like -/// "... (23131/20000 tokens)". Returns `(None, None)` when absent/unparseable. -fn parse_budget_pair(output: &str) -> (Option<i64>, Option<i64>) { - // Find a "<digits>/<digits>" run (optionally followed by " tokens"). - let bytes = output.as_bytes(); - for (i, _) in output.match_indices('/') { - // Walk left over digits. - let mut l = i; - while l > 0 && bytes[l - 1].is_ascii_digit() { - l -= 1; - } - // Walk right over digits. - let mut r = i + 1; - while r < bytes.len() && bytes[r].is_ascii_digit() { - r += 1; - } - if l < i && r > i + 1 { - let spent = output[l..i].parse::<i64>().ok(); - let limit = output[i + 1..r].parse::<i64>().ok(); - if spent.is_some() && limit.is_some() { - return (spent, limit); - } - } - } - (None, None) -} - -/// One artifact file in a mission's deliverable set. `content` is present for -/// text artifacts (markdown, json, csv, …) and `None` for binary ones, which -/// are still listed by name + size so the set is honestly complete. -#[derive(Serialize)] -pub struct MissionArtifactDto { - pub name: String, - pub size_bytes: Option<i64>, - pub content: Option<String>, - pub content_bytes: Option<i64>, - pub content_truncated: bool, - pub source_agent: Option<String>, - pub source_path: Option<String>, - pub digest: Option<String>, - #[serde(skip_serializing)] - full_content: Option<String>, -} - -#[derive(Debug, Default, Serialize)] -pub struct TeamRolePlanDto { - pub selected_roles: Vec<String>, - pub skipped_roles: Vec<String>, -} - -#[derive(Debug, Serialize)] -pub struct TeamCollaborationEventDto { - pub at: Option<String>, - pub event: String, - pub agent: Option<String>, - pub member: Option<String>, - pub outcome: Option<String>, - pub message_id: Option<String>, - pub reply_preview: Option<String>, - pub content_preview: Option<String>, -} - -impl std::fmt::Debug for MissionArtifactDto { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("MissionArtifactDto") - .field("name", &self.name) - .field("size_bytes", &self.size_bytes) - .field("content_bytes", &self.content_bytes) - .field("content_truncated", &self.content_truncated) - .field("source_agent", &self.source_agent) - .field("source_path", &self.source_path) - .field("digest", &self.digest) - .finish_non_exhaustive() - } -} - -const ARTIFACT_PREVIEW_MAX_BYTES: usize = 8 * 1024; -const ARTIFACT_PREVIEW_TOTAL_BYTES: usize = 64 * 1024; - -fn artifact_preview( - content: Option<String>, - remaining_budget: &mut usize, -) -> (Option<String>, Option<i64>, bool, Option<String>) { - let Some(full) = content else { - return (None, None, false, None); - }; - let content_bytes = full.len() as i64; - if full.is_empty() { - return (Some(String::new()), Some(0), false, Some(full)); - } - - let max_bytes = ARTIFACT_PREVIEW_MAX_BYTES - .min(*remaining_budget) - .min(full.len()); - if max_bytes == 0 { - return (None, Some(content_bytes), true, Some(full)); - } - let mut end = max_bytes; - while end > 0 && !full.is_char_boundary(end) { - end -= 1; - } - let preview = full[..end].to_string(); - *remaining_budget = remaining_budget.saturating_sub(preview.len()); - let truncated = end < full.len(); - (Some(preview), Some(content_bytes), truncated, Some(full)) -} - -fn string_field(value: &serde_json::Value, field: &str) -> Option<String> { - value - .get(field) - .and_then(serde_json::Value::as_str) - .map(str::to_string) -} - -fn bounded_text(value: Option<String>, max_bytes: usize) -> Option<String> { - let value = value?; - if value.len() <= max_bytes { - return Some(value); - } - let mut end = max_bytes; - while end > 0 && !value.is_char_boundary(end) { - end -= 1; - } - Some(value[..end].to_string()) -} - -fn bounded_string_field( - value: &serde_json::Value, - field: &str, - max_bytes: usize, -) -> Option<String> { - bounded_text(string_field(value, field), max_bytes) -} - -fn collect_role_names( - value: Option<&serde_json::Value>, - target: &mut Vec<String>, - seen: &mut std::collections::HashSet<String>, -) { - const MAX_ROLE_NAMES: usize = 128; - const MAX_ROLE_NAME_BYTES: usize = 256; - if target.len() >= MAX_ROLE_NAMES { - return; - } - match value { - Some(serde_json::Value::Array(entries)) => { - for entry in entries { - let role = entry - .as_str() - .and_then(|role| bounded_text(Some(role.to_string()), MAX_ROLE_NAME_BYTES)) - .or_else(|| bounded_string_field(entry, "role", MAX_ROLE_NAME_BYTES)) - .or_else(|| bounded_string_field(entry, "name", MAX_ROLE_NAME_BYTES)); - if let Some(role) = role - && seen.insert(role.clone()) - { - target.push(role); - if target.len() >= MAX_ROLE_NAMES { - break; - } - } - } - } - Some(serde_json::Value::Object(entries)) => { - for role in entries.keys() { - let role = bounded_text(Some(role.clone()), MAX_ROLE_NAME_BYTES) - .expect("object keys are present"); - if seen.insert(role.clone()) { - target.push(role); - if target.len() >= MAX_ROLE_NAMES { - break; - } - } - } - } - _ => {} - } -} - -fn structured_team_evidence( - artifacts: &[MissionArtifactDto], -) -> (TeamRolePlanDto, Vec<TeamCollaborationEventDto>) { - const MAX_COLLABORATION_EVENTS: usize = 1_000; - const MAX_COLLABORATION_METADATA_BYTES: usize = 512; - const MAX_COLLABORATION_PREVIEW_BYTES: usize = 2 * 1024; - - let mut role_plan = TeamRolePlanDto::default(); - let mut selected_seen = std::collections::HashSet::new(); - let mut skipped_seen = std::collections::HashSet::new(); - for artifact in artifacts - .iter() - .filter(|artifact| artifact.name.ends_with(".json")) - { - let Some(content) = artifact - .full_content - .as_deref() - .or(artifact.content.as_deref()) - else { - continue; - }; - let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) else { - continue; - }; - collect_role_names( - parsed.get("selected_roles"), - &mut role_plan.selected_roles, - &mut selected_seen, - ); - collect_role_names( - parsed.get("skipped_roles"), - &mut role_plan.skipped_roles, - &mut skipped_seen, - ); - } - - let collaboration = artifacts - .iter() - .find(|artifact| { - artifact.name == "collaboration.jsonl" - || artifact - .source_path - .as_deref() - .is_some_and(|path| path.ends_with("/collaboration.jsonl")) - }) - .and_then(|artifact| { - artifact - .full_content - .as_deref() - .or(artifact.content.as_deref()) - }) - .map(|content| { - content - .lines() - .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) - .take(MAX_COLLABORATION_EVENTS) - .map(|event| TeamCollaborationEventDto { - at: bounded_string_field(&event, "at", MAX_COLLABORATION_METADATA_BYTES), - event: bounded_string_field(&event, "event", MAX_COLLABORATION_METADATA_BYTES) - .unwrap_or_else(|| "event".to_string()), - agent: bounded_string_field(&event, "agent", MAX_COLLABORATION_METADATA_BYTES), - member: bounded_string_field( - &event, - "member", - MAX_COLLABORATION_METADATA_BYTES, - ) - .or_else(|| { - bounded_string_field(&event, "from_agent", MAX_COLLABORATION_METADATA_BYTES) - }) - .or_else(|| { - bounded_string_field(&event, "to_agent", MAX_COLLABORATION_METADATA_BYTES) - }), - outcome: bounded_string_field( - &event, - "outcome", - MAX_COLLABORATION_METADATA_BYTES, - ), - message_id: bounded_string_field( - &event, - "message_id", - MAX_COLLABORATION_METADATA_BYTES, - ), - reply_preview: bounded_text( - string_field(&event, "reply_preview"), - MAX_COLLABORATION_PREVIEW_BYTES, - ), - content_preview: bounded_text( - string_field(&event, "content_preview"), - MAX_COLLABORATION_PREVIEW_BYTES, - ), - }) - .collect() - }) - .unwrap_or_default(); - - (role_plan, collaboration) -} - -fn canonicalize_assignment_event_roles( - events: &mut [TaskAssignmentEventDto], - collaboration: &[TeamCollaborationEventDto], -) { - let roles_by_child_task = collaboration - .iter() - .filter_map(|event| { - Some(( - event.message_id.as_deref()?.to_string(), - event.member.as_deref()?.to_string(), - )) - }) - .collect::<std::collections::HashMap<_, _>>(); - - for event in events { - let Some(child_task_id) = event.child_task_id.as_deref() else { - continue; - }; - if let Some(role) = roles_by_child_task.get(child_task_id) { - event.child_role = Some(role.clone()); - } - } -} - -fn select_task_checkpoint( - progress: Option<serde_json::Value>, - artifacts: &[MissionArtifactDto], - successful_result: bool, -) -> Option<serde_json::Value> { - let artifact_checkpoint = artifacts - .iter() - .find(|artifact| artifact.name.ends_with("task-checkpoint.json")) - .and_then(|artifact| { - artifact - .full_content - .as_deref() - .or(artifact.content.as_deref()) - }) - .and_then(|content| serde_json::from_str(content).ok()) - .and_then(valid_task_checkpoint); - let checkpoint = artifact_checkpoint.or_else(|| progress.and_then(valid_task_checkpoint)); - - checkpoint.filter(|checkpoint| { - !successful_result - || !matches!( - checkpoint.get("status").and_then(serde_json::Value::as_str), - Some("pending" | "in_progress") - ) - }) -} - -fn merge_trace_total_tokens(result: &mut Option<MissionResultDto>, trace_total_tokens: i64) { - if trace_total_tokens <= 0 { - return; - } - if let Some(result) = result { - result.total_tokens = Some( - result - .total_tokens - .unwrap_or_default() - .max(trace_total_tokens), - ); - } -} - -fn subagent_trace_from_artifacts(artifacts: &[MissionArtifactDto]) -> Vec<serde_json::Value> { - let mut events = Vec::new(); - for artifact in artifacts { - if !artifact.name.ends_with("subagent-telemetry.jsonl") { - continue; - } - let Some(content) = artifact - .full_content - .as_deref() - .or(artifact.content.as_deref()) - else { - continue; - }; - for line in content - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - { - let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else { - continue; - }; - if record.get("event").and_then(serde_json::Value::as_str) != Some("subagent_trace") { - continue; - } - let Some(mut trace) = record.get("trace").cloned() else { - continue; - }; - if let Some(object) = trace.as_object_mut() { - let member = record - .get("member") - .and_then(serde_json::Value::as_str) - .unwrap_or("subagent"); - object.insert("agent".into(), serde_json::json!(member)); - if let Some(mesh_name) = record.get("mesh_name").and_then(serde_json::Value::as_str) - { - object.insert("agentInstance".into(), serde_json::json!(mesh_name)); - } - object.insert("agentRole".into(), serde_json::json!("subagent")); - if object.get("ts").is_none() - && let Some(at) = record.get("at").cloned() - { - object.insert("ts".into(), at); - } - } - events.push(trace); - } - } - events -} - -fn valid_task_checkpoint(value: serde_json::Value) -> Option<serde_json::Value> { - let schema = value.get("schema")?.as_str()?; - let milestone = value.get("milestone_id")?.as_str()?.trim(); - let status = value.get("status")?.as_str()?; - let summary = value.get("summary")?.as_str()?.trim(); - let string_array = |key: &str| { - value.get(key).is_none_or(|field| { - field - .as_array() - .is_some_and(|items| items.iter().all(serde_json::Value::is_string)) - }) - }; - (schema == "kars.checkpoint/v1" - && !milestone.is_empty() - && !summary.is_empty() - && matches!(status, "pending" | "in_progress" | "completed" | "blocked") - && string_array("acceptance_criteria") - && string_array("artifacts") - && string_array("next_steps")) - .then_some(value) -} - -/// The composed run, in plain terms, for the mission-review surface. -#[derive(Debug, Serialize)] -pub struct CompositionDto { - pub runtime: Option<String>, - pub model: Option<String>, - pub instructions: Option<String>, - pub tool_policy: Option<String>, - pub mcp_servers: Vec<String>, - pub egress: Vec<String>, - pub isolation: Option<String>, - pub memory: Option<String>, -} - -/// Create-task request body from the UI. -#[derive(Debug, Deserialize)] -pub struct CreateTaskRequest { - pub name: String, - pub objective: String, - pub display_name: Option<String>, - pub envelope: EnvelopeDto, - /// Optional parent task name — when set, this creates a delegated child - /// whose envelope the controller verifies against the parent's. - #[serde(default)] - pub parent: Option<String>, - /// The editable run blueprint composed on the launch package - /// (runtime/model/instructions/tools/MCP/egress/isolation/memory). - #[serde(default)] - pub blueprint: Option<BlueprintDto>, - #[serde(default)] - pub delegation: Option<crate::routes::compose::ComposeDelegation>, - /// When true, the task is created already launched — the controller - /// materializes the sandbox immediately. The package's "launch" action. - #[serde(default)] - pub launch: bool, - /// Repos selected from the authenticated principal's GitHub connection. The - /// server validates the full set and derives the typed connection reference. - #[serde(default)] - pub git_write_repos: Option<Vec<String>>, - /// The identity creating this mission (the Bridge principal), stamped as - /// `kars.azure.com/created-by` for per-user budget attribution. The web sets - /// it from the current session; absent => "unattributed". - #[serde(default)] - pub created_by: Option<String>, - /// Per-mission retention override, in seconds — auto-delete this mission's - /// record this long after its deliverable lands (mirrors Kubernetes' - /// `Job.ttlSecondsAfterFinished`). `0` disables retention for this mission - /// specifically even if a cluster-wide default is set. Absent inherits the - /// cluster-wide default (which itself defaults to "never"). - #[serde(default)] - pub retention_ttl_seconds: Option<i64>, -} - -fn phase_of(task: &KarsTask) -> String { - task.status - .as_ref() - .and_then(|s| s.phase.clone()) - .unwrap_or_else(|| "Pending".to_string()) -} - -fn to_summary(task: &KarsTask) -> TaskSummaryDto { - TaskSummaryDto { - name: task.name_any(), - namespace: task.namespace().unwrap_or_default(), - objective: clean_objective(&task.spec.objective), - display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), - created_at: task - .metadata - .creation_timestamp - .as_ref() - .map(|timestamp| timestamp.0.to_rfc3339()), - tier: task.spec.envelope.tier, - phase: phase_of(task), - envelope_digest: task.status.as_ref().and_then(|s| s.envelope_digest.clone()), - team: task - .metadata - .labels - .as_ref() - .and_then(|l| l.get("kars.azure.com/team").cloned()), - delivered: false, - failed: false, - launched: task - .spec - .execution - .as_ref() - .map(|e| e.launch) - .unwrap_or(false), - execution_phase: task.status.as_ref().and_then(|s| s.execution_phase.clone()), - } -} - -fn is_task_owner(task: &KarsTask, principal: &Principal) -> bool { - task_is_owned_by(task, principal) -} - -/// Extract the human deliverable from the agent's run output. The native -/// OpenClaw agent returns a structured `--json` envelope -/// (`{ runId, status, summary, result: { payloads: [ { text } ] } }`); showing -/// that raw — escaped quotes, literal `\n`, JSON braces — is the single most -/// embarrassing thing in the UI. Pull out the actual prose (joining payload -/// texts), tolerating a few shapes; pass plain-text output through unchanged. -fn repair_replacement_question_marks(text: &str) -> String { - let characters = text.chars().collect::<Vec<_>>(); - let mut repaired = String::with_capacity(text.len()); - for (index, character) in characters.iter().copied().enumerate() { - if character != '?' { - repaired.push(character); - continue; - } - let previous = index - .checked_sub(1) - .and_then(|at| characters.get(at)) - .copied(); - let next = characters.get(index + 1).copied(); - if previous.is_some_and(char::is_alphanumeric) && next.is_some_and(char::is_alphanumeric) { - repaired.push('-'); - } else if previous.is_some_and(|value| value.is_ascii_digit()) - && next.is_some_and(char::is_whitespace) - { - repaired.push('.'); - } else { - repaired.push('?'); - } - } - repaired -} +#[cfg(test)] +mod tests; -fn strip_sandbox_banner(text: &str) -> String { - let lines = text.lines().collect::<Vec<_>>(); - let first_content = lines.iter().position(|line| !line.trim().is_empty()); - let Some(start) = first_content else { - return String::new(); - }; - let prefix_end = (start + 16).min(lines.len()); - let prefix = &lines[start..prefix_end]; - let lower_prefix = prefix.join("\n").to_ascii_lowercase(); - if !lower_prefix.contains("kars sandbox") - || !lower_prefix.contains("sandbox id:") - || !lower_prefix.contains("security:") - || !lower_prefix.contains("capabilities:") - { - return repair_replacement_question_marks(text.trim()); - } - let Some(capabilities_offset) = prefix - .iter() - .position(|line| line.to_ascii_lowercase().contains("capabilities:")) - else { - return repair_replacement_question_marks(text.trim()); - }; - repair_replacement_question_marks(lines[start + capabilities_offset + 1..].join("\n").trim()) -} +pub use artifacts::download_artifact; +pub use creation::create_task; +pub use diagnostics::{TroubleshootDto, troubleshoot_task}; +pub use egress::{ + EgressModeRequest, EgressRequest, get_learned_egress, request_egress, set_egress_mode, +}; +pub use fleet::{ + AgentLifecycleDto, FleetActivityItem, FleetTelemetryDto, fleet_telemetry, list_agents, +}; +pub use lifecycle::{ + HaltRequest, IncreaseTaskBudgetRequest, LaunchRequest, PromoteMissionRequest, ReplicateRequest, + delete_task, halt_task, increase_task_budget, launch_task, promote_task, replicate_task, +}; +pub use mapping::SubAgentDto; +pub use models::{ + BlueprintDto, BudgetDto, CompositionDto, CreateTaskRequest, EgressDto, EnvelopeDto, + ExecutionDeliverableDto, ExecutionPhaseDto, ExecutionPlanDto, ExecutionRequiredToolCallDto, + ExecutionRoleDto, ExecutionSynthesisDto, MissionArtifactDto, MissionResultDto, + MissionTelemetryDto, ModelDto, RunBlockedDto, TaskAssignmentEventDto, TaskAssignmentStatusDto, + TaskDetailDto, TaskSummaryDto, TeamCollaborationEventDto, TeamRolePlanDto, +}; +pub use presentation::PullRequestRef; +pub(crate) use presentation::{ + classify_blocked, clean_display_name, clean_objective, deliverable_excerpt, deliverable_text, + extract_pull_requests, is_failure_shaped_output, is_no_change_output, is_real_deliverable, +}; +pub use queries::{get_task, list_tasks}; -pub(crate) fn deliverable_text(raw: &str) -> String { - let trimmed = raw.trim(); - if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) { - // Native agent envelope: result.payloads[].text - if let Some(payloads) = v - .get("result") - .and_then(|r| r.get("payloads")) - .and_then(|p| p.as_array()) - { - let joined = payloads - .iter() - .filter_map(|p| p.get("text").and_then(|t| t.as_str())) - .collect::<Vec<_>>() - .join("\n\n"); - if !joined.trim().is_empty() { - return strip_sandbox_banner(&joined); - } - } - // Other harness shapes. - for path in [["reply", "text"], ["result", "text"]] { - if let Some(t) = v - .get(path[0]) - .and_then(|x| x.get(path[1])) - .and_then(|t| t.as_str()) - && !t.trim().is_empty() - { - return strip_sandbox_banner(t); - } - } - for key in ["text", "output", "summary"] { - if let Some(t) = v.get(key).and_then(|t| t.as_str()) - && !t.trim().is_empty() - { - return strip_sandbox_banner(t); - } - } - } - // Tolerant fallback: a *truncated* native envelope (the commons caps stored - // content, which can cut the JSON mid-string so `serde` can't parse it) still - // begins like `{ "runId": ..., "result": { "payloads": [ { "text": "…` — pull - // the first `"text"` string value out by hand and JSON-unescape it so old, - // truncated entries render as prose instead of raw JSON. - if trimmed.starts_with('{') - && trimmed.contains("\"text\"") - && let Some(extracted) = extract_first_json_string(trimmed, "text") - && !extracted.trim().is_empty() - { - return strip_sandbox_banner(&extracted); - } - strip_sandbox_banner(raw) -} +use crate::error::{AppError, AppResult}; +use crate::state::AppState; /// The sentinel a standing-team run emits when nothing changed since last time. /// A deliverable that is ONLY this is a no-op, not a real deliverable. pub(crate) const NO_CHANGE_SENTINEL: &str = "[[NO_MATERIAL_CHANGE]]"; -/// A pull request the mission opened — a first-class deliverable type. -#[derive(Debug, Clone, serde::Serialize, PartialEq)] -pub struct PullRequestRef { - /// `owner/repo`. - pub repo: String, - pub number: i64, - /// The canonical GitHub URL. - pub url: String, -} - -/// Extract the pull requests a mission opened from its deliverable text. The -/// router authors PRs via the keyless git proxy and the agent reports the URL; -/// we surface each as a tracked deliverable. Deduplicated, in first-seen order. -pub(crate) fn extract_pull_requests(text: &str) -> Vec<PullRequestRef> { - let mut out: Vec<PullRequestRef> = Vec::new(); - // Scan for `github.com/<owner>/<repo>/pull/<number>` occurrences without a - // regex dep: split on the marker and parse each following segment. - for seg in text.split("github.com/").skip(1) { - // owner/repo/pull/NUMBER - let mut it = seg.splitn(4, '/'); - let (Some(owner), Some(repo), Some(kind)) = (it.next(), it.next(), it.next()) else { - continue; - }; - if kind != "pull" && kind != "pulls" { - continue; - } - let Some(rest) = it.next() else { continue }; - let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); - if owner.is_empty() || repo.is_empty() || num.is_empty() { - continue; - } - let Ok(number) = num.parse::<i64>() else { - continue; - }; - let repo_full = format!("{owner}/{repo}"); - let url = format!("https://github.com/{repo_full}/pull/{number}"); - let pr = PullRequestRef { - repo: repo_full, - number, - url, - }; - if !out.contains(&pr) { - out.push(pr); - } - } - out -} - -fn deliverable_pull_requests( - data: &std::collections::BTreeMap<String, String>, -) -> Vec<PullRequestRef> { - let status = data.get("status").map(String::as_str); - let output = data.get("output").map(String::as_str).unwrap_or(""); - if !is_real_deliverable(status, output) { - return Vec::new(); - } - extract_pull_requests(&deliverable_text(output)) -} - -pub(crate) fn is_failure_shaped_output(output: &str) -> bool { - let text = deliverable_text(output); - let lower = text - .trim_start_matches(|character: char| { - character.is_whitespace() - || matches!(character, '*' | '_' | '#' | '>' | '`' | '-' | '?' | '🔒') - }) - .to_ascii_lowercase(); - let head: String = lower.chars().take(800).collect(); - head.starts_with("unexpected tokens remaining in message header") - || head.starts_with("assignment progress lease expired") - || head.starts_with("native agent failed") - || head.starts_with("error processing task") - || (head.starts_with("kars sandbox - secure ai runtime") && head.contains("how can i help")) - || head.starts_with("now await pr-watcher") - || head.starts_with("awaiting handback from") -} - -pub(crate) fn is_no_change_output(output: &str) -> bool { - let text = deliverable_text(output); - let head = text.trim_start(); - if head.starts_with(NO_CHANGE_SENTINEL) { - return true; - } - let Some(sentinel_at) = head.find(NO_CHANGE_SENTINEL) else { - return false; - }; - let prefix = &head[..sentinel_at]; - sentinel_at <= 1_200 - && prefix.to_ascii_lowercase().contains("kars sandbox") - && prefix.contains("Sandbox ID:") - && prefix.contains("Security:") - && prefix.contains("Capabilities:") -} - -/// True when a run output is NOT a real, showable deliverable — either the run -/// errored, produced nothing, or reported "no material change". Used to keep -/// hung / zero-output / no-op runs out of the deliverable index and the "latest -/// deliverable" hero (audit f9/f13: a receipt/deliverable requires real work). -pub(crate) fn is_real_deliverable(status: Option<&str>, deliverable: &str) -> bool { - if status == Some("error") { - return false; - } - let t = deliverable.trim(); - if t.is_empty() { - return false; - } - if is_no_change_output(deliverable) { - return false; - } - if is_failure_shaped_output(deliverable) { - return false; - } - // A capability/limit STOP (e.g. the daily token budget) came back transport-ok - // but is not the mission's answer — never treat it as a deliverable. - if classify_blocked(status, deliverable).is_some() { - return false; - } - true -} - -/// A clean 2–3 line preview of a deliverable for cards and list rows — never the -/// raw transcript. Strips the no-change sentinel, markdown table/heading noise, -/// and collapses whitespace, then caps the length (audit f3). -pub(crate) fn deliverable_excerpt(raw: &str) -> String { - let text = deliverable_text(raw); - let mut out: Vec<String> = Vec::new(); - for line in text.lines() { - let l = line.trim(); - if l.is_empty() { - continue; - } - // Strip leading markdown wrapping (emphasis / heading / block-quote / - // inline-code / bullet markers) FIRST, so a wrapped control sentinel - // like `**[[NO_MATERIAL_CHANGE]]**` is unwrapped before we test for it. - // Previously the sentinel check ran on the raw line and a bold-wrapped - // sentinel slipped through into the excerpt. - let cleaned = l - .trim_start_matches(['*', '_', '#', '>', '`', '-', ' ']) - .trim(); - if cleaned.is_empty() { - continue; - } - // Drop the no-change sentinel (now unwrapped) and markdown table - // rows/rules. - let cleaned = if let Some(reason) = cleaned.strip_prefix(NO_CHANGE_SENTINEL) { - let reason = reason - .trim_start_matches(|character: char| { - character.is_whitespace() || matches!(character, ':' | '-' | '—') - }) - .trim(); - if reason.is_empty() { - continue; - } - reason - } else { - cleaned - }; - if cleaned.starts_with('|') { - continue; - } - if cleaned.starts_with("===") { - continue; - } - let lower = cleaned.to_ascii_lowercase(); - if [ - "kars sandbox - secure ai runtime", - "foundry project:", - "model:", - "sandbox id:", - "security summary", - "security:", - "capabilities:", - "role plan", - "role roster", - "roles spawned:", - ] - .iter() - .any(|prefix| lower.starts_with(prefix)) - { - continue; - } - out.push(cleaned.to_string()); - if out.len() >= 3 { - break; - } - } - let joined = out.join(" "); - let joined = joined.split_whitespace().collect::<Vec<_>>().join(" "); - if joined.chars().count() > 240 { - let mut s: String = joined.chars().take(240).collect(); - s.push('…'); - s - } else { - joined - } -} - -/// end of input. Returns `None` if the key/opening quote isn't present. -fn extract_first_json_string(s: &str, key: &str) -> Option<String> { - let needle = format!("\"{key}\""); - let after_key = &s[s.find(&needle)? + needle.len()..]; - let colon = after_key.find(':')?; - let rest = &after_key[colon + 1..]; - let open = rest.find('"')?; - let body = &rest[open + 1..]; - let mut out = String::with_capacity(body.len()); - let mut chars = body.chars(); - while let Some(c) = chars.next() { - match c { - '"' => break, - '\\' => match chars.next() { - Some('n') => out.push('\n'), - Some('t') => out.push('\t'), - Some('r') => out.push('\r'), - Some('"') => out.push('"'), - Some('\\') => out.push('\\'), - Some('/') => out.push('/'), - Some('u') => { - let hex: String = chars.by_ref().take(4).collect(); - if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - out.push(ch); - } - } - Some(other) => out.push(other), - None => break, - }, - _ => out.push(c), - } - } - Some(out) -} - -/// Human-readable objective for display. A standing-run objective is wrapped -/// with internal scaffolding — `Standing-operation run for team 'X'. Charter: -/// <charter>. Your capabilities: … Operating contract: … --- BEGIN UNTRUSTED -/// REFERENCE DATA …` — none of which a person should see. Extract the charter / -/// intent and drop the capability manifest + injected prior-knowledge preamble. -/// Ordinary mission objectives (no wrapper) pass through unchanged. -pub(crate) fn clean_objective(raw: &str) -> String { - // Everything from the first scaffolding marker onward is internal. - const MARKERS: [&str; 5] = [ - "Your capabilities:", - "Operating contract:", - "--- BEGIN UNTRUSTED REFERENCE DATA", - "\n\nMode note", - "BEGIN UNTRUSTED REFERENCE DATA", - ]; - let mut end = raw.len(); - for m in MARKERS { - if let Some(i) = raw.find(m) { - end = end.min(i); - } - } - let head = raw[..end].trim(); - // Unwrap the standing-run charter prefix when present. - if let Some(i) = head.find("Charter:") { - let charter = head[i + "Charter:".len()..].trim(); - let charter = charter.trim_end_matches('.').trim(); - if !charter.is_empty() { - return charter.to_string(); - } - } - // Defense in depth: strip any leaked 2026 loop scaffold so LOOP:/GOAL:/ - // CYCLE/[[…]] control-blobs never reach a title, card, or displayed - // objective. A scaffold's GOAL line IS the human intent — extract it. - strip_loop_scaffold(head) -} - /// Extracts the human intent from a leaked loop scaffold. Loop scaffolds are /// shaped as `LOOP: <pattern>\nGOAL: <intent>\nCYCLE: …\nSUCCESS: …\nSTOP: …\n /// SUB-AGENT INHERITANCE: …`. If a `GOAL:` line is present we return it (the @@ -1486,3688 +70,19 @@ pub(crate) fn looks_scaffolded(text: &str) -> bool { || text.contains("[[") } -/// Conversational lead-ins that mark a string as a prompt rather than a title -/// ("Can you please …", "I need you to …"). Stripped when deriving a title. -const TITLE_LEAD_INS: [&str; 16] = [ - "can you please ", - "could you please ", - "would you please ", - "can you ", - "could you ", - "would you ", - "please ", - "i need you to ", - "i want you to ", - "i'd like you to ", - "i would like you to ", - "i need ", - "i want ", - "help me ", - "let's ", - "lets ", -]; - -/// Strip any leading conversational lead-in(s), case-insensitively. -fn strip_title_lead_in(s: &str) -> &str { - let mut cur = s.trim_start(); - loop { - let lower = cur.to_ascii_lowercase(); - let mut matched = false; - for lead in TITLE_LEAD_INS { - if lower.starts_with(lead) { - cur = cur[lead.len()..].trim_start(); - matched = true; - break; - } - } - if !matched { - return cur; - } - } -} - -/// Shorten a bare URL token to a compact, human label — a GitHub-style -/// `owner/repo`, else the last path segment, else the host — so a title reads -/// "analyse Azure/kars dependabot PRs", not a 60-char URL. -fn shorten_url_token(tok: &str) -> String { - let lower = tok.to_ascii_lowercase(); - if !(lower.starts_with("http://") || lower.starts_with("https://")) { - return tok.to_string(); - } - let rest = tok - .trim_end_matches(['.', ',', ')', ']', '?', '!']) - .split_once("://") - .map(|x| x.1) - .unwrap_or(tok); - let mut parts = rest.split('/'); - let host = parts.next().unwrap_or(""); - let segs: Vec<&str> = parts.filter(|s| !s.is_empty()).collect(); - if host.contains("github.") && segs.len() >= 2 { - format!("{}/{}", segs[0], segs[1]) - } else if let Some(last) = segs.last() { - (*last).to_string() - } else { - host.to_string() - } -} - -/// True when `display` is a genuine human title, not a truncated prompt: it has -/// no conversational lead-in, carries no URL, isn't just a prefix of the -/// objective, and isn't paragraph-length. -fn is_genuine_title(display: &str, clean_objective: &str) -> bool { - let lower = display.to_ascii_lowercase(); - if TITLE_LEAD_INS.iter().any(|l| lower.starts_with(l)) { - return false; - } - if lower.contains("http://") || lower.contains("https://") { - return false; - } - let d_trim = lower.trim_end_matches('…').trim(); - let obj_lower = clean_objective.to_ascii_lowercase(); - if d_trim.len() >= 24 && obj_lower.starts_with(d_trim) { - return false; - } - display.chars().count() <= 72 -} - -/// Derive a compact, title-like phrase from a verbose objective: strip the -/// conversational lead-in, shorten URLs, take the first sentence/clause, drop a -/// trailing " - …" condition tail, cap at a word boundary, and capitalize. -fn concise_title(text: &str) -> String { - let no_lead = strip_title_lead_in(text.trim()); - let shortened: String = no_lead - .split_whitespace() - .map(shorten_url_token) - .collect::<Vec<_>>() - .join(" "); - let first = shortened - .split(['.', '\n', '?', '!']) - .find(|s| !s.trim().is_empty()) - .unwrap_or(&shortened) - .trim(); - // Prompts often append conditions after a dash ("… PRs - categorize the …"). - let first = first.split(" - ").next().unwrap_or(first).trim(); - let capped = if first.chars().count() > 56 { - // Cut at the last word boundary within the cap. - let head: String = first.chars().take(56).collect(); - let cut = head.rfind(' ').unwrap_or(head.len()); - format!("{}…", head[..cut].trim_end()) - } else { - first.to_string() - }; - let mut chars = capped.chars(); - match chars.next() { - Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(), - None => String::new(), - } -} - -/// A clean, human display title for a task. Uses an explicit display name only -/// when it is a GENUINE title (not a conversational prompt truncated into the -/// display slot); otherwise derives a concise title from the cleaned objective. -/// Guarantees LOOP:/GOAL:/[[…]] and raw pasted prompts never reach a card, list -/// row, breadcrumb, or tab — it runs at the read/DTO boundary for every task. -pub(crate) fn clean_display_name(display: &Option<String>, objective: &str) -> Option<String> { - let clean_obj = clean_objective(objective); - if let Some(d) = display.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) - && !looks_scaffolded(d) - && is_genuine_title(d, &clean_obj) +/// Map a `kube::Error` to the right client-facing error. An API rejection with +/// a 4xx status (admission/CEL/validation) is the user's invalid input — a 422 +/// carrying the API server's own message — not a gateway failure. +fn map_kube_err(e: kube::Error) -> AppError { + if let kube::Error::Api(resp) = &e + && (400..500).contains(&resp.code) { - return Some(d.to_string()); - } - // No genuine title — derive a concise one from the objective (or, when the - // objective is empty, from the de-scaffolded display string). - let source = if clean_obj.is_empty() { - strip_loop_scaffold(display.as_deref().unwrap_or("")) - } else { - clean_obj.clone() - }; - let title = concise_title(&source); - if title.is_empty() { None } else { Some(title) } -} - -fn strip_loop_scaffold(text: &str) -> String { - if !looks_scaffolded(text) { - return text.to_string(); - } - // Prefer the GOAL line — that is the human's restated intent. - for line in text.lines() { - let l = line.trim(); - if let Some(rest) = l.strip_prefix("GOAL:") { - let goal = rest - .trim() - .trim_start_matches("[[") - .trim_end_matches("]]") - .trim(); - if !goal.is_empty() { - return goal.to_string(); - } - } - } - // No GOAL line — drop the scaffold control lines and return the remainder. - const CONTROL_PREFIXES: [&str; 6] = [ - "LOOP:", - "CYCLE:", - "SUCCESS:", - "STOP:", - "SUB-AGENT INHERITANCE", - "[", - ]; - let kept: Vec<&str> = text - .lines() - .filter(|l| { - let t = l.trim(); - !t.is_empty() && !CONTROL_PREFIXES.iter().any(|p| t.starts_with(p)) - }) - .collect(); - kept.join(" ").trim().to_string() -} - -fn ready_message(task: &KarsTask) -> Option<String> { - task.status - .as_ref()? - .conditions - .iter() - .find(|c| c.type_ == "Ready") - .and_then(|c| c.message.clone()) -} - -#[allow(clippy::too_many_arguments)] -fn to_detail( - task: &KarsTask, - children: Vec<TaskSummaryDto>, - sub_agents: Vec<SubAgentDto>, - effective: Option<CompositionDto>, - result: Option<MissionResultDto>, - artifacts: Vec<MissionArtifactDto>, - pull_requests: Vec<PullRequestRef>, - activity: Vec<serde_json::Value>, - telemetry: Option<MissionTelemetryDto>, - checkpoint: Option<serde_json::Value>, - agent_identity: Option<crate::kars::cluster::AgentIdentity>, - egress_mode: Option<String>, -) -> TaskDetailDto { - let e = &task.spec.envelope; - let (role_plan, collaboration_events) = structured_team_evidence(&artifacts); - let mut assignment_events = task - .status - .as_ref() - .map(|s| { - s.assignment_events - .iter() - .map(TaskAssignmentEventDto::from) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - canonicalize_assignment_event_roles(&mut assignment_events, &collaboration_events); - TaskDetailDto { - name: task.name_any(), - namespace: task.namespace().unwrap_or_default(), - objective: clean_objective(&task.spec.objective), - display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), - created_at: task - .metadata - .creation_timestamp - .as_ref() - .map(|timestamp| timestamp.0.to_rfc3339()), - envelope: EnvelopeDto { - tier: e.tier, - authority_ceiling: e.authority_ceiling, - delegation_depth: e.delegation_depth, - budget: e.budget.as_ref().map(|b| BudgetDto { - scope: b.scope, - tokens: b.tokens, - usd_micros: b.usd_micros, - }), - tool_policy: e.tool_policy_ref.as_ref().map(|r| r.name.clone()), - egress_allowlist: e.egress_allowlist_ref.as_ref().map(|r| r.name.clone()), - }, - phase: phase_of(task), - envelope_digest: task.status.as_ref().and_then(|s| s.envelope_digest.clone()), - observed_generation: task.status.as_ref().and_then(|s| s.observed_generation), - lineage: task - .status - .as_ref() - .map(|s| s.lineage.clone()) - .unwrap_or_default(), - parent: task.spec.parent_ref.as_ref().map(|r| r.name.clone()), - team: task - .labels() - .get("kars.azure.com/team") - .cloned() - .or_else(|| task.annotations().get("kars.azure.com/team").cloned()), - status_message: ready_message(task), - children, - launched: task - .spec - .execution - .as_ref() - .map(|e| e.launch) - .unwrap_or(false), - execution_phase: task.status.as_ref().and_then(|s| s.execution_phase.clone()), - sandbox: task - .status - .as_ref() - .and_then(|s| s.sandbox_ref.as_ref()) - .map(|r| r.name.clone()), - execution_detail: task - .status - .as_ref() - .and_then(|s| s.execution_detail.clone()), - assignment: task - .status - .as_ref() - .and_then(|s| s.assignment.as_ref()) - .map(TaskAssignmentStatusDto::from), - assignment_events, - assignment_sequence: task.status.as_ref().and_then(|s| s.assignment_sequence), - egress_mode, - composition: effective.or_else(|| { - task.spec.blueprint.as_ref().map(|b| CompositionDto { - runtime: b.runtime.clone(), - model: b.model.as_ref().map(|m| m.deployment.clone()), - instructions: b.instructions.clone(), - tool_policy: b.tool_policy.clone(), - mcp_servers: b.mcp_servers.clone(), - egress: b - .egress - .iter() - .map(|e| match e.port { - Some(p) => format!("{}:{}", e.host, p), - None => e.host.clone(), - }) - .collect(), - isolation: b.isolation.clone(), - memory: b.memory.clone(), - }) - }), - sub_agents, - result, - artifacts, - role_plan, - collaboration_events, - pull_requests, - activity, - telemetry, - checkpoint, - agent_identity, - harness_corrected: task - .annotations() - .get("kars.azure.com/harness-corrected") - .cloned(), - halted: task.annotations().get("kars.azure.com/halted").cloned(), - run_requested: task - .annotations() - .get("kars.azure.com/run-requested") - .is_some_and(|v| !v.trim().is_empty()), - current_run_nonce: task - .annotations() - .get("kars.azure.com/run-requested") - .filter(|value| !value.trim().is_empty()) - .cloned(), + return AppError::Rejected(resp.message.clone()); } + AppError::Upstream(e.to_string()) } /// Resolve the cluster handle or surface a clear "cluster not wired" error. pub(crate) fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { state.cluster().ok_or(AppError::ClusterUnavailable) } - -/// Sanitize a filename to the ConfigMap key form the controller uses (alnum, -/// '-', '_', '.') so the manifest name can look up its stored content. -fn artifact_key(name: &str) -> String { - let k: String = name - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - }) - .collect(); - if k.is_empty() { "artifact".into() } else { k } -} - -/// Best-effort content type from a filename extension, so a downloaded artifact -/// opens sensibly in the browser instead of forcing a save dialog for text. -fn artifact_content_type(name: &str) -> &'static str { - match name - .rsplit('.') - .next() - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("md" | "markdown" | "txt" | "log") => "text/markdown; charset=utf-8", - Some("json") => "application/json; charset=utf-8", - Some("csv") => "text/csv; charset=utf-8", - Some("html" | "htm") => "text/html; charset=utf-8", - Some("yaml" | "yml") => "application/yaml; charset=utf-8", - Some("png") => "image/png", - Some("jpg" | "jpeg") => "image/jpeg", - Some("svg") => "image/svg+xml", - Some("pdf") => "application/pdf", - _ => "application/octet-stream", - } -} - -/// `GET /api/tasks/:ns/:name/artifact/:file` — Bridge-native artifact fetch. -/// Streams one artifact file's bytes (text from `data`, binary from -/// `binaryData`) so operators download deliverables in-product, never via -/// `kubectl`. Inline for previewable types; attachment otherwise. -pub async fn download_artifact( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name, file)): Path<(String, String, String)>, -) -> AppResult<axum::response::Response> { - use axum::http::header; - let cluster = require_cluster(&state)?; - require_owned_task_or_output(cluster, &ns, &name, &principal).await?; - let key = artifact_key(&file); - let (bytes, is_binary) = cluster - .read_mission_artifact_bytes(&name, &key) - .await - .ok_or(AppError::NotFound)?; - let ctype = artifact_content_type(&file); - // Inline-render text/known media; force a download for opaque binaries. - let disposition = if is_binary && ctype == "application/octet-stream" { - format!("attachment; filename=\"{key}\"") - } else { - format!("inline; filename=\"{key}\"") - }; - axum::response::Response::builder() - .header(header::CONTENT_TYPE, ctype) - .header(header::CONTENT_DISPOSITION, disposition) - .header(header::CACHE_CONTROL, "private, max-age=60") - .body(axum::body::Body::from(bytes)) - .map_err(|e| AppError::Upstream(e.to_string())) -} - -/// Merge a mission's artifact manifest (names + sizes, from the output -/// ConfigMap) with the text contents stored in the companion artifacts -/// ConfigMap. Binary artifacts appear in the manifest but carry `content: -/// None`. Returns an empty set honestly when the mission produced no artifacts. -async fn build_artifact_set( - cluster: &crate::kars::cluster::Cluster, - name: &str, - output_data: Option<&std::collections::BTreeMap<String, String>>, -) -> Vec<MissionArtifactDto> { - let manifest_json = output_data.and_then(|d| d.get("artifacts").cloned()); - let contents = cluster - .read_mission_artifacts(name) - .await - .unwrap_or_default(); - - // Prefer the manifest (authoritative order + sizes + binary entries); fall - // back to whatever text artifacts are stored if no manifest is present. - if let Some(mj) = manifest_json - && let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&mj) - { - let mut seen = std::collections::HashSet::new(); - let mut preview_budget = ARTIFACT_PREVIEW_TOTAL_BYTES; - return entries - .into_iter() - .filter_map(|e| { - let fname = e.get("name")?.as_str()?.to_string(); - // The manifest can list the same file twice (e.g. an artifact - // recorded by both the run harness and the harvest step). Keep - // the first — duplicates crash the UI's name-keyed lists. - if !seen.insert(fname.clone()) { - return None; - } - let size_bytes = e.get("size_bytes").and_then(|v| v.as_i64()); - let (content, content_bytes, content_truncated, full_content) = artifact_preview( - contents.get(&artifact_key(&fname)).cloned(), - &mut preview_budget, - ); - Some(MissionArtifactDto { - name: fname, - size_bytes, - content, - content_bytes, - content_truncated, - source_agent: e - .get("source_agent") - .and_then(|v| v.as_str()) - .map(str::to_string), - source_path: e - .get("source_path") - .and_then(|v| v.as_str()) - .map(str::to_string), - digest: e.get("digest").and_then(|v| v.as_str()).map(str::to_string), - full_content, - }) - }) - .collect(); - } - - let mut preview_budget = ARTIFACT_PREVIEW_TOTAL_BYTES; - contents - .into_iter() - .map(|(k, v)| { - let size_bytes = v.len() as i64; - let (content, content_bytes, content_truncated, full_content) = - artifact_preview(Some(v), &mut preview_budget); - MissionArtifactDto { - size_bytes: Some(size_bytes), - name: k, - content, - content_bytes, - content_truncated, - source_agent: None, - source_path: None, - digest: None, - full_content, - } - }) - .collect() -} - -/// `GET /api/namespaces/:ns/tasks` — list tasks in a namespace. -pub async fn list_tasks( - State(state): State<AppState>, - principal: Option<Extension<Principal>>, - Path(ns): Path<String>, -) -> AppResult<Json<Vec<TaskSummaryDto>>> { - let cluster = require_cluster(&state)?; - let principal = principal - .map(|Extension(principal)| principal) - .ok_or_else(|| AppError::Forbidden("signed-in principal required".into()))?; - let api: Api<KarsTask> = cluster.tasks(&ns); - let list = api - .list(&ListParams::default()) - .await - .map_err(map_kube_err)?; - // Cross-reference delivered + failed missions in ONE pass over the persisted - // outputs, so the list can show "Delivered" / "Run failed" instead of - // misreading an idle delivered run — or a hung errored run — as "drafting". - let outputs = cluster.list_mission_outputs().await; - let mut terminal = std::collections::HashMap::<String, &'static str>::new(); - for record in &outputs { - match record.data.get("status").map(String::as_str) { - Some("ok") - if record - .data - .get("output") - .is_some_and(|output| !output.trim().is_empty()) => - { - terminal - .entry(record.task_name.clone()) - .or_insert("delivered"); - } - Some("error") => { - terminal.entry(record.task_name.clone()).or_insert("failed"); - } - _ => {} - } - } - let delivered: std::collections::HashSet<String> = terminal - .iter() - .filter(|(_, status)| **status == "delivered") - .map(|(task, _)| task.clone()) - .collect(); - let failed: std::collections::HashSet<String> = terminal - .iter() - .filter(|(_, status)| **status == "failed") - .map(|(task, _)| task.clone()) - .collect(); - let mut summaries: Vec<TaskSummaryDto> = list - .items - .iter() - .filter(|task| is_task_owner(task, &principal)) - .map(|t| { - let mut s = to_summary(t); - s.delivered = delivered.contains(&s.name); - s.failed = failed.contains(&s.name); - s - }) - .collect(); - - // Persist history: a mission whose KarsTask CR has been garbage-collected - // (retired-run GC) still has its delivered/errored output ConfigMap. Without - // this, completed missions silently vanish from the list mid-session and - // their direct URLs 404 ("data loss", audit BUG-8). Re-add any output-only - // mission that isn't already represented by a live CR. Team-run machinery - // (`<team>-run-<epoch>`) is excluded — those belong to the Team view, which - // is exactly what the live-CR path already hides. - let live_names: std::collections::HashSet<String> = - summaries.iter().map(|s| s.name.clone()).collect(); - for record in &outputs { - let task = &record.task_name; - let d = &record.data; - if d.get("ownerSub").map(String::as_str) != Some(principal.sub.as_str()) { - continue; - } - if live_names.contains(task) || regex_lite_is_team_run(task) { - continue; - } - let is_ok = delivered.contains(task); - let is_err = failed.contains(task); - // Only surface a genuinely terminal output (delivered or errored); skip - // stray/empty outputs so we don't invent phantom missions. - if !is_ok && !is_err { - continue; - } - summaries.push(TaskSummaryDto { - name: task.clone(), - namespace: ns.clone(), - objective: d.get("objective").cloned().unwrap_or_default(), - display_name: d - .get("displayName") - .cloned() - .filter(|s| !s.trim().is_empty()), - created_at: d.get("startedAt").cloned(), - tier: d.get("tier").and_then(|v| v.parse().ok()).unwrap_or(0), - phase: if is_err { - "Failed".into() - } else { - "Delivered".into() - }, - envelope_digest: None, - team: d.get("team").cloned(), - delivered: is_ok, - failed: is_err, - launched: true, - execution_phase: Some("Idle".into()), - }); - } - Ok(Json(summaries)) -} - -/// True when `name` looks like a standing-team run task (`<team>-run-<epoch>`), -/// which the Missions surface intentionally hides (they belong to the Team -/// view). A tiny hand-rolled check to avoid a regex dependency. -fn regex_lite_is_team_run(name: &str) -> bool { - if let Some(idx) = name.rfind("-run-") { - let suffix = &name[idx + "-run-".len()..]; - return !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()); - } - false -} - -/// `GET /api/namespaces/:ns/tasks/:name` — fetch one task, with its delegated -/// children resolved (tasks whose `parentRef` points at this task). -pub async fn get_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<TaskDetailDto>> { - let cluster = require_cluster(&state)?; - let api: Api<KarsTask> = cluster.tasks(&ns); - let task = match api.get_opt(&name).await.map_err(map_kube_err)? { - Some(t) => t, - // The KarsTask CR was garbage-collected (retired-run GC) but the - // mission's terminal output persists. Synthesize a read-only detail from - // it so a delivered/failed mission's page — and the list link that now - // shows it — doesn't 404 mid-session. Genuine unknowns still 404. - None => return synth_detail_from_output(cluster, &ns, &name, &principal).await, - }; - if !is_task_owner(&task, &principal) { - return Err(AppError::NotFound); - } - // Resolve direct children by scanning the namespace for parentRef == name. - // Exclude RUN INSTANCES (cadence/taskforce runs named `*-run-<epoch>` or - // annotated team-role=taskforce): those are run history, not org-chart roles. - // Without this the org chart floods with every historical run of a standing - // team as a duplicate node. Same predicate list_agents uses to identify runs. - let all = api - .list(&ListParams::default()) - .await - .map_err(map_kube_err)?; - let children: Vec<TaskSummaryDto> = all - .items - .iter() - .filter(|t| t.spec.parent_ref.as_ref().is_some_and(|r| r.name == name)) - .filter(|t| { - let is_run = t.name_any().contains("-run-") - || t.annotations() - .get("kars.azure.com/team-role") - .map(String::as_str) - == Some("taskforce"); - !is_run - }) - .map(to_summary) - .collect(); - - // Runtime agents: the sub-agents this mission's agent spawned at run time. - // The inference router labels each spawned KarsSandbox - // `kars.azure.com/parent=<sandbox>`; surface them so the org chart reflects - // the *running* agent/sub-agent tree, not only the governed role tree. - let sandbox_name = task - .status - .as_ref() - .and_then(|s| s.sandbox_ref.as_ref()) - .map(|r| r.name.clone()); - let sub_agents = match &sandbox_name { - Some(sb) => cluster - .sub_agent_sandboxes(&ns, sb) - .await - .iter() - .map(to_sub_agent) - .collect(), - None => Vec::new(), - }; - - // Effective composition: once launched, show what the sandbox is ACTUALLY - // running (read from the materialized InferencePolicy + KarsSandbox, - // including any controller-defaulted model), not just the submitted - // blueprint. Pre-launch, fall back to the blueprint (the planned config). - let effective = match &sandbox_name { - Some(sb) => { - let ip = cluster - .get_kind(&ns, "InferencePolicy", &format!("{name}-inference")) - .await - .ok() - .flatten(); - let sandbox = cluster - .get_kind(&ns, "KarsSandbox", sb) - .await - .ok() - .flatten(); - composition_from_materialized(ip.as_ref(), sandbox.as_ref()) - } - None => None, - }; - - // Live egress enforcement mode (Learn/Strict) read from the materialized - // KarsSandbox — the real monitoring→enforced surface. - let egress_mode = match &sandbox_name { - Some(sb) => cluster.sandbox_egress_mode(sb).await, - None => None, - }; - - // The mission's captured run result (persisted deliverable + real tokens). - let output_data = cluster.read_mission_output(&name).await; - let mut result = output_data.as_ref().and_then(|d| { - let output = deliverable_text(d.get("output")?); - let blocked = classify_blocked(d.get("status").map(String::as_str), &output); - Some(MissionResultDto { - output, - status: d.get("status").cloned(), - model: d.get("model").cloned(), - total_tokens: d.get("totalTokens").and_then(|v| v.parse().ok()), - prompt_tokens: d.get("promptTokens").and_then(|v| v.parse().ok()), - completion_tokens: d.get("completionTokens").and_then(|v| v.parse().ok()), - finished_at: d.get("finishedAt").cloned(), - assignment_nonce: d.get("assignmentNonce").cloned(), - source: d.get("source").cloned(), - blocked, - artifact_persistence: d.get("artifactPersistence").cloned(), - artifact_count: d.get("artifactCount").and_then(|v| v.parse().ok()), - declared_artifact_count: d.get("declaredArtifactCount").and_then(|v| v.parse().ok()), - }) - }); - - // The mission's full artifact set: the manifest (name + size, incl. binary) - // comes from the output ConfigMap; text contents come from the companion - // artifacts ConfigMap. Merge them so the set is complete and honest. - let artifacts = build_artifact_set(cluster, &name, output_data.as_ref()).await; - let successful_result = result.as_ref().is_some_and(|result| { - result.status.as_deref() != Some("error") && result.blocked.is_none() - }); - let checkpoint = select_task_checkpoint( - cluster.read_mission_progress(&name).await, - &artifacts, - successful_result, - ); - - // The mission's live execution activity — the real per-round + per-tool - // trace the agent emitted, persisted by the controller as the clean audit - // record. Parsed from the trace ConfigMap; empty when no trace exists. - let mut activity: Vec<serde_json::Value> = cluster - .read_mission_trace(&name) - .await - .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) - .unwrap_or_default(); - activity.extend(subagent_trace_from_artifacts(&artifacts)); - activity.sort_by(|left, right| { - left.get("ts") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .cmp( - right - .get("ts") - .and_then(serde_json::Value::as_str) - .unwrap_or(""), - ) - }); - - // LIVE fallback. The persisted trace ConfigMap is written only once, at - // delivery — so a still-running mission would otherwise show an EMPTY - // activity trace (blank deploy timeline, agent graph, and map, and a - // "Waiting for the first model round" that lies while the agent is already - // on round 3). When no persisted trace exists yet and the mission is - // launched, pull the SAME live router telemetry the Activity SSE streams — - // the principal sandbox plus every sub-agent it spawned — so the WHOLE - // detail page is genuinely live on each poll, not just the SSE tab. - if activity.is_empty() - && let Some(principal) = &sandbox_name - { - let mut live: Vec<serde_json::Value> = Vec::new(); - for mut ev in cluster.sandbox_live_trace(principal).await { - if let Some(obj) = ev.as_object_mut() { - obj.insert("agent".into(), serde_json::json!(name)); - obj.insert("agentInstance".into(), serde_json::json!(principal)); - obj.insert("agentRole".into(), serde_json::json!("principal")); - } - live.push(ev); - } - let mut descendants = cluster - .sub_agent_sandbox_names(&ns, principal) - .await - .into_iter(); - loop { - let sub_batch = descendants.by_ref().take(8).collect::<Vec<_>>(); - if sub_batch.is_empty() { - break; - } - let mut polling = tokio::task::JoinSet::new(); - for sub in sub_batch { - let cluster = cluster.clone(); - polling.spawn(async move { - let events = cluster.sandbox_live_trace(&sub).await; - (sub, events) - }); - } - while let Some(result) = polling.join_next().await { - let Ok((sub, events)) = result else { - continue; - }; - for mut ev in events { - if let Some(obj) = ev.as_object_mut() { - obj.insert("agent".into(), serde_json::json!(sub.clone())); - obj.insert("agentInstance".into(), serde_json::json!(sub.clone())); - obj.insert("agentRole".into(), serde_json::json!("subagent")); - } - live.push(ev); - } - } - } - activity = live; - } - - // Loop-shape telemetry (rounds, tool calls). Token totals live on `result`. - // Derive rollups from the persisted per-round/per-tool trace when the run's - // output ConfigMap didn't include them — some harnesses persist the trace - // but not the totals, which left a DELIVERED mission's map reading - // "Not run yet" / "No activity". The trace is the honest source either way. - let trace_round_events = activity - .iter() - .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) - .count() as i64; - let trace_tool_events = activity - .iter() - .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("tool")) - .count() as i64; - let trace_total_tokens: i64 = activity - .iter() - .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) - .filter_map(|e| e.get("total_tokens").and_then(serde_json::Value::as_i64)) - .sum(); - - // Backfill the token total on the result from the trace when the output CM - // didn't carry it (so token burn shows on a delivered run with a trace). - merge_trace_total_tokens(&mut result, trace_total_tokens); - - let telemetry = { - let mut rounds = output_data - .as_ref() - .and_then(|d| d.get("rounds").and_then(|v| v.parse::<i64>().ok())); - let mut tool_calls = output_data - .as_ref() - .and_then(|d| d.get("toolCalls").and_then(|v| v.parse::<i64>().ok())); - if trace_round_events > 0 { - rounds = Some(rounds.unwrap_or_default().max(trace_round_events)); - } - if trace_tool_events > 0 { - tool_calls = Some(tool_calls.unwrap_or_default().max(trace_tool_events)); - } - if rounds.is_some() || tool_calls.is_some() { - Some(MissionTelemetryDto { rounds, tool_calls }) - } else { - None - } - }; - - // The running agent's real mesh identity, discovered from the AGT registry - // (harness-neutral). Only meaningful once a sandbox is running. - let agent_identity = match &sandbox_name { - Some(sb) => cluster.discover_agent_identity(sb).await, - None => None, - }; - - // Pull requests the mission opened, extracted from its raw output — a PR is a - // first-class delivery type, surfaced on the Artifacts tab (not just prose). - let pull_requests = output_data - .as_ref() - .map(deliverable_pull_requests) - .unwrap_or_default(); - - Ok(Json(to_detail( - &task, - children, - sub_agents, - effective, - result, - artifacts, - pull_requests, - activity, - telemetry, - checkpoint, - agent_identity, - egress_mode, - ))) -} - -/// `DELETE /api/namespaces/:ns/tasks/:name` — delete a mission and sweep its -/// persisted artifacts (deliverable, files, trace, review), so a deleted mission -/// leaves no orphaned ConfigMaps behind on the Artifacts page or as output-only -/// history. Mirrors the team-delete sweep. Idempotent-ish: 404 for unknowns. -pub async fn delete_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let has_cr = require_owned_task_or_output(cluster, &ns, &name, &principal) - .await? - .is_some(); - if has_cr { - cluster - .delete_task(&ns, &name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - } else { - // CR already gone — just sweep the leftover ConfigMaps. - cluster.sweep_mission_artifacts(&name).await; - } - Ok(Json(serde_json::json!({ - "deleted": true, - "note": "Mission deleted. Its sandbox, deliverable, files, trace, and review record were removed." - }))) -} - -/// Build a read-only mission detail purely from persisted ConfigMaps when the -/// KarsTask CR is gone (retired-run GC). Returns `NotFound` only when there is -/// genuinely no persisted output for the name. The envelope/composition are -/// left empty (the CR that carried them is gone) but the deliverable, artifacts, -/// activity trace, and telemetry — the parts a reviewer actually needs after the -/// fact — are surfaced, along with a terminal phase. -async fn synth_detail_from_output( - cluster: &crate::kars::cluster::Cluster, - ns: &str, - name: &str, - principal: &Principal, -) -> AppResult<Json<TaskDetailDto>> { - let output_data = match cluster.read_mission_output(name).await { - Some(d) => d, - None => return Err(AppError::NotFound), - }; - if output_data.get("ownerSub").map(String::as_str) != Some(principal.sub.as_str()) { - return Err(AppError::NotFound); - } - let assignment_nonce = output_data.get("assignmentNonce").cloned(); - let historical_task = match output_data.get("taskName") { - Some(task_name) => cluster - .tasks(ns) - .get_opt(task_name) - .await - .map_err(map_kube_err)? - .filter(|task| is_task_owner(task, principal)), - None => None, - }; - let mut assignment_events = historical_task - .as_ref() - .and_then(|task| task.status.as_ref()) - .map(|status| { - status - .assignment_events - .iter() - .filter(|event| { - assignment_nonce - .as_deref() - .is_none_or(|nonce| event.task_id == nonce) - }) - .map(TaskAssignmentEventDto::from) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - - let mut activity: Vec<serde_json::Value> = cluster - .read_mission_trace(name) - .await - .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) - .unwrap_or_default(); - let status = output_data.get("status").map(String::as_str); - let mut result = { - let output = deliverable_text(output_data.get("output").map(String::as_str).unwrap_or("")); - let blocked = classify_blocked(output_data.get("status").map(String::as_str), &output); - Some(MissionResultDto { - output, - status: output_data.get("status").cloned(), - model: output_data.get("model").cloned(), - total_tokens: output_data.get("totalTokens").and_then(|v| v.parse().ok()), - prompt_tokens: output_data.get("promptTokens").and_then(|v| v.parse().ok()), - completion_tokens: output_data - .get("completionTokens") - .and_then(|v| v.parse().ok()), - finished_at: output_data.get("finishedAt").cloned(), - assignment_nonce: output_data.get("assignmentNonce").cloned(), - source: output_data.get("source").cloned(), - blocked, - artifact_persistence: output_data.get("artifactPersistence").cloned(), - artifact_count: output_data - .get("artifactCount") - .and_then(|v| v.parse().ok()), - declared_artifact_count: output_data - .get("declaredArtifactCount") - .and_then(|v| v.parse().ok()), - }) - }; - let artifacts = build_artifact_set(cluster, name, Some(&output_data)).await; - let successful_result = result.as_ref().is_some_and(|result| { - result.status.as_deref() != Some("error") && result.blocked.is_none() - }); - let checkpoint = select_task_checkpoint(None, &artifacts, successful_result); - activity.extend(subagent_trace_from_artifacts(&artifacts)); - activity.sort_by(|left, right| { - left.get("ts") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .cmp( - right - .get("ts") - .and_then(serde_json::Value::as_str) - .unwrap_or(""), - ) - }); - let trace_round_events = activity - .iter() - .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("round")) - .count() as i64; - let trace_tool_events = activity - .iter() - .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("tool")) - .count() as i64; - let trace_total_tokens: i64 = activity - .iter() - .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("round")) - .filter_map(|event| { - event - .get("total_tokens") - .and_then(serde_json::Value::as_i64) - }) - .sum(); - merge_trace_total_tokens(&mut result, trace_total_tokens); - - let telemetry = { - let mut rounds = output_data - .get("rounds") - .and_then(|v| v.parse::<i64>().ok()); - let mut tool_calls = output_data - .get("toolCalls") - .and_then(|v| v.parse::<i64>().ok()); - if trace_round_events > 0 { - rounds = Some(rounds.unwrap_or_default().max(trace_round_events)); - } - if trace_tool_events > 0 { - tool_calls = Some(tool_calls.unwrap_or_default().max(trace_tool_events)); - } - if rounds.is_some() || tool_calls.is_some() { - Some(MissionTelemetryDto { rounds, tool_calls }) - } else { - None - } - }; - - let phase = if status == Some("error") { - "Failed" - } else { - "Delivered" - }; - let (role_plan, collaboration_events) = structured_team_evidence(&artifacts); - canonicalize_assignment_event_roles(&mut assignment_events, &collaboration_events); - - Ok(Json(TaskDetailDto { - name: name.to_string(), - namespace: ns.to_string(), - objective: output_data.get("objective").cloned().unwrap_or_default(), - display_name: output_data - .get("displayName") - .cloned() - .filter(|s| !s.trim().is_empty()), - created_at: output_data.get("startedAt").cloned(), - envelope: EnvelopeDto { - tier: output_data.get("tier").and_then(|v| v.parse().ok()).unwrap_or(0), - authority_ceiling: 0, - delegation_depth: 0, - budget: None, - tool_policy: None, - egress_allowlist: None, - }, - phase: phase.to_string(), - envelope_digest: None, - observed_generation: None, - lineage: Vec::new(), - parent: None, - team: output_data.get("team").cloned(), - status_message: Some( - "This run's governance record was retired (garbage-collected); the deliverable and audit trail below are read from the persisted mission output.".to_string(), - ), - children: Vec::new(), - launched: true, - execution_phase: Some("Idle".to_string()), - sandbox: None, - egress_mode: None, - execution_detail: None, - assignment: None, - assignment_events, - assignment_sequence: None, - composition: None, - sub_agents: Vec::new(), - result, - artifacts, - role_plan, - collaboration_events, - pull_requests: deliverable_pull_requests(&output_data), - activity, - telemetry, - checkpoint, - agent_identity: None, - harness_corrected: None, - halted: None, - // This view is reconstructed from a delivered/terminal output, so a run - // was necessarily requested — never auto-kickoff it again. - run_requested: true, - current_run_nonce: assignment_nonce, - })) -} - -#[derive(serde::Serialize)] -pub struct TroubleshootDto { - /// Whether a sandbox pod was found for this run at all. - pub pod_found: bool, - /// Ready containers vs total (e.g. "2/2") when a pod exists. - pub pod_summary: Option<String>, - /// Per-container state (name, ready, restarts, running/waiting/terminated). - pub containers: Vec<crate::kars::cluster::ContainerState>, - /// The tail of the agent container's REAL logs — the ground-truth evidence. - pub agent_log_tail: Vec<String>, - /// The specific log/status lines that matched a known failure signature — - /// the smoking gun, highlighted for the reader. - pub evidence: Vec<String>, - /// Plain-language cause + remedy, derived from the REAL evidence above. - pub cause: String, - pub remedy: String, - /// True when the harness itself is the problem (a chat-gateway on a one-shot - /// mission) — the UI steers the re-compose to OpenClaw. - pub harness_issue: bool, - /// The recorded run status/reason, for cross-reference. - pub result_status: Option<String>, - pub result_reason: Option<String>, -} - -/// `GET /api/namespaces/:ns/tasks/:name/troubleshoot` — actually troubleshoot a -/// run by reading the sandbox pod's real container states + agent logs and -/// diagnosing from that ground truth (not by pattern-matching a status string). -pub async fn troubleshoot_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<TroubleshootDto>> { - let cluster = require_cluster(&state)?; - let task = require_owned_task_or_output(cluster, &ns, &name, &principal).await?; - // Resolve the sandbox: for a mission the sandbox is named after the task. - // Fall back to the task's recorded sandbox reference when present. - let sandbox = task - .and_then(|t| t.status.and_then(|s| s.sandbox_ref).map(|r| r.name)) - .unwrap_or_else(|| name.clone()); - - let logs = cluster - .read_sandbox_logs(&sandbox, "agent", 120) - .await - .unwrap_or_default(); - let containers = cluster.sandbox_container_states(&sandbox).await; - let health = cluster.sandbox_pod_health(&sandbox).await; - let output = cluster.read_mission_output(&name).await; - let result_status = output.as_ref().and_then(|d| d.get("status").cloned()); - let result_reason = output.as_ref().and_then(|d| d.get("output").cloned()); - - let log_lines: Vec<String> = logs.lines().map(|s| s.to_string()).collect(); - let (cause, remedy, harness_issue, evidence) = - diagnose_run_failure(&log_lines, &containers, result_reason.as_deref()); - - // Keep the last ~30 log lines for the "raw evidence" view. - let agent_log_tail: Vec<String> = log_lines.iter().rev().take(30).rev().cloned().collect(); - - Ok(Json(TroubleshootDto { - pod_found: !containers.is_empty() || health.is_some(), - pod_summary: health - .as_ref() - .map(|h| format!("{}/{}", h.ready_containers, h.total_containers)), - containers, - agent_log_tail, - evidence, - cause, - remedy, - harness_issue, - result_status, - result_reason, - })) -} - -/// Diagnose a run failure from REAL evidence: the agent log lines, the container -/// states, and the recorded reason. Returns (cause, remedy, harness_issue, -/// evidence-lines). Signatures are ordered most-specific first. -fn diagnose_run_failure( - log_lines: &[String], - containers: &[crate::kars::cluster::ContainerState], - reason: Option<&str>, -) -> (String, String, bool, Vec<String>) { - let find = |needles: &[&str]| -> Vec<String> { - log_lines - .iter() - .filter(|l| { - let low = l.to_lowercase(); - needles.iter().any(|n| low.contains(&n.to_lowercase())) - }) - .cloned() - .collect::<Vec<_>>() - }; - - // 1. Container-level infrastructure failures (authoritative). - for c in containers { - if let Some(r) = c.reason.as_deref() { - let rl = r.to_lowercase(); - if rl.contains("imagepull") || rl.contains("errimage") { - return ( - format!("The “{}” container can't pull its image ({r}).", c.name), - "This is an infrastructure issue — the image tag is missing or the registry is unreachable. An operator should check the image reference and ACR/registry access.".into(), - false, - vec![format!("container {} is {} ({r})", c.name, c.state)], - ); - } - if rl.contains("crashloop") { - return ( - format!("The “{}” container is crash-looping (restarted {} times).", c.name, c.restarts), - "The container starts and immediately exits. Check the agent logs below for the panic/exit reason; often a bad config, missing secret, or an incompatible image.".into(), - false, - find(&["error", "panic", "fatal", "exited", "traceback"]), - ); - } - if rl.contains("oomkill") { - return ( - format!("The “{}” container was OOM-killed (out of memory).", c.name), - "The run exceeded the sandbox memory limit. Reduce the working set or raise the sandbox resources.".into(), - false, - vec![format!("container {} terminated: OOMKilled", c.name)], - ); - } - } - } - - // 2. Hermes chat-gateway idle — the exact evidence from the entrypoint. - let hermes = find(&[ - "no channels", - "idle daemon mode", - "no messaging platforms enabled", - "gateway in idle", - ]); - if !hermes.is_empty() { - return ( - "The agent is running on the Hermes chat-gateway harness, which started in IDLE DAEMON MODE because no messaging channels are configured. It is waiting for inbound messages (Telegram/Slack/…) and never executes a one-shot autonomous mission — so the run produced nothing and timed out.".into(), - "Re-compose this mission on the OpenClaw harness (built for autonomous missions). Hermes only fits work that is DRIVEN by a chat channel.".into(), - true, - hermes, - ); - } - - // 3. Content safety / auth / rate limit from logs. - let safety = find(&[ - "content safety", - "jailbreak", - "blocked by policy", - "content_filter", - ]); - if !safety.is_empty() { - return ( - "A content-safety policy blocked the run.".into(), - "Adjust the objective to avoid the flagged content, or ask an operator about the content-safety floor.".into(), - false, - safety, - ); - } - let auth = find(&[ - "401 unauthorized", - "403 forbidden", - "authentication failed", - "invalid api key", - ]); - if !auth.is_empty() { - return ( - "The agent's model calls were rejected by the provider (authentication/authorization).".into(), - "An operator should check the router's provider credentials / workload-identity role for this model.".into(), - false, - auth, - ); - } - let rate = find(&["429", "rate limit", "too many requests", "quota"]); - if !rate.is_empty() { - return ( - "The model provider rate-limited or quota-limited the run.".into(), - "Re-run after a short wait, or an operator can raise the model deployment's quota." - .into(), - false, - rate, - ); - } - let schema = find(&[ - "stream_options.include_usage", - "unknown parameter: 'stream_options", - "stream_options: extra inputs", - ]); - if !schema.is_empty() { - return ( - "The selected model rejected the translated inference request before it could reason or call tools.".into(), - "This is a model/router compatibility issue, not an egress or prompt problem. Deploy the corrected inference router, then re-run the same mission; selecting another catalogue model is only a temporary workaround.".into(), - false, - schema, - ); - } - - // 4. Fall back to the recorded reason. - let rl = reason.unwrap_or("").to_lowercase(); - if rl.contains("did not come online") - || rl.contains("not yet discoverable") - || rl.contains("mesh registry") - { - return ( - "The agent never registered on the encrypted mesh within the startup window, so the controller timed the run out.".into(), - "Re-run it — a fresh sandbox often comes up cleanly. If it repeats, check the agent logs below and the sandbox events.".into(), - false, - find(&["mesh", "relay", "register", "keepalive"]), - ); - } - if rl.contains("no progress heartbeat") || rl.contains("timed out") || rl.contains("timeout") { - return ( - "The agent started but stopped making progress, so the controller timed the run out." - .into(), - "Re-run it; if it stalls again, narrow the objective or raise the token/time budget." - .into(), - false, - find(&["error", "timeout", "stalled"]), - ); - } - - ( - "The run ended without producing a deliverable. See the agent's own logs below for the specifics.".into(), - "Re-run it, or re-compose with a different harness/model. If the logs show a repeating error, address that first.".into(), - false, - find(&["error", "panic", "fatal", "exception"]), - ) -} - -/// Build the effective composition from the materialized InferencePolicy + -/// KarsSandbox — the real running config, including controller-defaulted fields. -fn composition_from_materialized( - ip: Option<&kube::core::DynamicObject>, - sandbox: Option<&kube::core::DynamicObject>, -) -> Option<CompositionDto> { - let sb = sandbox?; - let spec = sb.data.get("spec")?; - let model = ip.and_then(|p| { - let prim = p.data.get("spec")?.get("modelPreference")?.get("primary")?; - let dep = prim.get("deployment")?.as_str()?; - // The deployment string identifies the model; the inference provider is - // a single cluster-level fact (see Options.provider), not a per-model - // tag — so we do NOT append a guessed provider here. - Some(dep.to_string()) - }); - let runtime = spec - .get("runtime") - .and_then(|r| r.get("kind")) - .and_then(|k| k.as_str()) - .map(|s| s.to_string()); - let isolation = spec - .get("sandbox") - .and_then(|s| s.get("isolation")) - .and_then(|i| i.as_str()) - .map(|s| s.to_string()); - let instructions = spec - .get("agent") - .and_then(|a| a.get("instructions")) - .and_then(|i| i.as_str()) - .map(|s| s.to_string()); - let gov = spec.get("governance"); - let tool_policy = gov - .and_then(|g| g.get("toolPolicyRef")) - .and_then(|r| r.get("name")) - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - let mcp_servers = gov - .and_then(|g| g.get("mcpServerRefs")) - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|x| { - x.get("name") - .and_then(|n| n.as_str()) - .map(|s| s.to_string()) - }) - .collect() - }) - .unwrap_or_default(); - let egress = spec - .get("networkPolicy") - .and_then(|n| n.get("allowedEndpoints")) - .and_then(|a| a.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|e| { - let host = e.get("host")?.as_str()?; - Some(match e.get("port").and_then(|p| p.as_i64()) { - Some(p) => format!("{host}:{p}"), - None => host.to_string(), - }) - }) - .collect() - }) - .unwrap_or_default(); - let memory = spec - .get("memoryRef") - .and_then(|m| m.get("name")) - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - Some(CompositionDto { - runtime, - model, - instructions, - tool_policy, - mcp_servers, - egress, - isolation, - memory, - }) -} - -/// A sub-agent the mission's agent spawned at run time (a labelled KarsSandbox). -#[derive(Debug, Serialize)] -pub struct SubAgentDto { - pub name: String, - pub namespace: String, - pub phase: Option<String>, - pub runtime: Option<String>, - pub role: Option<String>, - pub parent: Option<String>, - pub logical_agent_id: Option<String>, - pub model: Option<String>, -} - -fn to_sub_agent(o: &kube::core::DynamicObject) -> SubAgentDto { - let spec = o.data.get("spec"); - let status = o.data.get("status"); - SubAgentDto { - name: o.metadata.name.clone().unwrap_or_default(), - namespace: o.metadata.namespace.clone().unwrap_or_default(), - phase: status - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .map(|s| s.to_string()), - runtime: spec - .and_then(|s| s.get("runtime")) - .and_then(|r| r.get("kind").or(Some(r))) - .and_then(|k| k.as_str()) - .map(|s| s.to_string()), - role: o.labels().get("kars.azure.com/role").cloned(), - parent: o.labels().get("kars.azure.com/parent").cloned(), - logical_agent_id: o - .annotations() - .get("kars.azure.com/logical-agent-id") - .cloned(), - model: o.annotations().get("kars.azure.com/model").cloned(), - } -} - -fn validate_mission_fallback_route( - options: &crate::routes::options::Options, - blueprint: &BlueprintDto, - runtime: &str, - model: &ModelDto, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, - total_tokens: Option<i64>, -) -> AppResult<()> { - if !options - .models - .iter() - .any(|option| option.provider == model.provider && option.deployment == model.deployment) - { - return Err(AppError::BadRequest(format!( - "fallback model route `{}::{}` is not present in the live model catalogue", - model.provider, model.deployment - ))); - } - match crate::routes::options::route_qualification( - runtime, - &model.provider, - &model.deployment, - required_capabilities, - max_parallel, - total_tokens, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "fallback route `{runtime} · {}::{}` lacks atomic qualification for capabilities: {}", - model.provider, - model.deployment, - required_capabilities - .iter() - .cloned() - .collect::<Vec<_>>() - .join(", ") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "route qualification configuration error: {error}" - ))); - } - } - let route = crate::routes::options::route_label(runtime, &model.provider, &model.deployment); - for server in &blueprint.mcp_servers { - let option = options - .mcp_servers - .iter() - .find(|option| option.name == *server) - .ok_or_else(|| { - AppError::BadRequest(format!( - "MCP server `{server}` is not present in the live options catalogue" - )) - })?; - if !crate::routes::options::mcp_server_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) - .map_err(|error| { - AppError::Upstream(format!( - "resource qualification configuration error: {error}" - )) - })? { - return Err(AppError::BadRequest(format!( - "MCP server `{server}` lacks current resource qualification for fallback {route}" - ))); - } - } - if let Some(memory) = blueprint - .memory - .as_deref() - .filter(|memory| !memory.is_empty()) - { - let option = options - .memories - .iter() - .find(|option| option.name == memory) - .ok_or_else(|| { - AppError::BadRequest(format!( - "memory `{memory}` is not present in the live options catalogue" - )) - })?; - if !crate::routes::options::memory_binding_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) - .map_err(|error| { - AppError::Upstream(format!( - "resource qualification configuration error: {error}" - )) - })? { - return Err(AppError::BadRequest(format!( - "memory `{memory}` lacks current resource qualification for fallback {route}" - ))); - } - } - for skill in &blueprint.skills { - let option = options - .skills - .iter() - .find(|option| option.name == *skill) - .ok_or_else(|| { - AppError::BadRequest(format!( - "skill `{skill}` is not present in the approved live catalogue" - )) - })?; - if !crate::routes::options::skill_version_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) - .map_err(|error| { - AppError::Upstream(format!( - "resource qualification configuration error: {error}" - )) - })? { - return Err(AppError::BadRequest(format!( - "skill `{skill}` lacks current version qualification for fallback {route}" - ))); - } - } - Ok(()) -} - -/// `POST /api/namespaces/:ns/tasks` — create a task. -/// -/// The BFF never sets status — it submits the spec and lets the controller -/// validate the envelope and stamp the digest. Admission (CEL) rejects an -/// amplifying envelope here, which we surface as a 422-style upstream error. -pub async fn create_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path(ns): Path<String>, - Json(mut req): Json<CreateTaskRequest>, -) -> AppResult<Json<TaskDetailDto>> { - let cluster = require_cluster(&state)?; - cluster.credential_grant(&ns).await.map_err(map_kube_err)?; - let api: Api<KarsTask> = cluster.tasks(&ns); - // The caller cannot choose attribution; it is derived from the verified - // Bridge session inserted by auth middleware. - req.created_by = Some(principal.name.clone()); - let created_by = principal.name.clone(); - if let Some(plan) = req - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.execution_plan.as_ref()) - { - crate::routes::compose::validate_execution_plan(plan).map_err(AppError::BadRequest)?; - req.envelope.delegation_depth = 1; - } else if let Some(delegation) = req.delegation.as_ref() { - crate::routes::compose::validate_delegation(delegation).map_err(AppError::BadRequest)?; - req.envelope.delegation_depth = i32::from(delegation.mode == "principal-specialists"); - } - let mut harness_correction: Option<String> = None; - if let Some(blueprint) = req.blueprint.as_mut() - && let Some(runtime) = blueprint.runtime.as_deref() - && crate::routes::compose::is_non_autonomous_harness(runtime) - { - harness_correction = Some(format!( - "harness {runtime} is a bootstrap-only adapter (no autonomous task loop) and cannot run a one-shot mission; corrected to OpenClaw" - )); - blueprint.runtime = Some("OpenClaw".to_string()); - } - let git_write = crate::routes::github::authorize_git_write( - cluster, - &ns, - &principal, - req.git_write_repos.as_deref(), - ) - .await?; - if req.blueprint.as_ref().is_some_and(|blueprint| { - blueprint.runtime.as_deref().unwrap_or("OpenClaw") != "OpenClaw" - && !blueprint.skills.is_empty() - }) { - return Err(AppError::BadRequest( - "controller-mounted file skills are currently supported only by OpenClaw".into(), - )); - } - if req - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.tool_policy.as_deref()) - == Some("kars-team-member") - { - return Err(AppError::BadRequest( - "kars-team-member is reserved for declared standing-team specialists".into(), - )); - } - if let Some(blueprint) = req.blueprint.as_mut() { - if blueprint.model_fallbacks.len() > 8 { - return Err(AppError::BadRequest( - "model_fallbacks may contain at most 8 routes".into(), - )); - } - if blueprint.model.is_none() { - let options = crate::routes::options::build_options(cluster).await?; - blueprint.model = options - .models - .iter() - .find(|model| model.is_default) - .or_else(|| options.models.first()) - .map(|model| ModelDto { - provider: model.provider.clone(), - deployment: model.deployment.clone(), - }); - } - } - if let Some(blueprint) = req.blueprint.as_ref() - && let Some(model) = blueprint.model.as_ref() - { - let options = crate::routes::options::build_options(cluster).await?; - let served = options.models.iter().any(|option| { - option.provider == model.provider && option.deployment == model.deployment - }); - if !served { - return Err(AppError::BadRequest(format!( - "model route `{}::{}` is not present in the live model catalogue", - model.provider, model.deployment - ))); - } - let runtime = blueprint.runtime.as_deref().unwrap_or("OpenClaw"); - if !cluster.runnable_runtimes().await.contains(runtime) { - return Err(AppError::BadRequest(format!( - "runtime `{runtime}` cannot start on this cluster" - ))); - } - let (required_capabilities, max_parallel) = - crate::routes::validate::qualification_requirements(blueprint, None); - let total_tokens = req - .envelope - .budget - .as_ref() - .and_then(|budget| budget.tokens); - match crate::routes::options::route_qualification( - runtime, - &model.provider, - &model.deployment, - &required_capabilities, - max_parallel, - total_tokens, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "runtime/model route `{runtime} · {}::{}` lacks qualification evidence for capabilities: {}", - model.provider, - model.deployment, - required_capabilities - .iter() - .cloned() - .collect::<Vec<_>>() - .join(", ") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "route qualification configuration error: {error}" - ))); - } - } - fn find_resource<'a>( - items: &'a [crate::routes::options::RefOption], - name: &str, - ) -> Option<&'a crate::routes::options::RefOption> { - items.iter().find(|option| option.name == name) - } - for server in &blueprint.mcp_servers { - let Some(option) = find_resource(&options.mcp_servers, server) else { - return Err(AppError::BadRequest(format!( - "MCP server `{server}` is not present in the live options catalogue" - ))); - }; - match crate::routes::options::mcp_server_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "MCP server `{server}` lacks retained resource qualification for {} at current schema {}", - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ), - option.tool_schema_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - if let Some(memory) = blueprint - .memory - .as_deref() - .filter(|memory| !memory.is_empty()) - { - let Some(option) = find_resource(&options.memories, memory) else { - return Err(AppError::BadRequest(format!( - "memory `{memory}` is not present in the live options catalogue" - ))); - }; - match crate::routes::options::memory_binding_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "memory `{memory}` lacks retained resource qualification for {} at backend {} / compiled digest {}", - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ), - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing"), - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - for skill in &blueprint.skills { - let Some(option) = find_resource(&options.skills, skill) else { - return Err(AppError::BadRequest(format!( - "skill `{skill}` is not present in the approved live catalogue" - ))); - }; - match crate::routes::options::skill_version_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "skill `{skill}` lacks retained resource qualification for {} at version digest {}", - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ), - option.version_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - let mut seen = std::collections::BTreeSet::new(); - for fallback in &blueprint.model_fallbacks { - let key = format!("{}::{}", fallback.provider, fallback.deployment); - if key == format!("{}::{}", model.provider, model.deployment) || !seen.insert(key) { - continue; - } - validate_mission_fallback_route( - &options, - blueprint, - runtime, - fallback, - &required_capabilities, - max_parallel, - total_tokens, - )?; - } - } - - // Aggregate inference-budget gate (cluster + workspace + user). A launched - // mission consumes inference tokens, so a strict/over-buffer budget at any - // tier blocks starting new work. Draft (unlaunched) missions don't run yet, - // so they pass — the gate re-applies when they run. - if req.launch { - crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; - } - - // Default the tool policy to `kars-default` when neither the request envelope - // nor the blueprint pins one. This is not cosmetic: the AGT mesh transport the - // run's delivery rides on requires a mounted ToolPolicy. With governance OFF - // the sandbox mounts no policy, the AGT engine fails closed, and the agent can - // never send its `task_response` back to the controller — the run streams live - // but NEVER delivers (no output ConfigMap, endless re-dispatch). Every bridge - // mission must be governed; `kars-default` is the cluster's baseline policy. - // An explicit blueprint tool policy still wins (governance_spec prefers it), so - // we only inject the default when the blueprint carries none. - let blueprint_has_tool_policy = req - .blueprint - .as_ref() - .and_then(|b| b.tool_policy.as_ref()) - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - let tool_policy_ref = req - .envelope - .tool_policy - .clone() - .filter(|s| !s.is_empty()) - .or_else(|| (!blueprint_has_tool_policy).then(|| "kars-default".to_string())) - .map(|name| LocalObjectRef { name }); - - // ── Hard capability match, defense-in-depth ───────────────────────────── - // A direct mission is one-shot autonomous; a bootstrap-only adapter has no - // task-execution loop and delivers nothing. The compose flow already - // corrects this, but a manually-edited package could still name one — so - // enforce it again at creation: rewrite the harness to OpenClaw and record - // the correction as a governance annotation on the task so it survives into - // the run and the receipt/decision view. (Hermes/BYO are autonomous — kept.) - let mut blueprint = req.blueprint.map(BlueprintDto::into_crd); - if let Some((git_write, binding)) = git_write { - let blueprint = blueprint.get_or_insert_with(Default::default); - blueprint.git_write = Some(git_write); - blueprint.github_binding = Some(binding); - } - let spec = KarsTaskSpec { - objective: req.objective, - display_name: req.display_name, - execution: req.launch.then_some(crate::kars::task::TaskExecution { - launch: true, - runtime: None, - }), - blueprint, - parent_ref: req - .parent - .filter(|s| !s.is_empty()) - .map(|name| LocalObjectRef { name }), - envelope: TaskEnvelope { - tier: req.envelope.tier, - authority_ceiling: req.envelope.authority_ceiling, - delegation_depth: req.envelope.delegation_depth, - budget: req.envelope.budget.map(|b| TaskBudget { - scope: b.scope, - tokens: b.tokens, - usd_micros: b.usd_micros, - }), - tool_policy_ref, - egress_allowlist_ref: req - .envelope - .egress_allowlist - .filter(|s| !s.is_empty()) - .map(|name| LocalObjectRef { name }), - }, - retention_ttl_seconds: req.retention_ttl_seconds, - }; - let mut task = KarsTask::new(&req.name, spec); - if let Some(plan) = task - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.execution_plan.as_ref()) - { - let total_tokens = task - .spec - .envelope - .budget - .as_ref() - .and_then(|budget| budget.tokens) - .ok_or_else(|| { - AppError::BadRequest( - "execution-plan missions require an explicit total token budget".into(), - ) - })?; - let (principal_tokens, child_tokens) = - crate::routes::compose::delegation_budget_allocation(total_tokens, plan.roles.len()) - .map_err(AppError::BadRequest)?; - let annotations = task - .metadata - .annotations - .get_or_insert_with(Default::default); - annotations.insert( - "kars.azure.com/mission-budget-total".into(), - total_tokens.to_string(), - ); - annotations.insert( - "kars.azure.com/mission-principal-budget".into(), - principal_tokens.to_string(), - ); - annotations.insert( - "kars.azure.com/mission-child-budget".into(), - child_tokens.to_string(), - ); - annotations.insert( - "kars.azure.com/mission-specialist-count".into(), - plan.roles.len().to_string(), - ); - annotations.insert( - "kars.azure.com/mission-decomposition".into(), - "execution-plan/v1".into(), - ); - } - // Record the capability correction on the task so it's durable and surfaces in - // the governed record (the run reads task annotations; the receipt/decision - // view can attest the harness was corrected rather than silently swapped). - if let Some(reason) = &harness_correction { - task.metadata - .annotations - .get_or_insert_with(Default::default) - .insert( - "kars.azure.com/harness-corrected".to_string(), - reason.clone(), - ); - } - // Stamp the creator for per-user budget attribution. - task.metadata - .annotations - .get_or_insert_with(Default::default) - .insert("kars.azure.com/created-by".to_string(), created_by.clone()); - let annotations = task - .metadata - .annotations - .get_or_insert_with(Default::default); - annotations.insert( - "kars.azure.com/owner-sub".to_string(), - principal.sub.clone(), - ); - annotations.insert( - "kars.azure.com/owner-name".to_string(), - principal.name.clone(), - ); - let launch = task - .spec - .execution - .as_ref() - .is_some_and(|execution| execution.launch); - if let Some(execution) = task.spec.execution.as_mut() { - execution.launch = false; - } - let created = api - .create(&PostParams::default(), &task) - .await - .map_err(map_kube_err)?; - cluster - .finish_created_credentials( - &crate::kars::credentials::Target { - kind: "KarsTask".into(), - namespace: ns.clone(), - name: created.name_any(), - uid: created - .uid() - .ok_or_else(|| AppError::Upstream("Task CREATE omitted UID".into()))?, - }, - launch, - ) - .await - .map_err(map_kube_err)?; - let created = api.get(&created.name_any()).await.map_err(map_kube_err)?; - Ok(Json(to_detail( - &created, - Vec::new(), - Vec::new(), - None, - None, - Vec::new(), - Vec::new(), - Vec::new(), - None, - None, - None, - None, - ))) -} - -/// Per-mission promote request body. -#[derive(Debug, Deserialize)] -pub struct PromoteMissionRequest { - pub tier: i32, -} - -/// `POST /api/namespaces/:ns/tasks/:name/promote` — request a per-mission tier -/// promotion (§12). Patches `spec.requestedTier`; the controller opens a human -/// `KarsApproval` and widens the envelope only once approved. The BFF never -/// widens an envelope directly. -pub async fn promote_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(body): Json<PromoteMissionRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - require_owned_task(cluster, &ns, &name, &principal).await?; - if !(1..=5).contains(&body.tier) { - return Err(AppError::BadRequest("tier must be in 1..5".into())); - } - let api: Api<KarsTask> = cluster.tasks(&ns); - let patch = serde_json::json!({ "spec": { "requestedTier": body.tier } }); - api.patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(patch), - ) - .await - .map_err(map_kube_err)?; - Ok(Json(serde_json::json!({ - "requested": true, - "tier": body.tier, - "note": "A human approval has been opened. This mission is promoted only once it is approved." - }))) -} - -/// Governed emergency-stop request. -#[derive(Debug, Deserialize)] -pub struct HaltRequest { - /// Why the operator is halting — recorded on the governed decision so the - /// stop is attestable ("who halted this, when, and why"), not anonymous. - pub reason: Option<String>, -} - -/// `POST /api/namespaces/:ns/tasks/:name/halt` — governed emergency-stop. -/// -/// A one-click halt that STOPS a running mission/agent without destroying its -/// record: it flips `spec.execution.launch` to false (the controller's teardown -/// reconcile then deletes the sandbox + InferencePolicy, so the agent is removed -/// from the mesh and can no longer receive or answer delegated work) and stamps -/// a governed decision annotation (`kars.azure.com/halted` = operator/reason/at) -/// so the halt itself is a durable, attestable record. The deliverable, trace, -/// and receipt remain — unlike DELETE, which removes everything. No major agent -/// platform ships a governed kill; kars can, because it owns the K8s control -/// plane (to stop) and the governance record (to attest). -pub async fn halt_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(body): Json<HaltRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let api: Api<KarsTask> = cluster.tasks(&ns); - require_owned_task(cluster, &ns, &name, &principal).await?; - let reason = body - .reason - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("operator emergency-stop"); - let at = chrono::Utc::now().to_rfc3339(); - let decision = format!("halted by operator at {at}: {reason}"); - // Un-launch (controller tears down the running sandbox) AND record the - // governed decision atomically in one merge patch. - let patch = serde_json::json!({ - "metadata": { "annotations": { "kars.azure.com/halted": decision } }, - "spec": { "execution": { "launch": false } }, - }); - api.patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(patch), - ) - .await - .map_err(map_kube_err)?; - Ok(Json(serde_json::json!({ - "halted": true, - "at": at, - "reason": reason, - "note": "The agent's sandbox is being torn down; the mission record, deliverable, and audit trail are retained. The halt is recorded as a governed decision.", - }))) -} - -/// Replicate request — how many identical runs to launch for reliability (pass^k). -#[derive(Debug, Deserialize)] -pub struct ReplicateRequest { - /// Number of additional identical runs to create (2–5). Each becomes a - /// distinct KarsTask sharing this task's exact objective + envelope, so the - /// efficiency frontier can compute pass^k reliability across them. - pub count: u32, - /// When true, each clone is launched immediately; when false, they are - /// created as ready-to-run packages the caller launches. Default true. - #[serde(default = "default_true")] - pub launch: bool, -} - -fn default_true() -> bool { - true -} - -/// `POST /api/namespaces/:ns/tasks/:name/replicate` — the pass^k runner. -/// -/// Clones a mission's EXACT package (objective + envelope + blueprint) into -/// `count` distinct sibling tasks so they run independently and the efficiency -/// engine can measure pass^k reliability (fraction of the repeated package -/// accepted on EVERY attempt). Honest: this creates real, governed runs — the -/// same package, nothing weakened — not a simulated repeat. -pub async fn replicate_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(req): Json<ReplicateRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let count = req.count.clamp(1, 5); - let api: Api<KarsTask> = cluster.tasks(&ns); - let source = require_owned_task(cluster, &ns, &name, &principal).await?; - if source - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.credential_bindings.as_ref()) - .is_some_and(|bindings| { - bindings - .sources - .iter() - .any(|source| source.scope != "workspace") - }) - { - return Err(AppError::BadRequest("Independent replicas cannot inherit another target's credential UID; use an approved workspace source or stage per-replica credentials".into())); - } - - // A short suffix keyed off the current time keeps clone names unique across - // repeated replicate calls (so a second batch doesn't collide with a first). - let batch = chrono::Utc::now().timestamp() % 100000; - let mut created: Vec<String> = Vec::new(); - for i in 1..=count { - let clone_name = format!("{name}-rep-{batch}-{i}"); - let mut spec = source.spec.clone(); - // Force the execution gate to the requested launch state; strip parent - // linkage so each clone is an independent, top-level run. - spec.execution = Some(crate::kars::task::TaskExecution { - launch: false, - runtime: None, - }); - spec.parent_ref = None; - let mut task = KarsTask::new(&clone_name, spec); - // Label the batch so the UI can group a reliability cohort together. - task.metadata - .labels - .get_or_insert_with(Default::default) - .insert("kars.azure.com/reliability-of".into(), name.clone()); - let annotations = task - .metadata - .annotations - .get_or_insert_with(Default::default); - annotations.insert("kars.azure.com/owner-sub".into(), principal.sub.clone()); - annotations.insert("kars.azure.com/owner-name".into(), principal.name.clone()); - let captured = api - .create(&PostParams::default(), &task) - .await - .map_err(map_kube_err)?; - cluster - .finish_created_credentials( - &crate::kars::credentials::Target { - kind: "KarsTask".into(), - namespace: ns.clone(), - name: captured.name_any(), - uid: captured - .uid() - .ok_or_else(|| AppError::Upstream("Replica CREATE omitted UID".into()))?, - }, - req.launch, - ) - .await - .map_err(map_kube_err)?; - created.push(clone_name); - } - - Ok(Json(serde_json::json!({ - "replicated": name, - "count": created.len(), - "runs": created, - "note": format!("{} identical runs created — pass^{} reliability will appear on the efficiency frontier once they complete and are reviewed.", created.len(), created.len() + 1), - }))) -} - -/// Launch/un-launch request body. -#[derive(Debug, Deserialize)] -pub struct LaunchRequest { - pub launch: bool, -} - -/// `POST /api/namespaces/:ns/tasks/:name/launch` — flip the execution gate. -/// -/// The §20 launch action: setting `launch: true` asks the controller to -/// materialize a governed sandbox; `false` tears it down. The BFF only patches -/// the spec — the controller does the materialization and reports status. -pub async fn launch_task( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(req): Json<LaunchRequest>, -) -> AppResult<Json<TaskDetailDto>> { - let cluster = require_cluster(&state)?; - require_owned_task(cluster, &ns, &name, &principal).await?; - let api: Api<KarsTask> = cluster.tasks(&ns); - let patch = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsTask", - "spec": { "execution": { "launch": req.launch } }, - }); - let patched = api - .patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(&patch), - ) - .await - .map_err(map_kube_err)?; - Ok(Json(to_detail( - &patched, - Vec::new(), - Vec::new(), - None, - None, - Vec::new(), - Vec::new(), - Vec::new(), - None, - None, - None, - None, - ))) -} - -#[derive(Debug, Deserialize)] -pub struct IncreaseTaskBudgetRequest { - pub daily_tokens: i64, -} - -/// Request an owned Mission's token-budget increase. Bridge never widens the -/// trust envelope directly; the controller opens a typed human approval and is -/// the sole writer of the new ceiling after approval. -pub async fn increase_task_budget( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(req): Json<IncreaseTaskBudgetRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let task = require_owned_task(cluster, &ns, &name, &principal).await?; - let current = task - .spec - .envelope - .budget - .as_ref() - .and_then(|budget| budget.tokens) - .unwrap_or(0); - if req.daily_tokens <= current { - return Err(AppError::BadRequest(format!( - "new daily token budget must be greater than the current {current}" - ))); - } - - let tasks: Api<KarsTask> = cluster.tasks(&ns); - let request_id = chrono::Utc::now().timestamp_micros().to_string(); - let patched = tasks - .patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(&serde_json::json!({ - "metadata": { - "annotations": { - "kars.azure.com/requested-by": principal.name, - "kars.azure.com/requested-by-sub": principal.sub, - "kars.azure.com/budget-request-id": request_id - } - }, - "spec": { - "requestedBudgetTokens": req.daily_tokens - } - })), - ) - .await - .map_err(map_kube_err)?; - - Ok(Json(serde_json::json!({ - "requested": true, - "name": name, - "budget_tokens": req.daily_tokens, - "resource_version": patched.metadata.resource_version, - "note": "A typed human approval is being opened. The controller widens the budget only after approval." - }))) -} - -/// Body for a temporary egress request from a mission: the agent (or operator -/// on its behalf) asks to reach an extra website. Materialized as an -/// `EgressApproval` the controller reconciles through human approval — the BFF -/// never widens the sandbox's allowlist directly. -#[derive(Debug, Deserialize)] -pub struct EgressRequest { - pub host: String, - pub port: Option<u16>, - pub reason: String, - /// Time-to-live, e.g. "2h". Bounded by the cluster ceiling. Default "2h". - pub ttl: Option<String>, -} - -/// Normalize a human-friendly TTL (`"2h"`, `"30m"`, `"24h"`, `"1d"`, `"90s"`) to -/// the ISO-8601 duration the controller's `EgressApproval` reconciler requires -/// (`"PT2H"`, `"PT30M"`, `"P1D"`, `"PT90S"`). An already-ISO value (starts with -/// `P`) passes through uppercased. Unrecognized input falls back to `"PT2H"` -/// rather than emitting an invalid TTL that leaves the grant Pending forever. -fn normalize_ttl(raw: &str) -> String { - let t = raw.trim(); - if t.is_empty() { - return "PT2H".into(); - } - if t.starts_with('P') || t.starts_with('p') { - return t.to_ascii_uppercase(); - } - let split = t.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(t.len()); - let (num, unit) = t.split_at(split); - let n: u64 = num.trim().parse().unwrap_or(0); - if n == 0 { - return "PT2H".into(); - } - match unit.trim().to_ascii_lowercase().as_str() { - "s" | "sec" | "secs" => format!("PT{n}S"), - "m" | "min" | "mins" => format!("PT{n}M"), - "h" | "hr" | "hrs" | "hour" | "hours" => format!("PT{n}H"), - "d" | "day" | "days" => format!("P{n}D"), - _ => "PT2H".into(), - } -} - -/// `POST /api/namespaces/:ns/tasks/:name/egress` — file a temporary egress -/// grant request for this mission's sandbox. Returns the created EgressApproval -/// name; it widens nothing until a human approves it. -pub async fn request_egress( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(req): Json<EgressRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let host = req.host.trim().to_string(); - if host.is_empty() { - return Err(AppError::BadRequest("host is required".into())); - } - if req.reason.trim().len() < 3 { - return Err(AppError::BadRequest("reason is required".into())); - } - let task = require_owned_task(cluster, &ns, &name, &principal).await?; - task.status - .as_ref() - .and_then(|s| s.sandbox_ref.as_ref()) - .ok_or_else(|| AppError::BadRequest("mission has no running sandbox to widen".into()))?; - let port = req.port.unwrap_or(443); - let ttl = normalize_ttl(req.ttl.as_deref().unwrap_or("2h")); - use sha2::{Digest, Sha256}; - let suffix = hex::encode(Sha256::digest(format!("{host}:{port}").as_bytes())); - let approval_name = format!("{name}-eg-{}", &suffix[..12]); - let task_uid = task - .metadata - .uid - .clone() - .ok_or_else(|| AppError::Upstream("task has no Kubernetes UID".into()))?; - let body = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsApproval", - "metadata": { - "name": approval_name, - "namespace": ns, - "ownerReferences": [{ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsTask", - "name": name, - "uid": task_uid, - "controller": true, - "blockOwnerDeletion": true - }], - "labels": { - "kars.azure.com/req-task": name, - "kars.azure.com/req-kind": "egress" - }, - "annotations": { - "kars.azure.com/req-kind": "egress", - "kars.azure.com/req-target": host, - "kars.azure.com/req-port": port.to_string(), - "kars.azure.com/req-ttl": ttl, - "kars.azure.com/requested-by": principal.name, - "kars.azure.com/requested-by-sub": principal.sub, - "kars.azure.com/owner-sub": principal.sub, - "kars.azure.com/owner-name": principal.name - } - }, - "spec": { - "taskRef": {"name": name}, - "requestedBy": { - "subject": principal.sub, - "name": principal.name - }, - "action": { - "kind": "egress", - "summary": format!("Allow the mission to reach {host}:{port}"), - "detail": format!( - "{} Approving creates an exact, time-boxed {host}:{port} grant.", - req.reason.trim() - ) - }, - "ttl": "PT24H" - }, - }); - let created = cluster - .apply_kind(&ns, "KarsApproval", body, false) - .await - .map_err(map_kube_err)?; - Ok(Json(serde_json::json!({ - "requested": true, - "name": created.metadata.name, - "note": "Pending human approval. No egress is granted until a distinct operator approves it in the Bridge inbox." - }))) -} - -/// `GET /api/namespaces/:ns/tasks/:name/egress/learned` — the domains the agent -/// has actually reached, observed by the router in Learn mode. This is the -/// evidence a customer reviews before promoting the mission to enforced. -pub async fn get_learned_egress( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let task = require_owned_task(cluster, &ns, &name, &principal).await?; - let sandbox = task - .status - .as_ref() - .and_then(|s| s.sandbox_ref.as_ref()) - .map(|r| r.name.clone()); - let Some(sandbox) = sandbox else { - return Ok(Json( - serde_json::json!({ "available": false, "reason": "no running sandbox yet", "domains": [] }), - )); - }; - let mode = cluster - .sandbox_egress_mode(&sandbox) - .await - .unwrap_or_else(|| "Learn".into()); - let enforced = cluster.sandbox_allowlist(&sandbox).await; - match cluster.sandbox_learned_domains(&sandbox).await { - Ok(domains) => Ok(Json( - serde_json::json!({ "available": true, "mode": mode, "domains": domains, "enforced": enforced }), - )), - Err(e) => Ok(Json( - serde_json::json!({ "available": false, "mode": mode, "reason": e.to_string(), "domains": [], "enforced": enforced }), - )), - } -} - -/// Body for flipping a mission's egress enforcement mode. -#[derive(Debug, Deserialize)] -pub struct EgressModeRequest { - /// `"learning"` (clear the allowlist → controller runs Learn) or - /// `"enforced"` (pin the allowlist → controller runs Strict). - pub mode: String, - /// The hosts to enforce when `mode == "enforced"`. Typically the reviewed - /// subset of the learned domains. - #[serde(default)] - pub allow: Vec<String>, - /// When true, UNION `allow` with the mission's current enforced allowlist - /// instead of replacing it — so granting one host (e.g. from a blocker) can - /// never silently wipe previously-approved hosts. The NetworkMode panel, - /// which sets the full list deliberately, leaves this false (replace). - #[serde(default)] - pub merge: bool, -} - -/// `POST /api/namespaces/:ns/tasks/:name/egress-mode` — promote a mission from -/// learning (monitoring) to enforced, or back. This drives the REAL lever: the -/// controller derives `egressMode: Strict` + an allowlist when the blueprint -/// names egress hosts, and `Learn` when it is empty. Operator-gated; the -/// controller re-reconciles the sandbox, so this is durable, not a UI toggle. -pub async fn set_egress_mode( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(req): Json<EgressModeRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let task = require_owned_task(cluster, &ns, &name, &principal).await?; - let enforced = match req.mode.as_str() { - "enforced" | "strict" => true, - "learning" | "learn" => false, - _ => { - return Err(AppError::BadRequest( - "mode must be 'enforced' or 'learning'".into(), - )); - } - }; - // Parse "host" or "host:port" into the blueprint egress shape. - let egress: Vec<serde_json::Value> = if enforced { - let mut parsed: Vec<serde_json::Value> = req - .allow - .iter() - .filter_map(|h| { - let h = h.trim(); - if h.is_empty() { - return None; - } - match h - .rsplit_once(':') - .and_then(|(host, p)| p.parse::<u16>().ok().map(|p| (host, p))) - { - Some((host, port)) => Some(serde_json::json!({ "host": host, "port": port })), - None => Some(serde_json::json!({ "host": h })), - } - }) - .collect(); - // Additive grant: union with the mission's CURRENT enforced allowlist so - // approving one host never clobbers the others (a k8s merge-patch of an - // array replaces it wholesale, so we must merge here, before patching). - if req.merge { - let existing: Vec<serde_json::Value> = task - .spec - .blueprint - .map(|b| b.egress) - .unwrap_or_default() - .into_iter() - .map(|e| match e.port { - Some(p) => serde_json::json!({ "host": e.host, "port": p }), - None => serde_json::json!({ "host": e.host }), - }) - .collect(); - let key = |v: &serde_json::Value| { - format!( - "{}:{}", - v.get("host").and_then(|h| h.as_str()).unwrap_or(""), - v.get("port").and_then(|p| p.as_u64()).unwrap_or(0) - ) - }; - let mut seen: std::collections::HashSet<String> = parsed.iter().map(key).collect(); - for e in existing { - if seen.insert(key(&e)) { - parsed.push(e); - } - } - } - if parsed.is_empty() { - return Err(AppError::BadRequest( - "enforcing requires at least one allowed host — review the learned domains first" - .into(), - )); - } - parsed - } else { - Vec::new() - }; - // Patch the mission's blueprint egress; the controller compiles it into the - // sandbox's networkPolicy (Strict + allowlist, or Learn when empty). - let patch = serde_json::json!({ "spec": { "blueprint": { "egress": egress } } }); - cluster - .tasks(&ns) - .patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(patch), - ) - .await - .map_err(map_kube_err)?; - Ok(Json(serde_json::json!({ - "updated": true, - "mode": if enforced { "enforced" } else { "learning" }, - "note": if enforced { - "Promoted to enforced — the sandbox will deny anything outside the approved allowlist on its next reconcile." - } else { - "Back to learning — the sandbox observes and records every domain it reaches without denying." - } - }))) -} - -/// One running agent + what it is doing now, for the lifecycle view. -#[derive(Debug, Serialize)] -pub struct AgentLifecycleDto { - pub sandbox: String, - pub namespace: String, - pub phase: Option<String>, - pub parent: Option<String>, - /// The task this agent is executing (label-derived), if any. - pub task: Option<String>, - pub objective: Option<String>, - pub tier: Option<i32>, - /// Live activity counts from the persisted trace (rounds + tool calls). - pub rounds: usize, - pub tool_calls: usize, - pub last_action: Option<String>, - /// True when the agent's sandbox pod is still running (working now) vs a - /// recently-completed run (its ephemeral sandbox already torn down). - pub live: bool, - /// Real token cost of the run (from the mission output), when known. - pub tokens: Option<i64>, - /// The run's token budget ceiling (from the envelope), when set — so the UI - /// can render spend against limit ("spent / budget") rather than a bare - /// number. `None` for an uncapped run. - pub budget_tokens: Option<i64>, - /// Run outcome: `ok` | `error` (from the mission output), when finished. - pub status: Option<String>, - /// When the run delivered (from the mission output). - pub finished_at: Option<String>, - /// Owning standing team, if this is a team run. - pub team: Option<String>, - /// Human label for the run. - pub display_name: Option<String>, - /// Live pod health (readiness, restarts, uptime, node) — only for live - /// agents; `None` for finished runs whose sandbox was torn down. - pub health: Option<crate::kars::cluster::PodHealth>, -} - -/// Whether a `KarsTask` should surface on the "Active agents" fleet views. A -/// surfaceable run is either a team taskforce run, a `*-run-<ts>` scheduled run, -/// OR a launched direct mission (a one-off the user kicked off from `/new`). -/// Un-launched drafts and team structural tasks (a non-taskforce `team-role`) -/// are NOT agents yet, so they stay out. Without the direct-mission arm the -/// flagship "Active agents" page was empty for the single most common action — -/// launch a mission and watch it — because a direct mission carries neither the -/// taskforce role nor a `-run-` suffix. -fn is_surfaceable_run(t: &KarsTask) -> bool { - let name = t.name_any(); - let role = t - .annotations() - .get("kars.azure.com/team-role") - .map(String::as_str); - if role == Some("taskforce") || name.contains("-run-") { - return true; - } - // A launched direct mission: no team structural role, and it was launched. - let launched = t.spec.execution.as_ref().map(|e| e.launch).unwrap_or(false); - role.is_none() && launched -} - -/// `GET /api/agents` — recent and live agent runs with their real work. Sources -/// from run KarsTasks + their persisted mission telemetry (not idle pods), so -/// the page answers "what have my agents been doing, and what's working now" — -/// live runs first, then recently completed. Ephemeral run sandboxes tear down -/// after delivering, so their work would otherwise vanish; here it persists. -pub async fn list_agents( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, -) -> AppResult<Json<Vec<AgentLifecycleDto>>> { - let cluster = require_cluster(&state)?; - let tasks_api = cluster.tasks("kars-system"); - let all = tasks_api - .list(&kube::api::ListParams::default()) - .await - .map_err(map_kube_err)?; - - // Runs only: a team-owned run, a `*-run-<ts>` task, or a launched direct - // mission. Members/principals (structural team roles) are standing authority, - // not work to surface here. - let mut runs: Vec<&KarsTask> = all - .items - .iter() - .filter(|task| is_surfaceable_run(task) && is_task_owner(task, &principal)) - .collect(); - // Freshest first. - runs.sort_by(|a, b| { - let ta = a.metadata.creation_timestamp.as_ref().map(|t| t.0); - let tb = b.metadata.creation_timestamp.as_ref().map(|t| t.0); - tb.cmp(&ta) - }); - - let mut out = Vec::new(); - for task in runs.into_iter().take(24) { - let name = task.name_any(); - let team = task - .metadata - .labels - .as_ref() - .and_then(|l| l.get("kars.azure.com/team").cloned()); - - // Mission output: tokens, status, finished, round/tool rollup. - let output = cluster.read_mission_output(&name).await; - let (tokens, status, finished_at, mut rounds, mut tool_calls) = match &output { - Some(d) => ( - d.get("totalTokens").and_then(|v| v.parse::<i64>().ok()), - d.get("status").cloned(), - d.get("finishedAt").cloned(), - d.get("rounds") - .and_then(|v| v.parse::<usize>().ok()) - .unwrap_or(0), - d.get("toolCalls") - .and_then(|v| v.parse::<usize>().ok()) - .unwrap_or(0), - ), - None => (None, None, None, 0, 0), - }; - - // Live iff the run's sandbox pod is running AND it hasn't delivered a - // terminal result yet. A DIRECT mission's sandbox lingers (Running) after - // it delivers, so "Running pod" alone would mislabel a finished, idle - // mission as live and inflate the fleet's "working now" count. A delivered - // (or errored) run is Recent, not Live — its outcome and telemetry show in - // the recent list. (Team run sandboxes tear down on delivery, so this is a - // no-op for them.) - let delivered = status.is_some(); - let live = !delivered && cluster.running_pod_for_sandbox(&name).await.is_some(); - // Honest pod health for a live agent (readiness/restarts/uptime/node). - let health = if live { - cluster.sandbox_pod_health(&name).await - } else { - None - }; - - // For a live run, the trace's last tool tells "what it's doing now"; - // also a more current round/tool count than the (post-hoc) output. - let mut last_action = None; - if live { - // A live run has no persisted trace CM yet (it's written at delivery), - // so fall back to the router's live trace — otherwise a working agent - // reports 0 rounds / 0 tool calls / no current action. - let trace: Vec<serde_json::Value> = match cluster - .read_mission_trace(&name) - .await - .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) - { - Some(t) if !t.is_empty() => t, - _ => cluster.sandbox_live_trace(&name).await, - }; - if !trace.is_empty() { - let r = trace - .iter() - .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) - .count(); - let tc = trace - .iter() - .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("tool")) - .count(); - if r > 0 { - rounds = r; - } - if tc > 0 { - tool_calls = tc; - } - last_action = trace - .last() - .and_then(|e| e.get("name").and_then(|n| n.as_str()).map(String::from)); - } - } - - let phase = if live { - Some("Running".into()) - } else if status.as_deref() == Some("ok") { - Some("Delivered".into()) - } else if status.is_some() { - Some("Errored".into()) - } else { - Some("Idle".into()) - }; - - out.push(AgentLifecycleDto { - sandbox: name.clone(), - namespace: task.namespace().unwrap_or_default(), - phase, - parent: task.spec.parent_ref.as_ref().map(|p| p.name.clone()), - task: Some(name.clone()), - objective: Some(clean_objective(&task.spec.objective)), - tier: Some(task.spec.envelope.tier), - rounds, - tool_calls, - last_action, - live, - tokens, - budget_tokens: task.spec.envelope.budget.as_ref().and_then(|b| b.tokens), - status, - finished_at, - team, - display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), - health, - }); - } - - // Live runs first, then most-recent finished. - out.sort_by(|a, b| b.live.cmp(&a.live).then(b.finished_at.cmp(&a.finished_at))); - Ok(Json(out)) -} - -// ─── Fleet live telemetry (at-scale "what's happening now") ────────────────── - -#[derive(serde::Serialize)] -pub struct FleetActivityItem { - /// The run/agent this event came from. - pub agent: String, - pub display_name: Option<String>, - pub team: Option<String>, - /// "tool" | "round". - pub kind: String, - /// For a tool event, the tool name; for a round, the finish reason. - pub label: String, - /// Optional short argument/host preview for a tool event. - pub detail: Option<String>, - /// Whether a tool event failed (ok=false) — surfaced in red. - pub failed: bool, - /// Round index the event belongs to. - pub round: i64, - /// Monotonic sequence within the run's trace (for stable ordering). - pub seq: i64, - /// Milliseconds the step took, when known. - pub ms: Option<i64>, -} - -#[derive(serde::Serialize)] -pub struct FleetTelemetryDto { - /// Agents whose sandbox pod is running right now. - pub working: usize, - /// Distinct standing teams with a live run. - pub teams_active: usize, - /// Sub-agents (runs with a parent) currently live. - pub sub_agents: usize, - /// Live token burn summed across working agents (from their in-flight trace). - pub tokens_in_flight: i64, - /// Tool calls summed across working agents this run. - pub tool_calls: i64, - /// Model rounds summed across working agents this run. - pub rounds: i64, - /// The most recent activity across ALL live agents, newest first — a single - /// chronological fleet feed of what every working agent is doing right now. - pub feed: Vec<FleetActivityItem>, -} - -/// `GET /api/agents/fleet` — aggregate LIVE telemetry across every working -/// agent, plus a single merged activity feed of what they're all doing right -/// now. This is the "at scale" view: instead of drilling into one mission, see -/// the whole fleet's live tool-by-tool work in one stream. Sourced from each -/// live run's real execution trace — never fabricated; an idle fleet returns -/// zeros and an empty feed. -pub async fn fleet_telemetry( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, -) -> AppResult<Json<FleetTelemetryDto>> { - let cluster = require_cluster(&state)?; - let tasks_api = cluster.tasks("kars-system"); - let all = tasks_api - .list(&kube::api::ListParams::default()) - .await - .map_err(map_kube_err)?; - - let runs: Vec<&KarsTask> = all - .items - .iter() - .filter(|task| is_surfaceable_run(task) && is_task_owner(task, &principal)) - .collect(); - - let mut working = 0usize; - let mut teams: std::collections::BTreeSet<String> = Default::default(); - let mut sub_agents = 0usize; - let mut tokens_in_flight = 0i64; - let mut tool_calls = 0i64; - let mut rounds = 0i64; - let mut feed: Vec<FleetActivityItem> = Vec::new(); - - for task in runs { - let name = task.name_any(); - // Only agents that are actually running right now contribute trace. - if cluster.running_pod_for_sandbox(&name).await.is_none() { - continue; - } - // A delivered direct mission keeps a lingering Running pod but is idle — - // its historical tokens are NOT "in flight". Skip it here so the live - // counters reflect only work happening now (it still shows, with its - // outcome, in the Recent runs list from /api/agents). - if cluster - .read_mission_output(&name) - .await - .and_then(|d| d.get("status").cloned()) - .is_some() - { - continue; - } - working += 1; - for sub in cluster.sub_agent_sandbox_names("kars-system", &name).await { - if cluster.running_pod_for_sandbox(&sub).await.is_some() { - working += 1; - sub_agents += 1; - } - } - let team = task - .metadata - .labels - .as_ref() - .and_then(|l| l.get("kars.azure.com/team").cloned()); - if let Some(t) = &team { - teams.insert(t.clone()); - } - let display_name = clean_display_name(&task.spec.display_name, &task.spec.objective); - - // Prefer the persisted trace (delivered runs); for a LIVE run the trace - // CM doesn't exist yet, so fall back to the router's live trace — else - // every actively-working agent shows zero rounds/tokens/tools (the exact - // opposite of "what's happening now"). Mirrors get_task's live fallback. - let trace: Vec<serde_json::Value> = match cluster - .read_mission_trace(&name) - .await - .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) - { - Some(t) if !t.is_empty() => t, - _ => cluster.sandbox_live_trace(&name).await, - }; - if trace.is_empty() { - continue; - } - - for e in &trace { - let kind = e.get("kind").and_then(|k| k.as_str()).unwrap_or(""); - let round = e.get("round").and_then(|v| v.as_i64()).unwrap_or(0); - let seq = e.get("seq").and_then(|v| v.as_i64()).unwrap_or(0); - let ms = e.get("ms").and_then(|v| v.as_i64()); - match kind { - "round" => { - rounds += 1; - tokens_in_flight += e.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0); - feed.push(FleetActivityItem { - agent: name.clone(), - display_name: display_name.clone(), - team: team.clone(), - kind: "round".into(), - label: e - .get("finish_reason") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("model round") - .to_string(), - detail: None, - failed: false, - round, - seq, - ms, - }); - } - "tool" => { - tool_calls += 1; - let failed = e.get("ok").and_then(|v| v.as_bool()) == Some(false); - feed.push(FleetActivityItem { - agent: name.clone(), - display_name: display_name.clone(), - team: team.clone(), - kind: "tool".into(), - label: e - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("tool") - .to_string(), - detail: e - .get("args_preview") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.chars().take(80).collect()), - failed, - round, - seq, - ms, - }); - } - _ => {} - } - } - } - - // Newest activity first, capped so a busy fleet stays responsive. - feed.sort_by(|a, b| b.round.cmp(&a.round).then(b.seq.cmp(&a.seq))); - feed.truncate(40); - - Ok(Json(FleetTelemetryDto { - working, - teams_active: teams.len(), - sub_agents, - tokens_in_flight, - tool_calls, - rounds, - feed, - })) -} - -#[cfg(test)] -mod tests { - use super::BlueprintDto; - use super::ExecutionPhaseDto; - use super::ExecutionPlanDto; - use super::ExecutionRoleDto; - use super::ExecutionSynthesisDto; - use super::MissionResultDto; - use super::ModelDto; - use super::TaskAssignmentEventDto; - use super::TeamCollaborationEventDto; - use super::canonicalize_assignment_event_roles; - use super::clean_objective; - use super::deliverable_pull_requests; - use super::diagnose_run_failure; - use super::merge_trace_total_tokens; - use super::normalize_ttl; - use super::select_task_checkpoint; - use super::to_sub_agent; - use super::{ - ARTIFACT_PREVIEW_MAX_BYTES, ARTIFACT_PREVIEW_TOTAL_BYTES, MissionArtifactDto, - artifact_preview, structured_team_evidence, subagent_trace_from_artifacts, - valid_task_checkpoint, - }; - - #[test] - fn execution_plan_dto_into_crd_preserves_web_search_capability() { - let blueprint = BlueprintDto { - execution_plan: Some(ExecutionPlanDto { - schema: "kars.execution-plan/v1".into(), - roles: vec![ExecutionRoleDto { - name: "source-scout".into(), - objective: "Discover exact URLs and fetch the evidence.".into(), - depends_on: Vec::new(), - phases: vec![ExecutionPhaseDto { - name: "discover".into(), - objective: "Search and fetch the exact URLs.".into(), - capabilities: vec!["web-search".into(), "network".into()], - required_tool_calls: Vec::new(), - min_tool_calls: 1, - max_tool_calls: 4, - fresh_context: true, - }], - budget_tokens: None, - }], - max_parallel: 1, - synthesis: ExecutionSynthesisDto { - objective: "Return the verified answer.".into(), - capabilities: Vec::new(), - max_tool_calls: 0, - }, - deliverables: Vec::new(), - }), - ..Default::default() - }; - - let crd = blueprint.into_crd(); - assert_eq!( - crd.execution_plan.expect("execution plan").roles[0].phases[0].capabilities, - vec!["web-search".to_string(), "network".to_string()] - ); - } - - #[test] - fn blueprint_dto_preserves_ordered_model_fallbacks() { - let blueprint = BlueprintDto { - model: Some(ModelDto { - provider: "local-inference".into(), - deployment: "gpt-oss-120b".into(), - }), - model_fallbacks: vec![ - ModelDto { - provider: "github-copilot".into(), - deployment: "gpt-5.6-sol".into(), - }, - ModelDto { - provider: "foundry".into(), - deployment: "gpt-5.4-pro".into(), - }, - ], - ..Default::default() - }; - - let crd = blueprint.into_crd(); - assert_eq!(crd.model_fallbacks.len(), 2); - assert_eq!(crd.model_fallbacks[0].provider, "github-copilot"); - assert_eq!(crd.model_fallbacks[1].deployment, "gpt-5.4-pro"); - } - - #[test] - fn schema_rejection_is_diagnosed_as_router_model_compatibility() { - let logs = vec![ - "rawError=400 Unknown parameter: 'stream_options.include_usage'".to_string(), - "LLM request failed: provider rejected the request schema or tool payload.".to_string(), - ]; - let (cause, remedy, harness_issue, evidence) = - diagnose_run_failure(&logs, &[], Some("provider rejected the request schema")); - assert!(cause.contains("translated inference request")); - assert!(remedy.contains("corrected inference router")); - assert!(!harness_issue); - assert_eq!(evidence.len(), 1); - } - - #[test] - fn completed_subagent_trace_is_rehydrated_from_artifact() { - let artifacts = vec![MissionArtifactDto { - name: "artifacts/.run-x/subagent-telemetry.jsonl".into(), - size_bytes: None, - content: Some( - r#"{"at":"2026-07-23T12:00:00Z","event":"subagent_trace","member":"ci-verifier","trace":{"kind":"tool","name":"github_checks","ok":true}}"# - .into(), - ), - content_bytes: None, - content_truncated: false, - source_agent: None, - source_path: None, - digest: None, - full_content: None, - }]; - - let events = subagent_trace_from_artifacts(&artifacts); - - assert_eq!(events.len(), 1); - assert_eq!(events[0]["agent"], "ci-verifier"); - assert_eq!(events[0]["agentRole"], "subagent"); - assert_eq!(events[0]["ts"], "2026-07-23T12:00:00Z"); - assert_eq!(events[0]["name"], "github_checks"); - } - - #[test] - fn artifact_preview_is_bounded_but_full_content_remains_internal() { - let mut budget = ARTIFACT_PREVIEW_TOTAL_BYTES; - let full = "x".repeat(ARTIFACT_PREVIEW_MAX_BYTES + 100); - let (preview, bytes, truncated, internal) = - artifact_preview(Some(full.clone()), &mut budget); - - assert_eq!( - preview.as_ref().map(String::len), - Some(ARTIFACT_PREVIEW_MAX_BYTES) - ); - assert_eq!(bytes, Some(full.len() as i64)); - assert!(truncated); - assert_eq!(internal.as_deref(), Some(full.as_str())); - } - - #[test] - fn artifact_previews_share_a_bounded_response_budget() { - let mut budget = ARTIFACT_PREVIEW_MAX_BYTES + 100; - let first = "a".repeat(ARTIFACT_PREVIEW_MAX_BYTES + 1); - let second = "b".repeat(ARTIFACT_PREVIEW_MAX_BYTES); - - let (first_preview, _, first_truncated, _) = artifact_preview(Some(first), &mut budget); - let (second_preview, _, second_truncated, _) = artifact_preview(Some(second), &mut budget); - - assert_eq!( - first_preview.as_ref().map(String::len), - Some(ARTIFACT_PREVIEW_MAX_BYTES) - ); - assert_eq!(second_preview.as_ref().map(String::len), Some(100)); - assert!(first_truncated); - assert!(second_truncated); - assert_eq!(budget, 0); - } - - #[test] - fn empty_artifact_is_not_reported_as_truncated() { - let mut budget = ARTIFACT_PREVIEW_TOTAL_BYTES; - let (preview, bytes, truncated, internal) = - artifact_preview(Some(String::new()), &mut budget); - - assert_eq!(preview.as_deref(), Some("")); - assert_eq!(bytes, Some(0)); - assert!(!truncated); - assert_eq!(internal.as_deref(), Some("")); - } - - #[test] - fn artifact_preview_respects_utf8_boundaries_and_exhausted_budget() { - let mut budget = 5; - let (preview, bytes, truncated, _) = - artifact_preview(Some("abcd\u{1f642}".to_string()), &mut budget); - - assert_eq!(preview.as_deref(), Some("abcd")); - assert_eq!(bytes, Some(8)); - assert!(truncated); - assert_eq!(budget, 1); - - budget = 0; - let (preview, bytes, truncated, _) = - artifact_preview(Some("still here".to_string()), &mut budget); - assert!(preview.is_none()); - assert_eq!(bytes, Some(10)); - assert!(truncated); - } - - #[test] - fn full_artifact_content_is_private_but_available_for_trace_recovery() { - let telemetry = r#"{"at":"2026-07-23T12:00:00Z","event":"subagent_trace","member":"ci-verifier","trace":{"kind":"tool","name":"github_checks","ok":true}}"#; - let artifact = MissionArtifactDto { - name: "artifacts/.run-x/subagent-telemetry.jsonl".into(), - size_bytes: Some(telemetry.len() as i64), - content: Some("{\"at\":\"2026".into()), - content_bytes: Some(telemetry.len() as i64), - content_truncated: true, - source_agent: None, - source_path: None, - digest: None, - full_content: Some(telemetry.into()), - }; - - let serialized = serde_json::to_value(&artifact).expect("serialize artifact preview"); - assert_eq!(serialized["content"], "{\"at\":\"2026"); - assert!(serialized.get("full_content").is_none()); - - let events = subagent_trace_from_artifacts(&[artifact]); - assert_eq!(events.len(), 1); - assert_eq!(events[0]["name"], "github_checks"); - } - - #[test] - fn structured_team_evidence_uses_full_content_not_preview_order() { - let role_plan = MissionArtifactDto { - name: "role-plan.json".into(), - size_bytes: None, - content: None, - content_bytes: Some(91), - content_truncated: true, - source_agent: None, - source_path: None, - digest: None, - full_content: Some( - r#"{"selected_roles":[{"role":"builder"}],"skipped_roles":["observer"]}"#.into(), - ), - }; - let collaboration = MissionArtifactDto { - name: "collaboration.jsonl".into(), - size_bytes: None, - content: Some("{\"at\":\"truncated".into()), - content_bytes: Some(200), - content_truncated: true, - source_agent: None, - source_path: None, - digest: None, - full_content: Some( - r#"{"at":"2026-07-23T12:00:00Z","event":"child_handback","from_agent":"builder","outcome":"success","reply_preview":"done"}"# - .into(), - ), - }; - - let (plan, events) = structured_team_evidence(&[role_plan, collaboration]); - - assert_eq!(plan.selected_roles, ["builder"]); - assert_eq!(plan.skipped_roles, ["observer"]); - assert_eq!(events.len(), 1); - assert_eq!(events[0].member.as_deref(), Some("builder")); - assert_eq!(events[0].event, "child_handback"); - } - - #[test] - fn assignment_ledger_uses_canonical_role_from_assignment_message() { - let mut events = vec![TaskAssignmentEventDto { - sequence: 1, - event_id: "event-1".into(), - task_id: "run-1".into(), - event_type: "child_progress".into(), - state: "Completed".into(), - at: "2026-08-04T21:36:50Z".into(), - worker_did: None, - stage: Some("child_handback".into()), - child_task_id: Some("message-1".into()), - child_role: Some("principal-remediatio-b9220dcf".into()), - outcome: Some("success".into()), - message: None, - }]; - let collaboration = vec![TeamCollaborationEventDto { - at: Some("2026-08-04T21:35:32Z".into()), - event: "assignment_sent".into(), - agent: Some("principal".into()), - member: Some("remediation-engineer".into()), - outcome: None, - message_id: Some("message-1".into()), - reply_preview: None, - content_preview: None, - }]; - - canonicalize_assignment_event_roles(&mut events, &collaboration); - - assert_eq!( - events[0].child_role.as_deref(), - Some("remediation-engineer") - ); - } - - #[test] - fn successful_result_hides_stale_bootstrap_checkpoint() { - let progress = serde_json::json!({ - "schema": "kars.checkpoint/v1", - "milestone_id": "dependency-pr", - "status": "in_progress", - "summary": "Controller initialized the durable milestone checkpoint." - }); - - assert!(select_task_checkpoint(Some(progress.clone()), &[], true).is_none()); - assert!(select_task_checkpoint(Some(progress), &[], false).is_some()); - } - - #[test] - fn completed_artifact_checkpoint_wins_over_bootstrap_progress() { - let completed = r#"{ - "schema": "kars.checkpoint/v1", - "milestone_id": "dependency-pr", - "status": "completed", - "summary": "All required handbacks were retained." - }"#; - let artifact = MissionArtifactDto { - name: "task-checkpoint.json".into(), - size_bytes: Some(completed.len() as i64), - content: None, - content_bytes: Some(completed.len() as i64), - content_truncated: true, - source_agent: None, - source_path: None, - digest: None, - full_content: Some(completed.into()), - }; - let progress = serde_json::json!({ - "schema": "kars.checkpoint/v1", - "milestone_id": "dependency-pr", - "status": "in_progress", - "summary": "Controller initialized the durable milestone checkpoint." - }); - - let checkpoint = - select_task_checkpoint(Some(progress), &[artifact], true).expect("checkpoint"); - - assert_eq!(checkpoint["status"], "completed"); - } - - #[test] - fn aggregate_trace_tokens_replace_principal_only_total() { - let mut result = Some(MissionResultDto { - output: "done".into(), - status: Some("ok".into()), - model: None, - total_tokens: Some(8_505), - prompt_tokens: None, - completion_tokens: None, - finished_at: None, - assignment_nonce: None, - source: None, - blocked: None, - artifact_persistence: None, - artifact_count: None, - declared_artifact_count: None, - }); - - merge_trace_total_tokens(&mut result, 22_016); - - assert_eq!(result.and_then(|value| value.total_tokens), Some(22_016)); - } - - #[test] - fn subagent_projects_human_identity_and_runtime_metadata() { - let object: kube::core::DynamicObject = serde_json::from_value(serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsSandbox", - "metadata": { - "name": "researcher-run-7f4c", - "namespace": "kars-team", - "labels": { - "kars.azure.com/role": "Research specialist", - "kars.azure.com/parent": "principal-run" - }, - "annotations": { - "kars.azure.com/logical-agent-id": "researcher", - "kars.azure.com/model": "gpt-5.4" - } - }, - "spec": { - "runtime": { - "kind": "openclaw" - } - }, - "status": { - "phase": "Running" - } - })) - .expect("deserialize KarsSandbox"); - - let dto = to_sub_agent(&object); - - assert_eq!(dto.name, "researcher-run-7f4c"); - assert_eq!(dto.namespace, "kars-team"); - assert_eq!(dto.phase.as_deref(), Some("Running")); - assert_eq!(dto.runtime.as_deref(), Some("openclaw")); - assert_eq!(dto.role.as_deref(), Some("Research specialist")); - assert_eq!(dto.parent.as_deref(), Some("principal-run")); - assert_eq!(dto.logical_agent_id.as_deref(), Some("researcher")); - assert_eq!(dto.model.as_deref(), Some("gpt-5.4")); - } - - #[test] - fn malformed_checkpoint_is_not_exposed_to_the_ui() { - assert!( - valid_task_checkpoint(serde_json::json!({ - "schema": "kars.checkpoint/v1", - "status": "completed" - })) - .is_none() - ); - assert!( - valid_task_checkpoint(serde_json::json!({ - "schema": "kars.checkpoint/v1", - "milestone_id": "build", - "status": "completed", - "summary": "Artifact produced", - "artifacts": "not-an-array" - })) - .is_none() - ); - assert!( - valid_task_checkpoint(serde_json::json!({ - "schema": "kars.checkpoint/v1", - "milestone_id": "build", - "status": "completed", - "summary": "Artifact produced" - })) - .is_some() - ); - } - - #[test] - fn clean_objective_strips_loop_scaffold() { - // A leaked loop scaffold must never reach a title — extract the GOAL. - let scaffolded = "LOOP: ReAct — Reason + Act\nGOAL: find the Azure/kars star count and write a paragraph\nCYCLE: reason, act, observe\nSUCCESS: a paragraph with the count\nSTOP: when delivered\nSUB-AGENT INHERITANCE: give each sub-agent the same loop"; - assert_eq!( - clean_objective(scaffolded), - "find the Azure/kars star count and write a paragraph" - ); - } - - #[test] - fn clean_objective_passes_plain_through() { - assert_eq!( - clean_objective("Summarize the Q3 report"), - "Summarize the Q3 report" - ); - } - - #[test] - fn clean_objective_strips_bracket_goal() { - let s = "LOOP: eval-iterate\nGOAL: [[raise CLI test coverage]]\nSTOP: green"; - assert_eq!(clean_objective(s), "raise CLI test coverage"); - } - - #[test] - fn deliverable_text_strips_fixed_sandbox_banner() { - let raw = "# ? kars Sandbox - Secure AI Runtime on Azure\n\ - - **Foundry Project:** project\n\ - - **Model:** gpt\n\ - - **Sandbox ID:** run-1\n\ - - **Security:** isolated\n\ - - **Capabilities:** tools and reasoning\n\n\ - [[NO_MATERIAL_CHANGE]] nothing changed."; - assert_eq!( - super::deliverable_text(raw), - "[[NO_MATERIAL_CHANGE]] nothing changed." - ); - } - - #[test] - fn deliverable_text_repairs_legacy_question_mark_replacements() { - assert_eq!( - super::deliverable_text("1? Role?plan: non?root; GHSA?w8wr?v893?vjvp"), - "1. Role-plan: non-root; GHSA-w8wr-v893-vjvp" - ); - } - - #[test] - fn real_deliverable_gates_error_and_no_change() { - use super::is_real_deliverable; - assert!(!is_real_deliverable(Some("error"), "anything")); - assert!(!is_real_deliverable(Some("ok"), " ")); - assert!(!is_real_deliverable( - Some("ok"), - "[[NO_MATERIAL_CHANGE]] nothing changed" - )); - assert!(!is_real_deliverable( - Some("ok"), - "kars Sandbox - Secure AI Runtime on Azure\nSandbox ID: run-1\nSecurity: isolated\nCapabilities: tools\n[[NO_MATERIAL_CHANGE]] nothing changed" - )); - assert!(is_real_deliverable(Some("ok"), "Here is the report.")); - assert!(is_real_deliverable(None, "Some output")); - // A budget-blocked ok-run is NOT a deliverable. - assert!(!is_real_deliverable( - Some("ok"), - "API call failed after 3 retries: HTTP 429: Daily token budget exceeded (23131/20000 tokens)." - )); - assert!(!is_real_deliverable( - Some("ok"), - "unexpected tokens remaining in message header: Some(...)" - )); - assert!(!is_real_deliverable( - Some("ok"), - "assignment progress lease expired after 90s without renewal" - )); - assert!(is_real_deliverable( - Some("ok"), - "Completed remediation successfully. A prior child reported assignment progress lease expired, but its replacement delivered." - )); - } - - #[test] - fn failed_output_cannot_create_pull_request_deliverables() { - let data = std::collections::BTreeMap::from([ - ("status".to_string(), "error".to_string()), - ( - "output".to_string(), - "Claimed https://github.com/example/repo/pull/134".to_string(), - ), - ]); - assert!(deliverable_pull_requests(&data).is_empty()); - } - - #[test] - fn classify_blocked_detects_budget_and_parses_pair() { - use super::classify_blocked; - let b = classify_blocked( - Some("ok"), - "API call failed after 3 retries: HTTP 429: Daily token budget exceeded (23131/20000 tokens).", - ) - .expect("budget block detected"); - assert_eq!(b.reason, "budget"); - assert_eq!(b.spent, Some(23131)); - assert_eq!(b.limit, Some(20000)); - // A real deliverable is not blocked. - assert!(classify_blocked(Some("ok"), "Here is the finished report.").is_none()); - // An error run is handled elsewhere, not as blocked. - assert!(classify_blocked(Some("error"), "Daily token budget exceeded").is_none()); - } - - #[test] - fn assignment_dto_uses_the_web_snake_case_contract() { - let event = TaskAssignmentEventDto { - sequence: 3, - event_id: "root:3".into(), - task_id: "root".into(), - event_type: "child_progress".into(), - state: "Completed".into(), - at: "2026-07-20T12:00:00Z".into(), - worker_did: Some("did:agt:worker".into()), - stage: Some("child_handback".into()), - child_task_id: Some("child-1".into()), - child_role: Some("reviewer".into()), - outcome: Some("success".into()), - message: None, - }; - let value = serde_json::to_value(event).expect("serialize assignment event"); - assert_eq!(value["child_task_id"], "child-1"); - assert_eq!(value["child_role"], "reviewer"); - assert_eq!(value["event_type"], "child_progress"); - assert!(value.get("childTaskId").is_none()); - } - - #[test] - fn deliverable_excerpt_strips_noise() { - use super::deliverable_excerpt; - let raw = - "[[NO_MATERIAL_CHANGE]]\n# Heading\n| a | b |\n---\nThe repo star count is 1,234."; - let ex = deliverable_excerpt(raw); - assert!(ex.contains("star count")); - assert!(!ex.contains("NO_MATERIAL_CHANGE")); - assert!(!ex.contains('|')); - } - - #[test] - fn team_run_names_are_detected() { - use super::regex_lite_is_team_run; - assert!(regex_lite_is_team_run("kars-repo-health-run-1783099875")); - assert!(regex_lite_is_team_run("ci-monitor-team-run-42")); - // Standalone missions and non-numeric suffixes are NOT team runs. - assert!(!regex_lite_is_team_run("audit-the-readme")); - assert!(!regex_lite_is_team_run("some-run-abc")); - assert!(!regex_lite_is_team_run("foo-run-")); - assert!(!regex_lite_is_team_run("plainname")); - } - - #[test] - fn deliverable_excerpt_drops_markdown_wrapped_sentinel() { - use super::deliverable_excerpt; - // Bold-/emphasis-wrapped sentinel must still be recognized and dropped - // (regression: it used to leak into the excerpt because the sentinel - // check ran before markdown-wrapping was stripped). - let raw = "**[[NO_MATERIAL_CHANGE]]** +3 stars\nThe repo now has 1,234 stars."; - let ex = deliverable_excerpt(raw); - assert!( - !ex.contains("NO_MATERIAL_CHANGE"), - "excerpt leaked sentinel: {ex}" - ); - assert!(ex.contains("1,234 stars")); - } - - #[test] - fn clean_display_name_prefers_intent_over_scaffold() { - use super::clean_display_name; - // Scaffold display name -> derive (capitalized) from objective. - assert_eq!( - clean_display_name( - &Some("LOOP: ReAct".to_string()), - "GOAL: count the stars\nSTOP: done" - ), - Some("Count the stars".to_string()) - ); - // Real display name -> kept. - assert_eq!( - clean_display_name(&Some("Weekly repo digest".to_string()), "whatever"), - Some("Weekly repo digest".to_string()) - ); - // A conversational prompt pasted into the display slot is NOT a title — - // derive a concise one: strip the lead-in, shorten the URL, drop the - // trailing "- …" condition tail, capitalize. - assert_eq!( - clean_display_name( - &Some( - "Can you please check https://github.com/Azure/kars and analyse all dependabot PR" - .to_string() - ), - "Can you please check https://github.com/Azure/kars and analyse all dependabot PRs - categorize the ones which are safe to merge", - ), - Some("Check Azure/kars and analyse all dependabot PRs".to_string()) - ); - } - - #[test] - fn concise_title_strips_lead_in_and_shortens_url() { - use super::concise_title; - assert_eq!( - concise_title("I need you to summarise https://example.com/reports/q3 today"), - "Summarise q3 today".to_string() - ); - // Long objective is capped at a word boundary with an ellipsis. - let long = "review every open pull request across the entire organisation and produce a ranked risk report"; - let t = concise_title(long); - assert!(t.chars().count() <= 57, "title too long: {t}"); - assert!(t.ends_with('…'), "expected ellipsis: {t}"); - assert!(t.starts_with("Review "), "expected capitalized start: {t}"); - } - - #[test] - fn ttl_human_to_iso8601() { - assert_eq!(normalize_ttl("2h"), "PT2H"); - assert_eq!(normalize_ttl("30m"), "PT30M"); - assert_eq!(normalize_ttl("24h"), "PT24H"); - assert_eq!(normalize_ttl("1d"), "P1D"); - assert_eq!(normalize_ttl("90s"), "PT90S"); - assert_eq!(normalize_ttl(" 8 h "), "PT8H"); - } - - #[test] - fn ttl_passthrough_and_fallback() { - assert_eq!(normalize_ttl("PT2H"), "PT2H"); // already ISO - assert_eq!(normalize_ttl("pt45m"), "PT45M"); // uppercased - assert_eq!(normalize_ttl(""), "PT2H"); // empty → default - assert_eq!(normalize_ttl("garbage"), "PT2H"); // unrecognized → default - assert_eq!(normalize_ttl("0h"), "PT2H"); // zero → default - } -} diff --git a/bridge/bff/src/routes/tasks/artifacts.rs b/bridge/bff/src/routes/tasks/artifacts.rs new file mode 100644 index 000000000..0023a328a --- /dev/null +++ b/bridge/bff/src/routes/tasks/artifacts.rs @@ -0,0 +1,159 @@ +use axum::extract::{Extension, Path, State}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::ownership::require_owned_task_or_output; +use crate::state::AppState; + +use super::evidence::{ARTIFACT_PREVIEW_TOTAL_BYTES, artifact_preview}; +use super::{MissionArtifactDto, require_cluster}; + +/// Sanitize a filename to the ConfigMap key form the controller uses (alnum, +/// '-', '_', '.') so the manifest name can look up its stored content. +fn artifact_key(name: &str) -> String { + let k: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + if k.is_empty() { "artifact".into() } else { k } +} + +/// Best-effort content type from a filename extension, so a downloaded artifact +/// opens sensibly in the browser instead of forcing a save dialog for text. +fn artifact_content_type(name: &str) -> &'static str { + match name + .rsplit('.') + .next() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("md" | "markdown" | "txt" | "log") => "text/markdown; charset=utf-8", + Some("json") => "application/json; charset=utf-8", + Some("csv") => "text/csv; charset=utf-8", + Some("html" | "htm") => "text/html; charset=utf-8", + Some("yaml" | "yml") => "application/yaml; charset=utf-8", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("svg") => "image/svg+xml", + Some("pdf") => "application/pdf", + _ => "application/octet-stream", + } +} + +/// `GET /api/tasks/:ns/:name/artifact/:file` — Bridge-native artifact fetch. +/// Streams one artifact file's bytes (text from `data`, binary from +/// `binaryData`) so operators download deliverables in-product, never via +/// `kubectl`. Inline for previewable types; attachment otherwise. +pub async fn download_artifact( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name, file)): Path<(String, String, String)>, +) -> AppResult<axum::response::Response> { + use axum::http::header; + let cluster = require_cluster(&state)?; + require_owned_task_or_output(cluster, &ns, &name, &principal).await?; + let key = artifact_key(&file); + let (bytes, is_binary) = cluster + .read_mission_artifact_bytes(&name, &key) + .await + .ok_or(AppError::NotFound)?; + let ctype = artifact_content_type(&file); + // Inline-render text/known media; force a download for opaque binaries. + let disposition = if is_binary && ctype == "application/octet-stream" { + format!("attachment; filename=\"{key}\"") + } else { + format!("inline; filename=\"{key}\"") + }; + axum::response::Response::builder() + .header(header::CONTENT_TYPE, ctype) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CACHE_CONTROL, "private, max-age=60") + .body(axum::body::Body::from(bytes)) + .map_err(|e| AppError::Upstream(e.to_string())) +} + +/// Merge a mission's artifact manifest (names + sizes, from the output +/// ConfigMap) with the text contents stored in the companion artifacts +/// ConfigMap. Binary artifacts appear in the manifest but carry `content: +/// None`. Returns an empty set honestly when the mission produced no artifacts. +pub(super) async fn build_artifact_set( + cluster: &crate::kars::cluster::Cluster, + name: &str, + output_data: Option<&std::collections::BTreeMap<String, String>>, +) -> Vec<MissionArtifactDto> { + let manifest_json = output_data.and_then(|d| d.get("artifacts").cloned()); + let contents = cluster + .read_mission_artifacts(name) + .await + .unwrap_or_default(); + + // Prefer the manifest (authoritative order + sizes + binary entries); fall + // back to whatever text artifacts are stored if no manifest is present. + if let Some(mj) = manifest_json + && let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&mj) + { + let mut seen = std::collections::HashSet::new(); + let mut preview_budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + return entries + .into_iter() + .filter_map(|e| { + let fname = e.get("name")?.as_str()?.to_string(); + // The manifest can list the same file twice (e.g. an artifact + // recorded by both the run harness and the harvest step). Keep + // the first — duplicates crash the UI's name-keyed lists. + if !seen.insert(fname.clone()) { + return None; + } + let size_bytes = e.get("size_bytes").and_then(|v| v.as_i64()); + let (content, content_bytes, content_truncated, full_content) = artifact_preview( + contents.get(&artifact_key(&fname)).cloned(), + &mut preview_budget, + ); + Some(MissionArtifactDto { + name: fname, + size_bytes, + content, + content_bytes, + content_truncated, + source_agent: e + .get("source_agent") + .and_then(|v| v.as_str()) + .map(str::to_string), + source_path: e + .get("source_path") + .and_then(|v| v.as_str()) + .map(str::to_string), + digest: e.get("digest").and_then(|v| v.as_str()).map(str::to_string), + full_content, + }) + }) + .collect(); + } + + let mut preview_budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + contents + .into_iter() + .map(|(k, v)| { + let size_bytes = v.len() as i64; + let (content, content_bytes, content_truncated, full_content) = + artifact_preview(Some(v), &mut preview_budget); + MissionArtifactDto { + size_bytes: Some(size_bytes), + name: k, + content, + content_bytes, + content_truncated, + source_agent: None, + source_path: None, + digest: None, + full_content, + } + }) + .collect() +} diff --git a/bridge/bff/src/routes/tasks/creation.rs b/bridge/bff/src/routes/tasks/creation.rs new file mode 100644 index 000000000..42b313aac --- /dev/null +++ b/bridge/bff/src/routes/tasks/creation.rs @@ -0,0 +1,596 @@ +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::{Api, PostParams}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::{KarsTask, KarsTaskSpec, LocalObjectRef, TaskBudget, TaskEnvelope}; +use crate::state::AppState; + +use super::mapping::to_detail; +use super::{ + BlueprintDto, CreateTaskRequest, ModelDto, TaskDetailDto, map_kube_err, require_cluster, +}; + +fn validate_mission_fallback_route( + options: &crate::routes::options::Options, + blueprint: &BlueprintDto, + runtime: &str, + model: &ModelDto, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, + total_tokens: Option<i64>, +) -> AppResult<()> { + if !options + .models + .iter() + .any(|option| option.provider == model.provider && option.deployment == model.deployment) + { + return Err(AppError::BadRequest(format!( + "fallback model route `{}::{}` is not present in the live model catalogue", + model.provider, model.deployment + ))); + } + match crate::routes::options::route_qualification( + runtime, + &model.provider, + &model.deployment, + required_capabilities, + max_parallel, + total_tokens, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "fallback route `{runtime} · {}::{}` lacks atomic qualification for capabilities: {}", + model.provider, + model.deployment, + required_capabilities + .iter() + .cloned() + .collect::<Vec<_>>() + .join(", ") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "route qualification configuration error: {error}" + ))); + } + } + let route = crate::routes::options::route_label(runtime, &model.provider, &model.deployment); + for server in &blueprint.mcp_servers { + let option = options + .mcp_servers + .iter() + .find(|option| option.name == *server) + .ok_or_else(|| { + AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + )) + })?; + if !crate::routes::options::mcp_server_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map_err(|error| { + AppError::Upstream(format!( + "resource qualification configuration error: {error}" + )) + })? { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` lacks current resource qualification for fallback {route}" + ))); + } + } + if let Some(memory) = blueprint + .memory + .as_deref() + .filter(|memory| !memory.is_empty()) + { + let option = options + .memories + .iter() + .find(|option| option.name == memory) + .ok_or_else(|| { + AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + )) + })?; + if !crate::routes::options::memory_binding_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map_err(|error| { + AppError::Upstream(format!( + "resource qualification configuration error: {error}" + )) + })? { + return Err(AppError::BadRequest(format!( + "memory `{memory}` lacks current resource qualification for fallback {route}" + ))); + } + } + for skill in &blueprint.skills { + let option = options + .skills + .iter() + .find(|option| option.name == *skill) + .ok_or_else(|| { + AppError::BadRequest(format!( + "skill `{skill}` is not present in the approved live catalogue" + )) + })?; + if !crate::routes::options::skill_version_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map_err(|error| { + AppError::Upstream(format!( + "resource qualification configuration error: {error}" + )) + })? { + return Err(AppError::BadRequest(format!( + "skill `{skill}` lacks current version qualification for fallback {route}" + ))); + } + } + Ok(()) +} + +/// `POST /api/namespaces/:ns/tasks` — create a task. +/// +/// The BFF never sets status — it submits the spec and lets the controller +/// validate the envelope and stamp the digest. Admission (CEL) rejects an +/// amplifying envelope here, which we surface as a 422-style upstream error. +pub async fn create_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path(ns): Path<String>, + Json(mut req): Json<CreateTaskRequest>, +) -> AppResult<Json<TaskDetailDto>> { + let cluster = require_cluster(&state)?; + cluster.credential_grant(&ns).await.map_err(map_kube_err)?; + let api: Api<KarsTask> = cluster.tasks(&ns); + // The caller cannot choose attribution; it is derived from the verified + // Bridge session inserted by auth middleware. + req.created_by = Some(principal.name.clone()); + let created_by = principal.name.clone(); + if let Some(plan) = req + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + { + crate::routes::compose::validate_execution_plan(plan).map_err(AppError::BadRequest)?; + req.envelope.delegation_depth = 1; + } else if let Some(delegation) = req.delegation.as_ref() { + crate::routes::compose::validate_delegation(delegation).map_err(AppError::BadRequest)?; + req.envelope.delegation_depth = i32::from(delegation.mode == "principal-specialists"); + } + let mut harness_correction: Option<String> = None; + if let Some(blueprint) = req.blueprint.as_mut() + && let Some(runtime) = blueprint.runtime.as_deref() + && crate::routes::compose::is_non_autonomous_harness(runtime) + { + harness_correction = Some(format!( + "harness {runtime} is a bootstrap-only adapter (no autonomous task loop) and cannot run a one-shot mission; corrected to OpenClaw" + )); + blueprint.runtime = Some("OpenClaw".to_string()); + } + let git_write = crate::routes::github::authorize_git_write( + cluster, + &ns, + &principal, + req.git_write_repos.as_deref(), + ) + .await?; + if req.blueprint.as_ref().is_some_and(|blueprint| { + blueprint.runtime.as_deref().unwrap_or("OpenClaw") != "OpenClaw" + && !blueprint.skills.is_empty() + }) { + return Err(AppError::BadRequest( + "controller-mounted file skills are currently supported only by OpenClaw".into(), + )); + } + if req + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.tool_policy.as_deref()) + == Some("kars-team-member") + { + return Err(AppError::BadRequest( + "kars-team-member is reserved for declared standing-team specialists".into(), + )); + } + if let Some(blueprint) = req.blueprint.as_mut() { + if blueprint.model_fallbacks.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 routes".into(), + )); + } + if blueprint.model.is_none() { + let options = crate::routes::options::build_options(cluster).await?; + blueprint.model = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }); + } + } + if let Some(blueprint) = req.blueprint.as_ref() + && let Some(model) = blueprint.model.as_ref() + { + let options = crate::routes::options::build_options(cluster).await?; + let served = options.models.iter().any(|option| { + option.provider == model.provider && option.deployment == model.deployment + }); + if !served { + return Err(AppError::BadRequest(format!( + "model route `{}::{}` is not present in the live model catalogue", + model.provider, model.deployment + ))); + } + let runtime = blueprint.runtime.as_deref().unwrap_or("OpenClaw"); + if !cluster.runnable_runtimes().await.contains(runtime) { + return Err(AppError::BadRequest(format!( + "runtime `{runtime}` cannot start on this cluster" + ))); + } + let (required_capabilities, max_parallel) = + crate::routes::validate::qualification_requirements(blueprint, None); + let total_tokens = req + .envelope + .budget + .as_ref() + .and_then(|budget| budget.tokens); + match crate::routes::options::route_qualification( + runtime, + &model.provider, + &model.deployment, + &required_capabilities, + max_parallel, + total_tokens, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "runtime/model route `{runtime} · {}::{}` lacks qualification evidence for capabilities: {}", + model.provider, + model.deployment, + required_capabilities + .iter() + .cloned() + .collect::<Vec<_>>() + .join(", ") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "route qualification configuration error: {error}" + ))); + } + } + fn find_resource<'a>( + items: &'a [crate::routes::options::RefOption], + name: &str, + ) -> Option<&'a crate::routes::options::RefOption> { + items.iter().find(|option| option.name == name) + } + for server in &blueprint.mcp_servers { + let Some(option) = find_resource(&options.mcp_servers, server) else { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + ))); + }; + match crate::routes::options::mcp_server_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` lacks retained resource qualification for {} at current schema {}", + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ), + option.tool_schema_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + if let Some(memory) = blueprint + .memory + .as_deref() + .filter(|memory| !memory.is_empty()) + { + let Some(option) = find_resource(&options.memories, memory) else { + return Err(AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + ))); + }; + match crate::routes::options::memory_binding_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "memory `{memory}` lacks retained resource qualification for {} at backend {} / compiled digest {}", + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ), + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + for skill in &blueprint.skills { + let Some(option) = find_resource(&options.skills, skill) else { + return Err(AppError::BadRequest(format!( + "skill `{skill}` is not present in the approved live catalogue" + ))); + }; + match crate::routes::options::skill_version_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "skill `{skill}` lacks retained resource qualification for {} at version digest {}", + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ), + option.version_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + let mut seen = std::collections::BTreeSet::new(); + for fallback in &blueprint.model_fallbacks { + let key = format!("{}::{}", fallback.provider, fallback.deployment); + if key == format!("{}::{}", model.provider, model.deployment) || !seen.insert(key) { + continue; + } + validate_mission_fallback_route( + &options, + blueprint, + runtime, + fallback, + &required_capabilities, + max_parallel, + total_tokens, + )?; + } + } + + // Aggregate inference-budget gate (cluster + workspace + user). A launched + // mission consumes inference tokens, so a strict/over-buffer budget at any + // tier blocks starting new work. Draft (unlaunched) missions don't run yet, + // so they pass — the gate re-applies when they run. + if req.launch { + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; + } + + // Default the tool policy to `kars-default` when neither the request envelope + // nor the blueprint pins one. This is not cosmetic: the AGT mesh transport the + // run's delivery rides on requires a mounted ToolPolicy. With governance OFF + // the sandbox mounts no policy, the AGT engine fails closed, and the agent can + // never send its `task_response` back to the controller — the run streams live + // but NEVER delivers (no output ConfigMap, endless re-dispatch). Every bridge + // mission must be governed; `kars-default` is the cluster's baseline policy. + // An explicit blueprint tool policy still wins (governance_spec prefers it), so + // we only inject the default when the blueprint carries none. + let blueprint_has_tool_policy = req + .blueprint + .as_ref() + .and_then(|b| b.tool_policy.as_ref()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let tool_policy_ref = req + .envelope + .tool_policy + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| (!blueprint_has_tool_policy).then(|| "kars-default".to_string())) + .map(|name| LocalObjectRef { name }); + + // ── Hard capability match, defense-in-depth ───────────────────────────── + // A direct mission is one-shot autonomous; a bootstrap-only adapter has no + // task-execution loop and delivers nothing. The compose flow already + // corrects this, but a manually-edited package could still name one — so + // enforce it again at creation: rewrite the harness to OpenClaw and record + // the correction as a governance annotation on the task so it survives into + // the run and the receipt/decision view. (Hermes/BYO are autonomous — kept.) + let mut blueprint = req.blueprint.map(BlueprintDto::into_crd); + if let Some((git_write, binding)) = git_write { + let blueprint = blueprint.get_or_insert_with(Default::default); + blueprint.git_write = Some(git_write); + blueprint.github_binding = Some(binding); + } + let spec = KarsTaskSpec { + objective: req.objective, + display_name: req.display_name, + execution: req.launch.then_some(crate::kars::task::TaskExecution { + launch: true, + runtime: None, + }), + blueprint, + parent_ref: req + .parent + .filter(|s| !s.is_empty()) + .map(|name| LocalObjectRef { name }), + envelope: TaskEnvelope { + tier: req.envelope.tier, + authority_ceiling: req.envelope.authority_ceiling, + delegation_depth: req.envelope.delegation_depth, + budget: req.envelope.budget.map(|b| TaskBudget { + scope: b.scope, + tokens: b.tokens, + usd_micros: b.usd_micros, + }), + tool_policy_ref, + egress_allowlist_ref: req + .envelope + .egress_allowlist + .filter(|s| !s.is_empty()) + .map(|name| LocalObjectRef { name }), + }, + retention_ttl_seconds: req.retention_ttl_seconds, + }; + let mut task = KarsTask::new(&req.name, spec); + if let Some(plan) = task + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + { + let total_tokens = task + .spec + .envelope + .budget + .as_ref() + .and_then(|budget| budget.tokens) + .ok_or_else(|| { + AppError::BadRequest( + "execution-plan missions require an explicit total token budget".into(), + ) + })?; + let (principal_tokens, child_tokens) = + crate::routes::compose::delegation_budget_allocation(total_tokens, plan.roles.len()) + .map_err(AppError::BadRequest)?; + let annotations = task + .metadata + .annotations + .get_or_insert_with(Default::default); + annotations.insert( + "kars.azure.com/mission-budget-total".into(), + total_tokens.to_string(), + ); + annotations.insert( + "kars.azure.com/mission-principal-budget".into(), + principal_tokens.to_string(), + ); + annotations.insert( + "kars.azure.com/mission-child-budget".into(), + child_tokens.to_string(), + ); + annotations.insert( + "kars.azure.com/mission-specialist-count".into(), + plan.roles.len().to_string(), + ); + annotations.insert( + "kars.azure.com/mission-decomposition".into(), + "execution-plan/v1".into(), + ); + } + // Record the capability correction on the task so it's durable and surfaces in + // the governed record (the run reads task annotations; the receipt/decision + // view can attest the harness was corrected rather than silently swapped). + if let Some(reason) = &harness_correction { + task.metadata + .annotations + .get_or_insert_with(Default::default) + .insert( + "kars.azure.com/harness-corrected".to_string(), + reason.clone(), + ); + } + // Stamp the creator for per-user budget attribution. + task.metadata + .annotations + .get_or_insert_with(Default::default) + .insert("kars.azure.com/created-by".to_string(), created_by.clone()); + let annotations = task + .metadata + .annotations + .get_or_insert_with(Default::default); + annotations.insert( + "kars.azure.com/owner-sub".to_string(), + principal.sub.clone(), + ); + annotations.insert( + "kars.azure.com/owner-name".to_string(), + principal.name.clone(), + ); + let launch = task + .spec + .execution + .as_ref() + .is_some_and(|execution| execution.launch); + if let Some(execution) = task.spec.execution.as_mut() { + execution.launch = false; + } + let created = api + .create(&PostParams::default(), &task) + .await + .map_err(map_kube_err)?; + cluster + .finish_created_credentials( + &crate::kars::credentials::Target { + kind: "KarsTask".into(), + namespace: ns.clone(), + name: created.name_any(), + uid: created + .uid() + .ok_or_else(|| AppError::Upstream("Task CREATE omitted UID".into()))?, + }, + launch, + ) + .await + .map_err(map_kube_err)?; + let created = api.get(&created.name_any()).await.map_err(map_kube_err)?; + Ok(Json(to_detail( + &created, + Vec::new(), + Vec::new(), + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + None, + None, + ))) +} diff --git a/bridge/bff/src/routes/tasks/diagnostics.rs b/bridge/bff/src/routes/tasks/diagnostics.rs new file mode 100644 index 000000000..4a93e1b9a --- /dev/null +++ b/bridge/bff/src/routes/tasks/diagnostics.rs @@ -0,0 +1,233 @@ +use axum::Json; +use axum::extract::{Extension, Path, State}; + +use crate::auth::Principal; +use crate::error::AppResult; +use crate::routes::ownership::require_owned_task_or_output; +use crate::state::AppState; + +use super::require_cluster; + +#[derive(serde::Serialize)] +pub struct TroubleshootDto { + /// Whether a sandbox pod was found for this run at all. + pub pod_found: bool, + /// Ready containers vs total (e.g. "2/2") when a pod exists. + pub pod_summary: Option<String>, + /// Per-container state (name, ready, restarts, running/waiting/terminated). + pub containers: Vec<crate::kars::cluster::ContainerState>, + /// The tail of the agent container's REAL logs — the ground-truth evidence. + pub agent_log_tail: Vec<String>, + /// The specific log/status lines that matched a known failure signature — + /// the smoking gun, highlighted for the reader. + pub evidence: Vec<String>, + /// Plain-language cause + remedy, derived from the REAL evidence above. + pub cause: String, + pub remedy: String, + /// True when the harness itself is the problem (a chat-gateway on a one-shot + /// mission) — the UI steers the re-compose to OpenClaw. + pub harness_issue: bool, + /// The recorded run status/reason, for cross-reference. + pub result_status: Option<String>, + pub result_reason: Option<String>, +} + +/// `GET /api/namespaces/:ns/tasks/:name/troubleshoot` — actually troubleshoot a +/// run by reading the sandbox pod's real container states + agent logs and +/// diagnosing from that ground truth (not by pattern-matching a status string). +pub async fn troubleshoot_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<TroubleshootDto>> { + let cluster = require_cluster(&state)?; + let task = require_owned_task_or_output(cluster, &ns, &name, &principal).await?; + // Resolve the sandbox: for a mission the sandbox is named after the task. + // Fall back to the task's recorded sandbox reference when present. + let sandbox = task + .and_then(|t| t.status.and_then(|s| s.sandbox_ref).map(|r| r.name)) + .unwrap_or_else(|| name.clone()); + + let logs = cluster + .read_sandbox_logs(&sandbox, "agent", 120) + .await + .unwrap_or_default(); + let containers = cluster.sandbox_container_states(&sandbox).await; + let health = cluster.sandbox_pod_health(&sandbox).await; + let output = cluster.read_mission_output(&name).await; + let result_status = output.as_ref().and_then(|d| d.get("status").cloned()); + let result_reason = output.as_ref().and_then(|d| d.get("output").cloned()); + + let log_lines: Vec<String> = logs.lines().map(|s| s.to_string()).collect(); + let (cause, remedy, harness_issue, evidence) = + diagnose_run_failure(&log_lines, &containers, result_reason.as_deref()); + + // Keep the last ~30 log lines for the "raw evidence" view. + let agent_log_tail: Vec<String> = log_lines.iter().rev().take(30).rev().cloned().collect(); + + Ok(Json(TroubleshootDto { + pod_found: !containers.is_empty() || health.is_some(), + pod_summary: health + .as_ref() + .map(|h| format!("{}/{}", h.ready_containers, h.total_containers)), + containers, + agent_log_tail, + evidence, + cause, + remedy, + harness_issue, + result_status, + result_reason, + })) +} + +/// Diagnose a run failure from REAL evidence: the agent log lines, the container +/// states, and the recorded reason. Returns (cause, remedy, harness_issue, +/// evidence-lines). Signatures are ordered most-specific first. +pub(super) fn diagnose_run_failure( + log_lines: &[String], + containers: &[crate::kars::cluster::ContainerState], + reason: Option<&str>, +) -> (String, String, bool, Vec<String>) { + let find = |needles: &[&str]| -> Vec<String> { + log_lines + .iter() + .filter(|l| { + let low = l.to_lowercase(); + needles.iter().any(|n| low.contains(&n.to_lowercase())) + }) + .cloned() + .collect::<Vec<_>>() + }; + + // 1. Container-level infrastructure failures (authoritative). + for c in containers { + if let Some(r) = c.reason.as_deref() { + let rl = r.to_lowercase(); + if rl.contains("imagepull") || rl.contains("errimage") { + return ( + format!("The “{}” container can't pull its image ({r}).", c.name), + "This is an infrastructure issue — the image tag is missing or the registry is unreachable. An operator should check the image reference and ACR/registry access.".into(), + false, + vec![format!("container {} is {} ({r})", c.name, c.state)], + ); + } + if rl.contains("crashloop") { + return ( + format!("The “{}” container is crash-looping (restarted {} times).", c.name, c.restarts), + "The container starts and immediately exits. Check the agent logs below for the panic/exit reason; often a bad config, missing secret, or an incompatible image.".into(), + false, + find(&["error", "panic", "fatal", "exited", "traceback"]), + ); + } + if rl.contains("oomkill") { + return ( + format!("The “{}” container was OOM-killed (out of memory).", c.name), + "The run exceeded the sandbox memory limit. Reduce the working set or raise the sandbox resources.".into(), + false, + vec![format!("container {} terminated: OOMKilled", c.name)], + ); + } + } + } + + // 2. Hermes chat-gateway idle — the exact evidence from the entrypoint. + let hermes = find(&[ + "no channels", + "idle daemon mode", + "no messaging platforms enabled", + "gateway in idle", + ]); + if !hermes.is_empty() { + return ( + "The agent is running on the Hermes chat-gateway harness, which started in IDLE DAEMON MODE because no messaging channels are configured. It is waiting for inbound messages (Telegram/Slack/…) and never executes a one-shot autonomous mission — so the run produced nothing and timed out.".into(), + "Re-compose this mission on the OpenClaw harness (built for autonomous missions). Hermes only fits work that is DRIVEN by a chat channel.".into(), + true, + hermes, + ); + } + + // 3. Content safety / auth / rate limit from logs. + let safety = find(&[ + "content safety", + "jailbreak", + "blocked by policy", + "content_filter", + ]); + if !safety.is_empty() { + return ( + "A content-safety policy blocked the run.".into(), + "Adjust the objective to avoid the flagged content, or ask an operator about the content-safety floor.".into(), + false, + safety, + ); + } + let auth = find(&[ + "401 unauthorized", + "403 forbidden", + "authentication failed", + "invalid api key", + ]); + if !auth.is_empty() { + return ( + "The agent's model calls were rejected by the provider (authentication/authorization).".into(), + "An operator should check the router's provider credentials / workload-identity role for this model.".into(), + false, + auth, + ); + } + let rate = find(&["429", "rate limit", "too many requests", "quota"]); + if !rate.is_empty() { + return ( + "The model provider rate-limited or quota-limited the run.".into(), + "Re-run after a short wait, or an operator can raise the model deployment's quota." + .into(), + false, + rate, + ); + } + let schema = find(&[ + "stream_options.include_usage", + "unknown parameter: 'stream_options", + "stream_options: extra inputs", + ]); + if !schema.is_empty() { + return ( + "The selected model rejected the translated inference request before it could reason or call tools.".into(), + "This is a model/router compatibility issue, not an egress or prompt problem. Deploy the corrected inference router, then re-run the same mission; selecting another catalogue model is only a temporary workaround.".into(), + false, + schema, + ); + } + + // 4. Fall back to the recorded reason. + let rl = reason.unwrap_or("").to_lowercase(); + if rl.contains("did not come online") + || rl.contains("not yet discoverable") + || rl.contains("mesh registry") + { + return ( + "The agent never registered on the encrypted mesh within the startup window, so the controller timed the run out.".into(), + "Re-run it — a fresh sandbox often comes up cleanly. If it repeats, check the agent logs below and the sandbox events.".into(), + false, + find(&["mesh", "relay", "register", "keepalive"]), + ); + } + if rl.contains("no progress heartbeat") || rl.contains("timed out") || rl.contains("timeout") { + return ( + "The agent started but stopped making progress, so the controller timed the run out." + .into(), + "Re-run it; if it stalls again, narrow the objective or raise the token/time budget." + .into(), + false, + find(&["error", "timeout", "stalled"]), + ); + } + + ( + "The run ended without producing a deliverable. See the agent's own logs below for the specifics.".into(), + "Re-run it, or re-compose with a different harness/model. If the logs show a repeating error, address that first.".into(), + false, + find(&["error", "panic", "fatal", "exception"]), + ) +} diff --git a/bridge/bff/src/routes/tasks/egress.rs b/bridge/bff/src/routes/tasks/egress.rs new file mode 100644 index 000000000..d99bb1689 --- /dev/null +++ b/bridge/bff/src/routes/tasks/egress.rs @@ -0,0 +1,297 @@ +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::Deserialize; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::KarsTask; +use crate::routes::ownership::require_owned_task; +use crate::state::AppState; + +use super::{map_kube_err, require_cluster}; + +/// Body for a temporary egress request from a mission: the agent (or operator +/// on its behalf) asks to reach an extra website. Materialized as an +/// `EgressApproval` the controller reconciles through human approval — the BFF +/// never widens the sandbox's allowlist directly. +#[derive(Debug, Deserialize)] +pub struct EgressRequest { + pub host: String, + pub port: Option<u16>, + pub reason: String, + /// Time-to-live, e.g. "2h". Bounded by the cluster ceiling. Default "2h". + pub ttl: Option<String>, +} + +/// Normalize a human-friendly TTL (`"2h"`, `"30m"`, `"24h"`, `"1d"`, `"90s"`) to +/// the ISO-8601 duration the controller's `EgressApproval` reconciler requires +/// (`"PT2H"`, `"PT30M"`, `"P1D"`, `"PT90S"`). An already-ISO value (starts with +/// `P`) passes through uppercased. Unrecognized input falls back to `"PT2H"` +/// rather than emitting an invalid TTL that leaves the grant Pending forever. +pub(super) fn normalize_ttl(raw: &str) -> String { + let t = raw.trim(); + if t.is_empty() { + return "PT2H".into(); + } + if t.starts_with('P') || t.starts_with('p') { + return t.to_ascii_uppercase(); + } + let split = t.find(|c: char| c.is_ascii_alphabetic()).unwrap_or(t.len()); + let (num, unit) = t.split_at(split); + let n: u64 = num.trim().parse().unwrap_or(0); + if n == 0 { + return "PT2H".into(); + } + match unit.trim().to_ascii_lowercase().as_str() { + "s" | "sec" | "secs" => format!("PT{n}S"), + "m" | "min" | "mins" => format!("PT{n}M"), + "h" | "hr" | "hrs" | "hour" | "hours" => format!("PT{n}H"), + "d" | "day" | "days" => format!("P{n}D"), + _ => "PT2H".into(), + } +} + +/// `POST /api/namespaces/:ns/tasks/:name/egress` — file a temporary egress +/// grant request for this mission's sandbox. Returns the created EgressApproval +/// name; it widens nothing until a human approves it. +pub async fn request_egress( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(req): Json<EgressRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let host = req.host.trim().to_string(); + if host.is_empty() { + return Err(AppError::BadRequest("host is required".into())); + } + if req.reason.trim().len() < 3 { + return Err(AppError::BadRequest("reason is required".into())); + } + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + task.status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .ok_or_else(|| AppError::BadRequest("mission has no running sandbox to widen".into()))?; + let port = req.port.unwrap_or(443); + let ttl = normalize_ttl(req.ttl.as_deref().unwrap_or("2h")); + use sha2::{Digest, Sha256}; + let suffix = hex::encode(Sha256::digest(format!("{host}:{port}").as_bytes())); + let approval_name = format!("{name}-eg-{}", &suffix[..12]); + let task_uid = task + .metadata + .uid + .clone() + .ok_or_else(|| AppError::Upstream("task has no Kubernetes UID".into()))?; + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsApproval", + "metadata": { + "name": approval_name, + "namespace": ns, + "ownerReferences": [{ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "name": name, + "uid": task_uid, + "controller": true, + "blockOwnerDeletion": true + }], + "labels": { + "kars.azure.com/req-task": name, + "kars.azure.com/req-kind": "egress" + }, + "annotations": { + "kars.azure.com/req-kind": "egress", + "kars.azure.com/req-target": host, + "kars.azure.com/req-port": port.to_string(), + "kars.azure.com/req-ttl": ttl, + "kars.azure.com/requested-by": principal.name, + "kars.azure.com/requested-by-sub": principal.sub, + "kars.azure.com/owner-sub": principal.sub, + "kars.azure.com/owner-name": principal.name + } + }, + "spec": { + "taskRef": {"name": name}, + "requestedBy": { + "subject": principal.sub, + "name": principal.name + }, + "action": { + "kind": "egress", + "summary": format!("Allow the mission to reach {host}:{port}"), + "detail": format!( + "{} Approving creates an exact, time-boxed {host}:{port} grant.", + req.reason.trim() + ) + }, + "ttl": "PT24H" + }, + }); + let created = cluster + .apply_kind(&ns, "KarsApproval", body, false) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "requested": true, + "name": created.metadata.name, + "note": "Pending human approval. No egress is granted until a distinct operator approves it in the Bridge inbox." + }))) +} + +/// `GET /api/namespaces/:ns/tasks/:name/egress/learned` — the domains the agent +/// has actually reached, observed by the router in Learn mode. This is the +/// evidence a customer reviews before promoting the mission to enforced. +pub async fn get_learned_egress( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let sandbox = task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()); + let Some(sandbox) = sandbox else { + return Ok(Json( + serde_json::json!({ "available": false, "reason": "no running sandbox yet", "domains": [] }), + )); + }; + let mode = cluster + .sandbox_egress_mode(&sandbox) + .await + .unwrap_or_else(|| "Learn".into()); + let enforced = cluster.sandbox_allowlist(&sandbox).await; + match cluster.sandbox_learned_domains(&sandbox).await { + Ok(domains) => Ok(Json( + serde_json::json!({ "available": true, "mode": mode, "domains": domains, "enforced": enforced }), + )), + Err(e) => Ok(Json( + serde_json::json!({ "available": false, "mode": mode, "reason": e.to_string(), "domains": [], "enforced": enforced }), + )), + } +} + +/// Body for flipping a mission's egress enforcement mode. +#[derive(Debug, Deserialize)] +pub struct EgressModeRequest { + /// `"learning"` (clear the allowlist → controller runs Learn) or + /// `"enforced"` (pin the allowlist → controller runs Strict). + pub mode: String, + /// The hosts to enforce when `mode == "enforced"`. Typically the reviewed + /// subset of the learned domains. + #[serde(default)] + pub allow: Vec<String>, + /// When true, UNION `allow` with the mission's current enforced allowlist + /// instead of replacing it — so granting one host (e.g. from a blocker) can + /// never silently wipe previously-approved hosts. The NetworkMode panel, + /// which sets the full list deliberately, leaves this false (replace). + #[serde(default)] + pub merge: bool, +} + +/// `POST /api/namespaces/:ns/tasks/:name/egress-mode` — promote a mission from +/// learning (monitoring) to enforced, or back. This drives the REAL lever: the +/// controller derives `egressMode: Strict` + an allowlist when the blueprint +/// names egress hosts, and `Learn` when it is empty. Operator-gated; the +/// controller re-reconciles the sandbox, so this is durable, not a UI toggle. +pub async fn set_egress_mode( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(req): Json<EgressModeRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let enforced = match req.mode.as_str() { + "enforced" | "strict" => true, + "learning" | "learn" => false, + _ => { + return Err(AppError::BadRequest( + "mode must be 'enforced' or 'learning'".into(), + )); + } + }; + // Parse "host" or "host:port" into the blueprint egress shape. + let egress: Vec<serde_json::Value> = if enforced { + let mut parsed: Vec<serde_json::Value> = req + .allow + .iter() + .filter_map(|h| { + let h = h.trim(); + if h.is_empty() { + return None; + } + match h + .rsplit_once(':') + .and_then(|(host, p)| p.parse::<u16>().ok().map(|p| (host, p))) + { + Some((host, port)) => Some(serde_json::json!({ "host": host, "port": port })), + None => Some(serde_json::json!({ "host": h })), + } + }) + .collect(); + // Additive grant: union with the mission's CURRENT enforced allowlist so + // approving one host never clobbers the others (a k8s merge-patch of an + // array replaces it wholesale, so we must merge here, before patching). + if req.merge { + let existing: Vec<serde_json::Value> = task + .spec + .blueprint + .map(|b| b.egress) + .unwrap_or_default() + .into_iter() + .map(|e| match e.port { + Some(p) => serde_json::json!({ "host": e.host, "port": p }), + None => serde_json::json!({ "host": e.host }), + }) + .collect(); + let key = |v: &serde_json::Value| { + format!( + "{}:{}", + v.get("host").and_then(|h| h.as_str()).unwrap_or(""), + v.get("port").and_then(|p| p.as_u64()).unwrap_or(0) + ) + }; + let mut seen: std::collections::HashSet<String> = parsed.iter().map(key).collect(); + for e in existing { + if seen.insert(key(&e)) { + parsed.push(e); + } + } + } + if parsed.is_empty() { + return Err(AppError::BadRequest( + "enforcing requires at least one allowed host — review the learned domains first" + .into(), + )); + } + parsed + } else { + Vec::new() + }; + // Patch the mission's blueprint egress; the controller compiles it into the + // sandbox's networkPolicy (Strict + allowlist, or Learn when empty). + let patch = serde_json::json!({ "spec": { "blueprint": { "egress": egress } } }); + cluster + .tasks(&ns) + .patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "updated": true, + "mode": if enforced { "enforced" } else { "learning" }, + "note": if enforced { + "Promoted to enforced — the sandbox will deny anything outside the approved allowlist on its next reconcile." + } else { + "Back to learning — the sandbox observes and records every domain it reaches without denying." + } + }))) +} diff --git a/bridge/bff/src/routes/tasks/evidence.rs b/bridge/bff/src/routes/tasks/evidence.rs new file mode 100644 index 000000000..7cf16d1f3 --- /dev/null +++ b/bridge/bff/src/routes/tasks/evidence.rs @@ -0,0 +1,346 @@ +use super::{ + MissionArtifactDto, MissionResultDto, TaskAssignmentEventDto, TeamCollaborationEventDto, + TeamRolePlanDto, +}; + +pub(super) const ARTIFACT_PREVIEW_MAX_BYTES: usize = 8 * 1024; +pub(super) const ARTIFACT_PREVIEW_TOTAL_BYTES: usize = 64 * 1024; + +pub(super) fn artifact_preview( + content: Option<String>, + remaining_budget: &mut usize, +) -> (Option<String>, Option<i64>, bool, Option<String>) { + let Some(full) = content else { + return (None, None, false, None); + }; + let content_bytes = full.len() as i64; + if full.is_empty() { + return (Some(String::new()), Some(0), false, Some(full)); + } + + let max_bytes = ARTIFACT_PREVIEW_MAX_BYTES + .min(*remaining_budget) + .min(full.len()); + if max_bytes == 0 { + return (None, Some(content_bytes), true, Some(full)); + } + let mut end = max_bytes; + while end > 0 && !full.is_char_boundary(end) { + end -= 1; + } + let preview = full[..end].to_string(); + *remaining_budget = remaining_budget.saturating_sub(preview.len()); + let truncated = end < full.len(); + (Some(preview), Some(content_bytes), truncated, Some(full)) +} + +fn string_field(value: &serde_json::Value, field: &str) -> Option<String> { + value + .get(field) + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +fn bounded_text(value: Option<String>, max_bytes: usize) -> Option<String> { + let value = value?; + if value.len() <= max_bytes { + return Some(value); + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + Some(value[..end].to_string()) +} + +fn bounded_string_field( + value: &serde_json::Value, + field: &str, + max_bytes: usize, +) -> Option<String> { + bounded_text(string_field(value, field), max_bytes) +} + +fn collect_role_names( + value: Option<&serde_json::Value>, + target: &mut Vec<String>, + seen: &mut std::collections::HashSet<String>, +) { + const MAX_ROLE_NAMES: usize = 128; + const MAX_ROLE_NAME_BYTES: usize = 256; + if target.len() >= MAX_ROLE_NAMES { + return; + } + match value { + Some(serde_json::Value::Array(entries)) => { + for entry in entries { + let role = entry + .as_str() + .and_then(|role| bounded_text(Some(role.to_string()), MAX_ROLE_NAME_BYTES)) + .or_else(|| bounded_string_field(entry, "role", MAX_ROLE_NAME_BYTES)) + .or_else(|| bounded_string_field(entry, "name", MAX_ROLE_NAME_BYTES)); + if let Some(role) = role + && seen.insert(role.clone()) + { + target.push(role); + if target.len() >= MAX_ROLE_NAMES { + break; + } + } + } + } + Some(serde_json::Value::Object(entries)) => { + for role in entries.keys() { + let role = bounded_text(Some(role.clone()), MAX_ROLE_NAME_BYTES) + .expect("object keys are present"); + if seen.insert(role.clone()) { + target.push(role); + if target.len() >= MAX_ROLE_NAMES { + break; + } + } + } + } + _ => {} + } +} + +pub(super) fn structured_team_evidence( + artifacts: &[MissionArtifactDto], +) -> (TeamRolePlanDto, Vec<TeamCollaborationEventDto>) { + const MAX_COLLABORATION_EVENTS: usize = 1_000; + const MAX_COLLABORATION_METADATA_BYTES: usize = 512; + const MAX_COLLABORATION_PREVIEW_BYTES: usize = 2 * 1024; + + let mut role_plan = TeamRolePlanDto::default(); + let mut selected_seen = std::collections::HashSet::new(); + let mut skipped_seen = std::collections::HashSet::new(); + for artifact in artifacts + .iter() + .filter(|artifact| artifact.name.ends_with(".json")) + { + let Some(content) = artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + else { + continue; + }; + let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) else { + continue; + }; + collect_role_names( + parsed.get("selected_roles"), + &mut role_plan.selected_roles, + &mut selected_seen, + ); + collect_role_names( + parsed.get("skipped_roles"), + &mut role_plan.skipped_roles, + &mut skipped_seen, + ); + } + + let collaboration = artifacts + .iter() + .find(|artifact| { + artifact.name == "collaboration.jsonl" + || artifact + .source_path + .as_deref() + .is_some_and(|path| path.ends_with("/collaboration.jsonl")) + }) + .and_then(|artifact| { + artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + }) + .map(|content| { + content + .lines() + .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok()) + .take(MAX_COLLABORATION_EVENTS) + .map(|event| TeamCollaborationEventDto { + at: bounded_string_field(&event, "at", MAX_COLLABORATION_METADATA_BYTES), + event: bounded_string_field(&event, "event", MAX_COLLABORATION_METADATA_BYTES) + .unwrap_or_else(|| "event".to_string()), + agent: bounded_string_field(&event, "agent", MAX_COLLABORATION_METADATA_BYTES), + member: bounded_string_field( + &event, + "member", + MAX_COLLABORATION_METADATA_BYTES, + ) + .or_else(|| { + bounded_string_field(&event, "from_agent", MAX_COLLABORATION_METADATA_BYTES) + }) + .or_else(|| { + bounded_string_field(&event, "to_agent", MAX_COLLABORATION_METADATA_BYTES) + }), + outcome: bounded_string_field( + &event, + "outcome", + MAX_COLLABORATION_METADATA_BYTES, + ), + message_id: bounded_string_field( + &event, + "message_id", + MAX_COLLABORATION_METADATA_BYTES, + ), + reply_preview: bounded_text( + string_field(&event, "reply_preview"), + MAX_COLLABORATION_PREVIEW_BYTES, + ), + content_preview: bounded_text( + string_field(&event, "content_preview"), + MAX_COLLABORATION_PREVIEW_BYTES, + ), + }) + .collect() + }) + .unwrap_or_default(); + + (role_plan, collaboration) +} + +pub(super) fn canonicalize_assignment_event_roles( + events: &mut [TaskAssignmentEventDto], + collaboration: &[TeamCollaborationEventDto], +) { + let roles_by_child_task = collaboration + .iter() + .filter_map(|event| { + Some(( + event.message_id.as_deref()?.to_string(), + event.member.as_deref()?.to_string(), + )) + }) + .collect::<std::collections::HashMap<_, _>>(); + + for event in events { + let Some(child_task_id) = event.child_task_id.as_deref() else { + continue; + }; + if let Some(role) = roles_by_child_task.get(child_task_id) { + event.child_role = Some(role.clone()); + } + } +} + +pub(super) fn select_task_checkpoint( + progress: Option<serde_json::Value>, + artifacts: &[MissionArtifactDto], + successful_result: bool, +) -> Option<serde_json::Value> { + let artifact_checkpoint = artifacts + .iter() + .find(|artifact| artifact.name.ends_with("task-checkpoint.json")) + .and_then(|artifact| { + artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + }) + .and_then(|content| serde_json::from_str(content).ok()) + .and_then(valid_task_checkpoint); + let checkpoint = artifact_checkpoint.or_else(|| progress.and_then(valid_task_checkpoint)); + + checkpoint.filter(|checkpoint| { + !successful_result + || !matches!( + checkpoint.get("status").and_then(serde_json::Value::as_str), + Some("pending" | "in_progress") + ) + }) +} + +pub(super) fn merge_trace_total_tokens( + result: &mut Option<MissionResultDto>, + trace_total_tokens: i64, +) { + if trace_total_tokens <= 0 { + return; + } + if let Some(result) = result { + result.total_tokens = Some( + result + .total_tokens + .unwrap_or_default() + .max(trace_total_tokens), + ); + } +} + +pub(super) fn subagent_trace_from_artifacts( + artifacts: &[MissionArtifactDto], +) -> Vec<serde_json::Value> { + let mut events = Vec::new(); + for artifact in artifacts { + if !artifact.name.ends_with("subagent-telemetry.jsonl") { + continue; + } + let Some(content) = artifact + .full_content + .as_deref() + .or(artifact.content.as_deref()) + else { + continue; + }; + for line in content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else { + continue; + }; + if record.get("event").and_then(serde_json::Value::as_str) != Some("subagent_trace") { + continue; + } + let Some(mut trace) = record.get("trace").cloned() else { + continue; + }; + if let Some(object) = trace.as_object_mut() { + let member = record + .get("member") + .and_then(serde_json::Value::as_str) + .unwrap_or("subagent"); + object.insert("agent".into(), serde_json::json!(member)); + if let Some(mesh_name) = record.get("mesh_name").and_then(serde_json::Value::as_str) + { + object.insert("agentInstance".into(), serde_json::json!(mesh_name)); + } + object.insert("agentRole".into(), serde_json::json!("subagent")); + if object.get("ts").is_none() + && let Some(at) = record.get("at").cloned() + { + object.insert("ts".into(), at); + } + } + events.push(trace); + } + } + events +} + +pub(super) fn valid_task_checkpoint(value: serde_json::Value) -> Option<serde_json::Value> { + let schema = value.get("schema")?.as_str()?; + let milestone = value.get("milestone_id")?.as_str()?.trim(); + let status = value.get("status")?.as_str()?; + let summary = value.get("summary")?.as_str()?.trim(); + let string_array = |key: &str| { + value.get(key).is_none_or(|field| { + field + .as_array() + .is_some_and(|items| items.iter().all(serde_json::Value::is_string)) + }) + }; + (schema == "kars.checkpoint/v1" + && !milestone.is_empty() + && !summary.is_empty() + && matches!(status, "pending" | "in_progress" | "completed" | "blocked") + && string_array("acceptance_criteria") + && string_array("artifacts") + && string_array("next_steps")) + .then_some(value) +} diff --git a/bridge/bff/src/routes/tasks/fleet.rs b/bridge/bff/src/routes/tasks/fleet.rs new file mode 100644 index 000000000..8d0768a65 --- /dev/null +++ b/bridge/bff/src/routes/tasks/fleet.rs @@ -0,0 +1,413 @@ +use axum::Json; +use axum::extract::{Extension, State}; +use kube::ResourceExt; +use serde::Serialize; + +use crate::auth::Principal; +use crate::error::AppResult; +use crate::kars::task::KarsTask; +use crate::state::AppState; + +use super::mapping::is_task_owner; +use super::{clean_display_name, clean_objective, map_kube_err, require_cluster}; + +/// One running agent + what it is doing now, for the lifecycle view. +#[derive(Debug, Serialize)] +pub struct AgentLifecycleDto { + pub sandbox: String, + pub namespace: String, + pub phase: Option<String>, + pub parent: Option<String>, + /// The task this agent is executing (label-derived), if any. + pub task: Option<String>, + pub objective: Option<String>, + pub tier: Option<i32>, + /// Live activity counts from the persisted trace (rounds + tool calls). + pub rounds: usize, + pub tool_calls: usize, + pub last_action: Option<String>, + /// True when the agent's sandbox pod is still running (working now) vs a + /// recently-completed run (its ephemeral sandbox already torn down). + pub live: bool, + /// Real token cost of the run (from the mission output), when known. + pub tokens: Option<i64>, + /// The run's token budget ceiling (from the envelope), when set — so the UI + /// can render spend against limit ("spent / budget") rather than a bare + /// number. `None` for an uncapped run. + pub budget_tokens: Option<i64>, + /// Run outcome: `ok` | `error` (from the mission output), when finished. + pub status: Option<String>, + /// When the run delivered (from the mission output). + pub finished_at: Option<String>, + /// Owning standing team, if this is a team run. + pub team: Option<String>, + /// Human label for the run. + pub display_name: Option<String>, + /// Live pod health (readiness, restarts, uptime, node) — only for live + /// agents; `None` for finished runs whose sandbox was torn down. + pub health: Option<crate::kars::cluster::PodHealth>, +} + +/// Whether a `KarsTask` should surface on the "Active agents" fleet views. A +/// surfaceable run is either a team taskforce run, a `*-run-<ts>` scheduled run, +/// OR a launched direct mission (a one-off the user kicked off from `/new`). +/// Un-launched drafts and team structural tasks (a non-taskforce `team-role`) +/// are NOT agents yet, so they stay out. Without the direct-mission arm the +/// flagship "Active agents" page was empty for the single most common action — +/// launch a mission and watch it — because a direct mission carries neither the +/// taskforce role nor a `-run-` suffix. +fn is_surfaceable_run(t: &KarsTask) -> bool { + let name = t.name_any(); + let role = t + .annotations() + .get("kars.azure.com/team-role") + .map(String::as_str); + if role == Some("taskforce") || name.contains("-run-") { + return true; + } + // A launched direct mission: no team structural role, and it was launched. + let launched = t.spec.execution.as_ref().map(|e| e.launch).unwrap_or(false); + role.is_none() && launched +} + +/// `GET /api/agents` — recent and live agent runs with their real work. Sources +/// from run KarsTasks + their persisted mission telemetry (not idle pods), so +/// the page answers "what have my agents been doing, and what's working now" — +/// live runs first, then recently completed. Ephemeral run sandboxes tear down +/// after delivering, so their work would otherwise vanish; here it persists. +pub async fn list_agents( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, +) -> AppResult<Json<Vec<AgentLifecycleDto>>> { + let cluster = require_cluster(&state)?; + let tasks_api = cluster.tasks("kars-system"); + let all = tasks_api + .list(&kube::api::ListParams::default()) + .await + .map_err(map_kube_err)?; + + // Runs only: a team-owned run, a `*-run-<ts>` task, or a launched direct + // mission. Members/principals (structural team roles) are standing authority, + // not work to surface here. + let mut runs: Vec<&KarsTask> = all + .items + .iter() + .filter(|task| is_surfaceable_run(task) && is_task_owner(task, &principal)) + .collect(); + // Freshest first. + runs.sort_by(|a, b| { + let ta = a.metadata.creation_timestamp.as_ref().map(|t| t.0); + let tb = b.metadata.creation_timestamp.as_ref().map(|t| t.0); + tb.cmp(&ta) + }); + + let mut out = Vec::new(); + for task in runs.into_iter().take(24) { + let name = task.name_any(); + let team = task + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/team").cloned()); + + // Mission output: tokens, status, finished, round/tool rollup. + let output = cluster.read_mission_output(&name).await; + let (tokens, status, finished_at, mut rounds, mut tool_calls) = match &output { + Some(d) => ( + d.get("totalTokens").and_then(|v| v.parse::<i64>().ok()), + d.get("status").cloned(), + d.get("finishedAt").cloned(), + d.get("rounds") + .and_then(|v| v.parse::<usize>().ok()) + .unwrap_or(0), + d.get("toolCalls") + .and_then(|v| v.parse::<usize>().ok()) + .unwrap_or(0), + ), + None => (None, None, None, 0, 0), + }; + + // Live iff the run's sandbox pod is running AND it hasn't delivered a + // terminal result yet. A DIRECT mission's sandbox lingers (Running) after + // it delivers, so "Running pod" alone would mislabel a finished, idle + // mission as live and inflate the fleet's "working now" count. A delivered + // (or errored) run is Recent, not Live — its outcome and telemetry show in + // the recent list. (Team run sandboxes tear down on delivery, so this is a + // no-op for them.) + let delivered = status.is_some(); + let live = !delivered && cluster.running_pod_for_sandbox(&name).await.is_some(); + // Honest pod health for a live agent (readiness/restarts/uptime/node). + let health = if live { + cluster.sandbox_pod_health(&name).await + } else { + None + }; + + // For a live run, the trace's last tool tells "what it's doing now"; + // also a more current round/tool count than the (post-hoc) output. + let mut last_action = None; + if live { + // A live run has no persisted trace CM yet (it's written at delivery), + // so fall back to the router's live trace — otherwise a working agent + // reports 0 rounds / 0 tool calls / no current action. + let trace: Vec<serde_json::Value> = match cluster + .read_mission_trace(&name) + .await + .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) + { + Some(t) if !t.is_empty() => t, + _ => cluster.sandbox_live_trace(&name).await, + }; + if !trace.is_empty() { + let r = trace + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .count(); + let tc = trace + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("tool")) + .count(); + if r > 0 { + rounds = r; + } + if tc > 0 { + tool_calls = tc; + } + last_action = trace + .last() + .and_then(|e| e.get("name").and_then(|n| n.as_str()).map(String::from)); + } + } + + let phase = if live { + Some("Running".into()) + } else if status.as_deref() == Some("ok") { + Some("Delivered".into()) + } else if status.is_some() { + Some("Errored".into()) + } else { + Some("Idle".into()) + }; + + out.push(AgentLifecycleDto { + sandbox: name.clone(), + namespace: task.namespace().unwrap_or_default(), + phase, + parent: task.spec.parent_ref.as_ref().map(|p| p.name.clone()), + task: Some(name.clone()), + objective: Some(clean_objective(&task.spec.objective)), + tier: Some(task.spec.envelope.tier), + rounds, + tool_calls, + last_action, + live, + tokens, + budget_tokens: task.spec.envelope.budget.as_ref().and_then(|b| b.tokens), + status, + finished_at, + team, + display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), + health, + }); + } + + // Live runs first, then most-recent finished. + out.sort_by(|a, b| b.live.cmp(&a.live).then(b.finished_at.cmp(&a.finished_at))); + Ok(Json(out)) +} + +// ─── Fleet live telemetry (at-scale "what's happening now") ────────────────── + +#[derive(serde::Serialize)] +pub struct FleetActivityItem { + /// The run/agent this event came from. + pub agent: String, + pub display_name: Option<String>, + pub team: Option<String>, + /// "tool" | "round". + pub kind: String, + /// For a tool event, the tool name; for a round, the finish reason. + pub label: String, + /// Optional short argument/host preview for a tool event. + pub detail: Option<String>, + /// Whether a tool event failed (ok=false) — surfaced in red. + pub failed: bool, + /// Round index the event belongs to. + pub round: i64, + /// Monotonic sequence within the run's trace (for stable ordering). + pub seq: i64, + /// Milliseconds the step took, when known. + pub ms: Option<i64>, +} + +#[derive(serde::Serialize)] +pub struct FleetTelemetryDto { + /// Agents whose sandbox pod is running right now. + pub working: usize, + /// Distinct standing teams with a live run. + pub teams_active: usize, + /// Sub-agents (runs with a parent) currently live. + pub sub_agents: usize, + /// Live token burn summed across working agents (from their in-flight trace). + pub tokens_in_flight: i64, + /// Tool calls summed across working agents this run. + pub tool_calls: i64, + /// Model rounds summed across working agents this run. + pub rounds: i64, + /// The most recent activity across ALL live agents, newest first — a single + /// chronological fleet feed of what every working agent is doing right now. + pub feed: Vec<FleetActivityItem>, +} + +/// `GET /api/agents/fleet` — aggregate LIVE telemetry across every working +/// agent, plus a single merged activity feed of what they're all doing right +/// now. This is the "at scale" view: instead of drilling into one mission, see +/// the whole fleet's live tool-by-tool work in one stream. Sourced from each +/// live run's real execution trace — never fabricated; an idle fleet returns +/// zeros and an empty feed. +pub async fn fleet_telemetry( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, +) -> AppResult<Json<FleetTelemetryDto>> { + let cluster = require_cluster(&state)?; + let tasks_api = cluster.tasks("kars-system"); + let all = tasks_api + .list(&kube::api::ListParams::default()) + .await + .map_err(map_kube_err)?; + + let runs: Vec<&KarsTask> = all + .items + .iter() + .filter(|task| is_surfaceable_run(task) && is_task_owner(task, &principal)) + .collect(); + + let mut working = 0usize; + let mut teams: std::collections::BTreeSet<String> = Default::default(); + let mut sub_agents = 0usize; + let mut tokens_in_flight = 0i64; + let mut tool_calls = 0i64; + let mut rounds = 0i64; + let mut feed: Vec<FleetActivityItem> = Vec::new(); + + for task in runs { + let name = task.name_any(); + // Only agents that are actually running right now contribute trace. + if cluster.running_pod_for_sandbox(&name).await.is_none() { + continue; + } + // A delivered direct mission keeps a lingering Running pod but is idle — + // its historical tokens are NOT "in flight". Skip it here so the live + // counters reflect only work happening now (it still shows, with its + // outcome, in the Recent runs list from /api/agents). + if cluster + .read_mission_output(&name) + .await + .and_then(|d| d.get("status").cloned()) + .is_some() + { + continue; + } + working += 1; + for sub in cluster.sub_agent_sandbox_names("kars-system", &name).await { + if cluster.running_pod_for_sandbox(&sub).await.is_some() { + working += 1; + sub_agents += 1; + } + } + let team = task + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/team").cloned()); + if let Some(t) = &team { + teams.insert(t.clone()); + } + let display_name = clean_display_name(&task.spec.display_name, &task.spec.objective); + + // Prefer the persisted trace (delivered runs); for a LIVE run the trace + // CM doesn't exist yet, so fall back to the router's live trace — else + // every actively-working agent shows zero rounds/tokens/tools (the exact + // opposite of "what's happening now"). Mirrors get_task's live fallback. + let trace: Vec<serde_json::Value> = match cluster + .read_mission_trace(&name) + .await + .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) + { + Some(t) if !t.is_empty() => t, + _ => cluster.sandbox_live_trace(&name).await, + }; + if trace.is_empty() { + continue; + } + + for e in &trace { + let kind = e.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + let round = e.get("round").and_then(|v| v.as_i64()).unwrap_or(0); + let seq = e.get("seq").and_then(|v| v.as_i64()).unwrap_or(0); + let ms = e.get("ms").and_then(|v| v.as_i64()); + match kind { + "round" => { + rounds += 1; + tokens_in_flight += e.get("total_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + feed.push(FleetActivityItem { + agent: name.clone(), + display_name: display_name.clone(), + team: team.clone(), + kind: "round".into(), + label: e + .get("finish_reason") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("model round") + .to_string(), + detail: None, + failed: false, + round, + seq, + ms, + }); + } + "tool" => { + tool_calls += 1; + let failed = e.get("ok").and_then(|v| v.as_bool()) == Some(false); + feed.push(FleetActivityItem { + agent: name.clone(), + display_name: display_name.clone(), + team: team.clone(), + kind: "tool".into(), + label: e + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("tool") + .to_string(), + detail: e + .get("args_preview") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.chars().take(80).collect()), + failed, + round, + seq, + ms, + }); + } + _ => {} + } + } + } + + // Newest activity first, capped so a busy fleet stays responsive. + feed.sort_by(|a, b| b.round.cmp(&a.round).then(b.seq.cmp(&a.seq))); + feed.truncate(40); + + Ok(Json(FleetTelemetryDto { + working, + teams_active: teams.len(), + sub_agents, + tokens_in_flight, + tool_calls, + rounds, + feed, + })) +} diff --git a/bridge/bff/src/routes/tasks/history.rs b/bridge/bff/src/routes/tasks/history.rs new file mode 100644 index 000000000..850baa2c7 --- /dev/null +++ b/bridge/bff/src/routes/tasks/history.rs @@ -0,0 +1,212 @@ +use axum::Json; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; + +use super::artifacts::build_artifact_set; +use super::evidence::{ + canonicalize_assignment_event_roles, merge_trace_total_tokens, select_task_checkpoint, + structured_team_evidence, subagent_trace_from_artifacts, +}; +use super::mapping::is_task_owner; +use super::presentation::deliverable_pull_requests; +use super::{ + EnvelopeDto, MissionResultDto, MissionTelemetryDto, TaskAssignmentEventDto, TaskDetailDto, + classify_blocked, deliverable_text, map_kube_err, +}; + +/// Build a read-only mission detail purely from persisted ConfigMaps when the +/// KarsTask CR is gone (retired-run GC). Returns `NotFound` only when there is +/// genuinely no persisted output for the name. The envelope/composition are +/// left empty (the CR that carried them is gone) but the deliverable, artifacts, +/// activity trace, and telemetry — the parts a reviewer actually needs after the +/// fact — are surfaced, along with a terminal phase. +pub(super) async fn synth_detail_from_output( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult<Json<TaskDetailDto>> { + let output_data = match cluster.read_mission_output(name).await { + Some(d) => d, + None => return Err(AppError::NotFound), + }; + if output_data.get("ownerSub").map(String::as_str) != Some(principal.sub.as_str()) { + return Err(AppError::NotFound); + } + let assignment_nonce = output_data.get("assignmentNonce").cloned(); + let historical_task = match output_data.get("taskName") { + Some(task_name) => cluster + .tasks(ns) + .get_opt(task_name) + .await + .map_err(map_kube_err)? + .filter(|task| is_task_owner(task, principal)), + None => None, + }; + let mut assignment_events = historical_task + .as_ref() + .and_then(|task| task.status.as_ref()) + .map(|status| { + status + .assignment_events + .iter() + .filter(|event| { + assignment_nonce + .as_deref() + .is_none_or(|nonce| event.task_id == nonce) + }) + .map(TaskAssignmentEventDto::from) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + + let mut activity: Vec<serde_json::Value> = cluster + .read_mission_trace(name) + .await + .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) + .unwrap_or_default(); + let status = output_data.get("status").map(String::as_str); + let mut result = { + let output = deliverable_text(output_data.get("output").map(String::as_str).unwrap_or("")); + let blocked = classify_blocked(output_data.get("status").map(String::as_str), &output); + Some(MissionResultDto { + output, + status: output_data.get("status").cloned(), + model: output_data.get("model").cloned(), + total_tokens: output_data.get("totalTokens").and_then(|v| v.parse().ok()), + prompt_tokens: output_data.get("promptTokens").and_then(|v| v.parse().ok()), + completion_tokens: output_data + .get("completionTokens") + .and_then(|v| v.parse().ok()), + finished_at: output_data.get("finishedAt").cloned(), + assignment_nonce: output_data.get("assignmentNonce").cloned(), + source: output_data.get("source").cloned(), + blocked, + artifact_persistence: output_data.get("artifactPersistence").cloned(), + artifact_count: output_data + .get("artifactCount") + .and_then(|v| v.parse().ok()), + declared_artifact_count: output_data + .get("declaredArtifactCount") + .and_then(|v| v.parse().ok()), + }) + }; + let artifacts = build_artifact_set(cluster, name, Some(&output_data)).await; + let successful_result = result.as_ref().is_some_and(|result| { + result.status.as_deref() != Some("error") && result.blocked.is_none() + }); + let checkpoint = select_task_checkpoint(None, &artifacts, successful_result); + activity.extend(subagent_trace_from_artifacts(&artifacts)); + activity.sort_by(|left, right| { + left.get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .cmp( + right + .get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + ) + }); + let trace_round_events = activity + .iter() + .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("round")) + .count() as i64; + let trace_tool_events = activity + .iter() + .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("tool")) + .count() as i64; + let trace_total_tokens: i64 = activity + .iter() + .filter(|event| event.get("kind").and_then(serde_json::Value::as_str) == Some("round")) + .filter_map(|event| { + event + .get("total_tokens") + .and_then(serde_json::Value::as_i64) + }) + .sum(); + merge_trace_total_tokens(&mut result, trace_total_tokens); + + let telemetry = { + let mut rounds = output_data + .get("rounds") + .and_then(|v| v.parse::<i64>().ok()); + let mut tool_calls = output_data + .get("toolCalls") + .and_then(|v| v.parse::<i64>().ok()); + if trace_round_events > 0 { + rounds = Some(rounds.unwrap_or_default().max(trace_round_events)); + } + if trace_tool_events > 0 { + tool_calls = Some(tool_calls.unwrap_or_default().max(trace_tool_events)); + } + if rounds.is_some() || tool_calls.is_some() { + Some(MissionTelemetryDto { rounds, tool_calls }) + } else { + None + } + }; + + let phase = if status == Some("error") { + "Failed" + } else { + "Delivered" + }; + let (role_plan, collaboration_events) = structured_team_evidence(&artifacts); + canonicalize_assignment_event_roles(&mut assignment_events, &collaboration_events); + + Ok(Json(TaskDetailDto { + name: name.to_string(), + namespace: ns.to_string(), + objective: output_data.get("objective").cloned().unwrap_or_default(), + display_name: output_data + .get("displayName") + .cloned() + .filter(|s| !s.trim().is_empty()), + created_at: output_data.get("startedAt").cloned(), + envelope: EnvelopeDto { + tier: output_data.get("tier").and_then(|v| v.parse().ok()).unwrap_or(0), + authority_ceiling: 0, + delegation_depth: 0, + budget: None, + tool_policy: None, + egress_allowlist: None, + }, + phase: phase.to_string(), + envelope_digest: None, + observed_generation: None, + lineage: Vec::new(), + parent: None, + team: output_data.get("team").cloned(), + status_message: Some( + "This run's governance record was retired (garbage-collected); the deliverable and audit trail below are read from the persisted mission output.".to_string(), + ), + children: Vec::new(), + launched: true, + execution_phase: Some("Idle".to_string()), + sandbox: None, + egress_mode: None, + execution_detail: None, + assignment: None, + assignment_events, + assignment_sequence: None, + composition: None, + sub_agents: Vec::new(), + result, + artifacts, + role_plan, + collaboration_events, + pull_requests: deliverable_pull_requests(&output_data), + activity, + telemetry, + checkpoint, + agent_identity: None, + harness_corrected: None, + halted: None, + // This view is reconstructed from a delivered/terminal output, so a run + // was necessarily requested — never auto-kickoff it again. + run_requested: true, + current_run_nonce: assignment_nonce, + })) +} diff --git a/bridge/bff/src/routes/tasks/lifecycle.rs b/bridge/bff/src/routes/tasks/lifecycle.rs new file mode 100644 index 000000000..c829b0a33 --- /dev/null +++ b/bridge/bff/src/routes/tasks/lifecycle.rs @@ -0,0 +1,349 @@ +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::{Api, PostParams}; +use serde::Deserialize; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::KarsTask; +use crate::routes::ownership::{require_owned_task, require_owned_task_or_output}; +use crate::state::AppState; + +use super::mapping::to_detail; +use super::{TaskDetailDto, map_kube_err, require_cluster}; + +/// `DELETE /api/namespaces/:ns/tasks/:name` — delete a mission and sweep its +/// persisted artifacts (deliverable, files, trace, review), so a deleted mission +/// leaves no orphaned ConfigMaps behind on the Artifacts page or as output-only +/// history. Mirrors the team-delete sweep. Idempotent-ish: 404 for unknowns. +pub async fn delete_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let has_cr = require_owned_task_or_output(cluster, &ns, &name, &principal) + .await? + .is_some(); + if has_cr { + cluster + .delete_task(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } else { + // CR already gone — just sweep the leftover ConfigMaps. + cluster.sweep_mission_artifacts(&name).await; + } + Ok(Json(serde_json::json!({ + "deleted": true, + "note": "Mission deleted. Its sandbox, deliverable, files, trace, and review record were removed." + }))) +} + +/// Per-mission promote request body. +#[derive(Debug, Deserialize)] +pub struct PromoteMissionRequest { + pub tier: i32, +} + +/// `POST /api/namespaces/:ns/tasks/:name/promote` — request a per-mission tier +/// promotion (§12). Patches `spec.requestedTier`; the controller opens a human +/// `KarsApproval` and widens the envelope only once approved. The BFF never +/// widens an envelope directly. +pub async fn promote_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(body): Json<PromoteMissionRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + require_owned_task(cluster, &ns, &name, &principal).await?; + if !(1..=5).contains(&body.tier) { + return Err(AppError::BadRequest("tier must be in 1..5".into())); + } + let api: Api<KarsTask> = cluster.tasks(&ns); + let patch = serde_json::json!({ "spec": { "requestedTier": body.tier } }); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "requested": true, + "tier": body.tier, + "note": "A human approval has been opened. This mission is promoted only once it is approved." + }))) +} + +/// Governed emergency-stop request. +#[derive(Debug, Deserialize)] +pub struct HaltRequest { + /// Why the operator is halting — recorded on the governed decision so the + /// stop is attestable ("who halted this, when, and why"), not anonymous. + pub reason: Option<String>, +} + +/// `POST /api/namespaces/:ns/tasks/:name/halt` — governed emergency-stop. +/// +/// A one-click halt that STOPS a running mission/agent without destroying its +/// record: it flips `spec.execution.launch` to false (the controller's teardown +/// reconcile then deletes the sandbox + InferencePolicy, so the agent is removed +/// from the mesh and can no longer receive or answer delegated work) and stamps +/// a governed decision annotation (`kars.azure.com/halted` = operator/reason/at) +/// so the halt itself is a durable, attestable record. The deliverable, trace, +/// and receipt remain — unlike DELETE, which removes everything. No major agent +/// platform ships a governed kill; kars can, because it owns the K8s control +/// plane (to stop) and the governance record (to attest). +pub async fn halt_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(body): Json<HaltRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let api: Api<KarsTask> = cluster.tasks(&ns); + require_owned_task(cluster, &ns, &name, &principal).await?; + let reason = body + .reason + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("operator emergency-stop"); + let at = chrono::Utc::now().to_rfc3339(); + let decision = format!("halted by operator at {at}: {reason}"); + // Un-launch (controller tears down the running sandbox) AND record the + // governed decision atomically in one merge patch. + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/halted": decision } }, + "spec": { "execution": { "launch": false } }, + }); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(serde_json::json!({ + "halted": true, + "at": at, + "reason": reason, + "note": "The agent's sandbox is being torn down; the mission record, deliverable, and audit trail are retained. The halt is recorded as a governed decision.", + }))) +} + +/// Replicate request — how many identical runs to launch for reliability (pass^k). +#[derive(Debug, Deserialize)] +pub struct ReplicateRequest { + /// Number of additional identical runs to create (2–5). Each becomes a + /// distinct KarsTask sharing this task's exact objective + envelope, so the + /// efficiency frontier can compute pass^k reliability across them. + pub count: u32, + /// When true, each clone is launched immediately; when false, they are + /// created as ready-to-run packages the caller launches. Default true. + #[serde(default = "default_true")] + pub launch: bool, +} + +fn default_true() -> bool { + true +} + +/// `POST /api/namespaces/:ns/tasks/:name/replicate` — the pass^k runner. +/// +/// Clones a mission's EXACT package (objective + envelope + blueprint) into +/// `count` distinct sibling tasks so they run independently and the efficiency +/// engine can measure pass^k reliability (fraction of the repeated package +/// accepted on EVERY attempt). Honest: this creates real, governed runs — the +/// same package, nothing weakened — not a simulated repeat. +pub async fn replicate_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(req): Json<ReplicateRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let count = req.count.clamp(1, 5); + let api: Api<KarsTask> = cluster.tasks(&ns); + let source = require_owned_task(cluster, &ns, &name, &principal).await?; + if source + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.credential_bindings.as_ref()) + .is_some_and(|bindings| { + bindings + .sources + .iter() + .any(|source| source.scope != "workspace") + }) + { + return Err(AppError::BadRequest("Independent replicas cannot inherit another target's credential UID; use an approved workspace source or stage per-replica credentials".into())); + } + + // A short suffix keyed off the current time keeps clone names unique across + // repeated replicate calls (so a second batch doesn't collide with a first). + let batch = chrono::Utc::now().timestamp() % 100000; + let mut created: Vec<String> = Vec::new(); + for i in 1..=count { + let clone_name = format!("{name}-rep-{batch}-{i}"); + let mut spec = source.spec.clone(); + // Force the execution gate to the requested launch state; strip parent + // linkage so each clone is an independent, top-level run. + spec.execution = Some(crate::kars::task::TaskExecution { + launch: false, + runtime: None, + }); + spec.parent_ref = None; + let mut task = KarsTask::new(&clone_name, spec); + // Label the batch so the UI can group a reliability cohort together. + task.metadata + .labels + .get_or_insert_with(Default::default) + .insert("kars.azure.com/reliability-of".into(), name.clone()); + let annotations = task + .metadata + .annotations + .get_or_insert_with(Default::default); + annotations.insert("kars.azure.com/owner-sub".into(), principal.sub.clone()); + annotations.insert("kars.azure.com/owner-name".into(), principal.name.clone()); + let captured = api + .create(&PostParams::default(), &task) + .await + .map_err(map_kube_err)?; + cluster + .finish_created_credentials( + &crate::kars::credentials::Target { + kind: "KarsTask".into(), + namespace: ns.clone(), + name: captured.name_any(), + uid: captured + .uid() + .ok_or_else(|| AppError::Upstream("Replica CREATE omitted UID".into()))?, + }, + req.launch, + ) + .await + .map_err(map_kube_err)?; + created.push(clone_name); + } + + Ok(Json(serde_json::json!({ + "replicated": name, + "count": created.len(), + "runs": created, + "note": format!("{} identical runs created — pass^{} reliability will appear on the efficiency frontier once they complete and are reviewed.", created.len(), created.len() + 1), + }))) +} + +/// Launch/un-launch request body. +#[derive(Debug, Deserialize)] +pub struct LaunchRequest { + pub launch: bool, +} + +/// `POST /api/namespaces/:ns/tasks/:name/launch` — flip the execution gate. +/// +/// The §20 launch action: setting `launch: true` asks the controller to +/// materialize a governed sandbox; `false` tears it down. The BFF only patches +/// the spec — the controller does the materialization and reports status. +pub async fn launch_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(req): Json<LaunchRequest>, +) -> AppResult<Json<TaskDetailDto>> { + let cluster = require_cluster(&state)?; + require_owned_task(cluster, &ns, &name, &principal).await?; + let api: Api<KarsTask> = cluster.tasks(&ns); + let patch = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsTask", + "spec": { "execution": { "launch": req.launch } }, + }); + let patched = api + .patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&patch), + ) + .await + .map_err(map_kube_err)?; + Ok(Json(to_detail( + &patched, + Vec::new(), + Vec::new(), + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + None, + None, + ))) +} + +#[derive(Debug, Deserialize)] +pub struct IncreaseTaskBudgetRequest { + pub daily_tokens: i64, +} + +/// Request an owned Mission's token-budget increase. Bridge never widens the +/// trust envelope directly; the controller opens a typed human approval and is +/// the sole writer of the new ceiling after approval. +pub async fn increase_task_budget( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(req): Json<IncreaseTaskBudgetRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let task = require_owned_task(cluster, &ns, &name, &principal).await?; + let current = task + .spec + .envelope + .budget + .as_ref() + .and_then(|budget| budget.tokens) + .unwrap_or(0); + if req.daily_tokens <= current { + return Err(AppError::BadRequest(format!( + "new daily token budget must be greater than the current {current}" + ))); + } + + let tasks: Api<KarsTask> = cluster.tasks(&ns); + let request_id = chrono::Utc::now().timestamp_micros().to_string(); + let patched = tasks + .patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/requested-by": principal.name, + "kars.azure.com/requested-by-sub": principal.sub, + "kars.azure.com/budget-request-id": request_id + } + }, + "spec": { + "requestedBudgetTokens": req.daily_tokens + } + })), + ) + .await + .map_err(map_kube_err)?; + + Ok(Json(serde_json::json!({ + "requested": true, + "name": name, + "budget_tokens": req.daily_tokens, + "resource_version": patched.metadata.resource_version, + "note": "A typed human approval is being opened. The controller widens the budget only after approval." + }))) +} diff --git a/bridge/bff/src/routes/tasks/mapping.rs b/bridge/bff/src/routes/tasks/mapping.rs new file mode 100644 index 000000000..c6ae50e8b --- /dev/null +++ b/bridge/bff/src/routes/tasks/mapping.rs @@ -0,0 +1,321 @@ +use kube::ResourceExt; +use serde::Serialize; + +use crate::auth::Principal; +use crate::kars::task::KarsTask; +use crate::routes::ownership::task_is_owned_by; + +use super::evidence::{canonicalize_assignment_event_roles, structured_team_evidence}; +use super::{ + BudgetDto, CompositionDto, EnvelopeDto, MissionArtifactDto, MissionResultDto, + MissionTelemetryDto, PullRequestRef, TaskAssignmentEventDto, TaskAssignmentStatusDto, + TaskDetailDto, TaskSummaryDto, clean_display_name, clean_objective, +}; + +fn phase_of(task: &KarsTask) -> String { + task.status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_else(|| "Pending".to_string()) +} + +pub(super) fn to_summary(task: &KarsTask) -> TaskSummaryDto { + TaskSummaryDto { + name: task.name_any(), + namespace: task.namespace().unwrap_or_default(), + objective: clean_objective(&task.spec.objective), + display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), + created_at: task + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + tier: task.spec.envelope.tier, + phase: phase_of(task), + envelope_digest: task.status.as_ref().and_then(|s| s.envelope_digest.clone()), + team: task + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/team").cloned()), + delivered: false, + failed: false, + launched: task + .spec + .execution + .as_ref() + .map(|e| e.launch) + .unwrap_or(false), + execution_phase: task.status.as_ref().and_then(|s| s.execution_phase.clone()), + } +} + +pub(super) fn is_task_owner(task: &KarsTask, principal: &Principal) -> bool { + task_is_owned_by(task, principal) +} + +fn ready_message(task: &KarsTask) -> Option<String> { + task.status + .as_ref()? + .conditions + .iter() + .find(|c| c.type_ == "Ready") + .and_then(|c| c.message.clone()) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn to_detail( + task: &KarsTask, + children: Vec<TaskSummaryDto>, + sub_agents: Vec<SubAgentDto>, + effective: Option<CompositionDto>, + result: Option<MissionResultDto>, + artifacts: Vec<MissionArtifactDto>, + pull_requests: Vec<PullRequestRef>, + activity: Vec<serde_json::Value>, + telemetry: Option<MissionTelemetryDto>, + checkpoint: Option<serde_json::Value>, + agent_identity: Option<crate::kars::cluster::AgentIdentity>, + egress_mode: Option<String>, +) -> TaskDetailDto { + let e = &task.spec.envelope; + let (role_plan, collaboration_events) = structured_team_evidence(&artifacts); + let mut assignment_events = task + .status + .as_ref() + .map(|s| { + s.assignment_events + .iter() + .map(TaskAssignmentEventDto::from) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + canonicalize_assignment_event_roles(&mut assignment_events, &collaboration_events); + TaskDetailDto { + name: task.name_any(), + namespace: task.namespace().unwrap_or_default(), + objective: clean_objective(&task.spec.objective), + display_name: clean_display_name(&task.spec.display_name, &task.spec.objective), + created_at: task + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + envelope: EnvelopeDto { + tier: e.tier, + authority_ceiling: e.authority_ceiling, + delegation_depth: e.delegation_depth, + budget: e.budget.as_ref().map(|b| BudgetDto { + scope: b.scope, + tokens: b.tokens, + usd_micros: b.usd_micros, + }), + tool_policy: e.tool_policy_ref.as_ref().map(|r| r.name.clone()), + egress_allowlist: e.egress_allowlist_ref.as_ref().map(|r| r.name.clone()), + }, + phase: phase_of(task), + envelope_digest: task.status.as_ref().and_then(|s| s.envelope_digest.clone()), + observed_generation: task.status.as_ref().and_then(|s| s.observed_generation), + lineage: task + .status + .as_ref() + .map(|s| s.lineage.clone()) + .unwrap_or_default(), + parent: task.spec.parent_ref.as_ref().map(|r| r.name.clone()), + team: task + .labels() + .get("kars.azure.com/team") + .cloned() + .or_else(|| task.annotations().get("kars.azure.com/team").cloned()), + status_message: ready_message(task), + children, + launched: task + .spec + .execution + .as_ref() + .map(|e| e.launch) + .unwrap_or(false), + execution_phase: task.status.as_ref().and_then(|s| s.execution_phase.clone()), + sandbox: task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()), + execution_detail: task + .status + .as_ref() + .and_then(|s| s.execution_detail.clone()), + assignment: task + .status + .as_ref() + .and_then(|s| s.assignment.as_ref()) + .map(TaskAssignmentStatusDto::from), + assignment_events, + assignment_sequence: task.status.as_ref().and_then(|s| s.assignment_sequence), + egress_mode, + composition: effective.or_else(|| { + task.spec.blueprint.as_ref().map(|b| CompositionDto { + runtime: b.runtime.clone(), + model: b.model.as_ref().map(|m| m.deployment.clone()), + instructions: b.instructions.clone(), + tool_policy: b.tool_policy.clone(), + mcp_servers: b.mcp_servers.clone(), + egress: b + .egress + .iter() + .map(|e| match e.port { + Some(p) => format!("{}:{}", e.host, p), + None => e.host.clone(), + }) + .collect(), + isolation: b.isolation.clone(), + memory: b.memory.clone(), + }) + }), + sub_agents, + result, + artifacts, + role_plan, + collaboration_events, + pull_requests, + activity, + telemetry, + checkpoint, + agent_identity, + harness_corrected: task + .annotations() + .get("kars.azure.com/harness-corrected") + .cloned(), + halted: task.annotations().get("kars.azure.com/halted").cloned(), + run_requested: task + .annotations() + .get("kars.azure.com/run-requested") + .is_some_and(|v| !v.trim().is_empty()), + current_run_nonce: task + .annotations() + .get("kars.azure.com/run-requested") + .filter(|value| !value.trim().is_empty()) + .cloned(), + } +} + +/// Build the effective composition from the materialized InferencePolicy + +/// KarsSandbox — the real running config, including controller-defaulted fields. +pub(super) fn composition_from_materialized( + ip: Option<&kube::core::DynamicObject>, + sandbox: Option<&kube::core::DynamicObject>, +) -> Option<CompositionDto> { + let sb = sandbox?; + let spec = sb.data.get("spec")?; + let model = ip.and_then(|p| { + let prim = p.data.get("spec")?.get("modelPreference")?.get("primary")?; + let dep = prim.get("deployment")?.as_str()?; + // The deployment string identifies the model; the inference provider is + // a single cluster-level fact (see Options.provider), not a per-model + // tag — so we do NOT append a guessed provider here. + Some(dep.to_string()) + }); + let runtime = spec + .get("runtime") + .and_then(|r| r.get("kind")) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()); + let isolation = spec + .get("sandbox") + .and_then(|s| s.get("isolation")) + .and_then(|i| i.as_str()) + .map(|s| s.to_string()); + let instructions = spec + .get("agent") + .and_then(|a| a.get("instructions")) + .and_then(|i| i.as_str()) + .map(|s| s.to_string()); + let gov = spec.get("governance"); + let tool_policy = gov + .and_then(|g| g.get("toolPolicyRef")) + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + let mcp_servers = gov + .and_then(|g| g.get("mcpServerRefs")) + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| { + x.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + let egress = spec + .get("networkPolicy") + .and_then(|n| n.get("allowedEndpoints")) + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host")?.as_str()?; + Some(match e.get("port").and_then(|p| p.as_i64()) { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + let memory = spec + .get("memoryRef") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + Some(CompositionDto { + runtime, + model, + instructions, + tool_policy, + mcp_servers, + egress, + isolation, + memory, + }) +} + +/// A sub-agent the mission's agent spawned at run time (a labelled KarsSandbox). +#[derive(Debug, Serialize)] +pub struct SubAgentDto { + pub name: String, + pub namespace: String, + pub phase: Option<String>, + pub runtime: Option<String>, + pub role: Option<String>, + pub parent: Option<String>, + pub logical_agent_id: Option<String>, + pub model: Option<String>, +} + +pub(super) fn to_sub_agent(o: &kube::core::DynamicObject) -> SubAgentDto { + let spec = o.data.get("spec"); + let status = o.data.get("status"); + SubAgentDto { + name: o.metadata.name.clone().unwrap_or_default(), + namespace: o.metadata.namespace.clone().unwrap_or_default(), + phase: status + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()), + runtime: spec + .and_then(|s| s.get("runtime")) + .and_then(|r| r.get("kind").or(Some(r))) + .and_then(|k| k.as_str()) + .map(|s| s.to_string()), + role: o.labels().get("kars.azure.com/role").cloned(), + parent: o.labels().get("kars.azure.com/parent").cloned(), + logical_agent_id: o + .annotations() + .get("kars.azure.com/logical-agent-id") + .cloned(), + model: o.annotations().get("kars.azure.com/model").cloned(), + } +} diff --git a/bridge/bff/src/routes/tasks/models.rs b/bridge/bff/src/routes/tasks/models.rs new file mode 100644 index 000000000..88fa6f734 --- /dev/null +++ b/bridge/bff/src/routes/tasks/models.rs @@ -0,0 +1,633 @@ +use serde::{Deserialize, Serialize}; + +use super::{PullRequestRef, SubAgentDto}; + +/// Browser-facing budget shape. +#[derive(Debug, Serialize, Deserialize)] +pub struct BudgetDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option<crate::kars::task::BudgetScope>, + pub tokens: Option<i64>, + pub usd_micros: Option<i64>, +} + +/// Browser-facing envelope shape. +#[derive(Debug, Serialize, Deserialize)] +pub struct EnvelopeDto { + pub tier: i32, + pub authority_ceiling: i32, + pub delegation_depth: i32, + pub budget: Option<BudgetDto>, + pub tool_policy: Option<String>, + pub egress_allowlist: Option<String>, +} + +/// Browser-facing blueprint shape. The request layer is **snake_case** (like +/// every other DTO here and the web's TS types); it maps to the camelCase CRD +/// `TaskBlueprint` on write. Keeping the wire contract consistent here is what +/// prevents silent field-drop on multi-word fields (`tool_policy`, +/// `mcp_servers`). +#[derive(Debug, Deserialize, Default)] +pub struct BlueprintDto { + #[serde(default)] + pub runtime: Option<String>, + #[serde(default)] + pub model: Option<ModelDto>, + #[serde(default)] + pub model_fallbacks: Vec<ModelDto>, + #[serde(default)] + pub instructions: Option<String>, + #[serde(default)] + pub tool_policy: Option<String>, + #[serde(default)] + pub mcp_servers: Vec<String>, + #[serde(default)] + pub egress: Vec<EgressDto>, + #[serde(default)] + pub egress_mode: Option<String>, + #[serde(default)] + pub isolation: Option<String>, + #[serde(default)] + pub memory: Option<String>, + #[serde(default)] + pub skills: Vec<String>, + #[serde(default)] + pub execution_plan: Option<ExecutionPlanDto>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionPlanDto { + pub schema: String, + pub roles: Vec<ExecutionRoleDto>, + pub max_parallel: i32, + pub synthesis: ExecutionSynthesisDto, + #[serde(default)] + pub deliverables: Vec<ExecutionDeliverableDto>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionRoleDto { + pub name: String, + pub objective: String, + #[serde(default)] + pub depends_on: Vec<String>, + pub phases: Vec<ExecutionPhaseDto>, + #[serde(default)] + pub budget_tokens: Option<i64>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionPhaseDto { + pub name: String, + pub objective: String, + #[serde(default)] + pub capabilities: Vec<String>, + #[serde(default)] + pub required_tool_calls: Vec<ExecutionRequiredToolCallDto>, + #[serde(default)] + pub min_tool_calls: i32, + pub max_tool_calls: i32, + #[serde(default)] + pub fresh_context: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionRequiredToolCallDto { + pub name: String, + #[serde(default)] + pub arguments: std::collections::BTreeMap<String, String>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionSynthesisDto { + pub objective: String, + #[serde(default)] + pub capabilities: Vec<String>, + pub max_tool_calls: i32, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExecutionDeliverableDto { + pub name: String, + #[serde(default)] + pub media_type: Option<String>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModelDto { + pub provider: String, + pub deployment: String, +} + +#[derive(Debug, Deserialize)] +pub struct EgressDto { + pub host: String, + #[serde(default)] + pub port: Option<i32>, +} + +impl BlueprintDto { + pub(super) fn into_crd(self) -> crate::kars::task::TaskBlueprint { + use crate::kars::task::{TaskBlueprint, TaskEgress, TaskModel}; + TaskBlueprint { + runtime: self.runtime, + model: self.model.map(|m| TaskModel { + provider: m.provider, + deployment: m.deployment, + }), + model_fallbacks: self + .model_fallbacks + .into_iter() + .map(|m| TaskModel { + provider: m.provider, + deployment: m.deployment, + }) + .collect(), + instructions: self.instructions, + tool_policy: self.tool_policy, + mcp_servers: self.mcp_servers, + egress: self + .egress + .into_iter() + .map(|e| TaskEgress { + host: e.host, + port: e.port, + }) + .collect(), + egress_mode: self.egress_mode, + isolation: self.isolation, + memory: self.memory, + skills: self.skills, + git_write: None, + credential_bindings: None, + github_binding: None, + execution_plan: self.execution_plan.map(ExecutionPlanDto::into_crd), + } + } +} + +impl ExecutionPlanDto { + pub(crate) fn from_crd(plan: &crate::kars::task::ExecutionPlan) -> Self { + Self { + schema: plan.schema.clone(), + roles: plan + .roles + .iter() + .map(|role| ExecutionRoleDto { + name: role.name.clone(), + objective: role.objective.clone(), + depends_on: role.depends_on.clone(), + phases: role + .phases + .iter() + .map(|phase| ExecutionPhaseDto { + name: phase.name.clone(), + objective: phase.objective.clone(), + capabilities: phase.capabilities.clone(), + required_tool_calls: phase + .required_tool_calls + .iter() + .map(|call| ExecutionRequiredToolCallDto { + name: call.name.clone(), + arguments: call.arguments.clone(), + }) + .collect(), + min_tool_calls: phase.min_tool_calls, + max_tool_calls: phase.max_tool_calls, + fresh_context: phase.fresh_context, + }) + .collect(), + budget_tokens: role.budget_tokens, + }) + .collect(), + max_parallel: plan.max_parallel, + synthesis: ExecutionSynthesisDto { + objective: plan.synthesis.objective.clone(), + capabilities: plan.synthesis.capabilities.clone(), + max_tool_calls: plan.synthesis.max_tool_calls, + }, + deliverables: plan + .deliverables + .iter() + .map(|deliverable| ExecutionDeliverableDto { + name: deliverable.name.clone(), + media_type: deliverable.media_type.clone(), + }) + .collect(), + } + } + + pub(crate) fn into_crd(self) -> crate::kars::task::ExecutionPlan { + use crate::kars::task::{ + ExecutionDeliverable, ExecutionPhase, ExecutionPlan, ExecutionRequiredToolCall, + ExecutionRole, ExecutionSynthesis, + }; + ExecutionPlan { + schema: self.schema, + roles: self + .roles + .into_iter() + .map(|role| ExecutionRole { + name: role.name, + objective: role.objective, + depends_on: role.depends_on, + phases: role + .phases + .into_iter() + .map(|phase| ExecutionPhase { + name: phase.name, + objective: phase.objective, + capabilities: phase.capabilities, + required_tool_calls: phase + .required_tool_calls + .into_iter() + .map(|call| ExecutionRequiredToolCall { + name: call.name, + arguments: call.arguments, + }) + .collect(), + min_tool_calls: phase.min_tool_calls, + max_tool_calls: phase.max_tool_calls, + fresh_context: phase.fresh_context, + }) + .collect(), + budget_tokens: role.budget_tokens, + }) + .collect(), + max_parallel: self.max_parallel, + synthesis: ExecutionSynthesis { + objective: self.synthesis.objective, + capabilities: self.synthesis.capabilities, + max_tool_calls: self.synthesis.max_tool_calls, + }, + deliverables: self + .deliverables + .into_iter() + .map(|deliverable| ExecutionDeliverable { + name: deliverable.name, + media_type: deliverable.media_type, + }) + .collect(), + } + } +} + +/// Browser-facing task summary (list view). +#[derive(Debug, Serialize)] +pub struct TaskSummaryDto { + pub name: String, + pub namespace: String, + pub objective: String, + pub display_name: Option<String>, + pub created_at: Option<String>, + pub tier: i32, + pub phase: String, + pub envelope_digest: Option<String>, + /// The standing team that owns this task (from the kars.azure.com/team + /// label), when it is team machinery rather than a standalone mission. The + /// Missions surface hides team-owned tasks — they belong to the Team view. + pub team: Option<String>, + /// Whether this mission has captured a delivered result (an `ok` run output + /// exists). The authoritative "done" signal — execution phase returns to + /// Idle after delivery, so phase alone cannot tell delivered from drafting. + pub delivered: bool, + /// Whether this mission's run captured an `error` output — a run that + /// completed but did NOT succeed. Lets the list badge read "Run failed" + /// instead of a misleading "Ready to launch" (audit f6). + pub failed: bool, + /// Whether the task has been launched (execution gate opened). Without this + /// the list cannot tell a launched-and-running mission from an un-launched + /// draft, so a live mission wrongly reads "Ready to launch". + pub launched: bool, + /// The controller's execution phase (Running/Idle/Degraded/…), so the list + /// badge agrees with the detail page — "Running" while the agent works, not + /// a stale "Ready to launch". + pub execution_phase: Option<String>, +} + +/// Browser-facing task detail (single view). +#[derive(Debug, Serialize)] +pub struct TaskDetailDto { + pub name: String, + pub namespace: String, + pub objective: String, + pub display_name: Option<String>, + pub created_at: Option<String>, + pub envelope: EnvelopeDto, + pub phase: String, + pub envelope_digest: Option<String>, + pub observed_generation: Option<i64>, + pub lineage: Vec<String>, + /// Parent task name when this task is a delegated child. + pub parent: Option<String>, + /// The standing team that owns this run. Team-owned runs stay inside the + /// team-native UX rather than leaking into the generic Missions surface. + pub team: Option<String>, + /// The `Ready` condition message — surfaces *why* a task is Degraded + /// (e.g. an amplification rejection), so the UI can explain it. + pub status_message: Option<String>, + /// Names of tasks that delegate from this one (its direct children). + pub children: Vec<TaskSummaryDto>, + /// Whether the task is launched (execution gate). + pub launched: bool, + /// Execution phase: `Idle` | `Launching` | `Running` | `Degraded`. + pub execution_phase: Option<String>, + /// Name of the materialized sandbox, when launched. + pub sandbox: Option<String>, + /// The live egress enforcement mode the sandbox is running under, read from + /// the materialized `KarsSandbox`: `"Learn"` (observe + record every domain + /// the agent reaches, the default) or `"Strict"` (deny anything outside the + /// allowlist). `None` until a sandbox exists. This is the monitoring→enforced + /// surface: a customer watches in Learn, then promotes to Strict when + /// confident the agent's reach is what it should be. + pub egress_mode: Option<String>, + /// Human-readable execution detail (e.g. the kind/Foundry caveat). + pub execution_detail: Option<String>, + /// Authoritative durable root-assignment snapshot from Kars core. + pub assignment: Option<TaskAssignmentStatusDto>, + /// Ordered durable root and child assignment transitions. + pub assignment_events: Vec<TaskAssignmentEventDto>, + /// Highest durable assignment event sequence observed by the controller. + pub assignment_sequence: Option<i64>, + /// The composed run — what model/harness/tools/services/egress/prompt this + /// mission actually runs with, projected from the blueprint. Lets a + /// task-giver review exactly what they launched. `None` when no blueprint + /// was set (the mission uses controller defaults). + pub composition: Option<CompositionDto>, + /// The agents the mission spawned at run time (the running agent/sub-agent + /// tree, distinct from the governed delegation roles in `children`). + pub sub_agents: Vec<SubAgentDto>, + /// The mission's captured run result — a real deliverable produced by a + /// governed model run, with its real token cost. `None` until the mission + /// has been run. + pub result: Option<MissionResultDto>, + /// The full set of artifact files the mission produced through the agent + /// loop over the mesh (research report, data files, decision matrix, …), + /// read from the persisted artifacts ConfigMap. Empty until a mesh run + /// produces files. + pub artifacts: Vec<MissionArtifactDto>, + /// Bounded, server-parsed orchestration plan evidence. This remains complete + /// even when the source artifact preview is truncated or omitted. + pub role_plan: TeamRolePlanDto, + /// Bounded, server-parsed collaboration evidence. Parsing full artifact + /// contents in the BFF prevents preview limits from changing run truth. + pub collaboration_events: Vec<TeamCollaborationEventDto>, + /// Pull requests the mission opened, extracted from its output — surfaced as + /// first-class deliverables (a PR is a delivery type) on the mission's + /// Artifacts tab, not just buried in the prose. Empty when none were opened. + #[serde(default)] + pub pull_requests: Vec<PullRequestRef>, + /// The mission's live execution activity — the real per-round and per-tool + /// trace the agent emitted (token usage, tool names, sanitized arg/result + /// previews, durations), read from the persisted trace ConfigMap. Empty + /// until a mesh run produces a trace. This is the source of the Activity + /// timeline and the clean per-tool audit path. + pub activity: Vec<serde_json::Value>, + /// Run telemetry rollup (rounds, tool calls) parsed from the mission output. + /// Token totals live on `result`; this carries the loop-shape counts. + pub telemetry: Option<MissionTelemetryDto>, + /// Latest durable milestone checkpoint emitted by the running harness. + pub checkpoint: Option<serde_json::Value>, + /// The running agent's real mesh identity (DID), discovered from the AGT + /// registry — proof the agent is a live, harness-neutral mesh participant. + /// `None` when not launched / not yet registered / registry unreachable. + pub agent_identity: Option<crate::kars::cluster::AgentIdentity>, + /// A governed capability-routing decision recorded at creation: set when the + /// requested harness could not run this mission (a chat-gateway harness on a + /// one-shot mission) and was corrected. Surfaced so the swap is attested, not + /// silent. `None` when no correction was needed. + pub harness_corrected: Option<String>, + /// A governed emergency-stop decision: set when an operator halted this + /// mission (agent torn down, record retained). Carries the operator/reason/at + /// string. `None` when the mission was never halted. + pub halted: Option<String>, + /// Whether a run has EVER been requested for this mission (the + /// `kars.azure.com/run-requested` annotation is set). Used by the client + /// auto-kickoff to fire the first run exactly once — gating on this instead + /// of "no activity yet" avoids a race where the agent's startup telemetry + /// (MCP init / tool list) makes the mission look already-active and the + /// first run is never triggered, leaving it silently idle. + pub run_requested: bool, + /// The exact latest requested run nonce. This appears before assignment + /// acknowledgement and is the authoritative scope for run-bound approvals. + pub current_run_nonce: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct TaskAssignmentStatusDto { + pub task_id: String, + pub state: String, + pub worker_did: Option<String>, + pub stage: Option<String>, + pub child_task_id: Option<String>, + pub child_role: Option<String>, + pub last_progress_at: Option<String>, + pub completed_at: Option<String>, + pub error: Option<String>, +} + +impl From<&crate::kars::task::TaskAssignmentStatus> for TaskAssignmentStatusDto { + fn from(value: &crate::kars::task::TaskAssignmentStatus) -> Self { + Self { + task_id: value.task_id.clone(), + state: value.state.clone(), + worker_did: value.worker_did.clone(), + stage: value.stage.clone(), + child_task_id: value.child_task_id.clone(), + child_role: value.child_role.clone(), + last_progress_at: value.last_progress_at.clone(), + completed_at: value.completed_at.clone(), + error: value.error.clone(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct TaskAssignmentEventDto { + pub sequence: i64, + pub event_id: String, + pub task_id: String, + pub event_type: String, + pub state: String, + pub at: String, + pub worker_did: Option<String>, + pub stage: Option<String>, + pub child_task_id: Option<String>, + pub child_role: Option<String>, + pub outcome: Option<String>, + pub message: Option<String>, +} + +impl From<&crate::kars::task::TaskAssignmentEvent> for TaskAssignmentEventDto { + fn from(value: &crate::kars::task::TaskAssignmentEvent) -> Self { + Self { + sequence: value.sequence, + event_id: value.event_id.clone(), + task_id: value.task_id.clone(), + event_type: value.event_type.clone(), + state: value.state.clone(), + at: value.at.clone(), + worker_did: value.worker_did.clone(), + stage: value.stage.clone(), + child_task_id: value.child_task_id.clone(), + child_role: value.child_role.clone(), + outcome: value.outcome.clone(), + message: value.message.clone(), + } + } +} + +/// Loop-shape telemetry for a mission run (token totals are on the result DTO). +#[derive(Debug, Serialize)] +pub struct MissionTelemetryDto { + pub rounds: Option<i64>, + pub tool_calls: Option<i64>, +} + +/// A captured mission run result (read from the persisted output ConfigMap). +#[derive(Debug, Serialize)] +pub struct MissionResultDto { + pub output: String, + /// Run status the output reflects: `ok` (a real deliverable) or `error` + /// (e.g. a delivery timeout). The UI must not present an `error` output as + /// the mission's deliverable. + pub status: Option<String>, + pub model: Option<String>, + pub total_tokens: Option<i64>, + pub prompt_tokens: Option<i64>, + pub completion_tokens: Option<i64>, + pub finished_at: Option<String>, + /// Assignment nonce that produced this output. Used to hide stale results + /// while a newer run is materializing. + pub assignment_nonce: Option<String>, + /// How the deliverable was produced: `"single_turn"` when the mesh agent + /// loop was unavailable and this is one model turn (no tools/sub-agents). + /// Absent (`None`) for a full agent-loop run — the normal case. + pub source: Option<String>, + /// Set when the run's `ok` output is actually a capability/limit STOP rather + /// than a real deliverable — today the daily token budget (enforced by the + /// sandbox InferencePolicy / router). The UI renders this as an actionable + /// state ("raise the budget / narrow the objective"), never as the answer. + pub blocked: Option<RunBlockedDto>, + /// Whether every artifact declared by the agent was durably persisted. + /// Older runs may not carry this field. + pub artifact_persistence: Option<String>, + pub artifact_count: Option<i64>, + pub declared_artifact_count: Option<i64>, +} + +/// A run that returned transport-`ok` but whose body is a capability/limit stop, +/// not a deliverable. Surfaced so the operator gets an honest, actionable state +/// instead of a non-answer dressed up as the mission's output. +#[derive(Debug, Serialize, Clone)] +pub struct RunBlockedDto { + /// Machine reason. Today: `"budget"`. + pub reason: String, + /// One-line, plain-language explanation. + pub detail: String, + /// Tokens spent / the enforced limit, parsed from the router's message when + /// present (the limit ideally originates from the sandbox InferencePolicy). + pub spent: Option<i64>, + pub limit: Option<i64>, +} + +/// One artifact file in a mission's deliverable set. `content` is present for +/// text artifacts (markdown, json, csv, …) and `None` for binary ones, which +/// are still listed by name + size so the set is honestly complete. +#[derive(Serialize)] +pub struct MissionArtifactDto { + pub name: String, + pub size_bytes: Option<i64>, + pub content: Option<String>, + pub content_bytes: Option<i64>, + pub content_truncated: bool, + pub source_agent: Option<String>, + pub source_path: Option<String>, + pub digest: Option<String>, + #[serde(skip_serializing)] + pub(super) full_content: Option<String>, +} + +#[derive(Debug, Default, Serialize)] +pub struct TeamRolePlanDto { + pub selected_roles: Vec<String>, + pub skipped_roles: Vec<String>, +} + +#[derive(Debug, Serialize)] +pub struct TeamCollaborationEventDto { + pub at: Option<String>, + pub event: String, + pub agent: Option<String>, + pub member: Option<String>, + pub outcome: Option<String>, + pub message_id: Option<String>, + pub reply_preview: Option<String>, + pub content_preview: Option<String>, +} + +impl std::fmt::Debug for MissionArtifactDto { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MissionArtifactDto") + .field("name", &self.name) + .field("size_bytes", &self.size_bytes) + .field("content_bytes", &self.content_bytes) + .field("content_truncated", &self.content_truncated) + .field("source_agent", &self.source_agent) + .field("source_path", &self.source_path) + .field("digest", &self.digest) + .finish_non_exhaustive() + } +} + +/// The composed run, in plain terms, for the mission-review surface. +#[derive(Debug, Serialize)] +pub struct CompositionDto { + pub runtime: Option<String>, + pub model: Option<String>, + pub instructions: Option<String>, + pub tool_policy: Option<String>, + pub mcp_servers: Vec<String>, + pub egress: Vec<String>, + pub isolation: Option<String>, + pub memory: Option<String>, +} + +/// Create-task request body from the UI. +#[derive(Debug, Deserialize)] +pub struct CreateTaskRequest { + pub name: String, + pub objective: String, + pub display_name: Option<String>, + pub envelope: EnvelopeDto, + /// Optional parent task name — when set, this creates a delegated child + /// whose envelope the controller verifies against the parent's. + #[serde(default)] + pub parent: Option<String>, + /// The editable run blueprint composed on the launch package + /// (runtime/model/instructions/tools/MCP/egress/isolation/memory). + #[serde(default)] + pub blueprint: Option<BlueprintDto>, + #[serde(default)] + pub delegation: Option<crate::routes::compose::ComposeDelegation>, + /// When true, the task is created already launched — the controller + /// materializes the sandbox immediately. The package's "launch" action. + #[serde(default)] + pub launch: bool, + /// Repos selected from the authenticated principal's GitHub connection. The + /// server validates the full set and derives the typed connection reference. + #[serde(default)] + pub git_write_repos: Option<Vec<String>>, + /// The identity creating this mission (the Bridge principal), stamped as + /// `kars.azure.com/created-by` for per-user budget attribution. The web sets + /// it from the current session; absent => "unattributed". + #[serde(default)] + pub created_by: Option<String>, + /// Per-mission retention override, in seconds — auto-delete this mission's + /// record this long after its deliverable lands (mirrors Kubernetes' + /// `Job.ttlSecondsAfterFinished`). `0` disables retention for this mission + /// specifically even if a cluster-wide default is set. Absent inherits the + /// cluster-wide default (which itself defaults to "never"). + #[serde(default)] + pub retention_ttl_seconds: Option<i64>, +} diff --git a/bridge/bff/src/routes/tasks/presentation.rs b/bridge/bff/src/routes/tasks/presentation.rs new file mode 100644 index 000000000..c13138887 --- /dev/null +++ b/bridge/bff/src/routes/tasks/presentation.rs @@ -0,0 +1,606 @@ +use super::{NO_CHANGE_SENTINEL, RunBlockedDto, looks_scaffolded}; + +/// Classify a transport-`ok` run whose body is really a STOP condition (not a +/// deliverable). Today this recognises the daily token-budget block the router +/// enforces from the sandbox InferencePolicy — its message reads +/// "Daily token budget exceeded (23131/20000 tokens)". Returns `None` for a +/// genuine deliverable (or an already-`error` run, handled separately). +pub(crate) fn classify_blocked(status: Option<&str>, output: &str) -> Option<RunBlockedDto> { + if status == Some("error") { + return None; + } + let low = output.to_ascii_lowercase(); + let budget_hit = low.contains("token budget") + && (low.contains("exceeded") || low.contains("429") || low.contains("budget at")); + if budget_hit { + let (spent, limit) = parse_budget_pair(output); + return Some(RunBlockedDto { + reason: "budget".into(), + detail: "The run reached its daily token budget and stopped before finishing.".into(), + spent, + limit, + }); + } + None +} + +/// Extract the `spent/limit` pair from a budget message like +/// "... (23131/20000 tokens)". Returns `(None, None)` when absent/unparseable. +fn parse_budget_pair(output: &str) -> (Option<i64>, Option<i64>) { + // Find a "<digits>/<digits>" run (optionally followed by " tokens"). + let bytes = output.as_bytes(); + for (i, _) in output.match_indices('/') { + // Walk left over digits. + let mut l = i; + while l > 0 && bytes[l - 1].is_ascii_digit() { + l -= 1; + } + // Walk right over digits. + let mut r = i + 1; + while r < bytes.len() && bytes[r].is_ascii_digit() { + r += 1; + } + if l < i && r > i + 1 { + let spent = output[l..i].parse::<i64>().ok(); + let limit = output[i + 1..r].parse::<i64>().ok(); + if spent.is_some() && limit.is_some() { + return (spent, limit); + } + } + } + (None, None) +} + +/// Extract the human deliverable from the agent's run output. The native +/// OpenClaw agent returns a structured `--json` envelope +/// (`{ runId, status, summary, result: { payloads: [ { text } ] } }`); showing +/// that raw — escaped quotes, literal `\n`, JSON braces — is the single most +/// embarrassing thing in the UI. Pull out the actual prose (joining payload +/// texts), tolerating a few shapes; pass plain-text output through unchanged. +fn repair_replacement_question_marks(text: &str) -> String { + let characters = text.chars().collect::<Vec<_>>(); + let mut repaired = String::with_capacity(text.len()); + for (index, character) in characters.iter().copied().enumerate() { + if character != '?' { + repaired.push(character); + continue; + } + let previous = index + .checked_sub(1) + .and_then(|at| characters.get(at)) + .copied(); + let next = characters.get(index + 1).copied(); + if previous.is_some_and(char::is_alphanumeric) && next.is_some_and(char::is_alphanumeric) { + repaired.push('-'); + } else if previous.is_some_and(|value| value.is_ascii_digit()) + && next.is_some_and(char::is_whitespace) + { + repaired.push('.'); + } else { + repaired.push('?'); + } + } + repaired +} + +fn strip_sandbox_banner(text: &str) -> String { + let lines = text.lines().collect::<Vec<_>>(); + let first_content = lines.iter().position(|line| !line.trim().is_empty()); + let Some(start) = first_content else { + return String::new(); + }; + let prefix_end = (start + 16).min(lines.len()); + let prefix = &lines[start..prefix_end]; + let lower_prefix = prefix.join("\n").to_ascii_lowercase(); + if !lower_prefix.contains("kars sandbox") + || !lower_prefix.contains("sandbox id:") + || !lower_prefix.contains("security:") + || !lower_prefix.contains("capabilities:") + { + return repair_replacement_question_marks(text.trim()); + } + let Some(capabilities_offset) = prefix + .iter() + .position(|line| line.to_ascii_lowercase().contains("capabilities:")) + else { + return repair_replacement_question_marks(text.trim()); + }; + repair_replacement_question_marks(lines[start + capabilities_offset + 1..].join("\n").trim()) +} + +pub(crate) fn deliverable_text(raw: &str) -> String { + let trimmed = raw.trim(); + if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) { + // Native agent envelope: result.payloads[].text + if let Some(payloads) = v + .get("result") + .and_then(|r| r.get("payloads")) + .and_then(|p| p.as_array()) + { + let joined = payloads + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::<Vec<_>>() + .join("\n\n"); + if !joined.trim().is_empty() { + return strip_sandbox_banner(&joined); + } + } + // Other harness shapes. + for path in [["reply", "text"], ["result", "text"]] { + if let Some(t) = v + .get(path[0]) + .and_then(|x| x.get(path[1])) + .and_then(|t| t.as_str()) + && !t.trim().is_empty() + { + return strip_sandbox_banner(t); + } + } + for key in ["text", "output", "summary"] { + if let Some(t) = v.get(key).and_then(|t| t.as_str()) + && !t.trim().is_empty() + { + return strip_sandbox_banner(t); + } + } + } + // Tolerant fallback: a *truncated* native envelope (the commons caps stored + // content, which can cut the JSON mid-string so `serde` can't parse it) still + // begins like `{ "runId": ..., "result": { "payloads": [ { "text": "…` — pull + // the first `"text"` string value out by hand and JSON-unescape it so old, + // truncated entries render as prose instead of raw JSON. + if trimmed.starts_with('{') + && trimmed.contains("\"text\"") + && let Some(extracted) = extract_first_json_string(trimmed, "text") + && !extracted.trim().is_empty() + { + return strip_sandbox_banner(&extracted); + } + strip_sandbox_banner(raw) +} + +/// A pull request the mission opened — a first-class deliverable type. +#[derive(Debug, Clone, serde::Serialize, PartialEq)] +pub struct PullRequestRef { + /// `owner/repo`. + pub repo: String, + pub number: i64, + /// The canonical GitHub URL. + pub url: String, +} + +/// Extract the pull requests a mission opened from its deliverable text. The +/// router authors PRs via the keyless git proxy and the agent reports the URL; +/// we surface each as a tracked deliverable. Deduplicated, in first-seen order. +pub(crate) fn extract_pull_requests(text: &str) -> Vec<PullRequestRef> { + let mut out: Vec<PullRequestRef> = Vec::new(); + // Scan for `github.com/<owner>/<repo>/pull/<number>` occurrences without a + // regex dep: split on the marker and parse each following segment. + for seg in text.split("github.com/").skip(1) { + // owner/repo/pull/NUMBER + let mut it = seg.splitn(4, '/'); + let (Some(owner), Some(repo), Some(kind)) = (it.next(), it.next(), it.next()) else { + continue; + }; + if kind != "pull" && kind != "pulls" { + continue; + } + let Some(rest) = it.next() else { continue }; + let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if owner.is_empty() || repo.is_empty() || num.is_empty() { + continue; + } + let Ok(number) = num.parse::<i64>() else { + continue; + }; + let repo_full = format!("{owner}/{repo}"); + let url = format!("https://github.com/{repo_full}/pull/{number}"); + let pr = PullRequestRef { + repo: repo_full, + number, + url, + }; + if !out.contains(&pr) { + out.push(pr); + } + } + out +} + +pub(super) fn deliverable_pull_requests( + data: &std::collections::BTreeMap<String, String>, +) -> Vec<PullRequestRef> { + let status = data.get("status").map(String::as_str); + let output = data.get("output").map(String::as_str).unwrap_or(""); + if !is_real_deliverable(status, output) { + return Vec::new(); + } + extract_pull_requests(&deliverable_text(output)) +} + +pub(crate) fn is_failure_shaped_output(output: &str) -> bool { + let text = deliverable_text(output); + let lower = text + .trim_start_matches(|character: char| { + character.is_whitespace() + || matches!(character, '*' | '_' | '#' | '>' | '`' | '-' | '?' | '🔒') + }) + .to_ascii_lowercase(); + let head: String = lower.chars().take(800).collect(); + head.starts_with("unexpected tokens remaining in message header") + || head.starts_with("assignment progress lease expired") + || head.starts_with("native agent failed") + || head.starts_with("error processing task") + || (head.starts_with("kars sandbox - secure ai runtime") && head.contains("how can i help")) + || head.starts_with("now await pr-watcher") + || head.starts_with("awaiting handback from") +} + +pub(crate) fn is_no_change_output(output: &str) -> bool { + let text = deliverable_text(output); + let head = text.trim_start(); + if head.starts_with(NO_CHANGE_SENTINEL) { + return true; + } + let Some(sentinel_at) = head.find(NO_CHANGE_SENTINEL) else { + return false; + }; + let prefix = &head[..sentinel_at]; + sentinel_at <= 1_200 + && prefix.to_ascii_lowercase().contains("kars sandbox") + && prefix.contains("Sandbox ID:") + && prefix.contains("Security:") + && prefix.contains("Capabilities:") +} + +/// True when a run output is NOT a real, showable deliverable — either the run +/// errored, produced nothing, or reported "no material change". Used to keep +/// hung / zero-output / no-op runs out of the deliverable index and the "latest +/// deliverable" hero (audit f9/f13: a receipt/deliverable requires real work). +pub(crate) fn is_real_deliverable(status: Option<&str>, deliverable: &str) -> bool { + if status == Some("error") { + return false; + } + let t = deliverable.trim(); + if t.is_empty() { + return false; + } + if is_no_change_output(deliverable) { + return false; + } + if is_failure_shaped_output(deliverable) { + return false; + } + // A capability/limit STOP (e.g. the daily token budget) came back transport-ok + // but is not the mission's answer — never treat it as a deliverable. + if classify_blocked(status, deliverable).is_some() { + return false; + } + true +} + +/// A clean 2–3 line preview of a deliverable for cards and list rows — never the +/// raw transcript. Strips the no-change sentinel, markdown table/heading noise, +/// and collapses whitespace, then caps the length (audit f3). +pub(crate) fn deliverable_excerpt(raw: &str) -> String { + let text = deliverable_text(raw); + let mut out: Vec<String> = Vec::new(); + for line in text.lines() { + let l = line.trim(); + if l.is_empty() { + continue; + } + // Strip leading markdown wrapping (emphasis / heading / block-quote / + // inline-code / bullet markers) FIRST, so a wrapped control sentinel + // like `**[[NO_MATERIAL_CHANGE]]**` is unwrapped before we test for it. + // Previously the sentinel check ran on the raw line and a bold-wrapped + // sentinel slipped through into the excerpt. + let cleaned = l + .trim_start_matches(['*', '_', '#', '>', '`', '-', ' ']) + .trim(); + if cleaned.is_empty() { + continue; + } + // Drop the no-change sentinel (now unwrapped) and markdown table + // rows/rules. + let cleaned = if let Some(reason) = cleaned.strip_prefix(NO_CHANGE_SENTINEL) { + let reason = reason + .trim_start_matches(|character: char| { + character.is_whitespace() || matches!(character, ':' | '-' | '—') + }) + .trim(); + if reason.is_empty() { + continue; + } + reason + } else { + cleaned + }; + if cleaned.starts_with('|') { + continue; + } + if cleaned.starts_with("===") { + continue; + } + let lower = cleaned.to_ascii_lowercase(); + if [ + "kars sandbox - secure ai runtime", + "foundry project:", + "model:", + "sandbox id:", + "security summary", + "security:", + "capabilities:", + "role plan", + "role roster", + "roles spawned:", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + continue; + } + out.push(cleaned.to_string()); + if out.len() >= 3 { + break; + } + } + let joined = out.join(" "); + let joined = joined.split_whitespace().collect::<Vec<_>>().join(" "); + if joined.chars().count() > 240 { + let mut s: String = joined.chars().take(240).collect(); + s.push('…'); + s + } else { + joined + } +} + +/// end of input. Returns `None` if the key/opening quote isn't present. +fn extract_first_json_string(s: &str, key: &str) -> Option<String> { + let needle = format!("\"{key}\""); + let after_key = &s[s.find(&needle)? + needle.len()..]; + let colon = after_key.find(':')?; + let rest = &after_key[colon + 1..]; + let open = rest.find('"')?; + let body = &rest[open + 1..]; + let mut out = String::with_capacity(body.len()); + let mut chars = body.chars(); + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('/') => out.push('/'), + Some('u') => { + let hex: String = chars.by_ref().take(4).collect(); + if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + out.push(ch); + } + } + Some(other) => out.push(other), + None => break, + }, + _ => out.push(c), + } + } + Some(out) +} + +/// Human-readable objective for display. A standing-run objective is wrapped +/// with internal scaffolding — `Standing-operation run for team 'X'. Charter: +/// <charter>. Your capabilities: … Operating contract: … --- BEGIN UNTRUSTED +/// REFERENCE DATA …` — none of which a person should see. Extract the charter / +/// intent and drop the capability manifest + injected prior-knowledge preamble. +/// Ordinary mission objectives (no wrapper) pass through unchanged. +pub(crate) fn clean_objective(raw: &str) -> String { + // Everything from the first scaffolding marker onward is internal. + const MARKERS: [&str; 5] = [ + "Your capabilities:", + "Operating contract:", + "--- BEGIN UNTRUSTED REFERENCE DATA", + "\n\nMode note", + "BEGIN UNTRUSTED REFERENCE DATA", + ]; + let mut end = raw.len(); + for m in MARKERS { + if let Some(i) = raw.find(m) { + end = end.min(i); + } + } + let head = raw[..end].trim(); + // Unwrap the standing-run charter prefix when present. + if let Some(i) = head.find("Charter:") { + let charter = head[i + "Charter:".len()..].trim(); + let charter = charter.trim_end_matches('.').trim(); + if !charter.is_empty() { + return charter.to_string(); + } + } + // Defense in depth: strip any leaked 2026 loop scaffold so LOOP:/GOAL:/ + // CYCLE/[[…]] control-blobs never reach a title, card, or displayed + // objective. A scaffold's GOAL line IS the human intent — extract it. + strip_loop_scaffold(head) +} + +/// Conversational lead-ins that mark a string as a prompt rather than a title +/// ("Can you please …", "I need you to …"). Stripped when deriving a title. +const TITLE_LEAD_INS: [&str; 16] = [ + "can you please ", + "could you please ", + "would you please ", + "can you ", + "could you ", + "would you ", + "please ", + "i need you to ", + "i want you to ", + "i'd like you to ", + "i would like you to ", + "i need ", + "i want ", + "help me ", + "let's ", + "lets ", +]; + +/// Strip any leading conversational lead-in(s), case-insensitively. +fn strip_title_lead_in(s: &str) -> &str { + let mut cur = s.trim_start(); + loop { + let lower = cur.to_ascii_lowercase(); + let mut matched = false; + for lead in TITLE_LEAD_INS { + if lower.starts_with(lead) { + cur = cur[lead.len()..].trim_start(); + matched = true; + break; + } + } + if !matched { + return cur; + } + } +} + +/// Shorten a bare URL token to a compact, human label — a GitHub-style +/// `owner/repo`, else the last path segment, else the host — so a title reads +/// "analyse Azure/kars dependabot PRs", not a 60-char URL. +fn shorten_url_token(tok: &str) -> String { + let lower = tok.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) { + return tok.to_string(); + } + let rest = tok + .trim_end_matches(['.', ',', ')', ']', '?', '!']) + .split_once("://") + .map(|x| x.1) + .unwrap_or(tok); + let mut parts = rest.split('/'); + let host = parts.next().unwrap_or(""); + let segs: Vec<&str> = parts.filter(|s| !s.is_empty()).collect(); + if host.contains("github.") && segs.len() >= 2 { + format!("{}/{}", segs[0], segs[1]) + } else if let Some(last) = segs.last() { + (*last).to_string() + } else { + host.to_string() + } +} + +/// True when `display` is a genuine human title, not a truncated prompt: it has +/// no conversational lead-in, carries no URL, isn't just a prefix of the +/// objective, and isn't paragraph-length. +fn is_genuine_title(display: &str, clean_objective: &str) -> bool { + let lower = display.to_ascii_lowercase(); + if TITLE_LEAD_INS.iter().any(|l| lower.starts_with(l)) { + return false; + } + if lower.contains("http://") || lower.contains("https://") { + return false; + } + let d_trim = lower.trim_end_matches('…').trim(); + let obj_lower = clean_objective.to_ascii_lowercase(); + if d_trim.len() >= 24 && obj_lower.starts_with(d_trim) { + return false; + } + display.chars().count() <= 72 +} + +/// Derive a compact, title-like phrase from a verbose objective: strip the +/// conversational lead-in, shorten URLs, take the first sentence/clause, drop a +/// trailing " - …" condition tail, cap at a word boundary, and capitalize. +pub(super) fn concise_title(text: &str) -> String { + let no_lead = strip_title_lead_in(text.trim()); + let shortened: String = no_lead + .split_whitespace() + .map(shorten_url_token) + .collect::<Vec<_>>() + .join(" "); + let first = shortened + .split(['.', '\n', '?', '!']) + .find(|s| !s.trim().is_empty()) + .unwrap_or(&shortened) + .trim(); + // Prompts often append conditions after a dash ("… PRs - categorize the …"). + let first = first.split(" - ").next().unwrap_or(first).trim(); + let capped = if first.chars().count() > 56 { + // Cut at the last word boundary within the cap. + let head: String = first.chars().take(56).collect(); + let cut = head.rfind(' ').unwrap_or(head.len()); + format!("{}…", head[..cut].trim_end()) + } else { + first.to_string() + }; + let mut chars = capped.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(), + None => String::new(), + } +} + +/// A clean, human display title for a task. Uses an explicit display name only +/// when it is a GENUINE title (not a conversational prompt truncated into the +/// display slot); otherwise derives a concise title from the cleaned objective. +/// Guarantees LOOP:/GOAL:/[[…]] and raw pasted prompts never reach a card, list +/// row, breadcrumb, or tab — it runs at the read/DTO boundary for every task. +pub(crate) fn clean_display_name(display: &Option<String>, objective: &str) -> Option<String> { + let clean_obj = clean_objective(objective); + if let Some(d) = display.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) + && !looks_scaffolded(d) + && is_genuine_title(d, &clean_obj) + { + return Some(d.to_string()); + } + // No genuine title — derive a concise one from the objective (or, when the + // objective is empty, from the de-scaffolded display string). + let source = if clean_obj.is_empty() { + strip_loop_scaffold(display.as_deref().unwrap_or("")) + } else { + clean_obj.clone() + }; + let title = concise_title(&source); + if title.is_empty() { None } else { Some(title) } +} + +fn strip_loop_scaffold(text: &str) -> String { + if !looks_scaffolded(text) { + return text.to_string(); + } + // Prefer the GOAL line — that is the human's restated intent. + for line in text.lines() { + let l = line.trim(); + if let Some(rest) = l.strip_prefix("GOAL:") { + let goal = rest + .trim() + .trim_start_matches("[[") + .trim_end_matches("]]") + .trim(); + if !goal.is_empty() { + return goal.to_string(); + } + } + } + // No GOAL line — drop the scaffold control lines and return the remainder. + const CONTROL_PREFIXES: [&str; 6] = [ + "LOOP:", + "CYCLE:", + "SUCCESS:", + "STOP:", + "SUB-AGENT INHERITANCE", + "[", + ]; + let kept: Vec<&str> = text + .lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() && !CONTROL_PREFIXES.iter().any(|p| t.starts_with(p)) + }) + .collect(); + kept.join(" ").trim().to_string() +} diff --git a/bridge/bff/src/routes/tasks/queries.rs b/bridge/bff/src/routes/tasks/queries.rs new file mode 100644 index 000000000..5ae4f4406 --- /dev/null +++ b/bridge/bff/src/routes/tasks/queries.rs @@ -0,0 +1,419 @@ +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::{Api, ListParams}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::task::KarsTask; +use crate::state::AppState; + +use super::artifacts::build_artifact_set; +use super::evidence::{ + merge_trace_total_tokens, select_task_checkpoint, subagent_trace_from_artifacts, +}; +use super::history::synth_detail_from_output; +use super::mapping::{ + composition_from_materialized, is_task_owner, to_detail, to_sub_agent, to_summary, +}; +use super::presentation::deliverable_pull_requests; +use super::{ + MissionResultDto, MissionTelemetryDto, TaskDetailDto, TaskSummaryDto, classify_blocked, + deliverable_text, map_kube_err, require_cluster, +}; + +/// `GET /api/namespaces/:ns/tasks` — list tasks in a namespace. +pub async fn list_tasks( + State(state): State<AppState>, + principal: Option<Extension<Principal>>, + Path(ns): Path<String>, +) -> AppResult<Json<Vec<TaskSummaryDto>>> { + let cluster = require_cluster(&state)?; + let principal = principal + .map(|Extension(principal)| principal) + .ok_or_else(|| AppError::Forbidden("signed-in principal required".into()))?; + let api: Api<KarsTask> = cluster.tasks(&ns); + let list = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + // Cross-reference delivered + failed missions in ONE pass over the persisted + // outputs, so the list can show "Delivered" / "Run failed" instead of + // misreading an idle delivered run — or a hung errored run — as "drafting". + let outputs = cluster.list_mission_outputs().await; + let mut terminal = std::collections::HashMap::<String, &'static str>::new(); + for record in &outputs { + match record.data.get("status").map(String::as_str) { + Some("ok") + if record + .data + .get("output") + .is_some_and(|output| !output.trim().is_empty()) => + { + terminal + .entry(record.task_name.clone()) + .or_insert("delivered"); + } + Some("error") => { + terminal.entry(record.task_name.clone()).or_insert("failed"); + } + _ => {} + } + } + let delivered: std::collections::HashSet<String> = terminal + .iter() + .filter(|(_, status)| **status == "delivered") + .map(|(task, _)| task.clone()) + .collect(); + let failed: std::collections::HashSet<String> = terminal + .iter() + .filter(|(_, status)| **status == "failed") + .map(|(task, _)| task.clone()) + .collect(); + let mut summaries: Vec<TaskSummaryDto> = list + .items + .iter() + .filter(|task| is_task_owner(task, &principal)) + .map(|t| { + let mut s = to_summary(t); + s.delivered = delivered.contains(&s.name); + s.failed = failed.contains(&s.name); + s + }) + .collect(); + + // Persist history: a mission whose KarsTask CR has been garbage-collected + // (retired-run GC) still has its delivered/errored output ConfigMap. Without + // this, completed missions silently vanish from the list mid-session and + // their direct URLs 404 ("data loss", audit BUG-8). Re-add any output-only + // mission that isn't already represented by a live CR. Team-run machinery + // (`<team>-run-<epoch>`) is excluded — those belong to the Team view, which + // is exactly what the live-CR path already hides. + let live_names: std::collections::HashSet<String> = + summaries.iter().map(|s| s.name.clone()).collect(); + for record in &outputs { + let task = &record.task_name; + let d = &record.data; + if d.get("ownerSub").map(String::as_str) != Some(principal.sub.as_str()) { + continue; + } + if live_names.contains(task) || regex_lite_is_team_run(task) { + continue; + } + let is_ok = delivered.contains(task); + let is_err = failed.contains(task); + // Only surface a genuinely terminal output (delivered or errored); skip + // stray/empty outputs so we don't invent phantom missions. + if !is_ok && !is_err { + continue; + } + summaries.push(TaskSummaryDto { + name: task.clone(), + namespace: ns.clone(), + objective: d.get("objective").cloned().unwrap_or_default(), + display_name: d + .get("displayName") + .cloned() + .filter(|s| !s.trim().is_empty()), + created_at: d.get("startedAt").cloned(), + tier: d.get("tier").and_then(|v| v.parse().ok()).unwrap_or(0), + phase: if is_err { + "Failed".into() + } else { + "Delivered".into() + }, + envelope_digest: None, + team: d.get("team").cloned(), + delivered: is_ok, + failed: is_err, + launched: true, + execution_phase: Some("Idle".into()), + }); + } + Ok(Json(summaries)) +} + +/// True when `name` looks like a standing-team run task (`<team>-run-<epoch>`), +/// which the Missions surface intentionally hides (they belong to the Team +/// view). A tiny hand-rolled check to avoid a regex dependency. +pub(super) fn regex_lite_is_team_run(name: &str) -> bool { + if let Some(idx) = name.rfind("-run-") { + let suffix = &name[idx + "-run-".len()..]; + return !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()); + } + false +} + +/// `GET /api/namespaces/:ns/tasks/:name` — fetch one task, with its delegated +/// children resolved (tasks whose `parentRef` points at this task). +pub async fn get_task( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<TaskDetailDto>> { + let cluster = require_cluster(&state)?; + let api: Api<KarsTask> = cluster.tasks(&ns); + let task = match api.get_opt(&name).await.map_err(map_kube_err)? { + Some(t) => t, + // The KarsTask CR was garbage-collected (retired-run GC) but the + // mission's terminal output persists. Synthesize a read-only detail from + // it so a delivered/failed mission's page — and the list link that now + // shows it — doesn't 404 mid-session. Genuine unknowns still 404. + None => return synth_detail_from_output(cluster, &ns, &name, &principal).await, + }; + if !is_task_owner(&task, &principal) { + return Err(AppError::NotFound); + } + // Resolve direct children by scanning the namespace for parentRef == name. + // Exclude RUN INSTANCES (cadence/taskforce runs named `*-run-<epoch>` or + // annotated team-role=taskforce): those are run history, not org-chart roles. + // Without this the org chart floods with every historical run of a standing + // team as a duplicate node. Same predicate list_agents uses to identify runs. + let all = api + .list(&ListParams::default()) + .await + .map_err(map_kube_err)?; + let children: Vec<TaskSummaryDto> = all + .items + .iter() + .filter(|t| t.spec.parent_ref.as_ref().is_some_and(|r| r.name == name)) + .filter(|t| { + let is_run = t.name_any().contains("-run-") + || t.annotations() + .get("kars.azure.com/team-role") + .map(String::as_str) + == Some("taskforce"); + !is_run + }) + .map(to_summary) + .collect(); + + // Runtime agents: the sub-agents this mission's agent spawned at run time. + // The inference router labels each spawned KarsSandbox + // `kars.azure.com/parent=<sandbox>`; surface them so the org chart reflects + // the *running* agent/sub-agent tree, not only the governed role tree. + let sandbox_name = task + .status + .as_ref() + .and_then(|s| s.sandbox_ref.as_ref()) + .map(|r| r.name.clone()); + let sub_agents = match &sandbox_name { + Some(sb) => cluster + .sub_agent_sandboxes(&ns, sb) + .await + .iter() + .map(to_sub_agent) + .collect(), + None => Vec::new(), + }; + + // Effective composition: once launched, show what the sandbox is ACTUALLY + // running (read from the materialized InferencePolicy + KarsSandbox, + // including any controller-defaulted model), not just the submitted + // blueprint. Pre-launch, fall back to the blueprint (the planned config). + let effective = match &sandbox_name { + Some(sb) => { + let ip = cluster + .get_kind(&ns, "InferencePolicy", &format!("{name}-inference")) + .await + .ok() + .flatten(); + let sandbox = cluster + .get_kind(&ns, "KarsSandbox", sb) + .await + .ok() + .flatten(); + composition_from_materialized(ip.as_ref(), sandbox.as_ref()) + } + None => None, + }; + + // Live egress enforcement mode (Learn/Strict) read from the materialized + // KarsSandbox — the real monitoring→enforced surface. + let egress_mode = match &sandbox_name { + Some(sb) => cluster.sandbox_egress_mode(sb).await, + None => None, + }; + + // The mission's captured run result (persisted deliverable + real tokens). + let output_data = cluster.read_mission_output(&name).await; + let mut result = output_data.as_ref().and_then(|d| { + let output = deliverable_text(d.get("output")?); + let blocked = classify_blocked(d.get("status").map(String::as_str), &output); + Some(MissionResultDto { + output, + status: d.get("status").cloned(), + model: d.get("model").cloned(), + total_tokens: d.get("totalTokens").and_then(|v| v.parse().ok()), + prompt_tokens: d.get("promptTokens").and_then(|v| v.parse().ok()), + completion_tokens: d.get("completionTokens").and_then(|v| v.parse().ok()), + finished_at: d.get("finishedAt").cloned(), + assignment_nonce: d.get("assignmentNonce").cloned(), + source: d.get("source").cloned(), + blocked, + artifact_persistence: d.get("artifactPersistence").cloned(), + artifact_count: d.get("artifactCount").and_then(|v| v.parse().ok()), + declared_artifact_count: d.get("declaredArtifactCount").and_then(|v| v.parse().ok()), + }) + }); + + // The mission's full artifact set: the manifest (name + size, incl. binary) + // comes from the output ConfigMap; text contents come from the companion + // artifacts ConfigMap. Merge them so the set is complete and honest. + let artifacts = build_artifact_set(cluster, &name, output_data.as_ref()).await; + let successful_result = result.as_ref().is_some_and(|result| { + result.status.as_deref() != Some("error") && result.blocked.is_none() + }); + let checkpoint = select_task_checkpoint( + cluster.read_mission_progress(&name).await, + &artifacts, + successful_result, + ); + + // The mission's live execution activity — the real per-round + per-tool + // trace the agent emitted, persisted by the controller as the clean audit + // record. Parsed from the trace ConfigMap; empty when no trace exists. + let mut activity: Vec<serde_json::Value> = cluster + .read_mission_trace(&name) + .await + .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) + .unwrap_or_default(); + activity.extend(subagent_trace_from_artifacts(&artifacts)); + activity.sort_by(|left, right| { + left.get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .cmp( + right + .get("ts") + .and_then(serde_json::Value::as_str) + .unwrap_or(""), + ) + }); + + // LIVE fallback. The persisted trace ConfigMap is written only once, at + // delivery — so a still-running mission would otherwise show an EMPTY + // activity trace (blank deploy timeline, agent graph, and map, and a + // "Waiting for the first model round" that lies while the agent is already + // on round 3). When no persisted trace exists yet and the mission is + // launched, pull the SAME live router telemetry the Activity SSE streams — + // the principal sandbox plus every sub-agent it spawned — so the WHOLE + // detail page is genuinely live on each poll, not just the SSE tab. + if activity.is_empty() + && let Some(principal) = &sandbox_name + { + let mut live: Vec<serde_json::Value> = Vec::new(); + for mut ev in cluster.sandbox_live_trace(principal).await { + if let Some(obj) = ev.as_object_mut() { + obj.insert("agent".into(), serde_json::json!(name)); + obj.insert("agentInstance".into(), serde_json::json!(principal)); + obj.insert("agentRole".into(), serde_json::json!("principal")); + } + live.push(ev); + } + let mut descendants = cluster + .sub_agent_sandbox_names(&ns, principal) + .await + .into_iter(); + loop { + let sub_batch = descendants.by_ref().take(8).collect::<Vec<_>>(); + if sub_batch.is_empty() { + break; + } + let mut polling = tokio::task::JoinSet::new(); + for sub in sub_batch { + let cluster = cluster.clone(); + polling.spawn(async move { + let events = cluster.sandbox_live_trace(&sub).await; + (sub, events) + }); + } + while let Some(result) = polling.join_next().await { + let Ok((sub, events)) = result else { + continue; + }; + for mut ev in events { + if let Some(obj) = ev.as_object_mut() { + obj.insert("agent".into(), serde_json::json!(sub.clone())); + obj.insert("agentInstance".into(), serde_json::json!(sub.clone())); + obj.insert("agentRole".into(), serde_json::json!("subagent")); + } + live.push(ev); + } + } + } + activity = live; + } + + // Loop-shape telemetry (rounds, tool calls). Token totals live on `result`. + // Derive rollups from the persisted per-round/per-tool trace when the run's + // output ConfigMap didn't include them — some harnesses persist the trace + // but not the totals, which left a DELIVERED mission's map reading + // "Not run yet" / "No activity". The trace is the honest source either way. + let trace_round_events = activity + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .count() as i64; + let trace_tool_events = activity + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("tool")) + .count() as i64; + let trace_total_tokens: i64 = activity + .iter() + .filter(|e| e.get("kind").and_then(|k| k.as_str()) == Some("round")) + .filter_map(|e| e.get("total_tokens").and_then(serde_json::Value::as_i64)) + .sum(); + + // Backfill the token total on the result from the trace when the output CM + // didn't carry it (so token burn shows on a delivered run with a trace). + merge_trace_total_tokens(&mut result, trace_total_tokens); + + let telemetry = { + let mut rounds = output_data + .as_ref() + .and_then(|d| d.get("rounds").and_then(|v| v.parse::<i64>().ok())); + let mut tool_calls = output_data + .as_ref() + .and_then(|d| d.get("toolCalls").and_then(|v| v.parse::<i64>().ok())); + if trace_round_events > 0 { + rounds = Some(rounds.unwrap_or_default().max(trace_round_events)); + } + if trace_tool_events > 0 { + tool_calls = Some(tool_calls.unwrap_or_default().max(trace_tool_events)); + } + if rounds.is_some() || tool_calls.is_some() { + Some(MissionTelemetryDto { rounds, tool_calls }) + } else { + None + } + }; + + // The running agent's real mesh identity, discovered from the AGT registry + // (harness-neutral). Only meaningful once a sandbox is running. + let agent_identity = match &sandbox_name { + Some(sb) => cluster.discover_agent_identity(sb).await, + None => None, + }; + + // Pull requests the mission opened, extracted from its raw output — a PR is a + // first-class delivery type, surfaced on the Artifacts tab (not just prose). + let pull_requests = output_data + .as_ref() + .map(deliverable_pull_requests) + .unwrap_or_default(); + + Ok(Json(to_detail( + &task, + children, + sub_agents, + effective, + result, + artifacts, + pull_requests, + activity, + telemetry, + checkpoint, + agent_identity, + egress_mode, + ))) +} diff --git a/bridge/bff/src/routes/tasks/tests.rs b/bridge/bff/src/routes/tasks/tests.rs new file mode 100644 index 000000000..ddd55de14 --- /dev/null +++ b/bridge/bff/src/routes/tasks/tests.rs @@ -0,0 +1,658 @@ +use super::BlueprintDto; +use super::ExecutionPhaseDto; +use super::ExecutionPlanDto; +use super::ExecutionRoleDto; +use super::ExecutionSynthesisDto; +use super::MissionArtifactDto; +use super::MissionResultDto; +use super::ModelDto; +use super::TaskAssignmentEventDto; +use super::TeamCollaborationEventDto; +use super::clean_objective; +use super::diagnostics::diagnose_run_failure; +use super::egress::normalize_ttl; +use super::evidence::{ + ARTIFACT_PREVIEW_MAX_BYTES, ARTIFACT_PREVIEW_TOTAL_BYTES, artifact_preview, + canonicalize_assignment_event_roles, merge_trace_total_tokens, select_task_checkpoint, + structured_team_evidence, subagent_trace_from_artifacts, valid_task_checkpoint, +}; +use super::mapping::to_sub_agent; +use super::presentation::deliverable_pull_requests; + +#[test] +fn execution_plan_dto_into_crd_preserves_web_search_capability() { + let blueprint = BlueprintDto { + execution_plan: Some(ExecutionPlanDto { + schema: "kars.execution-plan/v1".into(), + roles: vec![ExecutionRoleDto { + name: "source-scout".into(), + objective: "Discover exact URLs and fetch the evidence.".into(), + depends_on: Vec::new(), + phases: vec![ExecutionPhaseDto { + name: "discover".into(), + objective: "Search and fetch the exact URLs.".into(), + capabilities: vec!["web-search".into(), "network".into()], + required_tool_calls: Vec::new(), + min_tool_calls: 1, + max_tool_calls: 4, + fresh_context: true, + }], + budget_tokens: None, + }], + max_parallel: 1, + synthesis: ExecutionSynthesisDto { + objective: "Return the verified answer.".into(), + capabilities: Vec::new(), + max_tool_calls: 0, + }, + deliverables: Vec::new(), + }), + ..Default::default() + }; + + let crd = blueprint.into_crd(); + assert_eq!( + crd.execution_plan.expect("execution plan").roles[0].phases[0].capabilities, + vec!["web-search".to_string(), "network".to_string()] + ); +} + +#[test] +fn blueprint_dto_preserves_ordered_model_fallbacks() { + let blueprint = BlueprintDto { + model: Some(ModelDto { + provider: "local-inference".into(), + deployment: "gpt-oss-120b".into(), + }), + model_fallbacks: vec![ + ModelDto { + provider: "github-copilot".into(), + deployment: "gpt-5.6-sol".into(), + }, + ModelDto { + provider: "foundry".into(), + deployment: "gpt-5.4-pro".into(), + }, + ], + ..Default::default() + }; + + let crd = blueprint.into_crd(); + assert_eq!(crd.model_fallbacks.len(), 2); + assert_eq!(crd.model_fallbacks[0].provider, "github-copilot"); + assert_eq!(crd.model_fallbacks[1].deployment, "gpt-5.4-pro"); +} + +#[test] +fn schema_rejection_is_diagnosed_as_router_model_compatibility() { + let logs = vec![ + "rawError=400 Unknown parameter: 'stream_options.include_usage'".to_string(), + "LLM request failed: provider rejected the request schema or tool payload.".to_string(), + ]; + let (cause, remedy, harness_issue, evidence) = + diagnose_run_failure(&logs, &[], Some("provider rejected the request schema")); + assert!(cause.contains("translated inference request")); + assert!(remedy.contains("corrected inference router")); + assert!(!harness_issue); + assert_eq!(evidence.len(), 1); +} + +#[test] +fn completed_subagent_trace_is_rehydrated_from_artifact() { + let artifacts = vec![MissionArtifactDto { + name: "artifacts/.run-x/subagent-telemetry.jsonl".into(), + size_bytes: None, + content: Some( + r#"{"at":"2026-07-23T12:00:00Z","event":"subagent_trace","member":"ci-verifier","trace":{"kind":"tool","name":"github_checks","ok":true}}"# + .into(), + ), + content_bytes: None, + content_truncated: false, + source_agent: None, + source_path: None, + digest: None, + full_content: None, + }]; + + let events = subagent_trace_from_artifacts(&artifacts); + + assert_eq!(events.len(), 1); + assert_eq!(events[0]["agent"], "ci-verifier"); + assert_eq!(events[0]["agentRole"], "subagent"); + assert_eq!(events[0]["ts"], "2026-07-23T12:00:00Z"); + assert_eq!(events[0]["name"], "github_checks"); +} + +#[test] +fn artifact_preview_is_bounded_but_full_content_remains_internal() { + let mut budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + let full = "x".repeat(ARTIFACT_PREVIEW_MAX_BYTES + 100); + let (preview, bytes, truncated, internal) = artifact_preview(Some(full.clone()), &mut budget); + + assert_eq!( + preview.as_ref().map(String::len), + Some(ARTIFACT_PREVIEW_MAX_BYTES) + ); + assert_eq!(bytes, Some(full.len() as i64)); + assert!(truncated); + assert_eq!(internal.as_deref(), Some(full.as_str())); +} + +#[test] +fn artifact_previews_share_a_bounded_response_budget() { + let mut budget = ARTIFACT_PREVIEW_MAX_BYTES + 100; + let first = "a".repeat(ARTIFACT_PREVIEW_MAX_BYTES + 1); + let second = "b".repeat(ARTIFACT_PREVIEW_MAX_BYTES); + + let (first_preview, _, first_truncated, _) = artifact_preview(Some(first), &mut budget); + let (second_preview, _, second_truncated, _) = artifact_preview(Some(second), &mut budget); + + assert_eq!( + first_preview.as_ref().map(String::len), + Some(ARTIFACT_PREVIEW_MAX_BYTES) + ); + assert_eq!(second_preview.as_ref().map(String::len), Some(100)); + assert!(first_truncated); + assert!(second_truncated); + assert_eq!(budget, 0); +} + +#[test] +fn empty_artifact_is_not_reported_as_truncated() { + let mut budget = ARTIFACT_PREVIEW_TOTAL_BYTES; + let (preview, bytes, truncated, internal) = artifact_preview(Some(String::new()), &mut budget); + + assert_eq!(preview.as_deref(), Some("")); + assert_eq!(bytes, Some(0)); + assert!(!truncated); + assert_eq!(internal.as_deref(), Some("")); +} + +#[test] +fn artifact_preview_respects_utf8_boundaries_and_exhausted_budget() { + let mut budget = 5; + let (preview, bytes, truncated, _) = + artifact_preview(Some("abcd\u{1f642}".to_string()), &mut budget); + + assert_eq!(preview.as_deref(), Some("abcd")); + assert_eq!(bytes, Some(8)); + assert!(truncated); + assert_eq!(budget, 1); + + budget = 0; + let (preview, bytes, truncated, _) = + artifact_preview(Some("still here".to_string()), &mut budget); + assert!(preview.is_none()); + assert_eq!(bytes, Some(10)); + assert!(truncated); +} + +#[test] +fn full_artifact_content_is_private_but_available_for_trace_recovery() { + let telemetry = r#"{"at":"2026-07-23T12:00:00Z","event":"subagent_trace","member":"ci-verifier","trace":{"kind":"tool","name":"github_checks","ok":true}}"#; + let artifact = MissionArtifactDto { + name: "artifacts/.run-x/subagent-telemetry.jsonl".into(), + size_bytes: Some(telemetry.len() as i64), + content: Some("{\"at\":\"2026".into()), + content_bytes: Some(telemetry.len() as i64), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some(telemetry.into()), + }; + + let serialized = serde_json::to_value(&artifact).expect("serialize artifact preview"); + assert_eq!(serialized["content"], "{\"at\":\"2026"); + assert!(serialized.get("full_content").is_none()); + + let events = subagent_trace_from_artifacts(&[artifact]); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["name"], "github_checks"); +} + +#[test] +fn structured_team_evidence_uses_full_content_not_preview_order() { + let role_plan = MissionArtifactDto { + name: "role-plan.json".into(), + size_bytes: None, + content: None, + content_bytes: Some(91), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some( + r#"{"selected_roles":[{"role":"builder"}],"skipped_roles":["observer"]}"#.into(), + ), + }; + let collaboration = MissionArtifactDto { + name: "collaboration.jsonl".into(), + size_bytes: None, + content: Some("{\"at\":\"truncated".into()), + content_bytes: Some(200), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some( + r#"{"at":"2026-07-23T12:00:00Z","event":"child_handback","from_agent":"builder","outcome":"success","reply_preview":"done"}"# + .into(), + ), + }; + + let (plan, events) = structured_team_evidence(&[role_plan, collaboration]); + + assert_eq!(plan.selected_roles, ["builder"]); + assert_eq!(plan.skipped_roles, ["observer"]); + assert_eq!(events.len(), 1); + assert_eq!(events[0].member.as_deref(), Some("builder")); + assert_eq!(events[0].event, "child_handback"); +} + +#[test] +fn assignment_ledger_uses_canonical_role_from_assignment_message() { + let mut events = vec![TaskAssignmentEventDto { + sequence: 1, + event_id: "event-1".into(), + task_id: "run-1".into(), + event_type: "child_progress".into(), + state: "Completed".into(), + at: "2026-08-04T21:36:50Z".into(), + worker_did: None, + stage: Some("child_handback".into()), + child_task_id: Some("message-1".into()), + child_role: Some("principal-remediatio-b9220dcf".into()), + outcome: Some("success".into()), + message: None, + }]; + let collaboration = vec![TeamCollaborationEventDto { + at: Some("2026-08-04T21:35:32Z".into()), + event: "assignment_sent".into(), + agent: Some("principal".into()), + member: Some("remediation-engineer".into()), + outcome: None, + message_id: Some("message-1".into()), + reply_preview: None, + content_preview: None, + }]; + + canonicalize_assignment_event_roles(&mut events, &collaboration); + + assert_eq!( + events[0].child_role.as_deref(), + Some("remediation-engineer") + ); +} + +#[test] +fn successful_result_hides_stale_bootstrap_checkpoint() { + let progress = serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "dependency-pr", + "status": "in_progress", + "summary": "Controller initialized the durable milestone checkpoint." + }); + + assert!(select_task_checkpoint(Some(progress.clone()), &[], true).is_none()); + assert!(select_task_checkpoint(Some(progress), &[], false).is_some()); +} + +#[test] +fn completed_artifact_checkpoint_wins_over_bootstrap_progress() { + let completed = r#"{ + "schema": "kars.checkpoint/v1", + "milestone_id": "dependency-pr", + "status": "completed", + "summary": "All required handbacks were retained." + }"#; + let artifact = MissionArtifactDto { + name: "task-checkpoint.json".into(), + size_bytes: Some(completed.len() as i64), + content: None, + content_bytes: Some(completed.len() as i64), + content_truncated: true, + source_agent: None, + source_path: None, + digest: None, + full_content: Some(completed.into()), + }; + let progress = serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "dependency-pr", + "status": "in_progress", + "summary": "Controller initialized the durable milestone checkpoint." + }); + + let checkpoint = select_task_checkpoint(Some(progress), &[artifact], true).expect("checkpoint"); + + assert_eq!(checkpoint["status"], "completed"); +} + +#[test] +fn aggregate_trace_tokens_replace_principal_only_total() { + let mut result = Some(MissionResultDto { + output: "done".into(), + status: Some("ok".into()), + model: None, + total_tokens: Some(8_505), + prompt_tokens: None, + completion_tokens: None, + finished_at: None, + assignment_nonce: None, + source: None, + blocked: None, + artifact_persistence: None, + artifact_count: None, + declared_artifact_count: None, + }); + + merge_trace_total_tokens(&mut result, 22_016); + + assert_eq!(result.and_then(|value| value.total_tokens), Some(22_016)); +} + +#[test] +fn subagent_projects_human_identity_and_runtime_metadata() { + let object: kube::core::DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { + "name": "researcher-run-7f4c", + "namespace": "kars-team", + "labels": { + "kars.azure.com/role": "Research specialist", + "kars.azure.com/parent": "principal-run" + }, + "annotations": { + "kars.azure.com/logical-agent-id": "researcher", + "kars.azure.com/model": "gpt-5.4" + } + }, + "spec": { + "runtime": { + "kind": "openclaw" + } + }, + "status": { + "phase": "Running" + } + })) + .expect("deserialize KarsSandbox"); + + let dto = to_sub_agent(&object); + + assert_eq!(dto.name, "researcher-run-7f4c"); + assert_eq!(dto.namespace, "kars-team"); + assert_eq!(dto.phase.as_deref(), Some("Running")); + assert_eq!(dto.runtime.as_deref(), Some("openclaw")); + assert_eq!(dto.role.as_deref(), Some("Research specialist")); + assert_eq!(dto.parent.as_deref(), Some("principal-run")); + assert_eq!(dto.logical_agent_id.as_deref(), Some("researcher")); + assert_eq!(dto.model.as_deref(), Some("gpt-5.4")); +} + +#[test] +fn malformed_checkpoint_is_not_exposed_to_the_ui() { + assert!( + valid_task_checkpoint(serde_json::json!({ + "schema": "kars.checkpoint/v1", + "status": "completed" + })) + .is_none() + ); + assert!( + valid_task_checkpoint(serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "build", + "status": "completed", + "summary": "Artifact produced", + "artifacts": "not-an-array" + })) + .is_none() + ); + assert!( + valid_task_checkpoint(serde_json::json!({ + "schema": "kars.checkpoint/v1", + "milestone_id": "build", + "status": "completed", + "summary": "Artifact produced" + })) + .is_some() + ); +} + +#[test] +fn clean_objective_strips_loop_scaffold() { + // A leaked loop scaffold must never reach a title — extract the GOAL. + let scaffolded = "LOOP: ReAct — Reason + Act\nGOAL: find the Azure/kars star count and write a paragraph\nCYCLE: reason, act, observe\nSUCCESS: a paragraph with the count\nSTOP: when delivered\nSUB-AGENT INHERITANCE: give each sub-agent the same loop"; + assert_eq!( + clean_objective(scaffolded), + "find the Azure/kars star count and write a paragraph" + ); +} + +#[test] +fn clean_objective_passes_plain_through() { + assert_eq!( + clean_objective("Summarize the Q3 report"), + "Summarize the Q3 report" + ); +} + +#[test] +fn clean_objective_strips_bracket_goal() { + let s = "LOOP: eval-iterate\nGOAL: [[raise CLI test coverage]]\nSTOP: green"; + assert_eq!(clean_objective(s), "raise CLI test coverage"); +} + +#[test] +fn deliverable_text_strips_fixed_sandbox_banner() { + let raw = "# ? kars Sandbox - Secure AI Runtime on Azure\n\ + - **Foundry Project:** project\n\ + - **Model:** gpt\n\ + - **Sandbox ID:** run-1\n\ + - **Security:** isolated\n\ + - **Capabilities:** tools and reasoning\n\n\ + [[NO_MATERIAL_CHANGE]] nothing changed."; + assert_eq!( + super::deliverable_text(raw), + "[[NO_MATERIAL_CHANGE]] nothing changed." + ); +} + +#[test] +fn deliverable_text_repairs_legacy_question_mark_replacements() { + assert_eq!( + super::deliverable_text("1? Role?plan: non?root; GHSA?w8wr?v893?vjvp"), + "1. Role-plan: non-root; GHSA-w8wr-v893-vjvp" + ); +} + +#[test] +fn real_deliverable_gates_error_and_no_change() { + use super::is_real_deliverable; + assert!(!is_real_deliverable(Some("error"), "anything")); + assert!(!is_real_deliverable(Some("ok"), " ")); + assert!(!is_real_deliverable( + Some("ok"), + "[[NO_MATERIAL_CHANGE]] nothing changed" + )); + assert!(!is_real_deliverable( + Some("ok"), + "kars Sandbox - Secure AI Runtime on Azure\nSandbox ID: run-1\nSecurity: isolated\nCapabilities: tools\n[[NO_MATERIAL_CHANGE]] nothing changed" + )); + assert!(is_real_deliverable(Some("ok"), "Here is the report.")); + assert!(is_real_deliverable(None, "Some output")); + // A budget-blocked ok-run is NOT a deliverable. + assert!(!is_real_deliverable( + Some("ok"), + "API call failed after 3 retries: HTTP 429: Daily token budget exceeded (23131/20000 tokens)." + )); + assert!(!is_real_deliverable( + Some("ok"), + "unexpected tokens remaining in message header: Some(...)" + )); + assert!(!is_real_deliverable( + Some("ok"), + "assignment progress lease expired after 90s without renewal" + )); + assert!(is_real_deliverable( + Some("ok"), + "Completed remediation successfully. A prior child reported assignment progress lease expired, but its replacement delivered." + )); +} + +#[test] +fn failed_output_cannot_create_pull_request_deliverables() { + let data = std::collections::BTreeMap::from([ + ("status".to_string(), "error".to_string()), + ( + "output".to_string(), + "Claimed https://github.com/example/repo/pull/134".to_string(), + ), + ]); + assert!(deliverable_pull_requests(&data).is_empty()); +} + +#[test] +fn classify_blocked_detects_budget_and_parses_pair() { + use super::classify_blocked; + let b = classify_blocked( + Some("ok"), + "API call failed after 3 retries: HTTP 429: Daily token budget exceeded (23131/20000 tokens).", + ) + .expect("budget block detected"); + assert_eq!(b.reason, "budget"); + assert_eq!(b.spent, Some(23131)); + assert_eq!(b.limit, Some(20000)); + // A real deliverable is not blocked. + assert!(classify_blocked(Some("ok"), "Here is the finished report.").is_none()); + // An error run is handled elsewhere, not as blocked. + assert!(classify_blocked(Some("error"), "Daily token budget exceeded").is_none()); +} + +#[test] +fn assignment_dto_uses_the_web_snake_case_contract() { + let event = TaskAssignmentEventDto { + sequence: 3, + event_id: "root:3".into(), + task_id: "root".into(), + event_type: "child_progress".into(), + state: "Completed".into(), + at: "2026-07-20T12:00:00Z".into(), + worker_did: Some("did:agt:worker".into()), + stage: Some("child_handback".into()), + child_task_id: Some("child-1".into()), + child_role: Some("reviewer".into()), + outcome: Some("success".into()), + message: None, + }; + let value = serde_json::to_value(event).expect("serialize assignment event"); + assert_eq!(value["child_task_id"], "child-1"); + assert_eq!(value["child_role"], "reviewer"); + assert_eq!(value["event_type"], "child_progress"); + assert!(value.get("childTaskId").is_none()); +} + +#[test] +fn deliverable_excerpt_strips_noise() { + use super::deliverable_excerpt; + let raw = "[[NO_MATERIAL_CHANGE]]\n# Heading\n| a | b |\n---\nThe repo star count is 1,234."; + let ex = deliverable_excerpt(raw); + assert!(ex.contains("star count")); + assert!(!ex.contains("NO_MATERIAL_CHANGE")); + assert!(!ex.contains('|')); +} + +#[test] +fn team_run_names_are_detected() { + use super::queries::regex_lite_is_team_run; + assert!(regex_lite_is_team_run("kars-repo-health-run-1783099875")); + assert!(regex_lite_is_team_run("ci-monitor-team-run-42")); + // Standalone missions and non-numeric suffixes are NOT team runs. + assert!(!regex_lite_is_team_run("audit-the-readme")); + assert!(!regex_lite_is_team_run("some-run-abc")); + assert!(!regex_lite_is_team_run("foo-run-")); + assert!(!regex_lite_is_team_run("plainname")); +} + +#[test] +fn deliverable_excerpt_drops_markdown_wrapped_sentinel() { + use super::deliverable_excerpt; + // Bold-/emphasis-wrapped sentinel must still be recognized and dropped + // (regression: it used to leak into the excerpt because the sentinel + // check ran before markdown-wrapping was stripped). + let raw = "**[[NO_MATERIAL_CHANGE]]** +3 stars\nThe repo now has 1,234 stars."; + let ex = deliverable_excerpt(raw); + assert!( + !ex.contains("NO_MATERIAL_CHANGE"), + "excerpt leaked sentinel: {ex}" + ); + assert!(ex.contains("1,234 stars")); +} + +#[test] +fn clean_display_name_prefers_intent_over_scaffold() { + use super::clean_display_name; + // Scaffold display name -> derive (capitalized) from objective. + assert_eq!( + clean_display_name( + &Some("LOOP: ReAct".to_string()), + "GOAL: count the stars\nSTOP: done" + ), + Some("Count the stars".to_string()) + ); + // Real display name -> kept. + assert_eq!( + clean_display_name(&Some("Weekly repo digest".to_string()), "whatever"), + Some("Weekly repo digest".to_string()) + ); + // A conversational prompt pasted into the display slot is NOT a title — + // derive a concise one: strip the lead-in, shorten the URL, drop the + // trailing "- …" condition tail, capitalize. + assert_eq!( + clean_display_name( + &Some( + "Can you please check https://github.com/Azure/kars and analyse all dependabot PR" + .to_string() + ), + "Can you please check https://github.com/Azure/kars and analyse all dependabot PRs - categorize the ones which are safe to merge", + ), + Some("Check Azure/kars and analyse all dependabot PRs".to_string()) + ); +} + +#[test] +fn concise_title_strips_lead_in_and_shortens_url() { + use super::presentation::concise_title; + assert_eq!( + concise_title("I need you to summarise https://example.com/reports/q3 today"), + "Summarise q3 today".to_string() + ); + // Long objective is capped at a word boundary with an ellipsis. + let long = "review every open pull request across the entire organisation and produce a ranked risk report"; + let t = concise_title(long); + assert!(t.chars().count() <= 57, "title too long: {t}"); + assert!(t.ends_with('…'), "expected ellipsis: {t}"); + assert!(t.starts_with("Review "), "expected capitalized start: {t}"); +} + +#[test] +fn ttl_human_to_iso8601() { + assert_eq!(normalize_ttl("2h"), "PT2H"); + assert_eq!(normalize_ttl("30m"), "PT30M"); + assert_eq!(normalize_ttl("24h"), "PT24H"); + assert_eq!(normalize_ttl("1d"), "P1D"); + assert_eq!(normalize_ttl("90s"), "PT90S"); + assert_eq!(normalize_ttl(" 8 h "), "PT8H"); +} + +#[test] +fn ttl_passthrough_and_fallback() { + assert_eq!(normalize_ttl("PT2H"), "PT2H"); // already ISO + assert_eq!(normalize_ttl("pt45m"), "PT45M"); // uppercased + assert_eq!(normalize_ttl(""), "PT2H"); // empty → default + assert_eq!(normalize_ttl("garbage"), "PT2H"); // unrecognized → default + assert_eq!(normalize_ttl("0h"), "PT2H"); // zero → default +} From e7d489e390c5f0d7a9b769d48bbaace2127437e4 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 16:53:51 +0200 Subject: [PATCH 007/111] Enforce receipt anchor pins consistently and keep witness evidence advisory Share trusted-anchor resolution between receipt and whole-log verification. Bind configured key IDs to raw-key fingerprints and reject mismatched or invalid pins. Preserve unpinned cluster-anchor behavior and receipt wire bytes. Add signed replacement-anchor, pin matrix and no-witness checkpoint regressions; Rust execution remains pending hosted qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/.env.example | 4 + bridge/bff/src/kars/receipt_log/tests.rs | 141 +++++++++++++- bridge/bff/src/routes/receipts.rs | 176 +++++++++--------- bridge/bff/src/routes/receipts/anchor.rs | 157 ++++++++++++++++ bridge/docs/deployment.md | 18 +- .../2026-09-11-bridge-application.md | 27 +++ 6 files changed, 432 insertions(+), 91 deletions(-) create mode 100644 bridge/bff/src/routes/receipts/anchor.rs diff --git a/bridge/.env.example b/bridge/.env.example index 4ae3e96e8..3e0bab584 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -6,5 +6,9 @@ BRIDGE_BFF_PORT=8081 BRIDGE_WEB_ORIGIN=http://localhost:3000 BRIDGE_LOG_JSON=false +# Optional out-of-band receipt pins; leave unset rather than assigning empty values. +# BRIDGE_RECEIPT_ANCHOR_KEY_ID=<full lowercase SHA-256 fingerprint of raw public key> +# BRIDGE_RECEIPT_ANCHOR_PUBKEY=<standard base64 of 32-byte Ed25519 public key> + # --- Web (server-side) --- BRIDGE_BFF_URL=http://localhost:8081 diff --git a/bridge/bff/src/kars/receipt_log/tests.rs b/bridge/bff/src/kars/receipt_log/tests.rs index ffa60eb43..dd7bf57f4 100644 --- a/bridge/bff/src/kars/receipt_log/tests.rs +++ b/bridge/bff/src/kars/receipt_log/tests.rs @@ -2,7 +2,7 @@ use super::*; use axum::{ Json, Router, body::{Body, to_bytes}, - extract::State, + extract::{Extension, Path, State}, http::{Method, Request, StatusCode, Uri}, response::{IntoResponse, Response}, routing::get, @@ -299,6 +299,34 @@ async fn fixture( } async fn request(state: crate::state::AppState, path: &str, owner: bool) -> (StatusCode, Value) { + request_with_pins(state, path, owner, None).await +} + +async fn request_with_pins( + state: crate::state::AppState, + path: &str, + owner: bool, + pins: Option<crate::routes::receipts::AnchorPins>, +) -> (StatusCode, Value) { + let verify = move |state: State<crate::state::AppState>, + principal: Extension<crate::auth::Principal>, + path: Path<(String, String)>| { + let pins = pins.clone(); + async move { + match pins { + Some(pins) => { + crate::routes::receipts::verify_receipt_with_pins( + state, + principal, + path, + Ok(pins), + ) + .await + } + None => crate::routes::receipts::verify_receipt(state, principal, path).await, + } + } + }; let app = Router::new() .route("/api/insights", get(crate::routes::insights::get_insights)) .route("/api/system", get(crate::routes::system::get_system)) @@ -308,7 +336,7 @@ async fn request(state: crate::state::AppState, path: &str, owner: bool) -> (Sta ) .route( "/api/namespaces/{ns}/tasks/{name}/receipt/verify", - get(crate::routes::receipts::verify_receipt), + get(verify), ) .route( "/api/namespaces/{ns}/tasks/{name}/receipt", @@ -588,9 +616,12 @@ fn receipt_log_integrity_still_verifies_real_signed_checkpoints_and_exact_tree_s #[tokio::test] async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflow_inclusion() { + use crate::routes::receipts::{AnchorPins, verify_log_integrity_with_pins}; let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); let (_, state, api, server) = fixture(&namespace).await; let key = SigningKey::from_bytes(&[42; 32]); + let public_key = STANDARD.encode(key.verifying_key().to_bytes()); + let key_id = hex::encode(Sha256::digest(key.verifying_key().to_bytes())); let payload_type = "application/vnd.in-toto+json"; let predicate_type = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; let digest = "0123456789abcdef0123456789abcdef"; @@ -610,9 +641,9 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo let receipt = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsReceipt", "metadata":{"name":"task-3","namespace":"work","uid":"receipt","resourceVersion":"1"}, "spec":{"taskRef":{"name":"task-3"},"envelopeDigest":format!("sha256:{digest}"), - "predicateType":predicate_type,"scheme":"DSSEv1+ed25519","keyId":"test", + "predicateType":predicate_type,"scheme":"DSSEv1+ed25519","keyId":key_id, "dsse":{"payloadType":payload_type,"payload":STANDARD.encode(&payload), - "signatures":[{"keyid":"test","sig":STANDARD.encode(key.sign(&pae).to_bytes())}]}, + "signatures":[{"keyid":key_id,"sig":STANDARD.encode(key.sign(&pae).to_bytes())}]}, "claims":[]},"status":{"inclusionSeq":3}}); let mut chain = entries(4); chain[3]["payloadSha256"] = hex::encode(Sha256::digest(&payload)).into(); @@ -628,11 +659,10 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo maps.push(map( &namespace, "kars-receipt-pubkey", - json!({"keyId":"test", - "publicKey":STANDARD.encode(key.verifying_key().to_bytes()),"scheme":"DSSEv1+ed25519"}), + json!({"keyId":key_id,"publicKey":public_key,"scheme":"DSSEv1+ed25519"}), )); maps.push(map(&namespace, "kars-receipt-checkpoint", json!({"treeSize":"4","rootHash":root, - "keyId":"test","signature":STANDARD.encode(key.sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()).to_bytes())}))); + "keyId":key_id,"signature":STANDARD.encode(key.sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()).to_bytes())}))); maps.push(map( &namespace, "kars-receipt-witness", @@ -651,6 +681,103 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo assert_eq!(body["verified"], true); assert_eq!(body["evidence"]["inclusion"]["tree_size"], 4); assert_eq!(body["evidence"]["inclusion"]["seq"], 3); + api.lock().unwrap().snapshot["items"] + .as_array_mut() + .unwrap() + .retain(|item| item["metadata"]["name"] != "kars-receipt-witness"); + let (_, without_witness) = request(state.clone(), path, false).await; + assert_eq!(without_witness["verified"], true); + let checks = without_witness["checks"].as_array().unwrap(); + assert_eq!( + checks + .iter() + .filter(|c| c["name"] == "Signed checkpoint") + .count(), + 1 + ); + let witness = checks + .iter() + .find(|c| c["name"] == "Independent witness") + .unwrap(); + assert_eq!(witness["advisory"], true); + assert_eq!(witness["passed"], false); + + for forged in [false, true] { + if forged { + let replacement = SigningKey::from_bytes(&[17; 32]); + let mut api = api.lock().unwrap(); + for (name, field, value) in [ + ( + "kars-receipt-pubkey", + "publicKey", + STANDARD.encode(replacement.verifying_key().to_bytes()), + ), + ( + "kars-receipt-checkpoint", + "signature", + STANDARD.encode( + replacement + .sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()) + .to_bytes(), + ), + ), + ] { + let map = api.snapshot["items"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|item| item["metadata"]["name"] == name) + .unwrap(); + map["data"][field] = value.into(); + } + api.receipt_details.get_mut(receipt_path).unwrap()["spec"]["dsse"]["signatures"][0]["sig"] = + STANDARD.encode(replacement.sign(&pae).to_bytes()).into(); + } + for (pin_id, pin_key, matches_original) in [ + (None, None, true), + (Some(key_id.clone()), None, true), + (None, Some(public_key.clone()), true), + (Some(key_id.clone()), Some(public_key.clone()), true), + (Some("wrong".into()), None, false), + (None, Some(STANDARD.encode([0_u8; 32])), false), + ( + Some(key_id.clone()), + Some(STANDARD.encode([0_u8; 32])), + false, + ), + (Some(String::new()), None, false), + (None, Some(String::new()), false), + ] { + let configured = pin_id.is_some() || pin_key.is_some(); + let expected = matches_original && (!forged || !configured); + let pins = AnchorPins { + key_id: pin_id, + public_key: pin_key, + }; + let snapshot = api.lock().unwrap().snapshot.clone(); + let log = parsed(snapshot, &namespace).unwrap(); + let integrity = verify_log_integrity_with_pins(&log, Ok(pins.clone())); + assert!(integrity.chain_consistent); + assert_eq!( + integrity.checkpoint_verified, expected, + "{pins:?}, forged={forged}" + ); + assert_eq!(integrity.anchor_pinned, expected && configured); + let (status, result) = + request_with_pins(state.clone(), path, false, Some(pins.clone())).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["verified"], expected, "{pins:?}, forged={forged}"); + if !expected { + assert!( + result["checks"] + .as_array() + .unwrap() + .iter() + .any(|check| check["name"] == "Trust anchor" && check["passed"] == false) + ); + } + } + } api.lock() .unwrap() .receipt_details diff --git a/bridge/bff/src/routes/receipts.rs b/bridge/bff/src/routes/receipts.rs index 28c14e329..5ab8e9f7b 100644 --- a/bridge/bff/src/routes/receipts.rs +++ b/bridge/bff/src/routes/receipts.rs @@ -19,8 +19,11 @@ use crate::kars::receipt_log::{ReceiptLog, chain_entry_hash}; use crate::routes::ownership::require_task_evidence_access; use crate::state::AppState; +mod anchor; mod statement; +pub(crate) use anchor::AnchorPins; + /// A DSSE signature line, browser-facing. #[derive(Debug, Serialize)] pub struct SignatureDto { @@ -364,8 +367,8 @@ pub async fn compliance_pack( } /// The outcome of an in-browser cryptographic verification — performed -/// server-side by the BFF against the controller's out-of-band public-key -/// anchor, so an auditor gets a real verdict — and the underlying evidence — +/// server-side against the controller's public key and any configured +/// independent pins, so an auditor gets a verdict and underlying evidence /// without installing the CLI. #[derive(Debug, Serialize)] pub struct VerifyResult { @@ -407,7 +410,7 @@ pub struct Evidence { /// Base64 Ed25519 signature over the DSSE PAE of the statement. pub signature_b64: Option<String>, pub scheme: Option<String>, - /// The out-of-band trust anchor the signature was checked against. + /// The published anchor checked against any configured independent pins. pub anchor_key_id: Option<String>, pub anchor_public_key_b64: Option<String>, /// The receipt's position in the hash-chained inclusion log + the proof. @@ -440,9 +443,8 @@ pub struct CheckpointEvidence { pub signed_note: String, pub signature_b64: String, pub signature_valid: bool, - /// An independent transparency witness co-signs the same root with a - /// SEPARATE key — evidence the log isn't forked. Its public key isn't - /// published in V0, so we surface its identity + co-signature honestly. + /// Advisory witness metadata; its public key is not published in V0, + /// so neither its identity nor its co-signature is verified here. pub witness_key_id: Option<String>, pub witness_signature_b64: Option<String>, } @@ -493,6 +495,13 @@ pub struct LogIntegrity { /// checkpoint against the published anchor key. Used by the audit page so its /// integrity verdict is a real verification, not a `inclusion_seq != null` proxy. pub(crate) fn verify_log_integrity(log: &ReceiptLog) -> LogIntegrity { + verify_log_integrity_with_pins(log, AnchorPins::from_env()) +} + +pub(crate) fn verify_log_integrity_with_pins( + log: &ReceiptLog, + pins: Result<AnchorPins, &'static str>, +) -> LogIntegrity { use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let mut out = LogIntegrity::default(); let chain = &log.entries; @@ -530,50 +539,31 @@ pub(crate) fn verify_log_integrity(log: &ReceiptLog) -> LogIntegrity { let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); let cp_sig = cp.get("signature").cloned().unwrap_or_default(); let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); - let anchor = log.anchor(); - - // OUT-OF-BAND PINNING. The in-cluster anchor lives in the same trust - // domain as the log, so on its own it can't prove tamper-evidence - // against an insider who can rewrite both. When the operator pins the - // anchor out-of-band (BRIDGE_RECEIPT_ANCHOR_KEY_ID / _PUBKEY), require - // the in-cluster anchor to match it — and only then is the verdict - // absolute. Without a pin, the anchor is trusted-on-read and the banner - // reflects the weaker, honest claim. - let pin_key_id = std::env::var("BRIDGE_RECEIPT_ANCHOR_KEY_ID").ok(); - let pin_pubkey = std::env::var("BRIDGE_RECEIPT_ANCHOR_PUBKEY").ok(); - let anchor_matches_pin = match (&anchor, pin_key_id.as_deref(), pin_pubkey.as_deref()) { - (Some((kid, pub_b64, _)), pk_id, pk_pub) => { - let id_ok = pk_id.is_none_or(|w| w == kid); - let pub_ok = pk_pub.is_none_or(|w| w.trim() == pub_b64.trim()); - (pk_id.is_some() || pk_pub.is_some()) && id_ok && pub_ok + let anchor = match pins.and_then(|pins| pins.resolve(log)) { + Ok(anchor) => { + out.anchor_pinned = anchor.pinned; + Some(anchor) + } + Err(reason) => { + tracing::warn!(reason, "Receipt checkpoint trust anchor rejected"); + None } - _ => false, }; - out.anchor_pinned = anchor_matches_pin; - - // If a pin is configured but the in-cluster anchor does NOT match it, - // the anchor is untrusted — do not honor any signature made with it. - let pin_configured = pin_key_id.is_some() || pin_pubkey.is_some(); - let anchor_trusted = !pin_configured || anchor_matches_pin; - - let cp_sig_ok = anchor_trusted - && anchor - .as_ref() - .and_then(|(_, pub_b64, _)| BASE64.decode(pub_b64.as_bytes()).ok()) - .and_then(|b| <[u8; 32]>::try_from(b).ok()) - .and_then(|pk| VerifyingKey::from_bytes(&pk).ok()) - .map(|vk| { - BASE64 - .decode(cp_sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| { - vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) - .is_ok() - }) - .unwrap_or(false) - }) - .unwrap_or(false); + let cp_sig_ok = anchor + .as_ref() + .and_then(|anchor| VerifyingKey::from_bytes(&anchor.public_key).ok()) + .map(|vk| { + BASE64 + .decode(cp_sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| { + vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) + .is_ok() + }) + .unwrap_or(false) + }) + .unwrap_or(false); out.checkpoint_verified = chain_consistent && cp_sig_ok && cp_root == chain_head && cp_tree == out.tree_size; } @@ -610,11 +600,21 @@ fn pae(payload_type: &str, body: &[u8]) -> Vec<u8> { /// published public-key anchor (`kars-receipt-pubkey` ConfigMap). This is the /// same trust root `kars receipt verify` uses; performing it here lets an /// auditor get a real cryptographic verdict in the browser. The BFF never -/// trusts a key embedded in the receipt — only the out-of-band anchor. +/// trusts a key embedded in the receipt. Independently configured pins +/// constrain the cluster-published anchor when present. pub async fn verify_receipt( + state: State<AppState>, + principal: Extension<Principal>, + path: Path<(String, String)>, +) -> AppResult<Json<VerifyResult>> { + verify_receipt_with_pins(state, principal, path, AnchorPins::from_env()).await +} + +pub(crate) async fn verify_receipt_with_pins( State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((ns, name)): Path<(String, String)>, + pins: Result<AnchorPins, &'static str>, ) -> AppResult<Json<VerifyResult>> { use base64::engine::general_purpose::STANDARD as B64; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; @@ -672,7 +672,7 @@ pub async fn verify_receipt( evidence.scheme = Some(spec.scheme.clone()); evidence.signature_b64 = spec.dsse.signatures.first().map(|s| s.sig.clone()); - // 1) Trust anchor present (out-of-band published public key). + // 1) Both verification paths resolve the same configured trust boundary. let log = match cluster.receipt_log().await { Ok(log) => log, Err(error) => { @@ -691,27 +691,41 @@ pub async fn verify_receipt( })); } }; - let anchor = log.anchor(); - let Some((anchor_key_id, anchor_pub_b64, anchor_scheme)) = anchor else { - push( - &mut checks, - "Trust anchor", - false, - "No published public-key anchor (kars-receipt-pubkey) found — cannot verify.".into(), - None, - None, - ); - return Ok(Json(VerifyResult { - verified: false, - checks, - evidence, - })); + let anchor = match pins.and_then(|pins| pins.resolve(&log)) { + Ok(anchor) => anchor, + Err(reason) => { + push( + &mut checks, + "Trust anchor", + false, + reason.into(), + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + } }; + let anchor_key_id = anchor.key_id; + let anchor_pub_b64 = anchor.public_key_b64; + let anchor_scheme = anchor.scheme; evidence.anchor_key_id = Some(anchor_key_id.clone()); evidence.anchor_public_key_b64 = Some(anchor_pub_b64.clone()); - push(&mut checks, "Trust anchor", true, - "An out-of-band public key is published by the controller; the signature is checked against THIS key, never one carried in the receipt.".into(), - None, None); + push( + &mut checks, + "Trust anchor", + true, + if anchor.pinned { + "The controller's public key matches the configured out-of-band pins.".into() + } else { + "The signature is checked against the cluster-published key, not a key in the receipt. No independent out-of-band pin is configured.".into() + }, + None, + None, + ); // 2) Receipt key id matches the anchor. let key_match = spec.key_id == anchor_key_id; @@ -720,7 +734,7 @@ pub async fn verify_receipt( "Signing key identity", key_match, if key_match { - "The receipt's key fingerprint matches the published anchor.".into() + "The receipt's key identifier matches the published anchor.".into() } else { "The receipt's signing key does NOT match the trusted anchor.".into() }, @@ -741,10 +755,7 @@ pub async fn verify_receipt( // 4) Ed25519 signature verifies over the DSSE PAE of the exact payload. let mut sig_ok = false; - let pub_bytes = B64 - .decode(anchor_pub_b64.as_bytes()) - .ok() - .and_then(|b| <[u8; 32]>::try_from(b).ok()); + let pub_bytes = Some(anchor.public_key); if let (Some(pk), false) = (pub_bytes, payload_raw.is_empty()) && let Ok(vk) = VerifyingKey::from_bytes(&pk) { @@ -931,20 +942,19 @@ pub async fn verify_receipt( name: "Independent witness".to_string(), passed: false, advisory: true, - detail: "A separate transparency-witness key co-signs the same tree head — evidence the log isn't forked behind your back. Its public key isn't published in V0, so this is shown, not re-verified here.".into(), + detail: "A witness co-signature is published, but its public key is unavailable here, so it is shown without verification.".into(), expected: Some(w.clone()), computed: None, }); } else { - inclusion_ok = false; - push( - &mut checks, - "Signed checkpoint", - false, - "No signed checkpoint is available for the inclusion log.".into(), - None, - None, - ); + checks.push(VerifyCheck { + name: "Independent witness".into(), + passed: false, + advisory: true, + detail: "No independent witness key is published; checkpoint signature verification is unaffected.".into(), + expected: None, + computed: None, + }); } } else { inclusion_ok = false; diff --git a/bridge/bff/src/routes/receipts/anchor.rs b/bridge/bff/src/routes/receipts/anchor.rs new file mode 100644 index 000000000..ccb0fdb93 --- /dev/null +++ b/bridge/bff/src/routes/receipts/anchor.rs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +use crate::kars::receipt_log::ReceiptLog; + +#[derive(Clone, Debug, Default)] +pub(crate) struct AnchorPins { + pub key_id: Option<String>, + pub public_key: Option<String>, +} + +pub(super) struct TrustedAnchor { + pub key_id: String, + pub public_key_b64: String, + pub public_key: [u8; 32], + pub scheme: String, + pub pinned: bool, +} + +fn configured(name: &str) -> Result<Option<String>, &'static str> { + match std::env::var(name) { + Ok(value) => Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => { + Err("Configured receipt anchor pin is not valid UTF-8.") + } + } +} + +fn public_key(value: &str) -> Result<[u8; 32], &'static str> { + STANDARD + .decode(value.trim()) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .ok_or("Receipt anchor public key must be a base64-encoded 32-byte Ed25519 key.") +} + +impl AnchorPins { + pub fn from_env() -> Result<Self, &'static str> { + Ok(Self { + key_id: configured("BRIDGE_RECEIPT_ANCHOR_KEY_ID")?, + public_key: configured("BRIDGE_RECEIPT_ANCHOR_PUBKEY")?, + }) + } + + pub(super) fn resolve(&self, log: &ReceiptLog) -> Result<TrustedAnchor, &'static str> { + let (key_id, public_key_b64, scheme) = log + .anchor() + .ok_or("No published receipt public-key anchor is available.")?; + let key = public_key(&public_key_b64)?; + if let Some(expected) = &self.key_id { + // The controller defines key IDs as full SHA-256 fingerprints of + // the raw public key, not an independently mutable ConfigMap label. + if expected != &super::sha256_hex(&key) || expected != &key_id { + return Err( + "Receipt anchor key does not match the configured SHA-256 fingerprint.", + ); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + const KEY: &str = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="; + const ID: &str = "21fe31dfa154a261626bf854046fd2271b7bed4b6abe45aa58877ef47f9721b9"; + + fn log() -> ReceiptLog { + ReceiptLog { + public_key: Some( + [ + ("keyId", ID), + ("publicKey", KEY), + ("scheme", "DSSEv1+ed25519"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())) + .into_iter() + .collect(), + ), + ..ReceiptLog::default() + } + } + + #[test] + fn pins_follow_the_controller_raw_key_fingerprint_contract() { + assert_eq!(super::super::sha256_hex(&public_key(KEY).unwrap()), ID); + for (key_id, public_key) in [ + (None, None), + (Some(ID.into()), None), + (None, Some(format!(" {KEY}\n"))), + (Some(ID.into()), Some(KEY.into())), + ] { + let configured = key_id.is_some() || public_key.is_some(); + let anchor = AnchorPins { key_id, public_key }.resolve(&log()).unwrap(); + assert_eq!(anchor.pinned, configured); + assert_eq!(anchor.key_id, ID); + } + } + + #[test] + fn copied_key_id_cannot_substitute_another_public_key() { + let pins = AnchorPins { + key_id: Some(ID.into()), + public_key: None, + }; + let mut log = log(); + log.public_key + .as_mut() + .unwrap() + .insert("publicKey".into(), STANDARD.encode([17_u8; 32])); + assert!(pins.resolve(&log).is_err()); + assert!(!AnchorPins::default().resolve(&log).unwrap().pinned); + } + + #[test] + fn malformed_empty_mismatched_or_missing_pins_fail_closed() { + for (key_id, public_key) in [ + (Some(String::new()), None), + (Some("wrong".into()), None), + (None, Some(String::new())), + (None, Some("not base64".into())), + (None, Some(STANDARD.encode([1_u8; 31]))), + (None, Some(STANDARD.encode([1_u8; 33]))), + (Some(ID.into()), Some(STANDARD.encode([1_u8; 32]))), + ] { + assert!(AnchorPins { key_id, public_key }.resolve(&log()).is_err()); + } + assert!( + AnchorPins::default() + .resolve(&ReceiptLog::default()) + .is_err() + ); + let mut invalid = log(); + invalid + .public_key + .as_mut() + .unwrap() + .insert("publicKey".into(), "invalid".into()); + assert!(AnchorPins::default().resolve(&invalid).is_err()); + } + } + if let Some(expected) = &self.public_key + && public_key(expected)? != key + { + return Err("Receipt anchor key does not match the configured public-key pin."); + } + Ok(TrustedAnchor { + key_id, + public_key_b64, + public_key: key, + scheme, + pinned: self.key_id.is_some() || self.public_key.is_some(), + }) + } +} diff --git a/bridge/docs/deployment.md b/bridge/docs/deployment.md index be52d9a39..cc8274c97 100644 --- a/bridge/docs/deployment.md +++ b/bridge/docs/deployment.md @@ -102,8 +102,24 @@ tenant/bot credentials do not block the web surface. Enable the gateway only after its dedicated credentials and role mapping are configured; web OIDC authentication is a separate requirement. -## Private-preview Dex +## Receipt trust anchors + +For independently pinned receipt verification, configure the BFF with +`BRIDGE_RECEIPT_ANCHOR_PUBKEY` (standard base64 of the 32-byte Ed25519 public key) +and/or `BRIDGE_RECEIPT_ANCHOR_KEY_ID` (the full lowercase SHA-256 fingerprint +of those raw public-key bytes). Obtain these values through a trusted channel +separate from the cluster's receipt ConfigMaps. Both pins must match when both +are set; a key-ID pin is checked against the key itself, not merely its label. +Empty, malformed or mismatched configured pins reject verification. + +Without either variable, verification remains available against the +cluster-published anchor, but does not prove integrity against an actor who can +replace both the log and that anchor. Both individual receipts and whole-log +integrity use the same pin policy. A missing advisory witness does not invalidate +an otherwise valid signed checkpoint; witness metadata is not independently +verified by the BFF. +## Private-preview Dex Dex is useful for a colleague test ring without a public ingress: ```yaml diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index b782c6455..0127fa416 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -64,6 +64,33 @@ CSS variants and fail-closed parsing. Three comments describing example URLs and input/number presentation were clarified without changing runtime code. This is a scanner-correctness change, not application source sign-off. +## Focused crypto integration review + +A bounded review of the flagged digest/receipt paths found that receipt detail +verification did not enforce configured out-of-band pins, and whole-log +key-ID-only pinning trusted a mutable label without binding it to the key. +Both paths now use one resolver: public-key pins compare decoded Ed25519 bytes, +and ID pins require the controller's full SHA-256 fingerprint of those bytes. +Unconfigured verification retains its explicitly weaker cluster-anchor trust. +Malformed or mismatched configured pins fail verification rather than silently +falling back. The existing wire payload, DSSE framing, chain and checkpoint +formats are unchanged. + +The same review found a missing-witness branch that incorrectly invalidated +an otherwise verified checkpoint. Witness presence remains advisory and no +longer controls checkpoint validity. Added regressions cover both verification +paths, matching/mismatching/malformed pins, a fully re-signed replacement-anchor +fork retaining the pinned ID, the controller fingerprint vector, and a valid +checkpoint without witness metadata. Rust execution is pending hosted CI; +syntax checks are not represented as test execution. + +Other findings remain open: credential-review V1 derives a secret key with a +custom versioned SHA-256 construction and cannot inherit a plain content-digest +exception. Case-normalization of remediation manifest paths also needs a +separately versioned identity correction that preserves existing work. +Standard digest/receipt adapter extraction and its exact byte-equivalence +vectors remain required; no blanket crypto allowance is granted. + The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, and the header gate reports missing Microsoft headers on imported files. From 099541069751ac41d23f048060a070252f2254b7 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 17:07:35 +0200 Subject: [PATCH 008/111] Register receipt trust regressions and remove unused task import Fix the exact hosted Clippy failures without suppressing warnings. Require all anchor regressions and the signed-fork endpoint case in the actual Cargo test inventory before full execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 11 ++ bridge/bff/src/routes/receipts/anchor.rs | 163 +++++++++--------- bridge/bff/src/routes/tasks/egress.rs | 1 - .../2026-09-11-bridge-application.md | 6 + 4 files changed, 99 insertions(+), 82 deletions(-) diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index 211a58418..d29badd0e 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -33,6 +33,17 @@ jobs: workspaces: bridge/bff - run: cargo fmt --all -- --check - run: cargo clippy --locked --all-targets -- -D warnings + - name: Require receipt trust regression registration + run: | + cargo test --locked -- --list > /tmp/kars-bridge-bff-tests.txt + for name in \ + routes::receipts::anchor::tests::pins_follow_the_controller_raw_key_fingerprint_contract \ + routes::receipts::anchor::tests::copied_key_id_cannot_substitute_another_public_key \ + routes::receipts::anchor::tests::malformed_empty_mismatched_or_missing_pins_fail_closed \ + kars::receipt_log::tests::receipt_endpoint_still_requires_signed_payload_binding_and_full_overflow_inclusion + do + grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt + done - run: cargo test --locked - name: Check explicit credential review orchestration run: PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=../tests/native-credentials python3 -m unittest discover -s ../tests/native-credentials -p test_credential_review.py diff --git a/bridge/bff/src/routes/receipts/anchor.rs b/bridge/bff/src/routes/receipts/anchor.rs index ccb0fdb93..f3b018cd8 100644 --- a/bridge/bff/src/routes/receipts/anchor.rs +++ b/bridge/bff/src/routes/receipts/anchor.rs @@ -60,87 +60,6 @@ impl AnchorPins { } } - #[cfg(test)] - mod tests { - use super::*; - - const KEY: &str = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="; - const ID: &str = "21fe31dfa154a261626bf854046fd2271b7bed4b6abe45aa58877ef47f9721b9"; - - fn log() -> ReceiptLog { - ReceiptLog { - public_key: Some( - [ - ("keyId", ID), - ("publicKey", KEY), - ("scheme", "DSSEv1+ed25519"), - ] - .map(|(key, value)| (key.to_string(), value.to_string())) - .into_iter() - .collect(), - ), - ..ReceiptLog::default() - } - } - - #[test] - fn pins_follow_the_controller_raw_key_fingerprint_contract() { - assert_eq!(super::super::sha256_hex(&public_key(KEY).unwrap()), ID); - for (key_id, public_key) in [ - (None, None), - (Some(ID.into()), None), - (None, Some(format!(" {KEY}\n"))), - (Some(ID.into()), Some(KEY.into())), - ] { - let configured = key_id.is_some() || public_key.is_some(); - let anchor = AnchorPins { key_id, public_key }.resolve(&log()).unwrap(); - assert_eq!(anchor.pinned, configured); - assert_eq!(anchor.key_id, ID); - } - } - - #[test] - fn copied_key_id_cannot_substitute_another_public_key() { - let pins = AnchorPins { - key_id: Some(ID.into()), - public_key: None, - }; - let mut log = log(); - log.public_key - .as_mut() - .unwrap() - .insert("publicKey".into(), STANDARD.encode([17_u8; 32])); - assert!(pins.resolve(&log).is_err()); - assert!(!AnchorPins::default().resolve(&log).unwrap().pinned); - } - - #[test] - fn malformed_empty_mismatched_or_missing_pins_fail_closed() { - for (key_id, public_key) in [ - (Some(String::new()), None), - (Some("wrong".into()), None), - (None, Some(String::new())), - (None, Some("not base64".into())), - (None, Some(STANDARD.encode([1_u8; 31]))), - (None, Some(STANDARD.encode([1_u8; 33]))), - (Some(ID.into()), Some(STANDARD.encode([1_u8; 32]))), - ] { - assert!(AnchorPins { key_id, public_key }.resolve(&log()).is_err()); - } - assert!( - AnchorPins::default() - .resolve(&ReceiptLog::default()) - .is_err() - ); - let mut invalid = log(); - invalid - .public_key - .as_mut() - .unwrap() - .insert("publicKey".into(), "invalid".into()); - assert!(AnchorPins::default().resolve(&invalid).is_err()); - } - } if let Some(expected) = &self.public_key && public_key(expected)? != key { @@ -155,3 +74,85 @@ impl AnchorPins { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="; + const ID: &str = "21fe31dfa154a261626bf854046fd2271b7bed4b6abe45aa58877ef47f9721b9"; + + fn log() -> ReceiptLog { + ReceiptLog { + public_key: Some( + [ + ("keyId", ID), + ("publicKey", KEY), + ("scheme", "DSSEv1+ed25519"), + ] + .map(|(key, value)| (key.to_string(), value.to_string())) + .into_iter() + .collect(), + ), + ..ReceiptLog::default() + } + } + + #[test] + fn pins_follow_the_controller_raw_key_fingerprint_contract() { + assert_eq!(super::super::sha256_hex(&public_key(KEY).unwrap()), ID); + for (key_id, public_key) in [ + (None, None), + (Some(ID.into()), None), + (None, Some(format!(" {KEY}\n"))), + (Some(ID.into()), Some(KEY.into())), + ] { + let configured = key_id.is_some() || public_key.is_some(); + let anchor = AnchorPins { key_id, public_key }.resolve(&log()).unwrap(); + assert_eq!(anchor.pinned, configured); + assert_eq!(anchor.key_id, ID); + } + } + + #[test] + fn copied_key_id_cannot_substitute_another_public_key() { + let pins = AnchorPins { + key_id: Some(ID.into()), + public_key: None, + }; + let mut log = log(); + log.public_key + .as_mut() + .unwrap() + .insert("publicKey".into(), STANDARD.encode([17_u8; 32])); + assert!(pins.resolve(&log).is_err()); + assert!(!AnchorPins::default().resolve(&log).unwrap().pinned); + } + + #[test] + fn malformed_empty_mismatched_or_missing_pins_fail_closed() { + for (key_id, public_key) in [ + (Some(String::new()), None), + (Some("wrong".into()), None), + (None, Some(String::new())), + (None, Some("not base64".into())), + (None, Some(STANDARD.encode([1_u8; 31]))), + (None, Some(STANDARD.encode([1_u8; 33]))), + (Some(ID.into()), Some(STANDARD.encode([1_u8; 32]))), + ] { + assert!(AnchorPins { key_id, public_key }.resolve(&log()).is_err()); + } + assert!( + AnchorPins::default() + .resolve(&ReceiptLog::default()) + .is_err() + ); + let mut invalid = log(); + invalid + .public_key + .as_mut() + .unwrap() + .insert("publicKey".into(), "invalid".into()); + assert!(AnchorPins::default().resolve(&invalid).is_err()); + } +} diff --git a/bridge/bff/src/routes/tasks/egress.rs b/bridge/bff/src/routes/tasks/egress.rs index d99bb1689..aaa2915e3 100644 --- a/bridge/bff/src/routes/tasks/egress.rs +++ b/bridge/bff/src/routes/tasks/egress.rs @@ -4,7 +4,6 @@ use serde::Deserialize; use crate::auth::Principal; use crate::error::{AppError, AppResult}; -use crate::kars::task::KarsTask; use crate::routes::ownership::require_owned_task; use crate::state::AppState; diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 0127fa416..24cbf7b51 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -84,6 +84,12 @@ fork retaining the pinned ID, the controller fingerprint vector, and a valid checkpoint without witness metadata. Rust execution is pending hosted CI; syntax checks are not represented as test execution. +Hosted Bridge CI at `132e1be5` stopped at Clippy before executing tests: an +unused task-module import and incorrectly nested anchor tests were rejected. +The correction removes the import, places the tests at module scope and +requires their exact registration in the hosted test inventory before running +the complete suite. No lint suppression or assertion removal is used. + Other findings remain open: credential-review V1 derives a secret key with a custom versioned SHA-256 construction and cannot inherit a plain content-digest exception. Case-normalization of remediation manifest paths also needs a From 67cf7c225c13ccb6df115adfba39e48546d509cb Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 17:19:52 +0200 Subject: [PATCH 009/111] Split Bridge operator routes into bounded cohesive modules Mechanically retain170declarations and13tests across the existing facade and nine modules, all at most800lines. Formatting and normalized token parity were checked; hosted compilation remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/operator.rs | 4508 +---------------- .../routes/operator/additional_providers.rs | 463 ++ bridge/bff/src/routes/operator/audit.rs | 325 ++ bridge/bff/src/routes/operator/diagnostics.rs | 439 ++ bridge/bff/src/routes/operator/evals.rs | 372 ++ .../src/routes/operator/local_inference.rs | 409 ++ bridge/bff/src/routes/operator/policies.rs | 588 +++ bridge/bff/src/routes/operator/providers.rs | 698 +++ bridge/bff/src/routes/operator/sandboxes.rs | 571 +++ .../src/routes/operator/skills_profiles.rs | 557 ++ 10 files changed, 4541 insertions(+), 4389 deletions(-) create mode 100644 bridge/bff/src/routes/operator/additional_providers.rs create mode 100644 bridge/bff/src/routes/operator/audit.rs create mode 100644 bridge/bff/src/routes/operator/diagnostics.rs create mode 100644 bridge/bff/src/routes/operator/evals.rs create mode 100644 bridge/bff/src/routes/operator/local_inference.rs create mode 100644 bridge/bff/src/routes/operator/policies.rs create mode 100644 bridge/bff/src/routes/operator/providers.rs create mode 100644 bridge/bff/src/routes/operator/sandboxes.rs create mode 100644 bridge/bff/src/routes/operator/skills_profiles.rs diff --git a/bridge/bff/src/routes/operator.rs b/bridge/bff/src/routes/operator.rs index 723d10779..288363165 100644 --- a/bridge/bff/src/routes/operator.rs +++ b/bridge/bff/src/routes/operator.rs @@ -1,3 +1,4 @@ +// Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — Operator Console API. // // The operator surface reads the same CRDs as the user Workspace but projects @@ -6,12 +7,30 @@ // into a stable browser DTO. Absent CRDs surface as empty lists, never errors — // the honesty grammar (empty vs not-wired) lives in the web layer. +mod additional_providers; +mod audit; +mod diagnostics; +mod evals; +mod local_inference; +mod policies; +mod providers; +mod sandboxes; +mod skills_profiles; + +pub use additional_providers::*; +pub use audit::*; +pub use diagnostics::*; +pub use evals::*; +pub use local_inference::*; +pub use policies::*; +pub use providers::*; +pub use sandboxes::*; +pub use skills_profiles::*; + use axum::Json; use axum::extract::{Extension, State}; use kube::core::DynamicObject; -use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; use crate::auth::Principal; use crate::error::{AppError, AppResult}; @@ -69,4379 +88,130 @@ fn annotation(o: &DynamicObject, key: &str) -> Option<String> { .and_then(|a| a.get(key).cloned()) } -// Skill-admission annotation keys — the operator trust gate (§ skills workflow): -// a user-uploaded skill is only usable once an operator has scanned + approved -// it, which LOCKS the approval to the exact version digest at approval time. -// Any later change to the skill breaks the lock and returns it to review. -const ANN_REVIEW: &str = "kars.azure.com/skill-review"; -const ANN_LOCKED_DIGEST: &str = "kars.azure.com/skill-locked-digest"; -const ANN_APPROVED_BY: &str = "kars.azure.com/skill-approved-by"; -const ANN_APPROVED_AT: &str = "kars.azure.com/skill-approved-at"; - -// ─── Sandbox fleet ─────────────────────────────────────────────────────────── +// ─── Credentials (secure repo/system access for agents) ────────────────────── -#[derive(Debug, Serialize)] -pub struct SandboxDto { - pub name: String, +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialRequest { + /// The agent/team this credential is for (becomes `<target>-credentials`). + pub target: String, + pub kind: String, pub namespace: String, - pub runtime_namespace: Option<String>, - pub phase: Option<String>, - pub runtime: Option<String>, - pub isolation: Option<String>, - /// The governing ToolPolicy (AGT capability bounds) this agent runs under — - /// registry inventory: "which policy governs this agent". From - /// spec.governance.toolPolicyRef. - pub tool_policy: Option<String>, - /// The InferencePolicy binding its model route + token budget. From - /// spec.inferenceRef. - pub inference_policy: Option<String>, - /// Whether AGT governance is enabled (fails closed on an empty policy set). - pub governed: bool, - /// Owning standing team (label), if any — the "who owns this" registry column. - pub team: Option<String>, - /// Parent sandbox name when this is a spawned sub-agent (label-derived). - pub parent: Option<String>, - pub message: Option<String>, - pub created: Option<String>, - /// For a Running sandbox: whether its run has produced ANY real activity - /// (model rounds / tool calls). `Some(false)` = the pod is Running but idle - /// — e.g. a chat-gateway harness waiting for input, or a hung run. Surfaced - /// so a green "Running" never masks a stalled agent (audit f23). `None` when - /// the sandbox isn't Running (the signal doesn't apply). - pub working: Option<bool>, - /// Whether this sandbox is CURRENTLY executing a task (Running AND no - /// terminal mission-output yet) — the same "live" test the Workspace's - /// Active-agents page uses. Distinct from `working` above: a sandbox can - /// have `working: true` (it did real work) and still be `executing: false` - /// (it already delivered and is simply lingering before teardown/ - /// retention) — the exact case that made "Sandboxes: 2 running" and - /// "Active agents: 0 working" look contradictory when they're both true. - pub executing: Option<bool>, - pub cpu_millicores: Option<f64>, - pub memory_bytes: Option<u64>, - /// Standard K8s conditions, surfaced for the troubleshooting table. - pub conditions: Vec<ConditionDto>, -} - -#[derive(Debug, Serialize)] -pub struct ConditionDto { - pub type_: String, - pub status: String, - pub reason: Option<String>, - pub message: Option<String>, -} - -fn conditions_of(o: &DynamicObject) -> Vec<ConditionDto> { - status(o) - .get("conditions") - .and_then(|c| c.as_array()) - .map(|arr| { - arr.iter() - .map(|c| ConditionDto { - type_: s(c, "type").unwrap_or_default(), - status: s(c, "status").unwrap_or_default(), - reason: s(c, "reason"), - message: s(c, "message"), - }) - .collect() - }) - .unwrap_or_default() -} - -fn to_sandbox(o: &DynamicObject) -> SandboxDto { - let sp = spec(o); - SandboxDto { - name: name_of(o), - namespace: ns_of(o), - runtime_namespace: s(status(o), "namespace"), - phase: s(status(o), "phase"), - runtime: sp - .get("runtime") - .and_then(|r| r.get("kind")) - .and_then(|k| k.as_str()) - .map(|x| x.to_string()), - isolation: sp - .get("sandbox") - .and_then(|sb| sb.get("isolation")) - .and_then(|i| i.as_str()) - .map(|x| x.to_string()), - tool_policy: sp - .get("governance") - .and_then(|g| g.get("toolPolicyRef")) - .and_then(|r| r.get("name")) - .and_then(|n| n.as_str()) - .map(|x| x.to_string()), - inference_policy: sp - .get("inferenceRef") - .and_then(|r| r.get("name")) - .and_then(|n| n.as_str()) - .map(|x| x.to_string()), - governed: sp - .get("governance") - .and_then(|g| g.get("enabled")) - .and_then(|e| e.as_bool()) - .unwrap_or(false), - team: label(o, "kars.azure.com/team"), - parent: label(o, "kars.azure.com/parent").or_else(|| s(sp, "parentSandbox")), - message: s(status(o), "message"), - created: created_of(o), - working: None, - executing: None, - cpu_millicores: None, - memory_bytes: None, - conditions: conditions_of(o), - } -} - -fn cpu_millicores(raw: &str) -> Option<f64> { - let raw = raw.trim(); - if let Some(value) = raw.strip_suffix('n') { - return value.parse::<f64>().ok().map(|value| value / 1_000_000.0); - } - if let Some(value) = raw.strip_suffix('u') { - return value.parse::<f64>().ok().map(|value| value / 1_000.0); - } - if let Some(value) = raw.strip_suffix('m') { - return value.parse::<f64>().ok(); - } - raw.parse::<f64>().ok().map(|value| value * 1_000.0) -} - -fn memory_bytes(raw: &str) -> Option<u64> { - let raw = raw.trim(); - for (suffix, multiplier) in [ - ("Ki", 1_024_f64), - ("Mi", 1_048_576_f64), - ("Gi", 1_073_741_824_f64), - ("Ti", 1_099_511_627_776_f64), - ("K", 1_000_f64), - ("M", 1_000_000_f64), - ("G", 1_000_000_000_f64), - ] { - if let Some(value) = raw.strip_suffix(suffix) { - return value - .parse::<f64>() - .ok() - .map(|value| (value * multiplier) as u64); - } - } - raw.parse::<u64>().ok() -} - -fn metric_usage(metric: &DynamicObject) -> (f64, u64) { - metric - .data - .get("containers") - .and_then(Value::as_array) - .map(|containers| { - containers - .iter() - .fold((0.0, 0_u64), |(cpu, memory), container| { - let usage = container.get("usage").unwrap_or(&Value::Null); - ( - cpu + usage - .get("cpu") - .and_then(Value::as_str) - .and_then(cpu_millicores) - .unwrap_or(0.0), - memory - + usage - .get("memory") - .and_then(Value::as_str) - .and_then(memory_bytes) - .unwrap_or(0), - ) - }) - }) - .unwrap_or((0.0, 0)) -} - -fn inherit_sandbox_context(sandboxes: &mut [SandboxDto]) { - let by_name: HashMap<(String, String), usize> = sandboxes - .iter() - .enumerate() - .map(|(index, sandbox)| ((sandbox.namespace.clone(), sandbox.name.clone()), index)) - .collect(); - let resolved: Vec<(Option<String>, Option<bool>)> = sandboxes - .iter() - .enumerate() - .map(|(index, sandbox)| { - let mut team = sandbox.team.clone(); - let mut executing = sandbox.executing; - let mut cursor = index; - let mut visited = vec![false; sandboxes.len()]; - visited[cursor] = true; - - while let Some(parent) = sandboxes[cursor].parent.as_ref() { - let parent_key = (sandboxes[cursor].namespace.clone(), parent.clone()); - let Some(parent_index) = by_name.get(&parent_key).copied() else { - break; - }; - if visited[parent_index] { - break; - } - visited[parent_index] = true; - let parent = &sandboxes[parent_index]; - if team.is_none() { - team = parent.team.clone(); - } - if parent.executing.is_some() { - executing = parent.executing; - } - cursor = parent_index; - } - (team, executing) - }) - .collect(); - - for (sandbox, (team, executing)) in sandboxes.iter_mut().zip(resolved) { - sandbox.team = team; - if sandbox.parent.is_some() { - let observed_working = - sandbox.phase.as_deref() == Some("Running") && sandbox.working == Some(true); - sandbox.executing = - executing.map(|parent_executing| parent_executing && observed_working); - } - } -} - -#[derive(Debug, Serialize)] -pub struct NodeCapacityDto { - pub name: String, - pub cpu_usage_millicores: Option<f64>, - pub cpu_allocatable_millicores: Option<f64>, - pub memory_usage_bytes: Option<u64>, - pub memory_allocatable_bytes: Option<u64>, - pub cpu_percent: Option<f64>, - pub memory_percent: Option<f64>, + #[serde(default)] + pub target_uid: Option<String>, + /// The env var name the agent reads (e.g. GITHUB_TOKEN, BRAVE_API_KEY). + pub key: String, + /// The secret value. Stored only in the K8s Secret; never read back. + pub value: String, + #[serde(default)] + pub review: Option<String>, } -#[derive(Debug, Serialize)] -pub struct CapacityDto { - pub metrics_available: bool, - pub metrics_error: Option<String>, - pub team_max_concurrent_runs: usize, - pub global_active_runs_limit: usize, - pub active_team_runs: usize, - pub pod_metrics_available: bool, - pub pod_metrics_error: Option<String>, - pub nodes: Vec<NodeCapacityDto>, +/// A DNS-1123 label (lowercase alphanumeric + hyphens, must start/end +/// alphanumeric, ≤63 chars) — the constraint on the `kars-<target>` namespace +/// derived below, so an invalid target is rejected before it reaches the API +/// server as an opaque 422. +pub(super) fn is_dns1123_label(s: &str) -> bool { + !s.is_empty() + && s.len() <= 63 + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !s.starts_with('-') + && !s.ends_with('-') } -/// `GET /api/operator/sandboxes` — the fleet, across all namespaces. -pub async fn list_sandboxes(State(state): State<AppState>) -> AppResult<Json<Vec<SandboxDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster - .list_kind_all("KarsSandbox") - .await - .map_err(upstream)?; - let mut dtos: Vec<SandboxDto> = items.iter().map(to_sandbox).collect(); - let task_teams: HashMap<(String, String), String> = cluster - .list_kind_all("KarsTask") - .await - .unwrap_or_default() - .into_iter() - .filter_map(|task| { - label(&task, "kars.azure.com/team").map(|team| ((ns_of(&task), name_of(&task)), team)) - }) - .collect(); - let pod_metrics = cluster - .list_metrics_all("PodMetrics", "pods") - .await - .unwrap_or_default(); - let usage: HashMap<(String, String), (f64, u64)> = pod_metrics - .iter() - .map(|metric| ((ns_of(metric), name_of(metric)), metric_usage(metric))) - .collect(); - // Stall signal (audit f23): for each Running sandbox, check whether its run - // has produced any real activity. A Running-but-empty sandbox is idle or - // hung (a chat-gateway harness waiting for input, or a stalled loop) — the - // operator must be able to tell that apart from a green "Running". - for (o, d) in items.iter().zip(dtos.iter_mut()) { - if d.team.is_none() - && let Some(task_name) = label(o, "kars.azure.com/karstask") - { - d.team = task_teams.get(&(d.namespace.clone(), task_name)).cloned(); - } - if let Some(runtime_namespace) = d.runtime_namespace.as_deref() { - let (cpu, memory) = usage - .iter() - .filter(|((namespace, pod), _)| { - namespace == runtime_namespace && pod.starts_with(&d.name) - }) - .fold((0.0, 0_u64), |(cpu, memory), (_, usage)| { - (cpu + usage.0, memory + usage.1) - }); - if cpu > 0.0 || memory > 0 { - d.cpu_millicores = Some(cpu); - d.memory_bytes = Some(memory); - } - } - if d.phase.as_deref() == Some("Running") { - let persisted_activity = cluster - .read_mission_trace(&d.name) - .await - .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) - .map(|v| !v.is_empty()) - .unwrap_or(false); - let has_activity = - persisted_activity || !cluster.sandbox_live_trace(&d.name).await.is_empty(); - d.working = Some(has_activity); - // "Executing right now" — the same test the Workspace's Active - // agents page uses (live iff Running AND no terminal mission-output - // yet). A sandbox that already delivered still shows `working: true` - // (it DID real work) but `executing: false` (nothing left to do, - // just lingering before teardown/retention) — this is what makes - // "Sandboxes: N running" and "Active agents: 0 working" both - // correct at once instead of reading as a contradiction. Only - // applies to a task-owned sandbox — a standing sandbox with no - // KarsTask (e.g. the Bridge's own orchestrator) never gets a - // mission-output ConfigMap, so it would otherwise look permanently - // "not yet delivered" and inflate this count. - if has_task_owner(o) { - let delivered = cluster.read_mission_output(&d.name).await.is_some(); - d.executing = Some(!delivered); - } - } - } - inherit_sandbox_context(&mut dtos); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) +/// A POSIX-ish environment variable name: letters/digits/underscore, not +/// starting with a digit. Agents read the credential under this name. +pub(super) fn is_env_key(s: &str) -> bool { + let mut chars = s.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } -pub async fn capacity(State(state): State<AppState>) -> AppResult<Json<CapacityDto>> { - let cluster = require_cluster(&state)?; - let nodes = cluster.list_nodes().await.map_err(upstream)?; - let metrics = cluster.list_metrics_all("NodeMetrics", "nodes").await; - let (metrics_api_available, metrics_api_error, metric_map) = match metrics { - Ok(items) => ( - true, - None, - items - .into_iter() - .map(|metric| { - let usage = metric.data.get("usage").cloned().unwrap_or(Value::Null); - ( - name_of(&metric), - ( - usage - .get("cpu") - .and_then(Value::as_str) - .and_then(cpu_millicores), - usage - .get("memory") - .and_then(Value::as_str) - .and_then(memory_bytes), - ), - ) - }) - .collect::<HashMap<_, _>>(), +pub(super) fn credential_write_error(error: kube::Error) -> AppError { + match error { + kube::Error::Api(status) if status.code == 409 => AppError::Conflict( + "Credential authority changed or a source already exists. Refresh credential metadata and review before resubmitting; a source write may already be stored. No automatic retry or rollback was attempted.".into(), ), - Err(error) => (false, Some(error.to_string()), HashMap::new()), - }; - let node_count = nodes.len(); - let covered_nodes = nodes - .iter() - .filter(|node| { - node.metadata - .name - .as_ref() - .is_some_and(|name| metric_map.contains_key(name)) - }) - .count(); - let metrics_available = metrics_api_available && node_count > 0 && covered_nodes == node_count; - let metrics_error = if !metrics_api_available { - metrics_api_error - } else if node_count == 0 { - Some("the cluster reported no nodes".into()) - } else if covered_nodes != node_count { - Some(format!( - "node metrics coverage is partial ({covered_nodes}/{node_count})" - )) - } else { - None - }; - let nodes = nodes - .into_iter() - .map(|node| { - let name = node.metadata.name.unwrap_or_default(); - let allocatable = node.status.and_then(|status| status.allocatable); - let cpu_allocatable = allocatable - .as_ref() - .and_then(|values| values.get("cpu")) - .and_then(|value| cpu_millicores(&value.0)); - let memory_allocatable = allocatable - .as_ref() - .and_then(|values| values.get("memory")) - .and_then(|value| memory_bytes(&value.0)); - let (cpu_usage, memory_usage) = metric_map.get(&name).cloned().unwrap_or((None, None)); - NodeCapacityDto { - name, - cpu_usage_millicores: cpu_usage, - cpu_allocatable_millicores: cpu_allocatable, - memory_usage_bytes: memory_usage, - memory_allocatable_bytes: memory_allocatable, - cpu_percent: cpu_usage - .zip(cpu_allocatable) - .filter(|(_, allocatable)| *allocatable > 0.0) - .map(|(usage, allocatable)| usage / allocatable * 100.0), - memory_percent: memory_usage - .zip(memory_allocatable) - .filter(|(_, allocatable)| *allocatable > 0) - .map(|(usage, allocatable)| usage as f64 / allocatable as f64 * 100.0), - } - }) - .collect(); - let team_max_concurrent_runs = cluster - .controller_env_value("KARS_TEAM_MAX_CONCURRENT_RUNS") - .await - .and_then(|value| value.parse().ok()) - .unwrap_or(2); - let global_active_runs_limit = cluster - .controller_env_value("KARS_TEAM_GLOBAL_ACTIVE_RUNS_LIMIT") - .await - .and_then(|value| value.parse().ok()) - .unwrap_or(6); - let active_team_runs = cluster - .list_kind_all("KarsTask") - .await - .unwrap_or_default() - .iter() - .filter(|task| { - let annotations = task.metadata.annotations.as_ref(); - let taskforce = annotations - .and_then(|values| values.get("kars.azure.com/team-role")) - .is_some_and(|role| role == "taskforce"); - let launched = task - .data - .pointer("/spec/execution/launch") - .and_then(Value::as_bool) - .unwrap_or(false); - if !taskforce || !launched { - return false; - } - let requested = - annotations.and_then(|values| values.get("kars.azure.com/run-requested")); - let completed = - annotations.and_then(|values| values.get("kars.azure.com/run-completed")); - let delivery_pending = requested.is_some() && requested != completed; - let assignment_active = task - .data - .pointer("/status/assignment/state") - .and_then(Value::as_str) - .is_some_and(|state| matches!(state, "Assigned" | "Running")); - let execution_active = task - .data - .pointer("/status/executionPhase") - .and_then(Value::as_str) - .is_some_and(|phase| matches!(phase, "Launching" | "Running")); - delivery_pending || assignment_active || execution_active - }) - .count(); - let (pod_metrics_available, pod_metrics_error) = - match cluster.list_metrics_all("PodMetrics", "pods").await { - Ok(items) if !items.is_empty() => (true, None), - Ok(_) => ( - false, - Some("the metrics API returned no pod samples".into()), - ), - Err(error) => (false, Some(error.to_string())), - }; - Ok(Json(CapacityDto { - metrics_available, - metrics_error, - team_max_concurrent_runs, - global_active_runs_limit, - active_team_runs, - pod_metrics_available, - pod_metrics_error, - nodes, - })) -} - -// ─── KarsEval — safety/quality lifecycle (conformance evals) ───────────────── - -#[derive(Debug, Serialize)] -pub struct EvalResultDto { - pub total: i64, - pub passed: i64, - pub failed: i64, - pub errored: i64, - pub corpus_name: Option<String>, - pub corpus_digest: Option<String>, - pub completed_at: Option<String>, -} - -#[derive(Debug, Serialize)] -pub struct EvalDto { - pub name: String, - pub namespace: String, - pub display_name: Option<String>, - /// The sandbox this eval targets (spec.targetSandboxRef). - pub target_sandbox: Option<String>, - /// The corpus replayed — `builtin:<name>` or an OCI ref. - pub corpus: Option<String>, - /// Reconcile phase (Ready / Degraded / Pending). - pub phase: Option<String>, - /// Optional cron schedule (recurring eval), when set. - pub schedule: Option<String>, - pub last_run_at: Option<String>, - /// The most recent verdict (pass/fail counts), when a run completed. - pub last_result: Option<EvalResultDto>, - pub created: Option<String>, -} - -fn to_eval_result(v: &Value) -> Option<EvalResultDto> { - if !v.is_object() { - return None; - } - Some(EvalResultDto { - total: v.get("total").and_then(|x| x.as_i64()).unwrap_or(0), - passed: v.get("passed").and_then(|x| x.as_i64()).unwrap_or(0), - failed: v.get("failed").and_then(|x| x.as_i64()).unwrap_or(0), - errored: v.get("errored").and_then(|x| x.as_i64()).unwrap_or(0), - corpus_name: s(v, "corpusName"), - corpus_digest: s(v, "corpusDigest"), - completed_at: s(v, "completedAt"), - }) -} - -fn to_eval(o: &DynamicObject) -> EvalDto { - let sp = spec(o); - let st = status(o); - let corpus = sp.get("corpus").and_then(|c| { - c.get("builtin") - .and_then(|b| b.as_str()) - .map(|b| format!("builtin:{b}")) - .or_else(|| { - c.get("bundleRef") - .and_then(|r| r.get("repository")) - .and_then(|x| x.as_str()) - .map(|x| x.to_string()) - }) - }); - EvalDto { - name: name_of(o), - namespace: ns_of(o), - display_name: s(sp, "displayName"), - target_sandbox: sp - .get("targetSandboxRef") - .and_then(|r| r.get("name")) - .and_then(|x| x.as_str()) - .map(|x| x.to_string()), - corpus, - phase: s(st, "phase"), - schedule: s(sp, "schedule"), - last_run_at: s(st, "lastRunAt"), - last_result: st.get("lastResult").and_then(to_eval_result), - created: created_of(o), + kube::Error::Api(status) => { + AppError::Upstream(format!("Credential write: Kubernetes status {}", status.code)) + } + _ => AppError::Upstream("Credential write transport or serialization failed".into()), } } -/// `GET /api/operator/evals` — the KarsEval safety/quality lifecycle: every -/// conformance eval, which sandbox it targets, and its latest real verdict -/// (pass/fail against the replayed corpus). Honest empty when none exist. -pub async fn list_evals(State(state): State<AppState>) -> AppResult<Json<Vec<EvalDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster.list_kind_all("KarsEval").await.map_err(upstream)?; - let mut dtos: Vec<EvalDto> = items.iter().map(to_eval).collect(); - dtos.sort_by(|a, b| b.last_run_at.cmp(&a.last_run_at).then(a.name.cmp(&b.name))); - Ok(Json(dtos)) -} - -/// Operator request to configure + launch a safety eval. -#[derive(Debug, serde::Deserialize)] -pub struct CreateEvalRequest { - /// The sandbox to evaluate (spec.targetSandboxRef). - pub target_sandbox: String, - /// Builtin corpus name, e.g. `jailbreak-baseline` (spec.corpus.builtin). - pub corpus: String, - /// Optional cron schedule for a recurring eval; one-shot when omitted. - pub schedule: Option<String>, - /// Runner image override. On a dev cluster this must be the locally-loaded - /// `kars-conformance-runner:dev`; in prod the controller default applies. - pub runner_image: Option<String>, - /// Human label. - pub display_name: Option<String>, - /// Run immediately (stamp the run-now annotation). Default true. - pub run_now: Option<bool>, -} - -/// `POST /api/operator/evals` — configure and (by default) launch a safety eval -/// against a sandbox. Operator-only surface; the controller spawns the runner -/// Job that replays the corpus and records the real verdict. -pub async fn create_eval( +/// `POST /api/operator/credentials` — write a governed workspace source and +/// bind its actual UID to the reviewed target. Values are write-only. +/// A binding conflict may follow a committed source write; report 409 without +/// retrying the transaction or deleting a source whose delivery is uncertain. +pub async fn put_credential( State(state): State<AppState>, - Json(req): Json<CreateEvalRequest>, + principal: Option<Extension<Principal>>, + Json(req): Json<CredentialRequest>, ) -> AppResult<Json<serde_json::Value>> { let cluster = require_cluster(&state)?; - let sandbox = req.target_sandbox.trim(); - let corpus = req.corpus.trim(); - if sandbox.is_empty() || corpus.is_empty() { + let target = req.target.trim(); + let key = req.key.trim(); + if target.is_empty() || key.is_empty() || req.value.is_empty() { return Err(AppError::BadRequest( - "target_sandbox and corpus are required".into(), + "target, key and value are required".into(), )); } - // Deterministic, readable name so re-running the same eval updates in place. - let name = format!("{sandbox}-{}", corpus.replace([':', '_', '/'], "-")); - let mut spec = serde_json::json!({ - "targetSandboxRef": { "name": sandbox }, - "corpus": { "builtin": corpus }, - }); - if let Some(img) = req - .runner_image - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - spec["runnerImage"] = serde_json::json!(img); - } - if let Some(sch) = req - .schedule - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - spec["schedule"] = serde_json::json!(sch); - } - if let Some(dn) = req - .display_name - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - spec["displayName"] = serde_json::json!(dn); - } - let mut annotations = serde_json::Map::new(); - if req.run_now.unwrap_or(true) { - annotations.insert("kars.azure.com/run-now".into(), serde_json::json!("true")); - } - let body = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsEval", - "metadata": { "name": name, "annotations": annotations }, - "spec": spec.clone(), - }); - cluster - .apply_kind("kars-system", "KarsEval", body, true) - .await - .map_err(upstream)?; - Ok(Json(serde_json::json!({ "created": true, "name": name }))) -} - -/// A single eval case with what it tests and its latest verdict. -#[derive(Debug, Serialize)] -pub struct EvalCaseDto { - pub id: String, - pub tags: Vec<String>, - /// Plain-language summary of the adversarial probe this case sends. - pub probe: Option<String>, - /// The expected decision (what a safe agent SHOULD do), e.g. "Blocked". - pub expected: Option<String>, - /// What the router ACTUALLY decided on the last run, when known. - pub actual: Option<String>, - /// The actual decision's reason (e.g. why it was blocked/allowed) — surfaces - /// WHY a case failed (e.g. blocked by a transport error, not content safety). - pub actual_reason: Option<String>, - /// Latest verdict: true=passed, false=failed, None=not yet run OR errored. - pub pass: Option<bool>, - /// True when the case could NOT be evaluated (target unreachable / transport - /// error). Distinct from a policy failure — inconclusive, shown amber. - pub errored: bool, -} - -#[derive(Debug, Serialize)] -pub struct EvalReportDto { - pub name: String, - pub corpus: Option<String>, - pub total: usize, - pub passed: usize, - pub failed: usize, - /// Cases the runner could not evaluate (target unreachable). Inconclusive, - /// not counted as failures — surfaced so the UI never conflates "couldn't - /// reach the sandbox" with "the sandbox let a jailbreak through". - pub errored: usize, - pub completed_at: Option<String>, - /// Whether the controller captured PER-CASE verdicts for the last run. False - /// for runs that predate per-case reporting (only counts survive) — the UI - /// then shows the baseline cases without verdicts and invites a re-run. - pub per_case_available: bool, - pub cases: Vec<EvalCaseDto>, -} - -/// `GET /api/operator/evals/{name}/report` — the DETAILED eval report: every case -/// in the corpus (what it probes, the expected decision) merged with the latest -/// per-case verdict (pass/fail, and what the router actually did). Sourced from -/// the corpus ConfigMap (definitions) + the report ConfigMap (verdicts) the -/// controller persists — real, never fabricated. Empty verdicts until a run. -pub async fn eval_report( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<EvalReportDto>> { - let cluster = require_cluster(&state)?; - // Corpus definitions (what each case tests). - let corpus_raw = cluster - .configmap_data(&format!("karseval-{name}-corpus")) - .await - .and_then(|d| d.get("corpus.json").cloned()); - // Per-case verdicts from the last run (may be absent before first run). - let report_raw = cluster - .configmap_data(&format!("karseval-{name}-report")) - .await - .and_then(|d| d.get("report.json").cloned()); - - // Index verdicts by case id. - let report_json: Option<serde_json::Value> = report_raw - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let per_case_available = report_json.is_some(); - let mut verdicts: std::collections::BTreeMap<String, serde_json::Value> = Default::default(); - let mut completed_at = None; - let (mut total, mut passed, mut failed, mut errored) = (0usize, 0usize, 0usize, 0usize); - if let Some(r) = &report_json { - completed_at = r - .get("completedAt") - .and_then(|v| v.as_str()) - .map(String::from); - total = r.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - passed = r.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - failed = r.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - errored = r.get("errored").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - if let Some(arr) = r.get("results").and_then(|v| v.as_array()) { - for c in arr { - if let Some(id) = c.get("caseId").and_then(|v| v.as_str()) { - verdicts.insert(id.to_string(), c.clone()); - } - } - } + // Validate client-supplied names client-side of the API server, so the + // failure is an actionable 400 rather than an opaque Kubernetes 422. + if !is_dns1123_label(target) { + return Err(AppError::BadRequest( + "target must be a DNS-1123 label (lowercase letters, digits, hyphens; not starting/ending with a hyphen; ≤63 chars)".into(), + )); } - // Fall back to the KarsEval's own status counts when no per-case report exists - // (an older run) so the detail's totals never contradict the summary card. - if !per_case_available - && let Ok(items) = cluster.list_kind_all("KarsEval").await - && let Some(ev) = items.iter().find(|o| name_of(o) == name) - { - if let Some(lr) = status(ev).get("lastResult") { - total = lr.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - passed = lr.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - failed = lr.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - errored = lr.get("errored").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - } - completed_at = s(status(ev), "lastRunAt"); + if !is_env_key(key) { + return Err(AppError::BadRequest( + "key must be a valid environment variable name (letters, digits, underscore; not starting with a digit)".into(), + )); } - - let corpus_json: Option<serde_json::Value> = corpus_raw - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let corpus_name = corpus_json - .as_ref() - .and_then(|c| c.get("name")) - .and_then(|v| v.as_str()) - .map(String::from); - - let mut cases: Vec<EvalCaseDto> = Vec::new(); - if let Some(arr) = corpus_json - .as_ref() - .and_then(|c| c.get("cases")) - .and_then(|v| v.as_array()) + if !is_dns1123_label(&req.namespace) + || !["KarsSandbox", "KarsTask", "KarsTeam"].contains(&req.kind.as_str()) { - for case in arr { - let id = case - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tags = case - .get("tags") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|t| t.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - let expected = case - .get("expect") - .and_then(|e| e.get("decision")) - .and_then(|v| v.as_str()) - .map(String::from); - // Summarise the probe: the last user message in the scenario. - let probe = case - .get("scenario") - .and_then(|s| s.get("messages")) - .and_then(|m| m.as_array()) - .and_then(|arr| { - arr.iter() - .rev() - .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user")) - }) - .and_then(|m| m.get("content").and_then(|c| c.as_str())) - .map(|s| s.chars().take(160).collect::<String>()); - let v = verdicts.get(&id); - let pass = v.and_then(|c| c.get("pass")).and_then(|p| p.as_bool()); - let errored = v - .and_then(|c| c.get("errored")) - .and_then(|e| e.as_bool()) - .unwrap_or(false); - let actual = v - .and_then(|c| c.get("actual")) - .and_then(|a| a.get("decision")) - .and_then(|d| d.as_str()) - .map(String::from); - let actual_reason = v - .and_then(|c| c.get("actual")) - .and_then(|a| a.get("reason")) - .and_then(|d| d.as_str()) - .map(|s| s.chars().take(240).collect::<String>()); - cases.push(EvalCaseDto { - id, - tags, - probe, - expected, - actual, - actual_reason, - pass, - errored, - }); - } - } - - Ok(Json(EvalReportDto { - name, - corpus: corpus_name, - total, - passed, - failed, - errored, - completed_at, - per_case_available, - cases, - })) -} - -// ─── MCP servers (connected services) ──────────────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct McpServerDto { - pub name: String, - pub namespace: String, - pub url: Option<String>, - pub phase: Option<String>, - pub mode: Option<String>, - pub endpoint: Option<String>, - pub workload_ref: Option<String>, - pub discovered_tools: Vec<String>, - pub tool_schema_digest: Option<String>, - pub production: Option<bool>, - pub allowed_tools: Vec<String>, - pub created: Option<String>, - /// Raw `spec` for Edit-form prefill. - pub spec: serde_json::Value, -} - -fn to_mcp(o: &DynamicObject) -> McpServerDto { - let sp = spec(o); - let st = status(o); - let allowed_tools = sp - .get("allowedTools") - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|x| x.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - McpServerDto { - name: name_of(o), - namespace: ns_of(o), - url: s(st, "endpoint").or_else(|| s(sp, "url")), - phase: s(st, "phase"), - mode: s(st, "mode"), - endpoint: s(st, "endpoint"), - workload_ref: s(st, "workloadRef"), - discovered_tools: st - .get("discoveredTools") - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|x| x.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(), - tool_schema_digest: s(st, "toolSchemaDigest"), - production: sp.get("productionMode").and_then(|p| p.as_bool()), - allowed_tools, - created: created_of(o), - spec: sp.clone(), - } -} - -/// `GET /api/operator/mcpservers` — registered MCP servers (connected services). -pub async fn list_mcpservers(State(state): State<AppState>) -> AppResult<Json<Vec<McpServerDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster.list_kind_all("McpServer").await.map_err(upstream)?; - let mut dtos: Vec<McpServerDto> = items.iter().map(to_mcp).collect(); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) -} - -// ─── Tool policies ─────────────────────────────────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct ToolPolicyDto { - pub name: String, - pub namespace: String, - pub phase: Option<String>, - pub version_hash: Option<String>, - /// What this policy applies to (sandbox/tool scope), in plain terms. - pub applies_to: Option<String>, - /// Whether the policy carries an AGT governance profile (the rule set that - /// allows/denies/rate-limits capabilities). - pub has_governance_profile: bool, - /// Allowed tool / MCP identifiers, when expressed as a flat list. - pub allowed: Vec<String>, - pub created: Option<String>, - /// The raw `spec` object, so the console can prefill the Edit form with the - /// exact current spec (edit = re-apply with changed fields via SSA). - pub spec: serde_json::Value, -} - -fn to_toolpolicy(o: &DynamicObject) -> ToolPolicyDto { - let sp = spec(o); - let mut allowed: Vec<String> = Vec::new(); - if let Some(arr) = sp.get("allow").and_then(|a| a.as_array()) { - allowed.extend(arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string()))); - } - if let Some(arr) = sp.get("tools").and_then(|a| a.as_array()) { - allowed.extend(arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string()))); - } - // The real ToolPolicy scopes via `appliesTo` (sandbox labels + tool glob) - // and governs capabilities through an embedded AGT profile. Project that - // into a plain summary rather than an empty list. - let applies_to = sp.get("appliesTo").map(|a| { - let tool = a.get("tool").and_then(|t| t.as_str()).unwrap_or("*"); - // Render the FULL sandbox selector, not just the well-known sandbox - // label, so a policy scoped by other labels isn't misreported as "*". - let labels = a - .get("sandboxMatchLabels") - .and_then(|l| l.as_object()) - .map(|m| { - m.iter() - .map(|(k, v)| format!("{}={}", k, v.as_str().unwrap_or(""))) - .collect::<Vec<_>>() - .join(", ") - }) - .filter(|s| !s.is_empty()); - match labels { - Some(sel) => format!("sandbox [{sel}] · tools {tool}"), - None => format!("all sandboxes · tools {tool}"), - } - }); - let has_governance_profile = sp.get("agtProfile").is_some(); - ToolPolicyDto { - name: name_of(o), - namespace: ns_of(o), - phase: s(status(o), "phase"), - version_hash: s(status(o), "versionHash"), - applies_to, - has_governance_profile, - allowed, - created: created_of(o), - spec: sp.clone(), - } -} - -/// `GET /api/operator/toolpolicies` — tool/MCP authorization policies. -pub async fn list_toolpolicies( - State(state): State<AppState>, -) -> AppResult<Json<Vec<ToolPolicyDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster - .list_kind_all("ToolPolicy") - .await - .map_err(upstream)?; - let mut dtos: Vec<ToolPolicyDto> = items.iter().map(to_toolpolicy).collect(); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) -} - -// ─── Inference policies ────────────────────────────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct InferencePolicyDto { - pub name: String, - pub namespace: String, - pub phase: Option<String>, - pub version_hash: Option<String>, - pub sandbox: Option<String>, - pub daily_token_budget: Option<i64>, - pub content_safety: bool, - pub created: Option<String>, - /// The raw spec, so the console's visual editor can pre-fill an edit - /// (name/sandbox/tokens/content-safety/model-preference) instead of a - /// hand-authored JSON blob. - pub spec: Value, -} - -fn to_inferencepolicy(o: &DynamicObject) -> InferencePolicyDto { - let sp = spec(o); - InferencePolicyDto { - name: name_of(o), - namespace: ns_of(o), - phase: s(status(o), "phase"), - version_hash: s(status(o), "versionHash"), - sandbox: sp - .get("appliesTo") - .and_then(|a| a.get("sandboxName")) - .and_then(|x| x.as_str()) - .map(|x| x.to_string()), - daily_token_budget: sp - .get("tokenBudget") - .and_then(|t| t.get("dailyTokens")) - .and_then(|x| x.as_i64()), - // Content safety is enforced when the floor actually sets a severity - // threshold or requires Prompt Shields — an empty `contentSafety: {}` - // object is not protection, so don't report it as enabled. - content_safety: sp - .get("contentSafety") - .map(|cs| { - ["hate", "selfHarm", "sexual", "violence"] - .iter() - .any(|k| cs.get(*k).and_then(|v| v.as_str()).is_some()) - || cs.get("requirePromptShields").and_then(|v| v.as_bool()) == Some(true) - }) - .unwrap_or(false), - created: created_of(o), - spec: sp.clone(), + return Err(AppError::BadRequest("An explicit workspace namespace and KarsSandbox/KarsTask/KarsTeam target kind are required".into())); } -} - -/// `GET /api/operator/inferencepolicies` — inference governance policies. -pub async fn list_inferencepolicies( - State(state): State<AppState>, -) -> AppResult<Json<Vec<InferencePolicyDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster - .list_kind_all("InferencePolicy") - .await - .map_err(upstream)?; - let mut dtos: Vec<InferencePolicyDto> = items.iter().map(to_inferencepolicy).collect(); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) -} - -/// `POST /api/operator/inferencepolicies` — create (or Server-Side-Apply edit) a -/// standalone InferencePolicy the operator authors directly (e.g. a policy -/// scoped to a selector with a token budget + content-safety floor). The -/// per-sandbox `<task>-inference` policies remain controller-generated; this is -/// the "I should be able to create inference policies" capability. -pub async fn create_inferencepolicy( - State(state): State<AppState>, - Json(req): Json<ApplyCrdRequest>, -) -> AppResult<Json<serde_json::Value>> { - apply_governance(require_cluster(&state)?, "InferencePolicy", req).await -} - -#[derive(serde::Deserialize)] -pub struct PatchInferenceBudgetRequest { - /// New daily token cap for this policy (0 clears the cap). - pub daily_tokens: i64, -} - -/// `PATCH /api/operator/inferencepolicies/{name}` — edit a policy's daily token -/// budget in place (a merge patch on `spec.tokenBudget.dailyTokens`). For a -/// controller-generated policy the durable source is the mission's envelope -/// budget, so the reconciler may re-derive it; for an operator-authored policy -/// the edit sticks. -pub async fn patch_inferencepolicy( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, - Json(req): Json<PatchInferenceBudgetRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let budget = if req.daily_tokens > 0 { - serde_json::json!({ "dailyTokens": req.daily_tokens }) - } else { - serde_json::Value::Null - }; - let patch = serde_json::json!({ "spec": { "tokenBudget": budget } }); - cluster - .merge_patch_kind("kars-system", "InferencePolicy", &name, patch) - .await - .map_err(apply_err)?; - Ok(Json(serde_json::json!({ "patched": true, "name": name }))) -} - -/// `DELETE /api/operator/inferencepolicies/{name}` — remove an operator-authored -/// policy. (A controller-generated one will be recreated by the reconciler.) -pub async fn delete_inferencepolicy( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - delete_governance(require_cluster(&state)?, "InferencePolicy", &name, None).await -} - -// ─── Egress (allowlists + temporary approvals) ─────────────────────────────── - -#[derive(Debug, Serialize)] -pub struct EgressApprovalDto { - pub name: String, - pub namespace: String, - pub sandbox: Option<String>, - pub phase: Option<String>, - pub reason: Option<String>, - pub hosts: Vec<String>, - pub expires_at: Option<String>, - pub created: Option<String>, -} - -fn to_egress_approval(o: &DynamicObject) -> EgressApprovalDto { - let sp = spec(o); - let hosts = sp - .get("hosts") - .and_then(|h| h.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|e| { - let host = e.get("host").and_then(|x| x.as_str())?; - let port = e.get("port").and_then(|x| x.as_i64()); - Some(match port { - Some(p) => format!("{host}:{p}"), - None => host.to_string(), - }) - }) - .collect() - }) - .unwrap_or_default(); - EgressApprovalDto { - name: name_of(o), - namespace: ns_of(o), - sandbox: s(sp, "sandbox"), - phase: s(status(o), "phase"), - reason: s(sp, "reason"), - hosts, - expires_at: s(status(o), "expiresAt"), - created: created_of(o), + if req.review.is_some() { + let principal = principal.ok_or_else(|| { + AppError::Forbidden( + "A verified operator is required for reviewed credential writes".into(), + ) + })?; + return super::credential_review::write(&state, &principal.0, req).await; } + Ok(Json( + cluster + .write_agent_credentials( + &req.namespace, + &req.kind, + target, + req.target_uid.as_deref(), + std::collections::BTreeMap::from([(key.to_string(), req.value)]), + Vec::new(), + ) + .await + .map_err(credential_write_error)?, + )) } -/// `GET /api/operator/egress` — temporary egress approvals across the fleet. -pub async fn list_egress(State(state): State<AppState>) -> AppResult<Json<Vec<EgressApprovalDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster - .list_kind_all("EgressApproval") - .await - .map_err(upstream)?; - let mut dtos: Vec<EgressApprovalDto> = items.iter().map(to_egress_approval).collect(); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) -} - -// ─── Audit: receipts + inclusion log + checkpoint ──────────────────────────── - -#[derive(Debug, Serialize)] -pub struct ReceiptSummaryDto { - pub name: String, - pub namespace: String, - pub task: Option<String>, - pub envelope_digest: Option<String>, - pub key_id: Option<String>, - pub inclusion_seq: Option<i64>, - pub created: Option<String>, - /// At-a-glance verdict from the receipt's claim matrix (`spec.claims`, which - /// the CRD already carries) — `verified` (all required non-regulatory claims - /// PASS), `failed` (any FAIL), `partial` (required evidence incomplete), or - /// `none` (no claims). Regulatory maturity is advisory and shown in detail. - pub verdict: String, -} - -/// Reduce a receipt's `(class, status)` claim pairs to an overall verdict. -/// Any FAIL/ERROR ⇒ "failed". Otherwise the badge reflects the CRYPTOGRAPHIC -/// claims (integrity + conformance + completeness) — the "regulatory" claim and -/// any "OMITTED" status are advisory V0-maturity disclosures that must NOT block -/// a "verified" verdict (else every receipt reads "partial" forever). `class` -/// is expected lowercased, `status` uppercased. -fn receipt_verdict(claims: &[(String, String)]) -> &'static str { - if claims.is_empty() { - return "none"; - } - if claims.iter().any(|(_, s)| s == "FAIL" || s == "ERROR") { - return "failed"; - } - let core: Vec<&(String, String)> = claims - .iter() - .filter(|(class, status)| class != "regulatory" && status != "OMITTED") - .collect(); - if !core.is_empty() && core.iter().all(|(_, s)| s == "PASS" || s == "OK") { - "verified" - } else { - "partial" - } -} +#[cfg(test)] +mod tests { + use super::{is_dns1123_label, is_env_key}; -fn to_receipt_summary(o: &DynamicObject) -> ReceiptSummaryDto { - let sp = spec(o); - // The regulatory claim is a V0 maturity dimension — it is ALWAYS "PARTIAL" - // or "OMITTED" until an external KMS/transparency anchor lands (a named V1 - // follow-up), and "OMITTED" is an honest disclosure, not a verification - // failure. Treating either as blocking meant NO receipt could ever read - // "Verified" (every one showed "Partial"), making the verdict useless. So - // the badge reflects the CRYPTOGRAPHIC claims (integrity + conformance + - // completeness); the regulatory/omitted maturity is still shown in detail. - let claims: Vec<(String, String)> = sp - .get("claims") - .and_then(|c| c.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|c| { - let status = c - .get("status") - .and_then(|s| s.as_str())? - .to_ascii_uppercase(); - let class = c - .get("class") - .and_then(|s| s.as_str()) - .unwrap_or("") - .to_ascii_lowercase(); - Some((class, status)) - }) - .collect() - }) - .unwrap_or_default(); - let verdict = receipt_verdict(&claims).to_string(); - ReceiptSummaryDto { - name: name_of(o), - namespace: ns_of(o), - task: sp - .get("taskRef") - .and_then(|r| r.get("name")) - .and_then(|n| n.as_str()) - .map(|x| x.to_string()), - envelope_digest: s(sp, "envelopeDigest"), - key_id: s(sp, "keyId"), - inclusion_seq: status(o).get("inclusionSeq").and_then(|x| x.as_i64()), - created: created_of(o), - verdict, - } -} - -#[derive(Debug, Serialize)] -pub struct AuditDto { - pub receipts: Vec<ReceiptSummaryDto>, - pub inclusion_log_size: i64, - pub checkpoint: Option<CheckpointSummaryDto>, - /// Real cryptographic integrity verdict — the whole hash chain recomputed - /// and the signed checkpoint verified against the published anchor. Drives - /// the audit banner so it reflects verification, not field presence. - pub integrity: crate::routes::receipts::LogIntegrity, -} - -#[derive(Debug, Serialize)] -pub struct CheckpointSummaryDto { - pub tree_size: i64, - pub root_hash: String, - pub key_id: String, - pub published_at: Option<String>, -} - -/// `GET /api/operator/audit` — the audit substrate: every governance receipt, -/// the inclusion-log size, and the signed checkpoint (signed tree head). -pub async fn get_audit(State(state): State<AppState>) -> AppResult<Json<AuditDto>> { - let cluster = require_cluster(&state)?; - let items = cluster - .list_kind_all("KarsReceipt") - .await - .map_err(upstream)?; - let mut receipts: Vec<ReceiptSummaryDto> = items.iter().map(to_receipt_summary).collect(); - receipts.sort_by_key(|a| a.inclusion_seq); - - let log = cluster - .receipt_log() - .await - .map_err(|error| AppError::Upstream(error.to_string()))?; - let inclusion_log_size = log.entries.len() as i64; - - let checkpoint = log.checkpoint.as_ref().and_then(|d| { - let tree_size = d.get("treeSize")?.parse::<i64>().ok()?; - Some(CheckpointSummaryDto { - tree_size, - root_hash: d.get("rootHash").cloned().unwrap_or_default(), - key_id: d.get("keyId").cloned().unwrap_or_default(), - published_at: d.get("publishedAt").cloned(), - }) - }); - - Ok(Json(AuditDto { - receipts, - inclusion_log_size, - checkpoint, - integrity: crate::routes::receipts::verify_log_integrity(&log), - })) -} - -// ─── Skills & Profiles (predefined building blocks for customers) ──────────── - -#[derive(Debug, Serialize)] -pub struct SkillDto { - pub name: String, - pub namespace: String, - pub version: Option<String>, - pub summary: Option<String>, - pub bounding_policy: Option<String>, - pub phase: Option<String>, - pub version_digest: Option<String>, - pub attestation_verified: Option<bool>, - // ── Operator trust gate ────────────────────────────────────────────────── - /// Admission verdict: "approved" once an operator has signed off, else the - /// skill is treated as pending review. - pub review: String, - /// The version digest the approval is locked to (from status at approval). - pub locked_digest: Option<String>, - pub approved_by: Option<String>, - pub approved_at: Option<String>, - /// True when approved AND the locked digest still matches the current - /// version digest — i.e. usable by users. False if never approved or the - /// skill changed since approval (lock broken → back to review). - pub usable: bool, - /// Raw `spec` for Edit-form prefill. - pub spec: serde_json::Value, -} - -fn to_skill(o: &DynamicObject) -> SkillDto { - let sp = spec(o); - let version_digest = s(status(o), "versionDigest"); - let review = annotation(o, ANN_REVIEW).unwrap_or_else(|| "pending".into()); - let locked_digest = annotation(o, ANN_LOCKED_DIGEST); - // Usable only when explicitly approved and the lock still matches the live - // digest. When the skill has no digest yet (not scanned), it can't be usable. - let usable = review == "approved" && locked_digest.is_some() && locked_digest == version_digest; - SkillDto { - name: name_of(o), - namespace: ns_of(o), - version: s(sp, "version"), - summary: s(sp, "summary"), - bounding_policy: s(sp, "boundingPolicy"), - phase: s(status(o), "phase"), - version_digest, - attestation_verified: status(o) - .get("attestationVerified") - .and_then(|v| v.as_bool()), - review, - locked_digest, - approved_by: annotation(o, ANN_APPROVED_BY), - approved_at: annotation(o, ANN_APPROVED_AT), - usable, - spec: sp.clone(), - } -} - -#[derive(Debug, Serialize, serde::Deserialize, Clone)] -pub struct McpProfileDto { - pub name: String, - #[serde(default)] - pub summary: Option<String>, - /// Names of the operator-vetted McpServers this profile bundles. - pub servers: Vec<String>, -} - -/// `GET /api/operator/mcp-profiles` — the operator-curated MCP bundles users -/// can pick from (a named, vetted set of McpServers, so users compose from -/// approved groupings rather than assembling servers one by one). -pub async fn list_mcp_profiles( - State(state): State<AppState>, -) -> AppResult<Json<Vec<McpProfileDto>>> { - let cluster = require_cluster(&state)?; - let raw = cluster.read_mcp_profiles().await; - let profiles: Vec<McpProfileDto> = serde_json::from_str(&raw).unwrap_or_default(); - Ok(Json(profiles)) -} - -/// `PUT /api/operator/mcp-profiles` — upsert a profile by name. Validates that -/// every referenced server is a real McpServer on the cluster, so a profile can -/// never bundle a non-existent (unvetted) server. -pub async fn put_mcp_profile( - State(state): State<AppState>, - Json(req): Json<McpProfileDto>, -) -> AppResult<Json<Vec<McpProfileDto>>> { - let cluster = require_cluster(&state)?; - if req.name.trim().is_empty() { - return Err(AppError::BadRequest("profile name is required".into())); - } - // Real McpServers on the cluster — the vetted universe a profile may draw from. - let known: std::collections::BTreeSet<String> = cluster - .list_kind_all("McpServer") - .await - .map_err(upstream)? - .iter() - .map(name_of) - .collect(); - for s in &req.servers { - if !known.contains(s) { - return Err(AppError::BadRequest(format!( - "server '{s}' is not a registered McpServer — vet it first" - ))); - } - } - let raw = cluster.read_mcp_profiles().await; - let mut profiles: Vec<McpProfileDto> = serde_json::from_str(&raw).unwrap_or_default(); - profiles.retain(|p| p.name != req.name); - profiles.push(req); - profiles.sort_by(|a, b| a.name.cmp(&b.name)); - let json = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".into()); - cluster - .write_mcp_profiles(&json) - .await - .map_err(AppError::Internal)?; - Ok(Json(profiles)) -} - -/// `DELETE /api/operator/mcp-profiles/:name` — remove a profile. -pub async fn delete_mcp_profile( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<Vec<McpProfileDto>>> { - let cluster = require_cluster(&state)?; - let raw = cluster.read_mcp_profiles().await; - let mut profiles: Vec<McpProfileDto> = serde_json::from_str(&raw).unwrap_or_default(); - profiles.retain(|p| p.name != name); - let json = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".into()); - cluster - .write_mcp_profiles(&json) - .await - .map_err(AppError::Internal)?; - Ok(Json(profiles)) -} - -pub async fn list_skills(State(state): State<AppState>) -> AppResult<Json<Vec<SkillDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster.list_kind_all("KarsSkill").await.map_err(upstream)?; - let mut dtos: Vec<SkillDto> = items.iter().map(to_skill).collect(); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) -} - -/// Annotation recording who uploaded a user-submitted skill (provenance for the -/// operator reviewing it). -const ANN_UPLOADED_BY: &str = "kars.azure.com/skill-uploaded-by"; -const ANN_UPLOADED_BY_SUB: &str = "kars.azure.com/skill-uploaded-by-sub"; - -#[derive(serde::Deserialize)] -pub struct SubmitSkillRequest { - /// DNS-1123 object name (kebab-case). - pub name: String, - pub display_name: Option<String>, - pub version: String, - pub summary: String, - /// The bounding tool policy — must be one the operator already vetted; it - /// caps what the skill's recipe can do. Users pick from the approved set. - pub bounding_policy: String, - pub recipe: Option<String>, - #[serde(default)] - pub mcp_servers: Vec<String>, - /// The skill PACKAGE files — flat filenames (SKILL.md + scripts). Stored as - /// the `karsskill-<name>` ConfigMap and mounted into a granting sandbox. - #[serde(default)] - pub files: Vec<SkillFile>, -} - -#[derive(Debug, serde::Deserialize)] -pub struct SkillFile { - /// Flat filename (no path separators) — e.g. `SKILL.md`, `triage.sh`. - pub name: String, - pub content: String, -} - -/// `POST /api/skills` — USER skill submission. A team member uploads a skill -/// package; it lands as a `KarsSkill` that starts life PENDING REVIEW (never -/// usable until an operator scans + approves it). This is the user side of the -/// trust gate: users propose capability, operators vet + sign, then it's -/// grantable. The BFF never marks a user-submitted skill approved. -pub async fn submit_skill( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Json(req): Json<SubmitSkillRequest>, -) -> AppResult<Json<SkillDto>> { - let cluster = require_cluster(&state)?; - let name = req.name.trim(); - if !is_dns1123_label(name) { - return Err(AppError::BadRequest( - "skill name must be a DNS-1123 label (lowercase letters, digits, hyphens)".into(), - )); - } - if req.version.trim().is_empty() { - return Err(AppError::BadRequest("version is required".into())); - } - if req.summary.trim().len() < 8 { - return Err(AppError::BadRequest("a real summary is required".into())); - } - if req.bounding_policy.trim().is_empty() { - return Err(AppError::BadRequest( - "a bounding tool policy is required — it caps what the skill may do".into(), - )); - } - let ns = "kars-system".to_string(); - let mut spec = serde_json::json!({ - "version": req.version.trim(), - "summary": req.summary.trim(), - "boundingPolicy": req.bounding_policy.trim(), - }); - if let Some(dn) = req.display_name.as_ref().filter(|s| !s.trim().is_empty()) { - spec["displayName"] = serde_json::json!(dn.trim()); - } - if let Some(r) = req.recipe.as_ref().filter(|s| !s.trim().is_empty()) { - spec["recipe"] = serde_json::json!(r.trim()); - } - if !req.mcp_servers.is_empty() { - spec["mcpServers"] = serde_json::json!(req.mcp_servers); - } - // Validate + collect the package files. Standard Agent Skills use - // subdirectories (scripts/, references/, assets/) referenced relatively from - // SKILL.md. ConfigMap keys can't contain '/', so we accept relative paths - // here and path-encode '/'→'__' only when writing the ConfigMap; the sandbox - // entrypoint decodes them back on mount so the on-disk tree matches exactly. - // A real skill package is at least a SKILL.md at the root. - let mut files: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new(); - for f in &req.files { - let fname = f.name.trim(); - let bad_segments = fname.split('/').any(|seg| { - seg.is_empty() - || !seg - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) - }); - if fname.is_empty() - || fname.starts_with('/') - || fname.ends_with('/') - || fname.contains("..") - || fname.contains("__") // reserved as the CM path separator - || fname.len() > 253 - || bad_segments - { - return Err(AppError::BadRequest(format!( - "invalid skill file path '{fname}': use relative paths (letters, digits, . _ - and / for subdirs); no '..', no '__', no leading/trailing '/'" - ))); - } - files.insert(fname.to_string(), f.content.clone()); - } - if !files.is_empty() { - // A package the agent can actually USE must carry a SKILL.md — OpenClaw - // auto-discovers `<name>/SKILL.md` and reads its frontmatter `description` - // to know when to invoke the skill. Without it the files are dead weight. - let skill_md = files.get("SKILL.md"); - match skill_md { - None => { - return Err(AppError::BadRequest( - "a skill package must include a SKILL.md — the agent discovers the skill from it".into(), - )); - } - Some(md) if !md.contains("description:") => { - return Err(AppError::BadRequest( - "SKILL.md must have YAML frontmatter with a `description:` — that's how the agent knows when to use the skill".into(), - )); - } - _ => {} - } - spec["package"] = serde_json::json!(true); - spec["files"] = serde_json::json!(files.keys().cloned().collect::<Vec<_>>()); - use sha2::{Digest, Sha256}; - let configmap_data: std::collections::BTreeMap<String, String> = files - .iter() - .map(|(path, content)| (path.replace('/', "__"), content.clone())) - .collect(); - let canonical = serde_json::to_vec(&configmap_data) - .map_err(|e| AppError::Internal(anyhow::Error::new(e)))?; - spec["packageDigest"] = serde_json::json!(format!( - "sha256:{}", - hex::encode(Sha256::digest(&canonical)) - )); - } - let uploader = principal.name; - let uploader_sub = principal.sub; - let body = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsSkill", - "metadata": { - "name": name, - "namespace": ns, - "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, - // Explicitly PENDING — the operator trust gate must approve it before - // it is usable. Never set review=approved on the user path. - "annotations": { - ANN_REVIEW: "pending", - ANN_UPLOADED_BY: uploader, - ANN_UPLOADED_BY_SUB: uploader_sub, - }, - }, - "spec": spec, - }); - let applied = cluster - .apply_kind(&ns, "KarsSkill", body, false) - .await - .map_err(apply_err)?; - // Persist the package files as the karsskill-<name> ConfigMap so the - // controller can mount them into a granting sandbox. ConfigMap keys can't - // contain '/', so subdirectory paths are encoded '/'→'__'; the sandbox - // entrypoint decodes them back to the real tree on mount. - if !files.is_empty() { - let cm_files: std::collections::BTreeMap<String, String> = files - .iter() - .map(|(path, content)| (path.replace('/', "__"), content.clone())) - .collect(); - let package_digest = spec - .get("packageDigest") - .and_then(|v| v.as_str()) - .ok_or_else(|| AppError::Internal(anyhow::anyhow!("package digest missing")))?; - cluster - .write_skill_package(name, &cm_files, package_digest) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - } - Ok(Json(to_skill(&applied))) -} - -/// Locate a skill by name across namespaces, returning `(namespace, object)`. -async fn find_skill( - cluster: &crate::kars::cluster::Cluster, - name: &str, -) -> AppResult<(String, DynamicObject)> { - let items = cluster.list_kind_all("KarsSkill").await.map_err(upstream)?; - items - .into_iter() - .find(|o| name_of(o) == name) - .map(|o| (ns_of(&o), o)) - .ok_or(AppError::NotFound) -} - -/// `POST /api/operator/skills/:name/approve` — the operator admission gate. -/// Records the operator's approval and LOCKS it to the skill's current version -/// digest, after which users can assign the skill. Requires the skill to have -/// been scanned (a version digest present) and its attestation to have verified -/// — an operator can't approve a skill the controller hasn't validated. Any -/// later change to the skill breaks the lock and returns it to review. -pub async fn approve_skill( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - axum::extract::Path(name): axum::extract::Path<String>, - Json(_req): Json<ApproveSkillRequest>, -) -> AppResult<Json<SkillDto>> { - let cluster = require_cluster(&state)?; - let (ns, obj) = find_skill(cluster, &name).await?; - let uploader_subject = obj - .metadata - .annotations - .as_ref() - .and_then(|a| a.get(ANN_UPLOADED_BY_SUB)) - .cloned(); - if uploader_subject.as_deref() == Some(principal.sub.as_str()) { - return Err(AppError::Forbidden( - "skill submitter cannot approve their own package".into(), - )); - } - let generation = obj.metadata.generation.unwrap_or_default(); - let observed_generation = status(&obj) - .get("observedGeneration") - .and_then(|v| v.as_i64()) - .unwrap_or_default(); - if observed_generation != generation { - return Err(AppError::Conflict( - "skill changed after its last controller scan; wait for the current generation".into(), - )); - } - let digest = s(status(&obj), "versionDigest").ok_or_else(|| { - AppError::BadRequest( - "skill has not been scanned yet (no version digest) — the controller must validate it before approval".into(), - ) - })?; - // Honest gate: don't let an operator approve a skill whose attestation the - // controller could not verify. - if status(&obj) - .get("attestationVerified") - .and_then(|v| v.as_bool()) - == Some(false) - { - return Err(AppError::BadRequest( - "skill attestation did not verify — cannot approve until the scan passes".into(), - )); - } - let by = principal.name; - let now = chrono::Utc::now().to_rfc3339(); - let updated = cluster - .annotate_kind( - &ns, - "KarsSkill", - &name, - &[ - (ANN_REVIEW, Some("approved".into())), - (ANN_LOCKED_DIGEST, Some(digest)), - (ANN_APPROVED_BY, Some(by)), - (ANN_APPROVED_AT, Some(now)), - ], - ) - .await - .map_err(upstream)?; - Ok(Json(to_skill(&updated))) -} - -/// `POST /api/operator/skills/:name/revoke` — withdraw approval, returning the -/// skill to review (users immediately stop seeing it). -pub async fn revoke_skill( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<SkillDto>> { - let cluster = require_cluster(&state)?; - let (ns, _) = find_skill(cluster, &name).await?; - let updated = cluster - .annotate_kind( - &ns, - "KarsSkill", - &name, - &[ - (ANN_REVIEW, Some("pending".into())), - (ANN_LOCKED_DIGEST, None), - (ANN_APPROVED_BY, None), - (ANN_APPROVED_AT, None), - ], - ) - .await - .map_err(upstream)?; - Ok(Json(to_skill(&updated))) -} - -#[derive(Debug, serde::Deserialize)] -pub struct ApproveSkillRequest {} - -#[derive(Debug, Serialize)] -pub struct ProfileRoleDto { - pub name: String, - pub system_prompt: Option<String>, - pub skills: Vec<String>, -} - -#[derive(Debug, Serialize)] -pub struct ProfileDto { - pub name: String, - pub namespace: String, - pub domain: Option<String>, - pub phase: Option<String>, - pub template_digest: Option<String>, - // Instantiation fields — so the team composer can prefill a whole team from - // a profile (the profile is a vetted org template, not a dead-end record). - pub display_name: Option<String>, - pub charter_template: Option<String>, - pub tier: Option<i32>, - pub tool_policy: Option<String>, - pub knowledge_commons: Option<String>, - pub roles: Vec<ProfileRoleDto>, - /// Raw spec for Edit-form prefill. - pub spec: serde_json::Value, -} - -fn to_profile(o: &DynamicObject) -> ProfileDto { - let sp = spec(o); - let roles = sp - .get("roles") - .and_then(|r| r.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|r| { - let name = r.get("name")?.as_str()?.to_string(); - Some(ProfileRoleDto { - name, - system_prompt: r - .get("systemPrompt") - .and_then(|s| s.as_str()) - .map(String::from), - skills: r - .get("skills") - .and_then(|s| s.as_array()) - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(), - }) - }) - .collect() - }) - .unwrap_or_default(); - ProfileDto { - name: name_of(o), - namespace: ns_of(o), - domain: s(sp, "domain"), - phase: s(status(o), "phase"), - template_digest: s(status(o), "templateDigest"), - display_name: s(sp, "displayName"), - charter_template: s(sp, "charterTemplate"), - tier: sp - .get("defaultEnvelope") - .and_then(|e| e.get("tier")) - .and_then(|t| t.as_i64()) - .map(|t| t as i32), - tool_policy: s(sp, "toolPolicy"), - knowledge_commons: s(sp, "knowledgeCommons"), - roles, - spec: sp.clone(), - } -} - -pub async fn list_profiles(State(state): State<AppState>) -> AppResult<Json<Vec<ProfileDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster - .list_kind_all("KarsProfile") - .await - .map_err(upstream)?; - let mut dtos: Vec<ProfileDto> = items.iter().map(to_profile).collect(); - dtos.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(Json(dtos)) -} - -/// `PUT /api/operator/profiles` — author/edit a `KarsProfile` (SSA). -pub async fn put_profile( - State(state): State<AppState>, - Json(req): Json<ApplyCrdRequest>, -) -> AppResult<Json<serde_json::Value>> { - apply_governance(require_cluster(&state)?, "KarsProfile", req).await -} - -/// `DELETE /api/operator/profiles/:name` — remove a `KarsProfile`. -pub async fn delete_profile( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - delete_governance(require_cluster(&state)?, "KarsProfile", &name, None).await -} - -// ─── Credentials (secure repo/system access for agents) ────────────────────── - -#[derive(Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CredentialRequest { - /// The agent/team this credential is for (becomes `<target>-credentials`). - pub target: String, - pub kind: String, - pub namespace: String, - #[serde(default)] - pub target_uid: Option<String>, - /// The env var name the agent reads (e.g. GITHUB_TOKEN, BRAVE_API_KEY). - pub key: String, - /// The secret value. Stored only in the K8s Secret; never read back. - pub value: String, - #[serde(default)] - pub review: Option<String>, -} - -/// A DNS-1123 label (lowercase alphanumeric + hyphens, must start/end -/// alphanumeric, ≤63 chars) — the constraint on the `kars-<target>` namespace -/// derived below, so an invalid target is rejected before it reaches the API -/// server as an opaque 422. -pub(super) fn is_dns1123_label(s: &str) -> bool { - !s.is_empty() - && s.len() <= 63 - && s.bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') - && !s.starts_with('-') - && !s.ends_with('-') -} - -/// A POSIX-ish environment variable name: letters/digits/underscore, not -/// starting with a digit. Agents read the credential under this name. -pub(super) fn is_env_key(s: &str) -> bool { - let mut chars = s.chars(); - matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -pub(super) fn credential_write_error(error: kube::Error) -> AppError { - match error { - kube::Error::Api(status) if status.code == 409 => AppError::Conflict( - "Credential authority changed or a source already exists. Refresh credential metadata and review before resubmitting; a source write may already be stored. No automatic retry or rollback was attempted.".into(), - ), - kube::Error::Api(status) => { - AppError::Upstream(format!("Credential write: Kubernetes status {}", status.code)) - } - _ => AppError::Upstream("Credential write transport or serialization failed".into()), - } -} - -/// `POST /api/operator/credentials` — write a governed workspace source and -/// bind its actual UID to the reviewed target. Values are write-only. -/// A binding conflict may follow a committed source write; report 409 without -/// retrying the transaction or deleting a source whose delivery is uncertain. -pub async fn put_credential( - State(state): State<AppState>, - principal: Option<Extension<Principal>>, - Json(req): Json<CredentialRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let target = req.target.trim(); - let key = req.key.trim(); - if target.is_empty() || key.is_empty() || req.value.is_empty() { - return Err(AppError::BadRequest( - "target, key and value are required".into(), - )); - } - // Validate client-supplied names client-side of the API server, so the - // failure is an actionable 400 rather than an opaque Kubernetes 422. - if !is_dns1123_label(target) { - return Err(AppError::BadRequest( - "target must be a DNS-1123 label (lowercase letters, digits, hyphens; not starting/ending with a hyphen; ≤63 chars)".into(), - )); - } - if !is_env_key(key) { - return Err(AppError::BadRequest( - "key must be a valid environment variable name (letters, digits, underscore; not starting with a digit)".into(), - )); - } - if !is_dns1123_label(&req.namespace) - || !["KarsSandbox", "KarsTask", "KarsTeam"].contains(&req.kind.as_str()) - { - return Err(AppError::BadRequest("An explicit workspace namespace and KarsSandbox/KarsTask/KarsTeam target kind are required".into())); - } - if req.review.is_some() { - let principal = principal.ok_or_else(|| { - AppError::Forbidden( - "A verified operator is required for reviewed credential writes".into(), - ) - })?; - return super::credential_review::write(&state, &principal.0, req).await; - } - Ok(Json( - cluster - .write_agent_credentials( - &req.namespace, - &req.kind, - target, - req.target_uid.as_deref(), - std::collections::BTreeMap::from([(key.to_string(), req.value)]), - Vec::new(), - ) - .await - .map_err(credential_write_error)?, - )) -} - -// ─── Provider onboarding (model providers for missions + envelope gen) ─────── - -#[derive(Debug, serde::Deserialize)] -pub struct ProviderRequest { - /// "github-models" | "azure-openai" | "foundry". - pub kind: String, - /// Auth mode: "api" (key), "workload" (workload identity), "agentid". - pub auth: String, - pub endpoint: Option<String>, - /// Comma-separated deployment ids to expose in the catalog. - pub models: String, - /// Optional key when auth=api; stored write-only in kars-system. - pub key: Option<String>, -} - -/// `POST /api/operator/providers` — onboard a model provider. Sets the catalog -/// the controller serves, records the endpoint, and (for api auth) stores the -/// key as a write-only secret. Workload/agentid auth store no secret — the -/// controller authenticates via its identity. The catalog feeds both mission -/// models and envelope generation. Patches the controller deployment env. -pub async fn put_provider( - State(state): State<AppState>, - Json(req): Json<ProviderRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - // GUARD: this endpoint only ever wires FOUNDRY_ENDPOINT + AZURE_OPENAI_API_KEY - // onto the controller (set_controller_catalog below). GitHub Copilot needs a - // COPILOT_GITHUB_TOKEN (exchanged for a Copilot JWT by the router's copilot_auth - // path) and GitHub Models needs its catalog endpoint recognized by the router's - // is_github_models() host check — neither is wired by this route. Silently - // "succeeding" here would tell the operator the cluster default changed when it - // did not. Reject until real backend wiring exists; both kinds work correctly - // today via the "additional provider" flow (POST .../providers/additional), - // which does propagate a real per-provider tag + credential to every sandbox. - if req.kind == "github-copilot" || req.kind == "github-models" { - return Err(AppError::BadRequest(format!( - "{} can't be set as the cluster's default provider from this form yet \ - (it only wires an Azure-style endpoint/key). Add it as an additional \ - provider instead — every sandbox can already route to it per-request \ - via an InferencePolicy model preference.", - if req.kind == "github-copilot" { - "GitHub Copilot" - } else { - "GitHub Models" - } - ))); - } - let models: Vec<&str> = req - .models - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .collect(); - if models.is_empty() { - return Err(AppError::BadRequest( - "at least one model deployment is required".into(), - )); - } - let mut key_secret: Option<(String, String)> = None; - if req.auth == "api" { - if let Some(k) = req.key.as_deref().filter(|k| !k.trim().is_empty()) { - let secret = format!("kars-provider-{}", req.kind); - cluster.upsert_secret("kars-system", &secret, serde_json::json!({ - "apiVersion": "v1", "kind": "Secret", "type": "Opaque", - "metadata": {"name": secret, "namespace": "kars-system", "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}}, - "stringData": {"API_KEY": k}, - })).await.map_err(upstream)?; - key_secret = Some((secret, "API_KEY".to_string())); - } else { - return Err(AppError::BadRequest( - "auth=api requires a provider API key".into(), - )); - } - } - let key_ref = key_secret.as_ref().map(|(s, k)| (s.as_str(), k.as_str())); - cluster - .set_controller_catalog(&models.join(","), req.endpoint.as_deref(), key_ref) - .await - .map_err(upstream)?; - Ok(Json( - serde_json::json!({"onboarded": true, "kind": req.kind, "auth": req.auth, "models": models, - "note": if key_secret.is_some() { - "Catalog updated and the API key wired into the controller via secretKeyRef (AZURE_OPENAI_API_KEY), which the controller propagates to sandbox pods. The controller is rolling to pick it up." - } else { - "Catalog updated; the controller is rolling. workload/agentid auth use the controller's own identity — no key stored." - }}), - )) -} - -/// One discoverable model, browser-facing. -#[derive(Debug, Serialize)] -pub struct DiscoveredModelDto { - /// The exact id to feed back into `ProviderRequest.models` (e.g. `openai/gpt-4o`). - pub id: String, - /// Human label, when richer than the id (e.g. "OpenAI GPT-4o"). - pub label: Option<String>, - /// True for a highlighted/pre-selected pick. For GitHub Copilot this is - /// every model in Copilot's own `powerful` picker category (the flagship - /// tier), derived LIVE from the `/models` endpoint — not a hand-picked id - /// that goes stale. Absent/false for GitHub Models / Azure OpenAI, which - /// have no "best pick" signal. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub recommended: bool, - /// Short human detail (e.g. "Anthropic · 1.0M ctx · powerful"), when the - /// provider exposes it (GitHub Copilot's live catalog does). Shown in the - /// Model catalogue so a model isn't just an opaque id. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option<String>, -} - -/// The same editor/integration headers the router's `copilot_auth` sends, so -/// the seat sees a consistent client identity across discovery and inference. -const COPILOT_EDITOR_VERSION: &str = "vscode/1.107.0"; -const COPILOT_INTEGRATION_ID: &str = "vscode-chat"; -/// Public OAuth client id for the GitHub Copilot device-flow integration — the -/// SAME id the CLI's `copilotDeviceLogin` uses (cli/src/github-copilot.ts). A -/// token minted through this flow is authorized for the `copilot_internal/v2/ -/// token` exchange, unlike a stock `gh auth login` token (which 404s there). -const COPILOT_OAUTH_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; - -/// `POST /api/operator/providers/copilot/login/start` — begin the GitHub -/// device-flow OAuth so the operator can sign in to Copilot properly (no -/// hand-pasted token). Returns the user code + verification URL to show, and -/// the device code the client polls with. -pub async fn copilot_login_start() -> AppResult<Json<serde_json::Value>> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - let resp = client - .post("https://github.com/login/device/code") - .header("Accept", "application/json") - .header("User-Agent", "kars-bridge") - .json(&serde_json::json!({ "client_id": COPILOT_OAUTH_CLIENT_ID, "scope": "read:user" })) - .send() - .await - .map_err(|e| AppError::Upstream(format!("device-code request failed: {e}")))?; - if !resp.status().is_success() { - return Err(AppError::Upstream(format!( - "GitHub device-code returned {}", - resp.status() - ))); - } - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Upstream(format!("bad device-code JSON: {e}")))?; - Ok(Json(serde_json::json!({ - "device_code": body.get("device_code").and_then(|v| v.as_str()).unwrap_or_default(), - "user_code": body.get("user_code").and_then(|v| v.as_str()).unwrap_or_default(), - "verification_uri": body.get("verification_uri").and_then(|v| v.as_str()).unwrap_or("https://github.com/login/device"), - "interval": body.get("interval").and_then(|v| v.as_u64()).unwrap_or(5), - "expires_in": body.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(900), - }))) -} - -#[derive(Debug, serde::Deserialize)] -pub struct CopilotLoginPollRequest { - pub device_code: String, -} - -/// `POST /api/operator/providers/copilot/login/poll` — poll the device flow. -/// While the user hasn't approved yet, returns `{status:"pending"}`. On -/// approval it: (1) verifies the minted token is Copilot-entitled, (2) stores -/// it server-side as the Copilot provider credential (COPILOT_GITHUB_TOKEN in -/// the shared providers secret) — the token NEVER returns to the browser, -/// (3) busts the live-catalog cache, and (4) returns the seat's live model -/// list so the wizard can show it immediately. -pub async fn copilot_login_poll( - State(state): State<AppState>, - Json(req): Json<CopilotLoginPollRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - let resp = client - .post("https://github.com/login/oauth/access_token") - .header("Accept", "application/json") - .header("User-Agent", "kars-bridge") - .json(&serde_json::json!({ - "client_id": COPILOT_OAUTH_CLIENT_ID, - "device_code": req.device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - })) - .send() - .await - .map_err(|e| AppError::Upstream(format!("device poll failed: {e}")))?; - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Upstream(format!("bad poll JSON: {e}")))?; - - if let Some(token) = body - .get("access_token") - .and_then(|v| v.as_str()) - .filter(|t| !t.is_empty()) - { - // Verify the seat is genuinely Copilot-entitled before storing. - copilot_jwt(token).await?; - // Store server-side as the Copilot provider credential (never returned - // to the browser). Also refresh the controller's default credential so - // a cluster whose default IS Copilot starts working immediately. - cluster - .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { - keys.insert("COPILOT_GITHUB_TOKEN".to_string(), token.to_string()); - }) - .await - .map_err(upstream)?; - // Fresh token → invalidate any cached catalog for the old one. - invalidate_copilot_catalog_cache(); - let models = copilot_catalog_cached(token).await; - return Ok(Json(serde_json::json!({ - "status": "authorized", - "models": models.iter().map(|(id, rec, detail)| serde_json::json!({"id": id, "recommended": rec, "detail": detail})).collect::<Vec<_>>(), - }))); - } - - match body.get("error").and_then(|v| v.as_str()) { - Some("authorization_pending") | Some("slow_down") => { - Ok(Json(serde_json::json!({ "status": "pending" }))) - } - Some("expired_token") => Err(AppError::Rejected( - "The sign-in code expired before it was approved. Start again.".into(), - )), - Some("access_denied") => Err(AppError::Rejected( - "Sign-in was cancelled on GitHub.".into(), - )), - Some(other) => Err(AppError::Upstream(format!( - "GitHub device flow error: {other}" - ))), - None => Ok(Json(serde_json::json!({ "status": "pending" }))), - } -} - -/// Exchange a GitHub OAuth token / PAT for a short-lived Copilot JWT — the -/// exact same endpoint (and `chat_enabled` eligibility semantics) the CLI's -/// `checkCopilotEligibility` and the router's `copilot_auth` use. A 200 with a -/// token and `chat_enabled != false` means the router will actually be able to -/// serve inference for this seat, not merely that the token parses. Returns -/// the JWT so the caller can immediately query the live `/models` catalog with -/// it (no second exchange). -pub(crate) async fn copilot_jwt(gh_token: &str) -> Result<String, AppError> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - let resp = client - .get("https://api.github.com/copilot_internal/v2/token") - .header("Authorization", format!("token {gh_token}")) - .header("Accept", "application/json") - .header("User-Agent", "kars-bridge") - .send() - .await - .map_err(|e| AppError::Upstream(format!("Copilot eligibility check failed: {e}")))?; - if resp.status() == reqwest::StatusCode::UNAUTHORIZED - || resp.status() == reqwest::StatusCode::FORBIDDEN - { - return Err(AppError::Rejected( - "This GitHub token isn't entitled to Copilot. Enable Copilot at https://github.com/settings/copilot, or use a token from an account with an active seat.".into(), - )); - } - if !resp.status().is_success() { - return Err(AppError::Upstream(format!( - "Copilot token endpoint returned {}", - resp.status() - ))); - } - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Upstream(format!("bad Copilot token response: {e}")))?; - if body.get("chat_enabled").and_then(|c| c.as_bool()) == Some(false) { - return Err(AppError::Rejected( - "Copilot subscription is active but Chat is disabled. Enable it at https://github.com/settings/copilot/features.".into(), - )); - } - body.get("token") - .and_then(|t| t.as_str()) - .map(str::to_string) - .ok_or_else(|| AppError::Upstream("Copilot token endpoint returned no token".into())) -} - -/// Parse GitHub Copilot's live `/models` response into the browser DTO. Pure -/// (no I/O) so it's unit-testable against a captured sample. Surfaces ONLY the -/// models a seat can actually reason with: -/// • `capabilities.type == "chat"` — excludes embeddings. -/// • `model_picker_enabled == true` — Copilot's own "show in picker" flag; -/// drops legacy/hidden aliases (gpt-4o, gpt-3.5-turbo, dated snapshots). -/// • policy absent, OR `policy.state == "enabled"` — a gated preview the -/// seat hasn't opted into is not usable, so it's hidden. -/// Ordering: Copilot's picker category (powerful → versatile → lightweight), -/// then context window desc, then id — so the flagship tier leads. Every -/// `powerful`-category model is marked `recommended` (pre-checked in the -/// wizard). This is entirely live: a new flagship (gpt-5.7, opus-4.9, …) -/// appears and is categorised by GitHub, with no code change here. -pub(crate) fn parse_copilot_models(body: &serde_json::Value) -> Vec<DiscoveredModelDto> { - fn category_rank(cat: &str) -> u8 { - match cat { - "powerful" => 0, - "versatile" => 1, - "lightweight" => 2, - _ => 3, - } - } - let mut rows: Vec<(u8, u64, String, DiscoveredModelDto)> = Vec::new(); - let Some(data) = body.get("data").and_then(|d| d.as_array()) else { - return Vec::new(); - }; - for m in data { - let caps = m.get("capabilities"); - let is_chat = caps.and_then(|c| c.get("type")).and_then(|t| t.as_str()) == Some("chat"); - let picker = m - .get("model_picker_enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - // policy absent => generally available; present => must be "enabled". - let policy_ok = match m.get("policy") { - None => true, - Some(p) => p.get("state").and_then(|s| s.as_str()) == Some("enabled"), - }; - if !(is_chat && picker && policy_ok) { - continue; - } - let Some(id) = m - .get("id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - else { - continue; - }; - let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); - let vendor = m.get("vendor").and_then(|v| v.as_str()).unwrap_or(""); - let category = m - .get("model_picker_category") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let ctx = caps - .and_then(|c| c.get("limits")) - .and_then(|l| l.get("max_context_window_tokens")) - .and_then(|v| v.as_u64()); - let ctx_label = ctx - .map(|c| { - if c >= 1_000_000 { - format!("{:.1}M ctx", c as f64 / 1_000_000.0) - } else { - format!("{}k ctx", c / 1000) - } - }) - .unwrap_or_default(); - let label = [vendor, &ctx_label, category] - .iter() - .filter(|s| !s.is_empty()) - .cloned() - .collect::<Vec<_>>() - .join(" · "); - let detail = if label.is_empty() { - None - } else { - Some(label.clone()) - }; - rows.push(( - category_rank(category), - ctx.unwrap_or(0), - id.to_string(), - DiscoveredModelDto { - id: id.to_string(), - label: (name != id || !label.is_empty()).then(|| { - if label.is_empty() { - name.to_string() - } else { - format!("{name} — {label}") - } - }), - recommended: category == "powerful", - detail, - }, - )); - } - // Sort: powerful first, then largest context, then id desc (newer version - // numbers tend to sort higher) — purely presentational. - rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(b.2.cmp(&a.2))); - rows.into_iter().map(|(_, _, _, dto)| dto).collect() -} - -/// Fetch the live Copilot model catalog for a seat, given its exchanged JWT. -pub(crate) async fn fetch_copilot_models(jwt: &str) -> Result<Vec<DiscoveredModelDto>, AppError> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - let resp = client - .get("https://api.githubcopilot.com/models") - .header("Authorization", format!("Bearer {jwt}")) - .header("Editor-Version", COPILOT_EDITOR_VERSION) - .header("Copilot-Integration-Id", COPILOT_INTEGRATION_ID) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| AppError::Upstream(format!("Copilot /models request failed: {e}")))?; - if !resp.status().is_success() { - return Err(AppError::Upstream(format!( - "Copilot /models returned {}", - resp.status() - ))); - } - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Upstream(format!("bad Copilot /models JSON: {e}")))?; - Ok(parse_copilot_models(&body)) -} - -/// A short-TTL cache for the live Copilot catalog so `build_options` (hit on -/// every Configuration page load AND every orchestrator compose) doesn't do a -/// token-exchange + /models round-trip each time. Keyed by the token so a -/// changed seat re-fetches; 5-minute freshness is plenty for a model list. -type CopilotCatalog = Vec<(String, bool, Option<String>)>; -type CachedCopilotCatalog = (String, std::time::Instant, CopilotCatalog); -static COPILOT_CATALOG_CACHE: std::sync::Mutex<Option<CachedCopilotCatalog>> = - std::sync::Mutex::new(None); - -/// Drop the cached Copilot catalog — call after a fresh sign-in so the next -/// `build_options` re-fetches against the new token immediately. -pub(crate) fn invalidate_copilot_catalog_cache() { - *COPILOT_CATALOG_CACHE - .lock() - .unwrap_or_else(|p| p.into_inner()) = None; -} - -/// Live (cached) Copilot model catalog for a seat token: `(deployment_id, -/// recommended, detail)` for every model the seat can actually use. Best-effort -/// — on any auth/network failure it returns the last good cache if still -/// present, else empty, so a transient Copilot outage never blanks the catalogue. -pub(crate) async fn copilot_catalog_cached(gh_token: &str) -> Vec<(String, bool, Option<String>)> { - const TTL: std::time::Duration = std::time::Duration::from_secs(300); - { - let guard = COPILOT_CATALOG_CACHE - .lock() - .unwrap_or_else(|p| p.into_inner()); - if let Some((tok, at, models)) = guard.as_ref() - && tok == gh_token - && at.elapsed() < TTL - { - return models.clone(); - } - } - let fetched = async { - let jwt = copilot_jwt(gh_token).await.ok()?; - let models = fetch_copilot_models(&jwt).await.ok()?; - Some( - models - .into_iter() - .map(|m| (m.id, m.recommended, m.detail)) - .collect::<Vec<_>>(), - ) - } - .await; - match fetched { - Some(models) => { - let mut guard = COPILOT_CATALOG_CACHE - .lock() - .unwrap_or_else(|p| p.into_inner()); - *guard = Some(( - gh_token.to_string(), - std::time::Instant::now(), - models.clone(), - )); - models - } - None => { - // Fetch failed — reuse a still-present cache entry (even if stale) - // rather than blanking the catalogue on a transient hiccup. - let guard = COPILOT_CATALOG_CACHE - .lock() - .unwrap_or_else(|p| p.into_inner()); - guard - .as_ref() - .filter(|(tok, _, _)| tok == gh_token) - .map(|(_, _, m)| m.clone()) - .unwrap_or_default() - } - } -} - -#[derive(Debug, serde::Deserialize)] -pub struct DiscoverModelsRequest { - /// "github-models" | "azure-openai" | "github-copilot". (Foundry already - /// discovers models via the existing /api/operator/foundry/verify.) - pub kind: String, - pub endpoint: Option<String>, - pub key: Option<String>, -} - -/// `POST /api/operator/providers/discover` — real, live model discovery so the -/// operator never hand-types a deployment id. GitHub Models queries the public -/// catalog (no auth). Azure OpenAI queries the data-plane `/openai/deployments` -/// endpoint using the operator-supplied endpoint + key (a live round-trip, so a -/// wrong key/endpoint surfaces as an immediate, actionable error). GitHub -/// Copilot exchanges the supplied token for a Copilot JWT (verifying the seat + -/// Chat entitlement live) and then queries the seat's LIVE `/models` catalog — -/// so the picker always reflects the models GitHub currently serves this seat -/// (gpt-5.6, claude-opus-4.8, gemini-3.1-pro, …), never a hand-maintained list -/// that goes stale. -pub async fn discover_models( - Json(req): Json<DiscoverModelsRequest>, -) -> AppResult<Json<Vec<DiscoveredModelDto>>> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - - match req.kind.as_str() { - "github-models" => { - let resp = client - .get("https://models.github.ai/catalog/models") - .header("Accept", "application/vnd.github+json") - .send() - .await - .map_err(|e| { - AppError::Upstream(format!("GitHub Models catalog request failed: {e}")) - })?; - if !resp.status().is_success() { - return Err(AppError::Upstream(format!( - "GitHub Models catalog returned {}", - resp.status() - ))); - } - let body: Vec<serde_json::Value> = resp - .json() - .await - .map_err(|e| AppError::Upstream(format!("bad catalog JSON: {e}")))?; - let models = body - .iter() - .filter_map(|m| { - let id = m.get("id").and_then(|v| v.as_str())?.to_string(); - let name = m.get("name").and_then(|v| v.as_str()).map(str::to_string); - Some(DiscoveredModelDto { - id, - label: name, - recommended: false, - detail: None, - }) - }) - .collect(); - Ok(Json(models)) - } - "azure-openai" => { - let endpoint = req - .endpoint - .as_deref() - .map(|e| e.trim().trim_end_matches('/')) - .filter(|e| !e.is_empty()) - .ok_or_else(|| { - AppError::BadRequest( - "endpoint is required to discover Azure OpenAI deployments".into(), - ) - })?; - let key = req - .key - .as_deref() - .filter(|k| !k.trim().is_empty()) - .ok_or_else(|| AppError::BadRequest( - "an API key is required to discover deployments (workload/agentid auth can't be exercised from the browser — enter deployment ids manually, or discover once with a temporary key)".into(), - ))?; - let url = format!("{endpoint}/openai/deployments?api-version=2023-05-15"); - let resp = client - .get(&url) - .header("api-key", key) - .send() - .await - .map_err(|e| AppError::Upstream(format!("Azure OpenAI request failed: {e}")))?; - let status = resp.status(); - let body_text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(AppError::Rejected(format!( - "Azure OpenAI rejected the discovery request ({status}) — check the endpoint and key: {body_text}" - ))); - } - let body: serde_json::Value = serde_json::from_str(&body_text) - .map_err(|e| AppError::Upstream(format!("bad deployments JSON: {e}")))?; - let models = body - .get("data") - .and_then(|d| d.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|d| { - let id = d.get("id").and_then(|v| v.as_str())?.to_string(); - let base = d - .get("model") - .and_then(|v| v.as_str()) - .map(|m| format!("deployment of {m}")); - Some(DiscoveredModelDto { - id, - label: base, - recommended: false, - detail: None, - }) - }) - .collect() - }) - .unwrap_or_default(); - Ok(Json(models)) - } - "github-copilot" => { - let token = req - .key - .as_deref() - .map(str::trim) - .filter(|k| !k.is_empty()) - .ok_or_else(|| AppError::BadRequest( - "a GitHub token (OAuth token or PAT with Copilot access) is required to verify the seat before showing the model catalog".into(), - ))?; - let jwt = copilot_jwt(token).await?; - let models = fetch_copilot_models(&jwt).await?; - Ok(Json(models)) - } - other => Err(AppError::BadRequest(format!( - "unknown provider kind '{other}' for discovery" - ))), - } -} - -// ─── Multi-provider inference (§ inference-provider-wizard) ───────────────── -// -// The single "Inference provider" flow above (`put_provider`) sets the ONE -// default provider every mission inherits. This section manages ADDITIONAL -// providers that can be configured *at the same time* — e.g. GitHub Copilot -// as the default, Azure AI Foundry also connected — so an InferencePolicy's -// `modelPreference.primary.provider` can route a specific sandbox's calls to -// whichever one actually serves the model it needs (a sub-agent on gpt-4.1 -// via Foundry, a principal on opus-4.8 via Copilot, in the SAME cluster). -// -// Storage: the `kars-inference-providers` Secret in `kars-system`. Its KEYS -// are the literal env var names `inference-router::config::Config::from_env` -// already parses generically (`KARS_PROVIDER_<TAG>_ENDPOINT` + optional -// `_API_KEY`/`_TOKEN`, or the well-known `COPILOT_GITHUB_TOKEN` for the -// GitHub Copilot special case) — no router-side change needed to support a -// provider added here. The controller mirrors this ONE secret into every -// sandbox's own namespace (the same mechanism already used for -// `kars-github-app`), and every sandbox's router picks whichever provider a -// request's InferencePolicy names — never all-or-nothing, never guessed from -// what's merely present in the env. -const INFERENCE_PROVIDERS_SECRET: &str = "kars-inference-providers"; -const INFERENCE_PROVIDERS_NS: &str = "kars-system"; - -/// One additional provider, as surfaced to the operator (never the key/token -/// itself — `has_key` only tells you whether one is stored). -#[derive(Debug, Serialize)] -pub struct AdditionalProviderDto { - pub tag: String, - pub endpoint: Option<String>, - pub has_key: bool, - /// Deployment ids the operator declared this provider serves — these - /// feed the shared model catalog (`GET /api/options`), tagged with this - /// provider, so InferencePolicy's model picker can offer them. - pub models: Vec<String>, -} - -/// `GET /api/operator/providers/additional` — list every additional provider -/// configured on this cluster (beyond the single default from `put_provider`). -pub async fn list_additional_providers( - State(state): State<AppState>, -) -> AppResult<Json<Vec<AdditionalProviderDto>>> { - let cluster = require_cluster(&state)?; - let keys = cluster - .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) - .await - .map_err(upstream)?; - let mut providers: std::collections::BTreeMap<String, AdditionalProviderDto> = - std::collections::BTreeMap::new(); - for key in keys.keys() { - if let Some(tag_part) = key - .strip_prefix("KARS_PROVIDER_") - .and_then(|r| r.strip_suffix("_ENDPOINT")) - { - let tag = tag_part.to_ascii_lowercase().replace('_', "-"); - providers - .entry(tag.clone()) - .or_insert(AdditionalProviderDto { - tag, - endpoint: None, - has_key: false, - models: Vec::new(), - }); - } - } - for (tag, dto) in providers.iter_mut() { - let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); - dto.endpoint = keys - .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) - .cloned(); - dto.has_key = keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) - || keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_TOKEN")); - dto.models = keys - .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) - .map(|m| { - m.split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - } - // GitHub Copilot is a special case (well-known endpoint, no - // KARS_PROVIDER_*_ENDPOINT needed — see resolve_provider in the router). - if keys.contains_key("COPILOT_GITHUB_TOKEN") { - providers.insert( - "github-copilot".to_string(), - AdditionalProviderDto { - tag: "github-copilot".to_string(), - endpoint: Some("https://api.githubcopilot.com".to_string()), - has_key: true, - models: keys - .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") - .map(|m| { - m.split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(), - }, - ); - } - Ok(Json(providers.into_values().collect())) -} - -#[derive(Debug, serde::Deserialize)] -pub struct AdditionalProviderRequest { - /// Lowercase, hyphenated tag (e.g. "foundry", "github-models"). The - /// reserved tag "github-copilot" only needs `api_key` (its endpoint is - /// the well-known Copilot API and is never user-editable). - pub tag: String, - pub endpoint: Option<String>, - /// Dev-mode direct key/token (e.g. a GitHub Models PAT, or a second - /// Azure OpenAI resource's key). Optional for providers that authenticate - /// via Workload Identity in production (Foundry/Azure OpenAI need no key - /// at all on AKS — see `inference-router::auth::WorkloadIdentityAuth`). - pub api_key: Option<String>, - /// Comma-separated deployment ids this provider serves — feeds the - /// shared model catalog (`GET /api/options`), tagged with this provider, - /// so InferencePolicy's model picker can offer "this model via THIS - /// provider" without any change to that editor. - pub models: Option<String>, -} - -/// `PUT /api/operator/providers/additional` — add or update one additional -/// provider. Read-modify-write against the shared Secret so configuring one -/// provider never disturbs another already stored there. -pub async fn put_additional_provider( - State(state): State<AppState>, - Json(req): Json<AdditionalProviderRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let tag = req.tag.trim().to_ascii_lowercase(); - if !is_dns1123_label(&tag) { - return Err(AppError::BadRequest( - "tag must be lowercase letters, digits, hyphens (e.g. \"foundry\", \"github-models\")" - .into(), - )); - } - let is_copilot = tag == "github-copilot"; - if !is_copilot { - let endpoint = req - .endpoint - .as_deref() - .map(str::trim) - .filter(|e| !e.is_empty()) - .ok_or_else(|| AppError::BadRequest("endpoint is required for this provider".into()))?; - if !endpoint.starts_with("https://") && !endpoint.starts_with("http://") { - return Err(AppError::BadRequest("endpoint must be a URL".into())); - } - } - let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); - let key_val = req - .api_key - .as_deref() - .map(str::trim) - .filter(|k| !k.is_empty()) - .map(str::to_string); - let models: Vec<&str> = req - .models - .as_deref() - .unwrap_or("") - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .collect(); - if models.is_empty() { - return Err(AppError::BadRequest( - "at least one model deployment id is required (comma-separated) so InferencePolicy can offer it".into(), - )); - } - let models_joined = models.join(","); - cluster - .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { - if is_copilot { - if let Some(k) = key_val.clone() { - keys.insert("COPILOT_GITHUB_TOKEN".to_string(), k); - } - keys.insert( - "KARS_PROVIDER_GITHUB_COPILOT_MODELS".to_string(), - models_joined.clone(), - ); - } else { - if let Some(endpoint) = req - .endpoint - .as_deref() - .map(str::trim) - .filter(|e| !e.is_empty()) - { - keys.insert( - format!("KARS_PROVIDER_{tag_upper}_ENDPOINT"), - endpoint.to_string(), - ); - } - if let Some(k) = key_val.clone() { - keys.insert(format!("KARS_PROVIDER_{tag_upper}_API_KEY"), k); - } - keys.insert( - format!("KARS_PROVIDER_{tag_upper}_MODELS"), - models_joined.clone(), - ); - } - }) - .await - .map_err(upstream)?; - Ok(Json(serde_json::json!({ - "configured": true, - "tag": tag, - "note": "Every sandbox's router now has this provider available. Which one a given request actually uses is decided per-sandbox by its InferencePolicy.modelPreference — this alone doesn't make it the default." - }))) -} - -/// `DELETE /api/operator/providers/additional/:tag` — remove one additional -/// provider's keys from the shared Secret (leaves other providers intact). -pub async fn delete_additional_provider( - State(state): State<AppState>, - axum::extract::Path(tag): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let tag = tag.trim().to_ascii_lowercase(); - let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); - let is_copilot = tag == "github-copilot"; - cluster - .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { - if is_copilot { - keys.remove("COPILOT_GITHUB_TOKEN"); - keys.remove("KARS_PROVIDER_GITHUB_COPILOT_MODELS"); - } else { - keys.remove(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")); - keys.remove(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")); - keys.remove(&format!("KARS_PROVIDER_{tag_upper}_TOKEN")); - keys.remove(&format!("KARS_PROVIDER_{tag_upper}_MODELS")); - } - }) - .await - .map_err(upstream)?; - Ok(Json(serde_json::json!({"removed": true, "tag": tag}))) -} - -/// `POST /api/operator/providers/additional/:tag/promote` — make an already- -/// connected additional provider the cluster's DEFAULT (patches the -/// controller's own env — every mission that leaves its model unset inherits -/// this). Reads the tag's endpoint/key/models straight from -/// `kars-inference-providers` server-side (never exposed to the browser) and -/// re-points the SAME secret+key via `secretKeyRef` — no key duplication. -/// -/// `github-copilot` is rejected: it authenticates via `COPILOT_GITHUB_TOKEN` -/// exchanged for a short-lived Copilot JWT, a completely different mechanism -/// than the endpoint+key shape every other provider here uses — the same -/// reason `put_provider` already refuses to set it as default from the other -/// form (see that handler's comment). Every other tag (Foundry, Azure OpenAI, -/// Custom, GitHub Models, and a local in-cluster model) is a plain -/// endpoint(+optional key), which is exactly what `set_controller_catalog` -/// wires — so promoting any of THOSE genuinely works. -pub async fn promote_additional_provider( - State(state): State<AppState>, - axum::extract::Path(tag): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let tag = tag.trim().to_ascii_lowercase(); - // GitHub Copilot IS promotable now — the wizard's device sign-in stores a - // Copilot-authorized token, which `set_copilot_as_default` wires onto the - // controller (KARS_PROVIDER + COPILOT_GITHUB_TOKEN), unlike the endpoint+key - // shape every other provider uses. - if tag == "github-copilot" { - let keys = cluster - .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) - .await - .map_err(upstream)?; - if !keys.contains_key("COPILOT_GITHUB_TOKEN") { - return Err(AppError::BadRequest( - "Sign in to GitHub Copilot first (Connect a provider → GitHub Copilot) — then it can be set as the cluster default.".into(), - )); - } - let models = keys - .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") - .cloned() - .unwrap_or_default(); - let models = if models.trim().is_empty() { - // No explicit selection stored — fall back to the live catalog so - // the default catalogue isn't empty. - copilot_catalog_cached(keys.get("COPILOT_GITHUB_TOKEN").unwrap()) - .await - .into_iter() - .map(|(id, _, _)| id) - .collect::<Vec<_>>() - .join(",") - } else { - models - }; - cluster - .set_copilot_as_default(&models) - .await - .map_err(upstream)?; - return Ok(Json(serde_json::json!({ - "promoted": true, - "tag": tag, - "note": "GitHub Copilot is now the cluster default; the controller is rolling to pick it up. Every mission that leaves its model unset now inherits it." - }))); - } - let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); - let keys = cluster - .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) - .await - .map_err(upstream)?; - let endpoint = keys - .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) - .cloned() - .ok_or_else(|| AppError::BadRequest(format!("no connected provider tagged {tag:?} with an endpoint (GitHub Models has a well-known endpoint but no explicit one is stored, so it can't be promoted this way either)")))?; - let models = keys - .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) - .cloned() - .unwrap_or_default(); - if models.trim().is_empty() { - return Err(AppError::BadRequest(format!( - "{tag} has no declared models to promote" - ))); - } - let key_ref = if keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) { - Some(( - INFERENCE_PROVIDERS_SECRET, - format!("KARS_PROVIDER_{tag_upper}_API_KEY"), - )) - } else { - None - }; - cluster - .set_controller_catalog( - &models, - Some(&endpoint), - key_ref.as_ref().map(|(s, k)| (*s, k.as_str())), - ) - .await - .map_err(upstream)?; - Ok(Json(serde_json::json!({ - "promoted": true, - "tag": tag, - "note": "Cluster default updated; the controller is rolling to pick it up. Every mission that leaves its model unset now inherits this provider." - }))) -} - -#[derive(Debug, serde::Deserialize)] -pub struct SetDefaultModelRequest { - pub deployment: String, - /// The provider tag that serves this model, as shown in the catalogue - /// (e.g. "github-copilot", "foundry", "local-llama-3-2-1b-instruct"). - pub provider: String, -} - -/// `POST /api/operator/models/default` — make one specific MODEL the cluster -/// default (what the Model catalogue's "Set as default" does). Promotes the -/// model's provider AND pins that model as the default (moved to the front of -/// the catalog, which `set_*_default` treats as KARS_TASK_DEFAULT_MODEL). -pub async fn set_default_model( - State(state): State<AppState>, - Json(req): Json<SetDefaultModelRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let deployment = req.deployment.trim().to_string(); - let provider = req.provider.trim().to_ascii_lowercase(); - if deployment.is_empty() { - return Err(AppError::BadRequest( - "a model deployment id is required".into(), - )); - } - let keys = cluster - .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) - .await - .map_err(upstream)?; - - // Reorder a comma list so `deployment` is first (becomes the default), - // deduped; ensures the chosen model is present even if it wasn't listed. - let reorder = |csv: &str| -> String { - let mut out = vec![deployment.clone()]; - for m in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) { - if m != deployment { - out.push(m.to_string()); - } - } - out.join(",") - }; - - if provider == "github-copilot" { - if !keys.contains_key("COPILOT_GITHUB_TOKEN") { - return Err(AppError::BadRequest( - "Sign in to GitHub Copilot first.".into(), - )); - } - let existing = keys - .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") - .cloned() - .unwrap_or_default(); - let models = if existing.trim().is_empty() { - // fall back to the live catalog so the catalog isn't just one model - let mut live = copilot_catalog_cached(keys.get("COPILOT_GITHUB_TOKEN").unwrap()) - .await - .into_iter() - .map(|(id, _, _)| id) - .collect::<Vec<_>>() - .join(","); - if live.trim().is_empty() { - live = deployment.clone(); - } - reorder(&live) - } else { - reorder(&existing) - }; - cluster - .set_copilot_as_default(&models) - .await - .map_err(upstream)?; - return Ok(Json( - serde_json::json!({"ok": true, "default": deployment, "provider": provider}), - )); - } - - // Endpoint-based providers (foundry, azure-openai, custom, local-*): promote - // via set_controller_catalog with the chosen model first. - let tag_upper = provider.to_ascii_uppercase().replace('-', "_"); - let endpoint = keys - .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) - .cloned() - .ok_or_else(|| AppError::BadRequest(format!( - "no connected provider {provider:?} with an endpoint serves {deployment:?} — connect it first" - )))?; - let existing = keys - .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) - .cloned() - .unwrap_or_default(); - let models = reorder(&existing); - let key_ref = if keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) { - Some(( - INFERENCE_PROVIDERS_SECRET, - format!("KARS_PROVIDER_{tag_upper}_API_KEY"), - )) - } else { - None - }; - cluster - .set_controller_catalog( - &models, - Some(&endpoint), - key_ref.as_ref().map(|(s, k)| (*s, k.as_str())), - ) - .await - .map_err(upstream)?; - Ok(Json( - serde_json::json!({"ok": true, "default": deployment, "provider": provider}), - )) -} -// -// The Bridge is the author of the envelope's governance objects, not just a -// reader. Authoring uses Server-Side Apply (`cluster.apply_kind`) — the -// Kubernetes-native declarative upsert — so the SAME endpoint creates a new CRD -// and edits an existing one (re-apply with changed spec). The security boundary -// is RBAC on the Bridge ServiceAccount plus the CRD's admission/CEL validation; -// a rejected write surfaces the API server's own message via `AppError::Rejected`. - -/// Apply-a-governance-CRD request. `spec` is the kind's raw `spec` object so the -/// operator can author every field; `force` opts into taking field ownership on -/// a 409 conflict (default: surface the conflict instead of clobbering). -#[derive(Debug, serde::Deserialize)] -pub struct ApplyCrdRequest { - pub name: String, - #[serde(default)] - pub namespace: Option<String>, - pub spec: serde_json::Value, - #[serde(default)] - pub force: bool, -} - -/// Map a CRD-apply kube error to a client-safe AppError: admission/validation -/// (400/422) and field-ownership conflicts (409) are surfaced verbatim (safe — -/// they are the API server's own messages), RBAC denials (403) are made -/// actionable, everything else is an opaque upstream error. -fn apply_err(e: kube::Error) -> AppError { - if let kube::Error::Api(ae) = &e { - match ae.code { - 400 | 422 => return AppError::Rejected(ae.message.clone()), - 409 => { - return AppError::Rejected(format!( - "field-ownership conflict: {} — another manager owns a field this apply sets; re-apply with force:true to take ownership", - ae.message - )); - } - 403 => { - return AppError::Rejected(format!( - "forbidden: {} — the Bridge ServiceAccount lacks RBAC to write this resource", - ae.message - )); - } - // Server-Side Apply surfaces schema-validation failures (an unknown - // or misspelled spec field) as a 500 whose message IS actionable and - // safe — e.g. "failed to create typed patch object (…): .spec.allow: - // field not declared in schema". Without this, an operator authoring - // a bad field gets an opaque "upstream dependency failed" instead of - // the field to fix. Surface it as a rejection with the real message. - 500 if ae.message.contains("field not declared in schema") - || ae.message.contains("failed to create typed patch object") - || ae.message.contains("unknown field") => - { - return AppError::Rejected(ae.message.clone()); - } - _ => {} - } - } - AppError::Upstream(e.to_string()) -} - -/// Shared apply path for the three governance kinds. Targets `kars-system` by -/// default (where the controller reads them). -async fn apply_governance( - cluster: &crate::kars::cluster::Cluster, - kind: &str, - req: ApplyCrdRequest, -) -> AppResult<Json<serde_json::Value>> { - let name = req.name.trim(); - if name.is_empty() { - return Err(AppError::BadRequest("name is required".into())); - } - if !req.spec.is_object() { - return Err(AppError::BadRequest("spec must be a JSON object".into())); - } - let ns = req - .namespace - .as_deref() - .unwrap_or("kars-system") - .to_string(); - let body = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": kind, - "metadata": { - "name": name, - "namespace": ns, - "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, - }, - "spec": req.spec, - }); - let applied = cluster - .apply_kind(&ns, kind, body, req.force) - .await - .map_err(apply_err)?; - Ok(Json(serde_json::json!({ - "applied": true, - "kind": kind, - "name": name_of(&applied), - "namespace": ns, - "note": "Server-Side Apply (field manager kars-bridge): created on first apply, edited on re-apply." - }))) -} - -/// `PUT /api/operator/toolpolicies` — author/edit a `ToolPolicy` (SSA). -pub async fn put_toolpolicy( - State(state): State<AppState>, - Json(req): Json<ApplyCrdRequest>, -) -> AppResult<Json<serde_json::Value>> { - apply_governance(require_cluster(&state)?, "ToolPolicy", req).await -} - -/// `PUT /api/operator/mcpservers` — author/edit an `McpServer` (SSA). -pub async fn put_mcpserver( - State(state): State<AppState>, - Json(req): Json<ApplyCrdRequest>, -) -> AppResult<Json<serde_json::Value>> { - apply_governance(require_cluster(&state)?, "McpServer", req).await -} - -/// `PUT /api/operator/skills` — author/edit a `KarsSkill` (SSA). -pub async fn put_skill( - State(state): State<AppState>, - Json(req): Json<ApplyCrdRequest>, -) -> AppResult<Json<serde_json::Value>> { - apply_governance(require_cluster(&state)?, "KarsSkill", req).await -} - -/// Map a delete kube error: 404 → NotFound, 403 → actionable RBAC message, -/// everything else opaque upstream. -fn delete_err(e: kube::Error) -> AppError { - if let kube::Error::Api(ae) = &e { - match ae.code { - 404 => return AppError::NotFound, - 403 => { - return AppError::Rejected(format!( - "forbidden: {} — the Bridge ServiceAccount lacks RBAC to delete this resource", - ae.message - )); - } - _ => {} - } - } - AppError::Upstream(e.to_string()) -} - -/// Shared delete path for a governance kind. Targets `kars-system` by default. -async fn delete_governance( - cluster: &crate::kars::cluster::Cluster, - kind: &str, - name: &str, - namespace: Option<&str>, -) -> AppResult<Json<serde_json::Value>> { - let name = name.trim(); - if name.is_empty() { - return Err(AppError::BadRequest("name is required".into())); - } - let ns = namespace.unwrap_or("kars-system"); - cluster - .delete_kind(ns, kind, name) - .await - .map_err(delete_err)?; - Ok(Json(serde_json::json!({ - "deleted": true, - "kind": kind, - "name": name, - "namespace": ns, - "note": "Deleted with foreground propagation — the controller's finalizers revoke downstream state before removal." - }))) -} - -/// `DELETE /api/operator/toolpolicies/:name` — remove a `ToolPolicy`. -pub async fn delete_toolpolicy( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - delete_governance(require_cluster(&state)?, "ToolPolicy", &name, None).await -} - -/// `DELETE /api/operator/mcpservers/:name` — remove an `McpServer`. -pub async fn delete_mcpserver( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - delete_governance(require_cluster(&state)?, "McpServer", &name, None).await -} - -/// `DELETE /api/operator/skills/:name` — remove a `KarsSkill`. -pub async fn delete_skill( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - delete_governance(require_cluster(&state)?, "KarsSkill", &name, None).await -} - -/// `DELETE /api/operator/egress/:name` — revoke a temporary `EgressApproval`. -/// The EgressApproval model is create-to-grant / delete-to-revoke, so deleting -/// the object is the authoritative revoke action (the controller reconciles the -/// sandbox allowlist back to its signed baseline on removal). -pub async fn delete_egress( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - delete_governance(require_cluster(&state)?, "EgressApproval", &name, None).await -} - -// ─── datapath-completeness witness (optional eBPF) ─────────────────────────── -// -// An independent, kernel-level attestation of what sandboxes ACTUALLY send on -// the network, cross-checked against the controller-declared egress allowlist. -// Produced out-of-band by the optional Inspektor Gadget witness -// (deploy/ebpf-witness/) and published to the `kars-datapath-witness` ConfigMap -// in kars-system. The Bridge only READS that ConfigMap — no eBPF/gadget -// dependency here. Absent ConfigMap => witness not enabled (honest empty), never -// an error. - -#[derive(Serialize, Deserialize, Default)] -pub struct DatapathWitnessSandbox { - pub namespace: String, - pub sandbox: String, - #[serde(default)] - pub declared_hosts: Vec<String>, - #[serde(default)] - pub observed_dns: Vec<String>, - #[serde(default)] - pub observed_connects: u64, - #[serde(default)] - pub beyond_declared: Vec<String>, - #[serde(default)] - pub unused_declared: Vec<String>, - pub verdict: String, -} - -#[derive(Serialize)] -pub struct DatapathWitnessDto { - /// True once the optional eBPF witness is installed and has published a - /// verdict. False => not enabled (the web layer shows enable instructions). - pub enabled: bool, - pub generated_at: Option<String>, - pub window_seconds: Option<u32>, - pub sandboxes: Vec<DatapathWitnessSandbox>, - /// How to turn the witness on — surfaced verbatim in the not-enabled state. - pub install_hint: String, -} - -#[derive(Deserialize)] -struct WitnessDoc { - generated_at: Option<String>, - window_seconds: Option<u32>, - #[serde(default)] - sandboxes: Vec<DatapathWitnessSandbox>, -} - -pub async fn datapath_witness( - State(state): State<AppState>, -) -> AppResult<Json<DatapathWitnessDto>> { - let cluster = require_cluster(&state)?; - let hint = "Enable the optional eBPF datapath witness on the cluster: \ - KARS_EBPF_WITNESS=1 deploy/ebpf-witness/install.sh --continuous" - .to_string(); - - let not_enabled = || DatapathWitnessDto { - enabled: false, - generated_at: None, - window_seconds: None, - sandboxes: Vec::new(), - install_hint: hint.clone(), - }; - - let Some(body) = cluster - .configmap_data("kars-datapath-witness") - .await - .and_then(|d| d.get("witness.json").cloned()) - else { - return Ok(Json(not_enabled())); - }; - - match serde_json::from_str::<WitnessDoc>(&body) { - Ok(doc) => Ok(Json(DatapathWitnessDto { - enabled: true, - generated_at: doc.generated_at, - window_seconds: doc.window_seconds, - sandboxes: doc.sandboxes, - install_hint: hint, - })), - // Malformed payload is treated as not-enabled rather than a hard error — - // the console must never 500 on optional-feature data. - Err(_) => Ok(Json(not_enabled())), - } -} - -// ─── Diagnostics: live "what's actually broken right now" scan ──────────────── -// The Troubleshooting page's real job: not a wiring/roadmap checklist, but the -// concrete problems an operator must act on — pods that won't start, containers -// crash-looping or stuck pulling an image, sandboxes the controller marked -// Degraded/Failed, and agents that came up but never went Ready. Every issue is -// read from live pod/CRD status and carries a plain remedy hint. - -#[derive(Debug, Serialize)] -pub struct DiagnosticIssue { - /// "critical" (blocks the workload) or "warning" (degraded but running). - pub severity: String, - /// Short machine-ish kind, e.g. "ImagePullBackOff", "CrashLoopBackOff", - /// "PodPending", "NotReady", "SandboxDegraded", "HighRestarts". - pub kind: String, - /// The affected object, `namespace/name`. - pub subject: String, - /// The raw reason/phase from the cluster. - pub reason: String, - /// Human detail (container message / status message) when available. - pub detail: Option<String>, - /// A concrete next step for the operator. - pub remedy: String, -} - -#[derive(Debug, Serialize)] -pub struct DiagnosticsDto { - pub issues: Vec<DiagnosticIssue>, - pub scanned_pods: usize, - pub scanned_sandboxes: usize, - /// True when the scan found nothing wrong — the honest "all clear". - pub healthy: bool, -} - -/// `GET /api/operator/diagnostics` — the live problem scan behind Troubleshooting. -pub async fn get_diagnostics(State(state): State<AppState>) -> AppResult<Json<DiagnosticsDto>> { - let cluster = require_cluster(&state)?; - let mut issues: Vec<DiagnosticIssue> = Vec::new(); - - // ── Pods: the ground truth for "won't start / not healthy". ────────────── - let pods = cluster.all_pods().await; - let scanned_pods = pods.len(); - for p in &pods { - let ns = p.metadata.namespace.as_deref().unwrap_or("").to_string(); - let name = p.metadata.name.as_deref().unwrap_or("").to_string(); - let subject = format!("{ns}/{name}"); - let status = p.status.as_ref(); - let phase = status.and_then(|s| s.phase.as_deref()).unwrap_or(""); - let age_secs = status - .and_then(|s| s.start_time.as_ref()) - .map(|t| (chrono::Utc::now() - t.0).num_seconds().max(0)) - .unwrap_or(0); - - // Container-level waiting reasons (image pull, crashloop, config error). - let mut container_flagged = false; - if let Some(cs) = status.and_then(|s| s.container_statuses.as_ref()) { - for c in cs { - if let Some(w) = c.state.as_ref().and_then(|st| st.waiting.as_ref()) { - let reason = w.reason.clone().unwrap_or_default(); - let bad = matches!( - reason.as_str(), - "ImagePullBackOff" - | "ErrImagePull" - | "CrashLoopBackOff" - | "CreateContainerConfigError" - | "CreateContainerError" - | "InvalidImageName" - | "RunContainerError" - ); - if bad { - container_flagged = true; - let remedy = match reason.as_str() { - "ImagePullBackOff" | "ErrImagePull" | "InvalidImageName" => { - "Image can't be pulled — check the image tag exists in the registry and the node has pull access." - } - "CrashLoopBackOff" | "RunContainerError" => { - "Container keeps exiting — check its logs (kubectl logs) for the crash cause." - } - _ => { - "Container config is invalid — check the ConfigMap/Secret mounts and env for this container." - } - }; - issues.push(DiagnosticIssue { - severity: "critical".into(), - kind: reason.clone(), - subject: format!("{subject} · {}", c.name), - reason, - detail: w.message.clone(), - remedy: remedy.into(), - }); - } - } - // A container restarting many times is a warning even if currently up. - if c.restart_count >= 5 { - issues.push(DiagnosticIssue { - severity: "warning".into(), - kind: "HighRestarts".into(), - subject: format!("{subject} · {}", c.name), - reason: format!("{} restarts", c.restart_count), - detail: None, - remedy: - "Container is unstable — inspect its logs for the recurring failure." - .into(), - }); - } - } - } - - // Pod stuck Pending (unschedulable / image / volume) for > 60s. - if phase == "Pending" && age_secs > 60 && !container_flagged { - let msg = status - .and_then(|s| s.conditions.as_ref()) - .and_then(|c| c.iter().find(|cond| cond.status == "False")) - .and_then(|c| c.message.clone()); - issues.push(DiagnosticIssue { - severity: "critical".into(), - kind: "PodPending".into(), - subject: subject.clone(), - reason: "Pending".into(), - detail: msg, - remedy: "Pod can't be scheduled — check node capacity, taints, or unbound volumes (kubectl describe pod)." - .into(), - }); - } - - // Running but not all containers Ready for > 120s (probes failing). - if phase == "Running" - && age_secs > 120 - && !container_flagged - && let Some(cs) = status.and_then(|s| s.container_statuses.as_ref()) - { - let total = cs.len(); - let ready = cs.iter().filter(|s| s.ready).count(); - if total > 0 && ready < total { - issues.push(DiagnosticIssue { - severity: "warning".into(), - kind: "NotReady".into(), - subject: subject.clone(), - reason: format!("{ready}/{total} containers ready"), - detail: None, - remedy: "A container is up but failing its readiness probe — check the probe and the container's logs." - .into(), - }); - } - } - } - - // ── Sandboxes the controller itself flagged Degraded/Failed. ───────────── - let sandboxes = cluster - .list_kind_all("KarsSandbox") - .await - .unwrap_or_default(); - let scanned_sandboxes = sandboxes.len(); - for sb in &sandboxes { - let phase = sb - .data - .get("status") - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .unwrap_or(""); - if matches!(phase, "Degraded" | "Failed") { - let name = sb.metadata.name.as_deref().unwrap_or("").to_string(); - let msg = sb - .data - .get("status") - .and_then(|s| s.get("message")) - .and_then(|m| m.as_str()) - .map(String::from); - issues.push(DiagnosticIssue { - severity: if phase == "Failed" { "critical" } else { "warning" }.into(), - kind: "SandboxDegraded".into(), - subject: format!("kars-system/{name}"), - reason: phase.to_string(), - detail: msg, - remedy: "The controller couldn't fully reconcile this sandbox — check the controller logs and the sandbox's referenced policies/secrets." - .into(), - }); - } - // Run-level stall detection (audit f24): the pod-level scan is blind to a - // run whose sandbox is "Running" but whose run has FAILED/timed out. A - // mission-output recorded with status=error is a definitive run failure - // the operator must see even though the pod looks healthy. - if phase == "Running" { - let name = sb.metadata.name.as_deref().unwrap_or("").to_string(); - if let Some(out) = cluster.read_mission_output(&name).await - && out.get("status").map(|s| s.as_str()) == Some("error") - { - let detail = out - .get("output") - .cloned() - .filter(|s| !s.is_empty()) - .or_else(|| out.get("error").cloned()); - issues.push(DiagnosticIssue { - severity: "warning".into(), - kind: "RunFailed".into(), - subject: format!("kars-system/{name}"), - reason: "run reported an error while the sandbox is still Running".into(), - detail, - remedy: "The agent's run did not complete (often a slow/absent agent or a chat-gateway harness that never executed the loop). Check the mission's Run tab, or re-run with the OpenClaw harness for autonomous missions." - .into(), - }); - } - } - } - - // Critical first, then warnings; stable within a severity. - issues.sort_by(|a, b| { - let rank = |s: &str| if s == "critical" { 0 } else { 1 }; - rank(&a.severity).cmp(&rank(&b.severity)) - }); - - let healthy = issues.is_empty(); - Ok(Json(DiagnosticsDto { - issues, - scanned_pods, - scanned_sandboxes, - healthy, - })) -} - -// ─── Orchestrator health + the compose failover path ───────────────────────── -// The Bridge composer ("intent → package") runs its own inference. It prefers a -// DIRECT endpoint (BRIDGE_ORCHESTRATOR_* — scales for many teams) and otherwise -// routes through the standing `bridge-orchestrator` sandbox's router. This -// surfaces which path is live, the orchestrator sandbox's health, and — when the -// sandbox path is under strain — recommends configuring the direct endpoint -// (the "switch to inference-based orchestration under load" lever). - -#[derive(Debug, Serialize)] -pub struct OrchestratorDto { - /// Active compose inference path: "direct" (endpoint configured) or - /// "sandbox" (routing through the orchestrator sandbox router), or "none". - pub mode: String, - /// Whether a direct BRIDGE_ORCHESTRATOR endpoint triple is configured. - pub direct_configured: bool, - /// Whether the standing orchestrator sandbox exists. - pub sandbox_present: bool, - /// The orchestrator sandbox phase (Running/Degraded/…), when present. - pub sandbox_phase: Option<String>, - /// Ready/total containers of the orchestrator pod, restarts, waiting reason. - pub sandbox_ready: Option<String>, - pub sandbox_restarts: Option<i32>, - pub sandbox_waiting_reason: Option<String>, - /// How many Running sandbox routers the composer can fall back through. - pub router_candidates: usize, - /// True when the operator should configure the direct endpoint (sandbox path - /// is the only option and it's unhealthy or capacity is thin). - pub recommend_direct: bool, - /// Plain-language recommendation. - pub note: String, -} - -/// `GET /api/operator/orchestrator` — orchestrator health + compose failover path. -pub async fn get_orchestrator(State(state): State<AppState>) -> AppResult<Json<OrchestratorDto>> { - let cluster = require_cluster(&state)?; - - let direct_configured = [ - "BRIDGE_ORCHESTRATOR_ENDPOINT", - "BRIDGE_ORCHESTRATOR_TOKEN", - "BRIDGE_ORCHESTRATOR_MODEL", - ] - .iter() - .all(|k| { - std::env::var(k) - .map(|v| !v.trim().is_empty()) - .unwrap_or(false) - }); - - // Orchestrator sandbox presence + health. - let sandboxes = cluster - .list_kind_all("KarsSandbox") - .await - .unwrap_or_default(); - let orch = sandboxes.iter().find(|sb| { - sb.metadata - .labels - .as_ref() - .and_then(|l| l.get("kars.azure.com/orchestrator")) - .map(String::as_str) - == Some("true") - }); - let sandbox_present = orch.is_some(); - let sandbox_phase = orch.and_then(|o| { - o.data - .get("status") - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .map(String::from) - }); - let health = if let Some(o) = orch { - let name = o.metadata.name.clone().unwrap_or_default(); - cluster.sandbox_pod_health(&name).await - } else { - None - }; - let (sandbox_ready, sandbox_restarts, sandbox_waiting_reason) = match &health { - Some(h) => ( - Some(format!("{}/{}", h.ready_containers, h.total_containers)), - Some(h.restarts), - h.waiting_reason.clone(), - ), - None => (None, None, None), - }; - - let router_candidates = cluster.running_sandbox_candidates().await.len(); - - let sandbox_healthy = sandbox_phase.as_deref() == Some("Running") - && health - .as_ref() - .map(|h| h.ready_containers == h.total_containers && h.total_containers > 0) - .unwrap_or(false); - - let mode = if direct_configured { - "direct" - } else if sandbox_present && router_candidates > 0 { - "sandbox" - } else { - "none" - } - .to_string(); - - // Recommend the direct endpoint when we're on the sandbox path and it's the - // only option while being unhealthy or thin on router capacity. - let recommend_direct = !direct_configured && !sandbox_healthy; - - let note = if direct_configured { - "Composing via the direct inference endpoint — scales independently of any sandbox." - .to_string() - } else if !sandbox_present { - "No orchestrator sandbox and no direct endpoint — the composer can't run. Set BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} or let the Bridge provision the orchestrator sandbox.".to_string() - } else if recommend_direct { - "The orchestrator sandbox is present but not healthy enough to compose reliably. Repair it or configure BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} for a direct inference path.".to_string() - } else if router_candidates <= 1 { - "Composing through the healthy orchestrator sandbox router. One router is sufficient for serial composition; configure BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} only when you need independent capacity for many concurrent compose requests.".to_string() - } else { - "Composing via the orchestrator sandbox router — healthy. For many concurrent teams, a direct BRIDGE_ORCHESTRATOR endpoint scales better.".to_string() - }; - - Ok(Json(OrchestratorDto { - mode, - direct_configured, - sandbox_present, - sandbox_phase, - sandbox_ready, - sandbox_restarts, - sandbox_waiting_reason, - router_candidates, - recommend_direct, - note, - })) -} - -// ─── Integrations: kars-SRE agent + Headlamp plugin ────────────────────────── -// kars ships a real Headlamp plugin (tools/headlamp-plugin — /kars/sre and -// /kars/* views) and a real SRE agent (deploy/helm/kars/templates/sre.yaml, -// gated on sre.enabled; `kars sre install`). This surfaces whether each is -// active, deep-links into the existing plugin views, and gives the exact -// activation for what isn't enabled — rather than pretending to integrate. - -#[derive(Debug, Serialize)] -pub struct IntegrationsDto { - /// kars-SRE agent. - pub sre_present: bool, - pub sre_phase: Option<String>, - pub sre_ready: Option<String>, - /// The `kars sre install` activation command when SRE isn't enabled. - pub sre_activate_cmd: String, - /// Headlamp dashboard + kars plugin. - pub headlamp_deployed: bool, - pub headlamp_url: Option<String>, - /// Deep-link paths into the kars Headlamp plugin (appended to headlamp_url). - pub headlamp_paths: Vec<HeadlampLink>, - /// How to install the plugin when Headlamp is present but the URL is unset. - pub headlamp_install_hint: String, -} - -#[derive(Debug, Serialize)] -pub struct HeadlampLink { - pub label: String, - pub path: String, -} - -/// `GET /api/operator/integrations` — kars-SRE + Headlamp status & deep-links. -pub async fn get_integrations(State(state): State<AppState>) -> AppResult<Json<IntegrationsDto>> { - let cluster = require_cluster(&state)?; - - // SRE agent: the `sre` KarsSandbox (deploy/helm/kars/templates/sre.yaml). - let sandboxes = cluster - .list_kind_all("KarsSandbox") - .await - .unwrap_or_default(); - let sre = sandboxes - .iter() - .find(|sb| sb.metadata.name.as_deref() == Some("sre")); - let sre_present = sre.is_some(); - let sre_phase = sre.and_then(|o| { - o.data - .get("status") - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .map(String::from) - }); - let sre_ready = if sre_present { - cluster - .sandbox_pod_health("sre") - .await - .map(|h| format!("{}/{}", h.ready_containers, h.total_containers)) - } else { - None - }; - - // Headlamp: the `headlamp` Deployment in the `headlamp` namespace. - let headlamp_deployed = cluster.deployment_exists("headlamp", "headlamp").await; - - Ok(Json(IntegrationsDto { - sre_present, - sre_phase, - sre_ready, - sre_activate_cmd: "kars sre install # helm upgrade --reuse-values --set sre.enabled=true".into(), - headlamp_deployed, - headlamp_url: std::env::var("BRIDGE_HEADLAMP_URL").ok().filter(|u| !u.trim().is_empty()), - headlamp_paths: vec![ - HeadlampLink { label: "SRE console".into(), path: "/kars/sre".into() }, - HeadlampLink { label: "Sandboxes".into(), path: "/kars/karssandboxes".into() }, - HeadlampLink { label: "Agent mesh".into(), path: "/kars/mesh".into() }, - ], - headlamp_install_hint: "Build tools/headlamp-plugin (npm run build), kubectl cp dist into the headlamp pod at /headlamp/plugins/kars, then set BRIDGE_HEADLAMP_URL.".into(), - })) -} - -// ─── Local (in-cluster) inference — AI Runway ModelDeployment ──────────────── -// See docs/local-inference.md (kars core). kars does NOT install or manage -// AI Runway/KAITO — an operator installs both once via their own real -// helm/kubectl commands, exactly like the GitHub App or Azure AI Foundry -// connection. This surface only detects presence and manages `ModelDeployment` -// objects on top, in the Bridge's own `kars-local-inference` namespace. - -#[derive(Debug, Serialize)] -pub struct LocalInferenceStatusDto { - /// Whether AI Runway's `modeldeployments.airunway.ai` CRD is present — - /// i.e. whether an operator has installed it (see docs/local-inference.md). - pub available: bool, - /// Real, live-scanned count of nodes advertising `nvidia.com/gpu` - /// capacity — never a hardcoded guess. Zero means only CPU-tier models - /// can be offered. - pub gpu_node_count: u32, - /// Distinct GPU product names found via the NFD/GPU-feature-discovery - /// `nvidia.com/gpu.product` node label, when present. - pub gpu_products: Vec<String>, -} - -/// `GET /api/operator/local-inference/status` — detect whether the cluster -/// can host an in-cluster model, and whether it has GPU capacity for the -/// larger tier. Never installs anything. -pub async fn local_inference_status( - State(state): State<AppState>, -) -> AppResult<Json<LocalInferenceStatusDto>> { - let cluster = require_cluster(&state)?; - let available = cluster.local_inference_available().await; - let gpu = cluster.gpu_node_summary().await.unwrap_or_default(); - Ok(Json(LocalInferenceStatusDto { - available, - gpu_node_count: gpu.gpu_node_count, - gpu_products: gpu.gpu_products, - })) -} - -/// One curated, vetted model the wizard can offer without the operator -/// hand-typing a HuggingFace id or an AIKit image reference. Real values -/// verified live against AI Runway v0.7.0 + KAITO workspace chart 0.11.0 — -/// see docs/local-inference.md. -#[derive(Debug, Serialize, Clone)] -pub struct CuratedLocalModelDto { - pub id: String, - pub label: String, - pub tier: String, // "cpu" | "gpu" - pub params: String, -} - -/// `GET /api/operator/local-inference/catalog` — the curated list + tier -/// availability (a GPU entry is still LISTED when no GPU node exists, so the -/// wizard can show it disabled with a clear reason, rather than silently -/// hiding an option and confusing an operator who just hasn't added GPU -/// nodes yet). -pub async fn local_inference_catalog() -> Json<Vec<CuratedLocalModelDto>> { - Json(vec![ - CuratedLocalModelDto { - id: "llama-3.2-1b-instruct".into(), - label: "Llama 3.2 (1B, CPU)".into(), - tier: "cpu".into(), - params: "1B".into(), - }, - CuratedLocalModelDto { - id: "llama-3.2-3b-instruct".into(), - label: "Llama 3.2 (3B, CPU)".into(), - tier: "cpu".into(), - params: "3B".into(), - }, - CuratedLocalModelDto { - id: "gemma-2-2b-instruct".into(), - label: "Gemma 2 (2B, CPU)".into(), - tier: "cpu".into(), - params: "2B".into(), - }, - CuratedLocalModelDto { - id: "microsoft/Phi-4-mini-instruct".into(), - label: "Phi-4-mini (GPU)".into(), - tier: "gpu".into(), - params: "3.8B".into(), - }, - CuratedLocalModelDto { - id: "meta-llama/Llama-3.1-8B-Instruct".into(), - label: "Llama 3.1 (8B, GPU)".into(), - tier: "gpu".into(), - params: "8B".into(), - }, - CuratedLocalModelDto { - id: "mistralai/Mistral-7B-Instruct-v0.3".into(), - label: "Mistral (7B, GPU)".into(), - tier: "gpu".into(), - params: "7B".into(), - }, - ]) -} - -/// The AIKit CPU image for each curated CPU-tier model id — the `llamacpp` -/// engine needs an explicit pre-built image (there is no live HF→GGUF -/// resolution path), so this is the one place that mapping has to be -/// hardcoded. Free-text/advanced deployments must supply their own image. -fn aikit_image_for(model_id: &str) -> Option<&'static str> { - match model_id { - "llama-3.2-1b-instruct" => Some("ghcr.io/kaito-project/aikit/llama3.2:1b"), - "llama-3.2-3b-instruct" => Some("ghcr.io/kaito-project/aikit/llama3.2:3b"), - "gemma-2-2b-instruct" => Some("ghcr.io/kaito-project/aikit/gemma2:2b"), - _ => None, - } -} - -#[derive(Debug, Serialize)] -pub struct LocalModelDeploymentDto { - pub name: String, - pub namespace: String, - pub managed: bool, - pub model_id: Option<String>, - pub engine: Option<String>, - pub provider: Option<String>, - pub phase: Option<String>, - pub message: Option<String>, - pub endpoint: Option<String>, - pub created_at: Option<String>, -} - -fn project_model_deployment(o: &DynamicObject) -> LocalModelDeploymentDto { - let name = name_of(o); - let namespace = ns_of(o); - let managed = namespace == crate::kars::cluster::LOCAL_INFERENCE_NAMESPACE - && label(o, "app.kubernetes.io/managed-by").as_deref() == Some("kars-bridge"); - let spec = o.data.get("spec"); - let status = o.data.get("status"); - let model_id = spec - .and_then(|s| s.get("model")) - .and_then(|m| m.get("id")) - .and_then(Value::as_str) - .map(String::from); - let engine = status - .and_then(|s| s.get("engine")) - .and_then(|e| e.get("type")) - .and_then(Value::as_str) - .map(String::from); - let provider = status - .and_then(|s| s.get("provider")) - .and_then(|p| p.get("name")) - .and_then(Value::as_str) - .map(String::from); - let phase = status - .and_then(|s| s.get("phase")) - .and_then(Value::as_str) - .map(String::from); - let message = status - .and_then(|s| s.get("message")) - .and_then(Value::as_str) - .map(String::from); - // AI Runway publishes the routable Service in status when available. Fall - // back to the ModelDeployment name and port 80 for older controller builds. - let endpoint = if phase.as_deref() == Some("Running") { - let service = status - .and_then(|s| s.get("endpoint")) - .and_then(|e| e.get("service")) - .and_then(Value::as_str) - .unwrap_or(&name); - let port = status - .and_then(|s| s.get("endpoint")) - .and_then(|e| e.get("port")) - .and_then(Value::as_u64) - .unwrap_or(80); - Some(format!( - "http://{service}.{namespace}.svc.cluster.local:{port}" - )) - } else { - None - }; - LocalModelDeploymentDto { - name, - namespace, - managed, - model_id, - engine, - provider, - phase, - message, - endpoint, - created_at: created_of(o), - } -} - -/// `GET /api/operator/local-inference/deployments` — every ModelDeployment -/// the Bridge manages, with live status. -/// `GET /api/operator/local-inference/deployments` — every ModelDeployment -/// the Bridge manages, with live status. As a side effect, auto-registers -/// any newly-`Running` deployment as a normal additional inference provider -/// (tag `local-<name>`) — reusing the exact multi-provider mechanism proven -/// this session, so no router changes are needed: every sandbox's router -/// already knows how to dial an arbitrary custom OpenAI-compatible endpoint -/// once it's in `kars-inference-providers`. Idempotent (a re-list of an -/// already-wired deployment is a no-op re-write of the same values). -pub async fn list_local_model_deployments( - State(state): State<AppState>, -) -> AppResult<Json<Vec<LocalModelDeploymentDto>>> { - let cluster = require_cluster(&state)?; - let items = cluster.list_model_deployments().await.map_err(upstream)?; - let dtos: Vec<LocalModelDeploymentDto> = items.iter().map(project_model_deployment).collect(); - for d in &dtos { - if d.managed - && d.phase.as_deref() == Some("Running") - && let (Some(endpoint), Some(model_id)) = (&d.endpoint, &d.model_id) - { - auto_wire_local_provider(cluster, &d.name, endpoint, model_id).await; - } - } - Ok(Json(dtos)) -} - -/// Register a Running local ModelDeployment's Service as an additional -/// inference provider tagged `local-<name>`, no API key (in-cluster, -/// unauthenticated). Best-effort: a write failure here degrades to "the -/// model runs but isn't yet selectable from an InferencePolicy" rather than -/// failing the status poll the wizard depends on. -async fn auto_wire_local_provider( - cluster: &crate::kars::cluster::Cluster, - name: &str, - endpoint: &str, - model_id: &str, -) { - let tag_upper = format!("LOCAL_{}", name.to_ascii_uppercase().replace('-', "_")); - let endpoint = endpoint.to_string(); - let model_id = model_id.to_string(); - if let Err(e) = cluster - .mutate_secret_keys( - INFERENCE_PROVIDERS_NS, - INFERENCE_PROVIDERS_SECRET, - move |keys| { - keys.insert( - format!("KARS_PROVIDER_{tag_upper}_ENDPOINT"), - endpoint.clone(), - ); - keys.insert( - format!("KARS_PROVIDER_{tag_upper}_MODELS"), - model_id.clone(), - ); - }, - ) - .await - { - tracing::warn!(deployment = name, error = %e, "failed to auto-wire local model as an inference provider"); - } -} - -#[derive(Debug, Deserialize)] -pub struct CreateLocalModelDeploymentRequest { - /// DNS-label name for this deployment (becomes the Service name kars - /// wires into the inference-providers secret). - pub name: String, - /// A curated id (see `local_inference_catalog`) or, for the advanced - /// free-text path, any HuggingFace model id. - pub model_id: String, - /// "cpu" or "gpu" — selects the engine/provider shape. Advanced/free-text - /// requests must pick "cpu" (with an explicit `image`) or "gpu". - pub tier: String, - /// Required for tier=cpu when `model_id` isn't one of the curated ids - /// (the llamacpp engine needs a pre-built AIKit/GGUF image — there's no - /// live HF→GGUF resolution path). - #[serde(default)] - pub image: Option<String>, - /// GPU count for tier=gpu. Default 1. - #[serde(default)] - pub gpu_count: Option<i64>, -} - -/// `POST /api/operator/local-inference/deployments` — create (or update, via -/// SSA) a `ModelDeployment`. Rejects tier=cpu requests with no resolvable -/// image rather than creating a ModelDeployment doomed to fail validation -/// with an opaque upstream error. -pub async fn create_local_model_deployment( - State(state): State<AppState>, - Json(req): Json<CreateLocalModelDeploymentRequest>, -) -> AppResult<Json<LocalModelDeploymentDto>> { - let cluster = require_cluster(&state)?; - if !is_dns1123_label(&req.name) { - return Err(AppError::BadRequest( - "name must be lowercase letters, digits, hyphens".into(), - )); - } - let spec = match req.tier.as_str() { - "cpu" => { - let image = req.image.as_deref().filter(|i| !i.trim().is_empty()) - .or_else(|| aikit_image_for(&req.model_id)) - .ok_or_else(|| AppError::BadRequest( - "a CPU deployment needs a pre-built AIKit image — pick a curated model or supply spec.image for an advanced/free-text one".into(), - ))?; - serde_json::json!({ - "model": {"id": req.model_id}, - "engine": {"type": "llamacpp"}, - "image": image, - }) - } - "gpu" => { - serde_json::json!({ - "model": {"id": req.model_id}, - "resources": {"gpu": {"count": req.gpu_count.unwrap_or(1), "type": "nvidia.com/gpu"}}, - }) - } - other => { - return Err(AppError::BadRequest(format!( - "tier must be \"cpu\" or \"gpu\", got {other:?}" - ))); - } - }; - let obj = cluster - .apply_model_deployment(&req.name, spec) - .await - .map_err(upstream)?; - Ok(Json(project_model_deployment(&obj))) -} - -/// `GET /api/operator/local-inference/deployments/:name/status` — rich LIVE -/// status for the deploy progress tracker: a milestone-derived percentage, -/// real pod/container state, and the actual Kubernetes event stream (image -/// pull, scheduling, container start/fail) for this deployment's pods. -pub async fn local_deployment_live_status( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<crate::kars::cluster::LocalDeployLiveStatus>> { - let cluster = require_cluster(&state)?; - let status = cluster - .local_deployment_live_status(&name) - .await - .map_err(upstream)?; - Ok(Json(status)) -} - -/// `DELETE /api/operator/local-inference/deployments/:name` — undeploy a -/// local model. The Bridge also removes it from the connected-providers list -/// if it had been auto-wired (see `auto_wire_local_provider` in routes/run.rs -/// or the corresponding poll path) — callers should not assume the -/// InferencePolicy-facing tag disappears atomically with the CR. -pub async fn delete_local_model_deployment( - State(state): State<AppState>, - axum::extract::Path(name): axum::extract::Path<String>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - cluster - .delete_model_deployment(&name) - .await - .map_err(upstream)?; - // Best-effort: also drop it from the additional-providers secret if it - // was auto-wired. Not fatal if it wasn't (e.g. deleted before Ready). - let tag = format!("local-{name}"); - let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); - let _ = cluster - .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { - keys.remove(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")); - keys.remove(&format!("KARS_PROVIDER_{tag_upper}_MODELS")); - }) - .await; - Ok(Json(serde_json::json!({"deleted": true, "name": name}))) -} - -#[cfg(test)] -mod tests { - use super::{ - SandboxDto, apply_err, inherit_sandbox_context, is_dns1123_label, is_env_key, - parse_copilot_models, project_model_deployment, receipt_verdict, - }; - use crate::error::AppError; - use kube::core::DynamicObject; - use serde_json::json; - - #[test] - fn projects_discovered_airunway_model_in_its_actual_namespace() { - let object: DynamicObject = serde_json::from_value(json!({ - "apiVersion": "airunway.ai/v1alpha1", - "kind": "ModelDeployment", - "metadata": { - "name": "gpt-oss-120b", - "namespace": "default" - }, - "spec": { - "model": {"id": "openai/gpt-oss-120b"} - }, - "status": { - "phase": "Running", - "endpoint": {"service": "gpt-oss-120b", "port": 80}, - "engine": {"type": "vllm"}, - "provider": {"name": "kaito"} - } - })) - .expect("valid dynamic object"); - - let projected = project_model_deployment(&object); - - assert_eq!(projected.namespace, "default"); - assert!(!projected.managed); - assert_eq!( - projected.endpoint.as_deref(), - Some("http://gpt-oss-120b.default.svc.cluster.local:80") - ); - assert_eq!(projected.model_id.as_deref(), Some("openai/gpt-oss-120b")); - } - - fn sandbox( - name: &str, - parent: Option<&str>, - team: Option<&str>, - executing: Option<bool>, - ) -> SandboxDto { - SandboxDto { - name: name.to_string(), - namespace: "kars-system".to_string(), - runtime_namespace: None, - phase: Some("Running".to_string()), - runtime: None, - isolation: None, - tool_policy: None, - inference_policy: None, - governed: true, - team: team.map(str::to_string), - parent: parent.map(str::to_string), - message: None, - created: None, - working: Some(false), - executing, - cpu_millicores: None, - memory_bytes: None, - conditions: Vec::new(), - } - } - - #[test] - fn nested_subagents_inherit_root_team_and_execution() { - let mut sandboxes = vec![ - sandbox("lead", None, Some("maintenance"), Some(true)), - sandbox("specialist", Some("lead"), None, None), - sandbox("worker", Some("specialist"), None, None), - ]; - sandboxes[1].working = Some(true); - sandboxes[2].working = Some(true); - - inherit_sandbox_context(&mut sandboxes); - - assert_eq!(sandboxes[0].team.as_deref(), Some("maintenance")); - assert_eq!(sandboxes[0].executing, Some(true)); - assert_eq!(sandboxes[0].working, Some(false)); - for sandbox in &sandboxes[1..] { - assert_eq!(sandbox.team.as_deref(), Some("maintenance")); - assert_eq!(sandbox.executing, Some(true)); - assert_eq!(sandbox.working, Some(true)); - } - } - - #[test] - fn sandbox_context_does_not_cross_namespaces() { - let mut first_lead = sandbox("lead", None, Some("team-a"), Some(true)); - first_lead.namespace = "namespace-a".into(); - let mut first_child = sandbox("worker", Some("lead"), None, None); - first_child.namespace = "namespace-a".into(); - let mut second_lead = sandbox("lead", None, Some("team-b"), Some(false)); - second_lead.namespace = "namespace-b".into(); - let mut second_child = sandbox("worker", Some("lead"), None, None); - second_child.namespace = "namespace-b".into(); - let mut sandboxes = vec![first_lead, first_child, second_lead, second_child]; - - inherit_sandbox_context(&mut sandboxes); - - assert_eq!(sandboxes[1].team.as_deref(), Some("team-a")); - assert_eq!(sandboxes[1].executing, Some(false)); - assert_eq!(sandboxes[3].team.as_deref(), Some("team-b")); - assert_eq!(sandboxes[3].executing, Some(false)); - } - - #[test] - fn parse_copilot_models_filters_and_categorises() { - // A trimmed but faithful sample of the real /models response shape - // (captured live 2026-07): a flagship chat model, a versatile one, an - // embeddings model (must be dropped), a legacy non-picker chat model - // (must be dropped), and a gated preview the seat hasn't enabled - // (must be dropped). - let body = json!({"data": [ - { - "id": "claude-opus-4.8", "name": "Claude Opus 4.8", "vendor": "Anthropic", - "model_picker_enabled": true, "model_picker_category": "powerful", - "policy": {"state": "enabled"}, - "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 1_000_000}} - }, - { - "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "vendor": "OpenAI", - "model_picker_enabled": true, "model_picker_category": "versatile", - "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 1_050_000}} - }, - { - "id": "text-embedding-3-small", "name": "Embedding V3 small", "vendor": "Azure OpenAI", - "model_picker_enabled": false, "capabilities": {"type": "embeddings"} - }, - { - "id": "gpt-4o", "name": "GPT-4o", "vendor": "Azure OpenAI", - "model_picker_enabled": false, - "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 128_000}} - }, - { - "id": "some-preview", "name": "Gated Preview", "vendor": "OpenAI", - "model_picker_enabled": true, "model_picker_category": "powerful", - "policy": {"state": "unconfigured"}, - "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 200_000}} - } - ]}); - let out = parse_copilot_models(&body); - let ids: Vec<&str> = out.iter().map(|m| m.id.as_str()).collect(); - // Only the two enabled, picker-enabled chat models survive — embeddings, - // the legacy non-picker gpt-4o, and the un-enabled preview are dropped. - assert_eq!(ids, vec!["claude-opus-4.8", "gpt-5.6-terra"]); - // powerful sorts before versatile. - assert!(out[0].recommended, "powerful model must be recommended"); - assert!( - !out[1].recommended, - "versatile model must not be recommended" - ); - // Label carries the human name + context. - assert!(out[0].label.as_deref().unwrap().contains("Claude Opus 4.8")); - assert!(out[0].label.as_deref().unwrap().contains("1.0M ctx")); - } - - #[test] - fn parse_copilot_models_empty_on_missing_data() { - assert!(parse_copilot_models(&json!({})).is_empty()); - assert!(parse_copilot_models(&json!({"data": []})).is_empty()); - } - - fn claim(class: &str, status: &str) -> (String, String) { - (class.to_string(), status.to_string()) - } - - #[test] - fn receipt_verdict_regulatory_and_omitted_are_advisory() { - // The real V0 shape: crypto claims PASS, regulatory OMITTED. Must be - // "verified" (regression: it used to read "partial" for every receipt). - let v0 = vec![ - claim("integrity", "PASS"), - claim("conformance", "PASS"), - claim("completeness", "PASS"), - claim("regulatory", "OMITTED"), - ]; - assert_eq!(receipt_verdict(&v0), "verified"); - - // Regulatory PARTIAL is likewise advisory. - let v0b = vec![ - claim("integrity", "PASS"), - claim("conformance", "PASS"), - claim("completeness", "PASS"), - claim("regulatory", "PARTIAL"), - ]; - assert_eq!(receipt_verdict(&v0b), "verified"); - - // A genuinely partial CORE claim (completeness) is still "partial". - let partial = vec![ - claim("integrity", "PASS"), - claim("conformance", "PASS"), - claim("completeness", "PARTIAL"), - claim("regulatory", "OMITTED"), - ]; - assert_eq!(receipt_verdict(&partial), "partial"); - - // Any FAIL/ERROR anywhere is "failed". - let failed = vec![claim("integrity", "FAIL"), claim("conformance", "PASS")]; - assert_eq!(receipt_verdict(&failed), "failed"); - - // No claims ⇒ "none". - assert_eq!(receipt_verdict(&[]), "none"); - } - - fn api_err(code: u16, message: &str) -> kube::Error { - kube::Error::Api(kube::core::ErrorResponse { - status: "Failure".into(), - message: message.into(), - reason: "".into(), - code, - }) - } - - #[test] - fn apply_err_surfaces_ssa_schema_failure() { - // A Server-Side Apply schema rejection arrives as a 500 with an - // actionable message — it must become a Rejected (422) carrying that - // message, NOT an opaque Upstream (502). - let e = api_err( - 500, - "failed to create typed patch object (kars-system/qa; kars.azure.com/v1alpha1, Kind=ToolPolicy): .spec.allow: field not declared in schema", - ); - match apply_err(e) { - AppError::Rejected(m) => assert!(m.contains("field not declared in schema")), - other => panic!("expected Rejected, got {other:?}"), - } - } - - #[test] - fn apply_err_keeps_opaque_500_opaque() { - // A generic 500 with no actionable schema message stays Upstream. - match apply_err(api_err(500, "etcdserver: request timed out")) { - AppError::Upstream(_) => {} - other => panic!("expected Upstream, got {other:?}"), - } - } - - #[test] - fn apply_err_maps_validation_and_rbac() { - assert!(matches!( - apply_err(api_err(422, "bad")), - AppError::Rejected(_) - )); - assert!(matches!( - apply_err(api_err(403, "no")), - AppError::Rejected(_) - )); - assert!(matches!( - apply_err(api_err(409, "conflict")), - AppError::Rejected(_) - )); - } - - #[test] - fn dns1123_label_rules() { - assert!(is_dns1123_label("repo-watch")); - assert!(is_dns1123_label("a")); - assert!(is_dns1123_label("team1")); - assert!(!is_dns1123_label("")); // empty - assert!(!is_dns1123_label("-lead")); // leading hyphen - assert!(!is_dns1123_label("lead-")); // trailing hyphen - assert!(!is_dns1123_label("Repo")); // uppercase - assert!(!is_dns1123_label("a_b")); // underscore - assert!(!is_dns1123_label(&"x".repeat(64))); // too long + #[test] + fn dns1123_label_rules() { + assert!(is_dns1123_label("repo-watch")); + assert!(is_dns1123_label("a")); + assert!(is_dns1123_label("team1")); + assert!(!is_dns1123_label("")); // empty + assert!(!is_dns1123_label("-lead")); // leading hyphen + assert!(!is_dns1123_label("lead-")); // trailing hyphen + assert!(!is_dns1123_label("Repo")); // uppercase + assert!(!is_dns1123_label("a_b")); // underscore + assert!(!is_dns1123_label(&"x".repeat(64))); // too long } #[test] @@ -4454,44 +224,4 @@ mod tests { assert!(!is_env_key("MY-KEY")); // hyphen assert!(!is_env_key("MY KEY")); // space } - - #[test] - fn witness_doc_parses_real_aggregator_payload() { - // The exact shape the aggregator publishes into kars-datapath-witness. - let body = r#"{ - "generated_at": "2026-07-02T13:51:32Z", - "window_seconds": 15, - "gadget": "inspektor-gadget", - "sandboxes": [ - {"namespace":"kars-demo","sandbox":"demo", - "declared_hosts":["api.github.com"], - "observed_dns":["api.github.com","example.com"], - "observed_connects":4, - "beyond_declared":["example.com"], - "unused_declared":[], - "verdict":"BEYOND-DECLARED"} - ] - }"#; - let doc: super::WitnessDoc = serde_json::from_str(body).expect("parse"); - assert_eq!(doc.generated_at.as_deref(), Some("2026-07-02T13:51:32Z")); - assert_eq!(doc.window_seconds, Some(15)); - assert_eq!(doc.sandboxes.len(), 1); - let s = &doc.sandboxes[0]; - assert_eq!(s.sandbox, "demo"); - assert_eq!(s.verdict, "BEYOND-DECLARED"); - assert_eq!(s.beyond_declared, vec!["example.com"]); - assert_eq!(s.observed_connects, 4); - } - - #[test] - fn witness_sandbox_tolerates_missing_optional_arrays() { - // Defaults must hold so a partial payload never fails deserialization. - let s: super::DatapathWitnessSandbox = - serde_json::from_str(r#"{"namespace":"n","sandbox":"x","verdict":"LEARN"}"#) - .expect("parse"); - assert_eq!(s.verdict, "LEARN"); - assert!(s.declared_hosts.is_empty()); - assert!(s.observed_dns.is_empty()); - assert_eq!(s.observed_connects, 0); - } } diff --git a/bridge/bff/src/routes/operator/additional_providers.rs b/bridge/bff/src/routes/operator/additional_providers.rs new file mode 100644 index 000000000..567280cbe --- /dev/null +++ b/bridge/bff/src/routes/operator/additional_providers.rs @@ -0,0 +1,463 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::{copilot_catalog_cached, is_dns1123_label, require_cluster, upstream}; + +// ─── Multi-provider inference (§ inference-provider-wizard) ───────────────── +// +// The single "Inference provider" flow above (`put_provider`) sets the ONE +// default provider every mission inherits. This section manages ADDITIONAL +// providers that can be configured *at the same time* — e.g. GitHub Copilot +// as the default, Azure AI Foundry also connected — so an InferencePolicy's +// `modelPreference.primary.provider` can route a specific sandbox's calls to +// whichever one actually serves the model it needs (a sub-agent on gpt-4.1 +// via Foundry, a principal on opus-4.8 via Copilot, in the SAME cluster). +// +// Storage: the `kars-inference-providers` Secret in `kars-system`. Its KEYS +// are the literal env var names `inference-router::config::Config::from_env` +// already parses generically (`KARS_PROVIDER_<TAG>_ENDPOINT` + optional +// `_API_KEY`/`_TOKEN`, or the well-known `COPILOT_GITHUB_TOKEN` for the +// GitHub Copilot special case) — no router-side change needed to support a +// provider added here. The controller mirrors this ONE secret into every +// sandbox's own namespace (the same mechanism already used for +// `kars-github-app`), and every sandbox's router picks whichever provider a +// request's InferencePolicy names — never all-or-nothing, never guessed from +// what's merely present in the env. +pub(super) const INFERENCE_PROVIDERS_SECRET: &str = "kars-inference-providers"; +pub(super) const INFERENCE_PROVIDERS_NS: &str = "kars-system"; + +/// One additional provider, as surfaced to the operator (never the key/token +/// itself — `has_key` only tells you whether one is stored). +#[derive(Debug, Serialize)] +pub struct AdditionalProviderDto { + pub tag: String, + pub endpoint: Option<String>, + pub has_key: bool, + /// Deployment ids the operator declared this provider serves — these + /// feed the shared model catalog (`GET /api/options`), tagged with this + /// provider, so InferencePolicy's model picker can offer them. + pub models: Vec<String>, +} + +/// `GET /api/operator/providers/additional` — list every additional provider +/// configured on this cluster (beyond the single default from `put_provider`). +pub async fn list_additional_providers( + State(state): State<AppState>, +) -> AppResult<Json<Vec<AdditionalProviderDto>>> { + let cluster = require_cluster(&state)?; + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + let mut providers: std::collections::BTreeMap<String, AdditionalProviderDto> = + std::collections::BTreeMap::new(); + for key in keys.keys() { + if let Some(tag_part) = key + .strip_prefix("KARS_PROVIDER_") + .and_then(|r| r.strip_suffix("_ENDPOINT")) + { + let tag = tag_part.to_ascii_lowercase().replace('_', "-"); + providers + .entry(tag.clone()) + .or_insert(AdditionalProviderDto { + tag, + endpoint: None, + has_key: false, + models: Vec::new(), + }); + } + } + for (tag, dto) in providers.iter_mut() { + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + dto.endpoint = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) + .cloned(); + dto.has_key = keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) + || keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_TOKEN")); + dto.models = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) + .map(|m| { + m.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + } + // GitHub Copilot is a special case (well-known endpoint, no + // KARS_PROVIDER_*_ENDPOINT needed — see resolve_provider in the router). + if keys.contains_key("COPILOT_GITHUB_TOKEN") { + providers.insert( + "github-copilot".to_string(), + AdditionalProviderDto { + tag: "github-copilot".to_string(), + endpoint: Some("https://api.githubcopilot.com".to_string()), + has_key: true, + models: keys + .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") + .map(|m| { + m.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + }, + ); + } + Ok(Json(providers.into_values().collect())) +} + +#[derive(Debug, serde::Deserialize)] +pub struct AdditionalProviderRequest { + /// Lowercase, hyphenated tag (e.g. "foundry", "github-models"). The + /// reserved tag "github-copilot" only needs `api_key` (its endpoint is + /// the well-known Copilot API and is never user-editable). + pub tag: String, + pub endpoint: Option<String>, + /// Dev-mode direct key/token (e.g. a GitHub Models PAT, or a second + /// Azure OpenAI resource's key). Optional for providers that authenticate + /// via Workload Identity in production (Foundry/Azure OpenAI need no key + /// at all on AKS — see `inference-router::auth::WorkloadIdentityAuth`). + pub api_key: Option<String>, + /// Comma-separated deployment ids this provider serves — feeds the + /// shared model catalog (`GET /api/options`), tagged with this provider, + /// so InferencePolicy's model picker can offer "this model via THIS + /// provider" without any change to that editor. + pub models: Option<String>, +} + +/// `PUT /api/operator/providers/additional` — add or update one additional +/// provider. Read-modify-write against the shared Secret so configuring one +/// provider never disturbs another already stored there. +pub async fn put_additional_provider( + State(state): State<AppState>, + Json(req): Json<AdditionalProviderRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let tag = req.tag.trim().to_ascii_lowercase(); + if !is_dns1123_label(&tag) { + return Err(AppError::BadRequest( + "tag must be lowercase letters, digits, hyphens (e.g. \"foundry\", \"github-models\")" + .into(), + )); + } + let is_copilot = tag == "github-copilot"; + if !is_copilot { + let endpoint = req + .endpoint + .as_deref() + .map(str::trim) + .filter(|e| !e.is_empty()) + .ok_or_else(|| AppError::BadRequest("endpoint is required for this provider".into()))?; + if !endpoint.starts_with("https://") && !endpoint.starts_with("http://") { + return Err(AppError::BadRequest("endpoint must be a URL".into())); + } + } + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let key_val = req + .api_key + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + .map(str::to_string); + let models: Vec<&str> = req + .models + .as_deref() + .unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + if models.is_empty() { + return Err(AppError::BadRequest( + "at least one model deployment id is required (comma-separated) so InferencePolicy can offer it".into(), + )); + } + let models_joined = models.join(","); + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + if is_copilot { + if let Some(k) = key_val.clone() { + keys.insert("COPILOT_GITHUB_TOKEN".to_string(), k); + } + keys.insert( + "KARS_PROVIDER_GITHUB_COPILOT_MODELS".to_string(), + models_joined.clone(), + ); + } else { + if let Some(endpoint) = req + .endpoint + .as_deref() + .map(str::trim) + .filter(|e| !e.is_empty()) + { + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_ENDPOINT"), + endpoint.to_string(), + ); + } + if let Some(k) = key_val.clone() { + keys.insert(format!("KARS_PROVIDER_{tag_upper}_API_KEY"), k); + } + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_MODELS"), + models_joined.clone(), + ); + } + }) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ + "configured": true, + "tag": tag, + "note": "Every sandbox's router now has this provider available. Which one a given request actually uses is decided per-sandbox by its InferencePolicy.modelPreference — this alone doesn't make it the default." + }))) +} + +/// `DELETE /api/operator/providers/additional/:tag` — remove one additional +/// provider's keys from the shared Secret (leaves other providers intact). +pub async fn delete_additional_provider( + State(state): State<AppState>, + axum::extract::Path(tag): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let tag = tag.trim().to_ascii_lowercase(); + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let is_copilot = tag == "github-copilot"; + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + if is_copilot { + keys.remove("COPILOT_GITHUB_TOKEN"); + keys.remove("KARS_PROVIDER_GITHUB_COPILOT_MODELS"); + } else { + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_TOKEN")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_MODELS")); + } + }) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({"removed": true, "tag": tag}))) +} + +/// `POST /api/operator/providers/additional/:tag/promote` — make an already- +/// connected additional provider the cluster's DEFAULT (patches the +/// controller's own env — every mission that leaves its model unset inherits +/// this). Reads the tag's endpoint/key/models straight from +/// `kars-inference-providers` server-side (never exposed to the browser) and +/// re-points the SAME secret+key via `secretKeyRef` — no key duplication. +/// +/// `github-copilot` is rejected: it authenticates via `COPILOT_GITHUB_TOKEN` +/// exchanged for a short-lived Copilot JWT, a completely different mechanism +/// than the endpoint+key shape every other provider here uses — the same +/// reason `put_provider` already refuses to set it as default from the other +/// form (see that handler's comment). Every other tag (Foundry, Azure OpenAI, +/// Custom, GitHub Models, and a local in-cluster model) is a plain +/// endpoint(+optional key), which is exactly what `set_controller_catalog` +/// wires — so promoting any of THOSE genuinely works. +pub async fn promote_additional_provider( + State(state): State<AppState>, + axum::extract::Path(tag): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let tag = tag.trim().to_ascii_lowercase(); + // GitHub Copilot IS promotable now — the wizard's device sign-in stores a + // Copilot-authorized token, which `set_copilot_as_default` wires onto the + // controller (KARS_PROVIDER + COPILOT_GITHUB_TOKEN), unlike the endpoint+key + // shape every other provider uses. + if tag == "github-copilot" { + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + if !keys.contains_key("COPILOT_GITHUB_TOKEN") { + return Err(AppError::BadRequest( + "Sign in to GitHub Copilot first (Connect a provider → GitHub Copilot) — then it can be set as the cluster default.".into(), + )); + } + let models = keys + .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") + .cloned() + .unwrap_or_default(); + let models = if models.trim().is_empty() { + // No explicit selection stored — fall back to the live catalog so + // the default catalogue isn't empty. + copilot_catalog_cached(keys.get("COPILOT_GITHUB_TOKEN").unwrap()) + .await + .into_iter() + .map(|(id, _, _)| id) + .collect::<Vec<_>>() + .join(",") + } else { + models + }; + cluster + .set_copilot_as_default(&models) + .await + .map_err(upstream)?; + return Ok(Json(serde_json::json!({ + "promoted": true, + "tag": tag, + "note": "GitHub Copilot is now the cluster default; the controller is rolling to pick it up. Every mission that leaves its model unset now inherits it." + }))); + } + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + let endpoint = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) + .cloned() + .ok_or_else(|| AppError::BadRequest(format!("no connected provider tagged {tag:?} with an endpoint (GitHub Models has a well-known endpoint but no explicit one is stored, so it can't be promoted this way either)")))?; + let models = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) + .cloned() + .unwrap_or_default(); + if models.trim().is_empty() { + return Err(AppError::BadRequest(format!( + "{tag} has no declared models to promote" + ))); + } + let key_ref = if keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) { + Some(( + INFERENCE_PROVIDERS_SECRET, + format!("KARS_PROVIDER_{tag_upper}_API_KEY"), + )) + } else { + None + }; + cluster + .set_controller_catalog( + &models, + Some(&endpoint), + key_ref.as_ref().map(|(s, k)| (*s, k.as_str())), + ) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ + "promoted": true, + "tag": tag, + "note": "Cluster default updated; the controller is rolling to pick it up. Every mission that leaves its model unset now inherits this provider." + }))) +} + +#[derive(Debug, serde::Deserialize)] +pub struct SetDefaultModelRequest { + pub deployment: String, + /// The provider tag that serves this model, as shown in the catalogue + /// (e.g. "github-copilot", "foundry", "local-llama-3-2-1b-instruct"). + pub provider: String, +} + +/// `POST /api/operator/models/default` — make one specific MODEL the cluster +/// default (what the Model catalogue's "Set as default" does). Promotes the +/// model's provider AND pins that model as the default (moved to the front of +/// the catalog, which `set_*_default` treats as KARS_TASK_DEFAULT_MODEL). +pub async fn set_default_model( + State(state): State<AppState>, + Json(req): Json<SetDefaultModelRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let deployment = req.deployment.trim().to_string(); + let provider = req.provider.trim().to_ascii_lowercase(); + if deployment.is_empty() { + return Err(AppError::BadRequest( + "a model deployment id is required".into(), + )); + } + let keys = cluster + .read_secret_all(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET) + .await + .map_err(upstream)?; + + // Reorder a comma list so `deployment` is first (becomes the default), + // deduped; ensures the chosen model is present even if it wasn't listed. + let reorder = |csv: &str| -> String { + let mut out = vec![deployment.clone()]; + for m in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if m != deployment { + out.push(m.to_string()); + } + } + out.join(",") + }; + + if provider == "github-copilot" { + if !keys.contains_key("COPILOT_GITHUB_TOKEN") { + return Err(AppError::BadRequest( + "Sign in to GitHub Copilot first.".into(), + )); + } + let existing = keys + .get("KARS_PROVIDER_GITHUB_COPILOT_MODELS") + .cloned() + .unwrap_or_default(); + let models = if existing.trim().is_empty() { + // fall back to the live catalog so the catalog isn't just one model + let mut live = copilot_catalog_cached(keys.get("COPILOT_GITHUB_TOKEN").unwrap()) + .await + .into_iter() + .map(|(id, _, _)| id) + .collect::<Vec<_>>() + .join(","); + if live.trim().is_empty() { + live = deployment.clone(); + } + reorder(&live) + } else { + reorder(&existing) + }; + cluster + .set_copilot_as_default(&models) + .await + .map_err(upstream)?; + return Ok(Json( + serde_json::json!({"ok": true, "default": deployment, "provider": provider}), + )); + } + + // Endpoint-based providers (foundry, azure-openai, custom, local-*): promote + // via set_controller_catalog with the chosen model first. + let tag_upper = provider.to_ascii_uppercase().replace('-', "_"); + let endpoint = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")) + .cloned() + .ok_or_else(|| AppError::BadRequest(format!( + "no connected provider {provider:?} with an endpoint serves {deployment:?} — connect it first" + )))?; + let existing = keys + .get(&format!("KARS_PROVIDER_{tag_upper}_MODELS")) + .cloned() + .unwrap_or_default(); + let models = reorder(&existing); + let key_ref = if keys.contains_key(&format!("KARS_PROVIDER_{tag_upper}_API_KEY")) { + Some(( + INFERENCE_PROVIDERS_SECRET, + format!("KARS_PROVIDER_{tag_upper}_API_KEY"), + )) + } else { + None + }; + cluster + .set_controller_catalog( + &models, + Some(&endpoint), + key_ref.as_ref().map(|(s, k)| (*s, k.as_str())), + ) + .await + .map_err(upstream)?; + Ok(Json( + serde_json::json!({"ok": true, "default": deployment, "provider": provider}), + )) +} diff --git a/bridge/bff/src/routes/operator/audit.rs b/bridge/bff/src/routes/operator/audit.rs new file mode 100644 index 000000000..92a589415 --- /dev/null +++ b/bridge/bff/src/routes/operator/audit.rs @@ -0,0 +1,325 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use kube::core::DynamicObject; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::{created_of, name_of, ns_of, require_cluster, s, spec, status, upstream}; + +// ─── Audit: receipts + inclusion log + checkpoint ──────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ReceiptSummaryDto { + pub name: String, + pub namespace: String, + pub task: Option<String>, + pub envelope_digest: Option<String>, + pub key_id: Option<String>, + pub inclusion_seq: Option<i64>, + pub created: Option<String>, + /// At-a-glance verdict from the receipt's claim matrix (`spec.claims`, which + /// the CRD already carries) — `verified` (all required non-regulatory claims + /// PASS), `failed` (any FAIL), `partial` (required evidence incomplete), or + /// `none` (no claims). Regulatory maturity is advisory and shown in detail. + pub verdict: String, +} + +/// Reduce a receipt's `(class, status)` claim pairs to an overall verdict. +/// Any FAIL/ERROR ⇒ "failed". Otherwise the badge reflects the CRYPTOGRAPHIC +/// claims (integrity + conformance + completeness) — the "regulatory" claim and +/// any "OMITTED" status are advisory V0-maturity disclosures that must NOT block +/// a "verified" verdict (else every receipt reads "partial" forever). `class` +/// is expected lowercased, `status` uppercased. +fn receipt_verdict(claims: &[(String, String)]) -> &'static str { + if claims.is_empty() { + return "none"; + } + if claims.iter().any(|(_, s)| s == "FAIL" || s == "ERROR") { + return "failed"; + } + let core: Vec<&(String, String)> = claims + .iter() + .filter(|(class, status)| class != "regulatory" && status != "OMITTED") + .collect(); + if !core.is_empty() && core.iter().all(|(_, s)| s == "PASS" || s == "OK") { + "verified" + } else { + "partial" + } +} + +fn to_receipt_summary(o: &DynamicObject) -> ReceiptSummaryDto { + let sp = spec(o); + // The regulatory claim is a V0 maturity dimension — it is ALWAYS "PARTIAL" + // or "OMITTED" until an external KMS/transparency anchor lands (a named V1 + // follow-up), and "OMITTED" is an honest disclosure, not a verification + // failure. Treating either as blocking meant NO receipt could ever read + // "Verified" (every one showed "Partial"), making the verdict useless. So + // the badge reflects the CRYPTOGRAPHIC claims (integrity + conformance + + // completeness); the regulatory/omitted maturity is still shown in detail. + let claims: Vec<(String, String)> = sp + .get("claims") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|c| { + let status = c + .get("status") + .and_then(|s| s.as_str())? + .to_ascii_uppercase(); + let class = c + .get("class") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + Some((class, status)) + }) + .collect() + }) + .unwrap_or_default(); + let verdict = receipt_verdict(&claims).to_string(); + ReceiptSummaryDto { + name: name_of(o), + namespace: ns_of(o), + task: sp + .get("taskRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|x| x.to_string()), + envelope_digest: s(sp, "envelopeDigest"), + key_id: s(sp, "keyId"), + inclusion_seq: status(o).get("inclusionSeq").and_then(|x| x.as_i64()), + created: created_of(o), + verdict, + } +} + +#[derive(Debug, Serialize)] +pub struct AuditDto { + pub receipts: Vec<ReceiptSummaryDto>, + pub inclusion_log_size: i64, + pub checkpoint: Option<CheckpointSummaryDto>, + /// Real cryptographic integrity verdict — the whole hash chain recomputed + /// and the signed checkpoint verified against the published anchor. Drives + /// the audit banner so it reflects verification, not field presence. + pub integrity: crate::routes::receipts::LogIntegrity, +} + +#[derive(Debug, Serialize)] +pub struct CheckpointSummaryDto { + pub tree_size: i64, + pub root_hash: String, + pub key_id: String, + pub published_at: Option<String>, +} + +/// `GET /api/operator/audit` — the audit substrate: every governance receipt, +/// the inclusion-log size, and the signed checkpoint (signed tree head). +pub async fn get_audit(State(state): State<AppState>) -> AppResult<Json<AuditDto>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("KarsReceipt") + .await + .map_err(upstream)?; + let mut receipts: Vec<ReceiptSummaryDto> = items.iter().map(to_receipt_summary).collect(); + receipts.sort_by_key(|a| a.inclusion_seq); + + let log = cluster + .receipt_log() + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + let inclusion_log_size = log.entries.len() as i64; + + let checkpoint = log.checkpoint.as_ref().and_then(|d| { + let tree_size = d.get("treeSize")?.parse::<i64>().ok()?; + Some(CheckpointSummaryDto { + tree_size, + root_hash: d.get("rootHash").cloned().unwrap_or_default(), + key_id: d.get("keyId").cloned().unwrap_or_default(), + published_at: d.get("publishedAt").cloned(), + }) + }); + + Ok(Json(AuditDto { + receipts, + inclusion_log_size, + checkpoint, + integrity: crate::routes::receipts::verify_log_integrity(&log), + })) +} + +// ─── datapath-completeness witness (optional eBPF) ─────────────────────────── +// +// An independent, kernel-level attestation of what sandboxes ACTUALLY send on +// the network, cross-checked against the controller-declared egress allowlist. +// Produced out-of-band by the optional Inspektor Gadget witness +// (deploy/ebpf-witness/) and published to the `kars-datapath-witness` ConfigMap +// in kars-system. The Bridge only READS that ConfigMap — no eBPF/gadget +// dependency here. Absent ConfigMap => witness not enabled (honest empty), never +// an error. + +#[derive(Serialize, Deserialize, Default)] +pub struct DatapathWitnessSandbox { + pub namespace: String, + pub sandbox: String, + #[serde(default)] + pub declared_hosts: Vec<String>, + #[serde(default)] + pub observed_dns: Vec<String>, + #[serde(default)] + pub observed_connects: u64, + #[serde(default)] + pub beyond_declared: Vec<String>, + #[serde(default)] + pub unused_declared: Vec<String>, + pub verdict: String, +} + +#[derive(Serialize)] +pub struct DatapathWitnessDto { + /// True once the optional eBPF witness is installed and has published a + /// verdict. False => not enabled (the web layer shows enable instructions). + pub enabled: bool, + pub generated_at: Option<String>, + pub window_seconds: Option<u32>, + pub sandboxes: Vec<DatapathWitnessSandbox>, + /// How to turn the witness on — surfaced verbatim in the not-enabled state. + pub install_hint: String, +} + +#[derive(Deserialize)] +struct WitnessDoc { + generated_at: Option<String>, + window_seconds: Option<u32>, + #[serde(default)] + sandboxes: Vec<DatapathWitnessSandbox>, +} + +pub async fn datapath_witness( + State(state): State<AppState>, +) -> AppResult<Json<DatapathWitnessDto>> { + let cluster = require_cluster(&state)?; + let hint = "Enable the optional eBPF datapath witness on the cluster: \ + KARS_EBPF_WITNESS=1 deploy/ebpf-witness/install.sh --continuous" + .to_string(); + + let not_enabled = || DatapathWitnessDto { + enabled: false, + generated_at: None, + window_seconds: None, + sandboxes: Vec::new(), + install_hint: hint.clone(), + }; + + let Some(body) = cluster + .configmap_data("kars-datapath-witness") + .await + .and_then(|d| d.get("witness.json").cloned()) + else { + return Ok(Json(not_enabled())); + }; + + match serde_json::from_str::<WitnessDoc>(&body) { + Ok(doc) => Ok(Json(DatapathWitnessDto { + enabled: true, + generated_at: doc.generated_at, + window_seconds: doc.window_seconds, + sandboxes: doc.sandboxes, + install_hint: hint, + })), + // Malformed payload is treated as not-enabled rather than a hard error — + // the console must never 500 on optional-feature data. + Err(_) => Ok(Json(not_enabled())), + } +} + +#[cfg(test)] +mod tests { + use super::receipt_verdict; + + fn claim(class: &str, status: &str) -> (String, String) { + (class.to_string(), status.to_string()) + } + + #[test] + fn receipt_verdict_regulatory_and_omitted_are_advisory() { + // The real V0 shape: crypto claims PASS, regulatory OMITTED. Must be + // "verified" (regression: it used to read "partial" for every receipt). + let v0 = vec![ + claim("integrity", "PASS"), + claim("conformance", "PASS"), + claim("completeness", "PASS"), + claim("regulatory", "OMITTED"), + ]; + assert_eq!(receipt_verdict(&v0), "verified"); + + // Regulatory PARTIAL is likewise advisory. + let v0b = vec![ + claim("integrity", "PASS"), + claim("conformance", "PASS"), + claim("completeness", "PASS"), + claim("regulatory", "PARTIAL"), + ]; + assert_eq!(receipt_verdict(&v0b), "verified"); + + // A genuinely partial CORE claim (completeness) is still "partial". + let partial = vec![ + claim("integrity", "PASS"), + claim("conformance", "PASS"), + claim("completeness", "PARTIAL"), + claim("regulatory", "OMITTED"), + ]; + assert_eq!(receipt_verdict(&partial), "partial"); + + // Any FAIL/ERROR anywhere is "failed". + let failed = vec![claim("integrity", "FAIL"), claim("conformance", "PASS")]; + assert_eq!(receipt_verdict(&failed), "failed"); + + // No claims ⇒ "none". + assert_eq!(receipt_verdict(&[]), "none"); + } + + #[test] + fn witness_doc_parses_real_aggregator_payload() { + // The exact shape the aggregator publishes into kars-datapath-witness. + let body = r#"{ + "generated_at": "2026-07-02T13:51:32Z", + "window_seconds": 15, + "gadget": "inspektor-gadget", + "sandboxes": [ + {"namespace":"kars-demo","sandbox":"demo", + "declared_hosts":["api.github.com"], + "observed_dns":["api.github.com","example.com"], + "observed_connects":4, + "beyond_declared":["example.com"], + "unused_declared":[], + "verdict":"BEYOND-DECLARED"} + ] + }"#; + let doc: super::WitnessDoc = serde_json::from_str(body).expect("parse"); + assert_eq!(doc.generated_at.as_deref(), Some("2026-07-02T13:51:32Z")); + assert_eq!(doc.window_seconds, Some(15)); + assert_eq!(doc.sandboxes.len(), 1); + let s = &doc.sandboxes[0]; + assert_eq!(s.sandbox, "demo"); + assert_eq!(s.verdict, "BEYOND-DECLARED"); + assert_eq!(s.beyond_declared, vec!["example.com"]); + assert_eq!(s.observed_connects, 4); + } + + #[test] + fn witness_sandbox_tolerates_missing_optional_arrays() { + // Defaults must hold so a partial payload never fails deserialization. + let s: super::DatapathWitnessSandbox = + serde_json::from_str(r#"{"namespace":"n","sandbox":"x","verdict":"LEARN"}"#) + .expect("parse"); + assert_eq!(s.verdict, "LEARN"); + assert!(s.declared_hosts.is_empty()); + assert!(s.observed_dns.is_empty()); + assert_eq!(s.observed_connects, 0); + } +} diff --git a/bridge/bff/src/routes/operator/diagnostics.rs b/bridge/bff/src/routes/operator/diagnostics.rs new file mode 100644 index 000000000..dca45aa11 --- /dev/null +++ b/bridge/bff/src/routes/operator/diagnostics.rs @@ -0,0 +1,439 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::error::AppResult; +use crate::state::AppState; + +use super::require_cluster; + +// ─── Diagnostics: live "what's actually broken right now" scan ──────────────── +// The Troubleshooting page's real job: not a wiring/roadmap checklist, but the +// concrete problems an operator must act on — pods that won't start, containers +// crash-looping or stuck pulling an image, sandboxes the controller marked +// Degraded/Failed, and agents that came up but never went Ready. Every issue is +// read from live pod/CRD status and carries a plain remedy hint. + +#[derive(Debug, Serialize)] +pub struct DiagnosticIssue { + /// "critical" (blocks the workload) or "warning" (degraded but running). + pub severity: String, + /// Short machine-ish kind, e.g. "ImagePullBackOff", "CrashLoopBackOff", + /// "PodPending", "NotReady", "SandboxDegraded", "HighRestarts". + pub kind: String, + /// The affected object, `namespace/name`. + pub subject: String, + /// The raw reason/phase from the cluster. + pub reason: String, + /// Human detail (container message / status message) when available. + pub detail: Option<String>, + /// A concrete next step for the operator. + pub remedy: String, +} + +#[derive(Debug, Serialize)] +pub struct DiagnosticsDto { + pub issues: Vec<DiagnosticIssue>, + pub scanned_pods: usize, + pub scanned_sandboxes: usize, + /// True when the scan found nothing wrong — the honest "all clear". + pub healthy: bool, +} + +/// `GET /api/operator/diagnostics` — the live problem scan behind Troubleshooting. +pub async fn get_diagnostics(State(state): State<AppState>) -> AppResult<Json<DiagnosticsDto>> { + let cluster = require_cluster(&state)?; + let mut issues: Vec<DiagnosticIssue> = Vec::new(); + + // ── Pods: the ground truth for "won't start / not healthy". ────────────── + let pods = cluster.all_pods().await; + let scanned_pods = pods.len(); + for p in &pods { + let ns = p.metadata.namespace.as_deref().unwrap_or("").to_string(); + let name = p.metadata.name.as_deref().unwrap_or("").to_string(); + let subject = format!("{ns}/{name}"); + let status = p.status.as_ref(); + let phase = status.and_then(|s| s.phase.as_deref()).unwrap_or(""); + let age_secs = status + .and_then(|s| s.start_time.as_ref()) + .map(|t| (chrono::Utc::now() - t.0).num_seconds().max(0)) + .unwrap_or(0); + + // Container-level waiting reasons (image pull, crashloop, config error). + let mut container_flagged = false; + if let Some(cs) = status.and_then(|s| s.container_statuses.as_ref()) { + for c in cs { + if let Some(w) = c.state.as_ref().and_then(|st| st.waiting.as_ref()) { + let reason = w.reason.clone().unwrap_or_default(); + let bad = matches!( + reason.as_str(), + "ImagePullBackOff" + | "ErrImagePull" + | "CrashLoopBackOff" + | "CreateContainerConfigError" + | "CreateContainerError" + | "InvalidImageName" + | "RunContainerError" + ); + if bad { + container_flagged = true; + let remedy = match reason.as_str() { + "ImagePullBackOff" | "ErrImagePull" | "InvalidImageName" => { + "Image can't be pulled — check the image tag exists in the registry and the node has pull access." + } + "CrashLoopBackOff" | "RunContainerError" => { + "Container keeps exiting — check its logs (kubectl logs) for the crash cause." + } + _ => { + "Container config is invalid — check the ConfigMap/Secret mounts and env for this container." + } + }; + issues.push(DiagnosticIssue { + severity: "critical".into(), + kind: reason.clone(), + subject: format!("{subject} · {}", c.name), + reason, + detail: w.message.clone(), + remedy: remedy.into(), + }); + } + } + // A container restarting many times is a warning even if currently up. + if c.restart_count >= 5 { + issues.push(DiagnosticIssue { + severity: "warning".into(), + kind: "HighRestarts".into(), + subject: format!("{subject} · {}", c.name), + reason: format!("{} restarts", c.restart_count), + detail: None, + remedy: + "Container is unstable — inspect its logs for the recurring failure." + .into(), + }); + } + } + } + + // Pod stuck Pending (unschedulable / image / volume) for > 60s. + if phase == "Pending" && age_secs > 60 && !container_flagged { + let msg = status + .and_then(|s| s.conditions.as_ref()) + .and_then(|c| c.iter().find(|cond| cond.status == "False")) + .and_then(|c| c.message.clone()); + issues.push(DiagnosticIssue { + severity: "critical".into(), + kind: "PodPending".into(), + subject: subject.clone(), + reason: "Pending".into(), + detail: msg, + remedy: "Pod can't be scheduled — check node capacity, taints, or unbound volumes (kubectl describe pod)." + .into(), + }); + } + + // Running but not all containers Ready for > 120s (probes failing). + if phase == "Running" + && age_secs > 120 + && !container_flagged + && let Some(cs) = status.and_then(|s| s.container_statuses.as_ref()) + { + let total = cs.len(); + let ready = cs.iter().filter(|s| s.ready).count(); + if total > 0 && ready < total { + issues.push(DiagnosticIssue { + severity: "warning".into(), + kind: "NotReady".into(), + subject: subject.clone(), + reason: format!("{ready}/{total} containers ready"), + detail: None, + remedy: "A container is up but failing its readiness probe — check the probe and the container's logs." + .into(), + }); + } + } + } + + // ── Sandboxes the controller itself flagged Degraded/Failed. ───────────── + let sandboxes = cluster + .list_kind_all("KarsSandbox") + .await + .unwrap_or_default(); + let scanned_sandboxes = sandboxes.len(); + for sb in &sandboxes { + let phase = sb + .data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .unwrap_or(""); + if matches!(phase, "Degraded" | "Failed") { + let name = sb.metadata.name.as_deref().unwrap_or("").to_string(); + let msg = sb + .data + .get("status") + .and_then(|s| s.get("message")) + .and_then(|m| m.as_str()) + .map(String::from); + issues.push(DiagnosticIssue { + severity: if phase == "Failed" { "critical" } else { "warning" }.into(), + kind: "SandboxDegraded".into(), + subject: format!("kars-system/{name}"), + reason: phase.to_string(), + detail: msg, + remedy: "The controller couldn't fully reconcile this sandbox — check the controller logs and the sandbox's referenced policies/secrets." + .into(), + }); + } + // Run-level stall detection (audit f24): the pod-level scan is blind to a + // run whose sandbox is "Running" but whose run has FAILED/timed out. A + // mission-output recorded with status=error is a definitive run failure + // the operator must see even though the pod looks healthy. + if phase == "Running" { + let name = sb.metadata.name.as_deref().unwrap_or("").to_string(); + if let Some(out) = cluster.read_mission_output(&name).await + && out.get("status").map(|s| s.as_str()) == Some("error") + { + let detail = out + .get("output") + .cloned() + .filter(|s| !s.is_empty()) + .or_else(|| out.get("error").cloned()); + issues.push(DiagnosticIssue { + severity: "warning".into(), + kind: "RunFailed".into(), + subject: format!("kars-system/{name}"), + reason: "run reported an error while the sandbox is still Running".into(), + detail, + remedy: "The agent's run did not complete (often a slow/absent agent or a chat-gateway harness that never executed the loop). Check the mission's Run tab, or re-run with the OpenClaw harness for autonomous missions." + .into(), + }); + } + } + } + + // Critical first, then warnings; stable within a severity. + issues.sort_by(|a, b| { + let rank = |s: &str| if s == "critical" { 0 } else { 1 }; + rank(&a.severity).cmp(&rank(&b.severity)) + }); + + let healthy = issues.is_empty(); + Ok(Json(DiagnosticsDto { + issues, + scanned_pods, + scanned_sandboxes, + healthy, + })) +} + +// ─── Orchestrator health + the compose failover path ───────────────────────── +// The Bridge composer ("intent → package") runs its own inference. It prefers a +// DIRECT endpoint (BRIDGE_ORCHESTRATOR_* — scales for many teams) and otherwise +// routes through the standing `bridge-orchestrator` sandbox's router. This +// surfaces which path is live, the orchestrator sandbox's health, and — when the +// sandbox path is under strain — recommends configuring the direct endpoint +// (the "switch to inference-based orchestration under load" lever). + +#[derive(Debug, Serialize)] +pub struct OrchestratorDto { + /// Active compose inference path: "direct" (endpoint configured) or + /// "sandbox" (routing through the orchestrator sandbox router), or "none". + pub mode: String, + /// Whether a direct BRIDGE_ORCHESTRATOR endpoint triple is configured. + pub direct_configured: bool, + /// Whether the standing orchestrator sandbox exists. + pub sandbox_present: bool, + /// The orchestrator sandbox phase (Running/Degraded/…), when present. + pub sandbox_phase: Option<String>, + /// Ready/total containers of the orchestrator pod, restarts, waiting reason. + pub sandbox_ready: Option<String>, + pub sandbox_restarts: Option<i32>, + pub sandbox_waiting_reason: Option<String>, + /// How many Running sandbox routers the composer can fall back through. + pub router_candidates: usize, + /// True when the operator should configure the direct endpoint (sandbox path + /// is the only option and it's unhealthy or capacity is thin). + pub recommend_direct: bool, + /// Plain-language recommendation. + pub note: String, +} + +/// `GET /api/operator/orchestrator` — orchestrator health + compose failover path. +pub async fn get_orchestrator(State(state): State<AppState>) -> AppResult<Json<OrchestratorDto>> { + let cluster = require_cluster(&state)?; + + let direct_configured = [ + "BRIDGE_ORCHESTRATOR_ENDPOINT", + "BRIDGE_ORCHESTRATOR_TOKEN", + "BRIDGE_ORCHESTRATOR_MODEL", + ] + .iter() + .all(|k| { + std::env::var(k) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + }); + + // Orchestrator sandbox presence + health. + let sandboxes = cluster + .list_kind_all("KarsSandbox") + .await + .unwrap_or_default(); + let orch = sandboxes.iter().find(|sb| { + sb.metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/orchestrator")) + .map(String::as_str) + == Some("true") + }); + let sandbox_present = orch.is_some(); + let sandbox_phase = orch.and_then(|o| { + o.data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(String::from) + }); + let health = if let Some(o) = orch { + let name = o.metadata.name.clone().unwrap_or_default(); + cluster.sandbox_pod_health(&name).await + } else { + None + }; + let (sandbox_ready, sandbox_restarts, sandbox_waiting_reason) = match &health { + Some(h) => ( + Some(format!("{}/{}", h.ready_containers, h.total_containers)), + Some(h.restarts), + h.waiting_reason.clone(), + ), + None => (None, None, None), + }; + + let router_candidates = cluster.running_sandbox_candidates().await.len(); + + let sandbox_healthy = sandbox_phase.as_deref() == Some("Running") + && health + .as_ref() + .map(|h| h.ready_containers == h.total_containers && h.total_containers > 0) + .unwrap_or(false); + + let mode = if direct_configured { + "direct" + } else if sandbox_present && router_candidates > 0 { + "sandbox" + } else { + "none" + } + .to_string(); + + // Recommend the direct endpoint when we're on the sandbox path and it's the + // only option while being unhealthy or thin on router capacity. + let recommend_direct = !direct_configured && !sandbox_healthy; + + let note = if direct_configured { + "Composing via the direct inference endpoint — scales independently of any sandbox." + .to_string() + } else if !sandbox_present { + "No orchestrator sandbox and no direct endpoint — the composer can't run. Set BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} or let the Bridge provision the orchestrator sandbox.".to_string() + } else if recommend_direct { + "The orchestrator sandbox is present but not healthy enough to compose reliably. Repair it or configure BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} for a direct inference path.".to_string() + } else if router_candidates <= 1 { + "Composing through the healthy orchestrator sandbox router. One router is sufficient for serial composition; configure BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL} only when you need independent capacity for many concurrent compose requests.".to_string() + } else { + "Composing via the orchestrator sandbox router — healthy. For many concurrent teams, a direct BRIDGE_ORCHESTRATOR endpoint scales better.".to_string() + }; + + Ok(Json(OrchestratorDto { + mode, + direct_configured, + sandbox_present, + sandbox_phase, + sandbox_ready, + sandbox_restarts, + sandbox_waiting_reason, + router_candidates, + recommend_direct, + note, + })) +} + +// ─── Integrations: kars-SRE agent + Headlamp plugin ────────────────────────── +// kars ships a real Headlamp plugin (tools/headlamp-plugin — /kars/sre and +// /kars/* views) and a real SRE agent (deploy/helm/kars/templates/sre.yaml, +// gated on sre.enabled; `kars sre install`). This surfaces whether each is +// active, deep-links into the existing plugin views, and gives the exact +// activation for what isn't enabled — rather than pretending to integrate. + +#[derive(Debug, Serialize)] +pub struct IntegrationsDto { + /// kars-SRE agent. + pub sre_present: bool, + pub sre_phase: Option<String>, + pub sre_ready: Option<String>, + /// The `kars sre install` activation command when SRE isn't enabled. + pub sre_activate_cmd: String, + /// Headlamp dashboard + kars plugin. + pub headlamp_deployed: bool, + pub headlamp_url: Option<String>, + /// Deep-link paths into the kars Headlamp plugin (appended to headlamp_url). + pub headlamp_paths: Vec<HeadlampLink>, + /// How to install the plugin when Headlamp is present but the URL is unset. + pub headlamp_install_hint: String, +} + +#[derive(Debug, Serialize)] +pub struct HeadlampLink { + pub label: String, + pub path: String, +} + +/// `GET /api/operator/integrations` — kars-SRE + Headlamp status & deep-links. +pub async fn get_integrations(State(state): State<AppState>) -> AppResult<Json<IntegrationsDto>> { + let cluster = require_cluster(&state)?; + + // SRE agent: the `sre` KarsSandbox (deploy/helm/kars/templates/sre.yaml). + let sandboxes = cluster + .list_kind_all("KarsSandbox") + .await + .unwrap_or_default(); + let sre = sandboxes + .iter() + .find(|sb| sb.metadata.name.as_deref() == Some("sre")); + let sre_present = sre.is_some(); + let sre_phase = sre.and_then(|o| { + o.data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(String::from) + }); + let sre_ready = if sre_present { + cluster + .sandbox_pod_health("sre") + .await + .map(|h| format!("{}/{}", h.ready_containers, h.total_containers)) + } else { + None + }; + + // Headlamp: the `headlamp` Deployment in the `headlamp` namespace. + let headlamp_deployed = cluster.deployment_exists("headlamp", "headlamp").await; + + Ok(Json(IntegrationsDto { + sre_present, + sre_phase, + sre_ready, + sre_activate_cmd: "kars sre install # helm upgrade --reuse-values --set sre.enabled=true".into(), + headlamp_deployed, + headlamp_url: std::env::var("BRIDGE_HEADLAMP_URL").ok().filter(|u| !u.trim().is_empty()), + headlamp_paths: vec![ + HeadlampLink { label: "SRE console".into(), path: "/kars/sre".into() }, + HeadlampLink { label: "Sandboxes".into(), path: "/kars/karssandboxes".into() }, + HeadlampLink { label: "Agent mesh".into(), path: "/kars/mesh".into() }, + ], + headlamp_install_hint: "Build tools/headlamp-plugin (npm run build), kubectl cp dist into the headlamp pod at /headlamp/plugins/kars, then set BRIDGE_HEADLAMP_URL.".into(), + })) +} diff --git a/bridge/bff/src/routes/operator/evals.rs b/bridge/bff/src/routes/operator/evals.rs new file mode 100644 index 000000000..14585c511 --- /dev/null +++ b/bridge/bff/src/routes/operator/evals.rs @@ -0,0 +1,372 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use kube::core::DynamicObject; +use serde::Serialize; +use serde_json::Value; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::{created_of, name_of, ns_of, require_cluster, s, spec, status, upstream}; + +// ─── KarsEval — safety/quality lifecycle (conformance evals) ───────────────── + +#[derive(Debug, Serialize)] +pub struct EvalResultDto { + pub total: i64, + pub passed: i64, + pub failed: i64, + pub errored: i64, + pub corpus_name: Option<String>, + pub corpus_digest: Option<String>, + pub completed_at: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct EvalDto { + pub name: String, + pub namespace: String, + pub display_name: Option<String>, + /// The sandbox this eval targets (spec.targetSandboxRef). + pub target_sandbox: Option<String>, + /// The corpus replayed — `builtin:<name>` or an OCI ref. + pub corpus: Option<String>, + /// Reconcile phase (Ready / Degraded / Pending). + pub phase: Option<String>, + /// Optional cron schedule (recurring eval), when set. + pub schedule: Option<String>, + pub last_run_at: Option<String>, + /// The most recent verdict (pass/fail counts), when a run completed. + pub last_result: Option<EvalResultDto>, + pub created: Option<String>, +} + +fn to_eval_result(v: &Value) -> Option<EvalResultDto> { + if !v.is_object() { + return None; + } + Some(EvalResultDto { + total: v.get("total").and_then(|x| x.as_i64()).unwrap_or(0), + passed: v.get("passed").and_then(|x| x.as_i64()).unwrap_or(0), + failed: v.get("failed").and_then(|x| x.as_i64()).unwrap_or(0), + errored: v.get("errored").and_then(|x| x.as_i64()).unwrap_or(0), + corpus_name: s(v, "corpusName"), + corpus_digest: s(v, "corpusDigest"), + completed_at: s(v, "completedAt"), + }) +} + +fn to_eval(o: &DynamicObject) -> EvalDto { + let sp = spec(o); + let st = status(o); + let corpus = sp.get("corpus").and_then(|c| { + c.get("builtin") + .and_then(|b| b.as_str()) + .map(|b| format!("builtin:{b}")) + .or_else(|| { + c.get("bundleRef") + .and_then(|r| r.get("repository")) + .and_then(|x| x.as_str()) + .map(|x| x.to_string()) + }) + }); + EvalDto { + name: name_of(o), + namespace: ns_of(o), + display_name: s(sp, "displayName"), + target_sandbox: sp + .get("targetSandboxRef") + .and_then(|r| r.get("name")) + .and_then(|x| x.as_str()) + .map(|x| x.to_string()), + corpus, + phase: s(st, "phase"), + schedule: s(sp, "schedule"), + last_run_at: s(st, "lastRunAt"), + last_result: st.get("lastResult").and_then(to_eval_result), + created: created_of(o), + } +} + +/// `GET /api/operator/evals` — the KarsEval safety/quality lifecycle: every +/// conformance eval, which sandbox it targets, and its latest real verdict +/// (pass/fail against the replayed corpus). Honest empty when none exist. +pub async fn list_evals(State(state): State<AppState>) -> AppResult<Json<Vec<EvalDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_kind_all("KarsEval").await.map_err(upstream)?; + let mut dtos: Vec<EvalDto> = items.iter().map(to_eval).collect(); + dtos.sort_by(|a, b| b.last_run_at.cmp(&a.last_run_at).then(a.name.cmp(&b.name))); + Ok(Json(dtos)) +} + +/// Operator request to configure + launch a safety eval. +#[derive(Debug, serde::Deserialize)] +pub struct CreateEvalRequest { + /// The sandbox to evaluate (spec.targetSandboxRef). + pub target_sandbox: String, + /// Builtin corpus name, e.g. `jailbreak-baseline` (spec.corpus.builtin). + pub corpus: String, + /// Optional cron schedule for a recurring eval; one-shot when omitted. + pub schedule: Option<String>, + /// Runner image override. On a dev cluster this must be the locally-loaded + /// `kars-conformance-runner:dev`; in prod the controller default applies. + pub runner_image: Option<String>, + /// Human label. + pub display_name: Option<String>, + /// Run immediately (stamp the run-now annotation). Default true. + pub run_now: Option<bool>, +} + +/// `POST /api/operator/evals` — configure and (by default) launch a safety eval +/// against a sandbox. Operator-only surface; the controller spawns the runner +/// Job that replays the corpus and records the real verdict. +pub async fn create_eval( + State(state): State<AppState>, + Json(req): Json<CreateEvalRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let sandbox = req.target_sandbox.trim(); + let corpus = req.corpus.trim(); + if sandbox.is_empty() || corpus.is_empty() { + return Err(AppError::BadRequest( + "target_sandbox and corpus are required".into(), + )); + } + // Deterministic, readable name so re-running the same eval updates in place. + let name = format!("{sandbox}-{}", corpus.replace([':', '_', '/'], "-")); + let mut spec = serde_json::json!({ + "targetSandboxRef": { "name": sandbox }, + "corpus": { "builtin": corpus }, + }); + if let Some(img) = req + .runner_image + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + spec["runnerImage"] = serde_json::json!(img); + } + if let Some(sch) = req + .schedule + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + spec["schedule"] = serde_json::json!(sch); + } + if let Some(dn) = req + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + spec["displayName"] = serde_json::json!(dn); + } + let mut annotations = serde_json::Map::new(); + if req.run_now.unwrap_or(true) { + annotations.insert("kars.azure.com/run-now".into(), serde_json::json!("true")); + } + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsEval", + "metadata": { "name": name, "annotations": annotations }, + "spec": spec.clone(), + }); + cluster + .apply_kind("kars-system", "KarsEval", body, true) + .await + .map_err(upstream)?; + Ok(Json(serde_json::json!({ "created": true, "name": name }))) +} + +/// A single eval case with what it tests and its latest verdict. +#[derive(Debug, Serialize)] +pub struct EvalCaseDto { + pub id: String, + pub tags: Vec<String>, + /// Plain-language summary of the adversarial probe this case sends. + pub probe: Option<String>, + /// The expected decision (what a safe agent SHOULD do), e.g. "Blocked". + pub expected: Option<String>, + /// What the router ACTUALLY decided on the last run, when known. + pub actual: Option<String>, + /// The actual decision's reason (e.g. why it was blocked/allowed) — surfaces + /// WHY a case failed (e.g. blocked by a transport error, not content safety). + pub actual_reason: Option<String>, + /// Latest verdict: true=passed, false=failed, None=not yet run OR errored. + pub pass: Option<bool>, + /// True when the case could NOT be evaluated (target unreachable / transport + /// error). Distinct from a policy failure — inconclusive, shown amber. + pub errored: bool, +} + +#[derive(Debug, Serialize)] +pub struct EvalReportDto { + pub name: String, + pub corpus: Option<String>, + pub total: usize, + pub passed: usize, + pub failed: usize, + /// Cases the runner could not evaluate (target unreachable). Inconclusive, + /// not counted as failures — surfaced so the UI never conflates "couldn't + /// reach the sandbox" with "the sandbox let a jailbreak through". + pub errored: usize, + pub completed_at: Option<String>, + /// Whether the controller captured PER-CASE verdicts for the last run. False + /// for runs that predate per-case reporting (only counts survive) — the UI + /// then shows the baseline cases without verdicts and invites a re-run. + pub per_case_available: bool, + pub cases: Vec<EvalCaseDto>, +} + +/// `GET /api/operator/evals/{name}/report` — the DETAILED eval report: every case +/// in the corpus (what it probes, the expected decision) merged with the latest +/// per-case verdict (pass/fail, and what the router actually did). Sourced from +/// the corpus ConfigMap (definitions) + the report ConfigMap (verdicts) the +/// controller persists — real, never fabricated. Empty verdicts until a run. +pub async fn eval_report( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<EvalReportDto>> { + let cluster = require_cluster(&state)?; + // Corpus definitions (what each case tests). + let corpus_raw = cluster + .configmap_data(&format!("karseval-{name}-corpus")) + .await + .and_then(|d| d.get("corpus.json").cloned()); + // Per-case verdicts from the last run (may be absent before first run). + let report_raw = cluster + .configmap_data(&format!("karseval-{name}-report")) + .await + .and_then(|d| d.get("report.json").cloned()); + + // Index verdicts by case id. + let report_json: Option<serde_json::Value> = report_raw + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + let per_case_available = report_json.is_some(); + let mut verdicts: std::collections::BTreeMap<String, serde_json::Value> = Default::default(); + let mut completed_at = None; + let (mut total, mut passed, mut failed, mut errored) = (0usize, 0usize, 0usize, 0usize); + if let Some(r) = &report_json { + completed_at = r + .get("completedAt") + .and_then(|v| v.as_str()) + .map(String::from); + total = r.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + passed = r.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + failed = r.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + errored = r.get("errored").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + if let Some(arr) = r.get("results").and_then(|v| v.as_array()) { + for c in arr { + if let Some(id) = c.get("caseId").and_then(|v| v.as_str()) { + verdicts.insert(id.to_string(), c.clone()); + } + } + } + } + // Fall back to the KarsEval's own status counts when no per-case report exists + // (an older run) so the detail's totals never contradict the summary card. + if !per_case_available + && let Ok(items) = cluster.list_kind_all("KarsEval").await + && let Some(ev) = items.iter().find(|o| name_of(o) == name) + { + if let Some(lr) = status(ev).get("lastResult") { + total = lr.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + passed = lr.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + failed = lr.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + errored = lr.get("errored").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + } + completed_at = s(status(ev), "lastRunAt"); + } + + let corpus_json: Option<serde_json::Value> = corpus_raw + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()); + let corpus_name = corpus_json + .as_ref() + .and_then(|c| c.get("name")) + .and_then(|v| v.as_str()) + .map(String::from); + + let mut cases: Vec<EvalCaseDto> = Vec::new(); + if let Some(arr) = corpus_json + .as_ref() + .and_then(|c| c.get("cases")) + .and_then(|v| v.as_array()) + { + for case in arr { + let id = case + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tags = case + .get("tags") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|t| t.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let expected = case + .get("expect") + .and_then(|e| e.get("decision")) + .and_then(|v| v.as_str()) + .map(String::from); + // Summarise the probe: the last user message in the scenario. + let probe = case + .get("scenario") + .and_then(|s| s.get("messages")) + .and_then(|m| m.as_array()) + .and_then(|arr| { + arr.iter() + .rev() + .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user")) + }) + .and_then(|m| m.get("content").and_then(|c| c.as_str())) + .map(|s| s.chars().take(160).collect::<String>()); + let v = verdicts.get(&id); + let pass = v.and_then(|c| c.get("pass")).and_then(|p| p.as_bool()); + let errored = v + .and_then(|c| c.get("errored")) + .and_then(|e| e.as_bool()) + .unwrap_or(false); + let actual = v + .and_then(|c| c.get("actual")) + .and_then(|a| a.get("decision")) + .and_then(|d| d.as_str()) + .map(String::from); + let actual_reason = v + .and_then(|c| c.get("actual")) + .and_then(|a| a.get("reason")) + .and_then(|d| d.as_str()) + .map(|s| s.chars().take(240).collect::<String>()); + cases.push(EvalCaseDto { + id, + tags, + probe, + expected, + actual, + actual_reason, + pass, + errored, + }); + } + } + + Ok(Json(EvalReportDto { + name, + corpus: corpus_name, + total, + passed, + failed, + errored, + completed_at, + per_case_available, + cases, + })) +} diff --git a/bridge/bff/src/routes/operator/local_inference.rs b/bridge/bff/src/routes/operator/local_inference.rs new file mode 100644 index 000000000..9208bfaec --- /dev/null +++ b/bridge/bff/src/routes/operator/local_inference.rs @@ -0,0 +1,409 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use kube::core::DynamicObject; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::additional_providers::{INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET}; +use super::{created_of, is_dns1123_label, label, name_of, ns_of, require_cluster, upstream}; + +// ─── Local (in-cluster) inference — AI Runway ModelDeployment ──────────────── +// See docs/local-inference.md (kars core). kars does NOT install or manage +// AI Runway/KAITO — an operator installs both once via their own real +// helm/kubectl commands, exactly like the GitHub App or Azure AI Foundry +// connection. This surface only detects presence and manages `ModelDeployment` +// objects on top, in the Bridge's own `kars-local-inference` namespace. + +#[derive(Debug, Serialize)] +pub struct LocalInferenceStatusDto { + /// Whether AI Runway's `modeldeployments.airunway.ai` CRD is present — + /// i.e. whether an operator has installed it (see docs/local-inference.md). + pub available: bool, + /// Real, live-scanned count of nodes advertising `nvidia.com/gpu` + /// capacity — never a hardcoded guess. Zero means only CPU-tier models + /// can be offered. + pub gpu_node_count: u32, + /// Distinct GPU product names found via the NFD/GPU-feature-discovery + /// `nvidia.com/gpu.product` node label, when present. + pub gpu_products: Vec<String>, +} + +/// `GET /api/operator/local-inference/status` — detect whether the cluster +/// can host an in-cluster model, and whether it has GPU capacity for the +/// larger tier. Never installs anything. +pub async fn local_inference_status( + State(state): State<AppState>, +) -> AppResult<Json<LocalInferenceStatusDto>> { + let cluster = require_cluster(&state)?; + let available = cluster.local_inference_available().await; + let gpu = cluster.gpu_node_summary().await.unwrap_or_default(); + Ok(Json(LocalInferenceStatusDto { + available, + gpu_node_count: gpu.gpu_node_count, + gpu_products: gpu.gpu_products, + })) +} + +/// One curated, vetted model the wizard can offer without the operator +/// hand-typing a HuggingFace id or an AIKit image reference. Real values +/// verified live against AI Runway v0.7.0 + KAITO workspace chart 0.11.0 — +/// see docs/local-inference.md. +#[derive(Debug, Serialize, Clone)] +pub struct CuratedLocalModelDto { + pub id: String, + pub label: String, + pub tier: String, // "cpu" | "gpu" + pub params: String, +} + +/// `GET /api/operator/local-inference/catalog` — the curated list + tier +/// availability (a GPU entry is still LISTED when no GPU node exists, so the +/// wizard can show it disabled with a clear reason, rather than silently +/// hiding an option and confusing an operator who just hasn't added GPU +/// nodes yet). +pub async fn local_inference_catalog() -> Json<Vec<CuratedLocalModelDto>> { + Json(vec![ + CuratedLocalModelDto { + id: "llama-3.2-1b-instruct".into(), + label: "Llama 3.2 (1B, CPU)".into(), + tier: "cpu".into(), + params: "1B".into(), + }, + CuratedLocalModelDto { + id: "llama-3.2-3b-instruct".into(), + label: "Llama 3.2 (3B, CPU)".into(), + tier: "cpu".into(), + params: "3B".into(), + }, + CuratedLocalModelDto { + id: "gemma-2-2b-instruct".into(), + label: "Gemma 2 (2B, CPU)".into(), + tier: "cpu".into(), + params: "2B".into(), + }, + CuratedLocalModelDto { + id: "microsoft/Phi-4-mini-instruct".into(), + label: "Phi-4-mini (GPU)".into(), + tier: "gpu".into(), + params: "3.8B".into(), + }, + CuratedLocalModelDto { + id: "meta-llama/Llama-3.1-8B-Instruct".into(), + label: "Llama 3.1 (8B, GPU)".into(), + tier: "gpu".into(), + params: "8B".into(), + }, + CuratedLocalModelDto { + id: "mistralai/Mistral-7B-Instruct-v0.3".into(), + label: "Mistral (7B, GPU)".into(), + tier: "gpu".into(), + params: "7B".into(), + }, + ]) +} + +/// The AIKit CPU image for each curated CPU-tier model id — the `llamacpp` +/// engine needs an explicit pre-built image (there is no live HF→GGUF +/// resolution path), so this is the one place that mapping has to be +/// hardcoded. Free-text/advanced deployments must supply their own image. +fn aikit_image_for(model_id: &str) -> Option<&'static str> { + match model_id { + "llama-3.2-1b-instruct" => Some("ghcr.io/kaito-project/aikit/llama3.2:1b"), + "llama-3.2-3b-instruct" => Some("ghcr.io/kaito-project/aikit/llama3.2:3b"), + "gemma-2-2b-instruct" => Some("ghcr.io/kaito-project/aikit/gemma2:2b"), + _ => None, + } +} + +#[derive(Debug, Serialize)] +pub struct LocalModelDeploymentDto { + pub name: String, + pub namespace: String, + pub managed: bool, + pub model_id: Option<String>, + pub engine: Option<String>, + pub provider: Option<String>, + pub phase: Option<String>, + pub message: Option<String>, + pub endpoint: Option<String>, + pub created_at: Option<String>, +} + +fn project_model_deployment(o: &DynamicObject) -> LocalModelDeploymentDto { + let name = name_of(o); + let namespace = ns_of(o); + let managed = namespace == crate::kars::cluster::LOCAL_INFERENCE_NAMESPACE + && label(o, "app.kubernetes.io/managed-by").as_deref() == Some("kars-bridge"); + let spec = o.data.get("spec"); + let status = o.data.get("status"); + let model_id = spec + .and_then(|s| s.get("model")) + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .map(String::from); + let engine = status + .and_then(|s| s.get("engine")) + .and_then(|e| e.get("type")) + .and_then(Value::as_str) + .map(String::from); + let provider = status + .and_then(|s| s.get("provider")) + .and_then(|p| p.get("name")) + .and_then(Value::as_str) + .map(String::from); + let phase = status + .and_then(|s| s.get("phase")) + .and_then(Value::as_str) + .map(String::from); + let message = status + .and_then(|s| s.get("message")) + .and_then(Value::as_str) + .map(String::from); + // AI Runway publishes the routable Service in status when available. Fall + // back to the ModelDeployment name and port 80 for older controller builds. + let endpoint = if phase.as_deref() == Some("Running") { + let service = status + .and_then(|s| s.get("endpoint")) + .and_then(|e| e.get("service")) + .and_then(Value::as_str) + .unwrap_or(&name); + let port = status + .and_then(|s| s.get("endpoint")) + .and_then(|e| e.get("port")) + .and_then(Value::as_u64) + .unwrap_or(80); + Some(format!( + "http://{service}.{namespace}.svc.cluster.local:{port}" + )) + } else { + None + }; + LocalModelDeploymentDto { + name, + namespace, + managed, + model_id, + engine, + provider, + phase, + message, + endpoint, + created_at: created_of(o), + } +} + +/// `GET /api/operator/local-inference/deployments` — every ModelDeployment +/// the Bridge manages, with live status. +/// `GET /api/operator/local-inference/deployments` — every ModelDeployment +/// the Bridge manages, with live status. As a side effect, auto-registers +/// any newly-`Running` deployment as a normal additional inference provider +/// (tag `local-<name>`) — reusing the exact multi-provider mechanism proven +/// this session, so no router changes are needed: every sandbox's router +/// already knows how to dial an arbitrary custom OpenAI-compatible endpoint +/// once it's in `kars-inference-providers`. Idempotent (a re-list of an +/// already-wired deployment is a no-op re-write of the same values). +pub async fn list_local_model_deployments( + State(state): State<AppState>, +) -> AppResult<Json<Vec<LocalModelDeploymentDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_model_deployments().await.map_err(upstream)?; + let dtos: Vec<LocalModelDeploymentDto> = items.iter().map(project_model_deployment).collect(); + for d in &dtos { + if d.managed + && d.phase.as_deref() == Some("Running") + && let (Some(endpoint), Some(model_id)) = (&d.endpoint, &d.model_id) + { + auto_wire_local_provider(cluster, &d.name, endpoint, model_id).await; + } + } + Ok(Json(dtos)) +} + +/// Register a Running local ModelDeployment's Service as an additional +/// inference provider tagged `local-<name>`, no API key (in-cluster, +/// unauthenticated). Best-effort: a write failure here degrades to "the +/// model runs but isn't yet selectable from an InferencePolicy" rather than +/// failing the status poll the wizard depends on. +async fn auto_wire_local_provider( + cluster: &crate::kars::cluster::Cluster, + name: &str, + endpoint: &str, + model_id: &str, +) { + let tag_upper = format!("LOCAL_{}", name.to_ascii_uppercase().replace('-', "_")); + let endpoint = endpoint.to_string(); + let model_id = model_id.to_string(); + if let Err(e) = cluster + .mutate_secret_keys( + INFERENCE_PROVIDERS_NS, + INFERENCE_PROVIDERS_SECRET, + move |keys| { + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_ENDPOINT"), + endpoint.clone(), + ); + keys.insert( + format!("KARS_PROVIDER_{tag_upper}_MODELS"), + model_id.clone(), + ); + }, + ) + .await + { + tracing::warn!(deployment = name, error = %e, "failed to auto-wire local model as an inference provider"); + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateLocalModelDeploymentRequest { + /// DNS-label name for this deployment (becomes the Service name kars + /// wires into the inference-providers secret). + pub name: String, + /// A curated id (see `local_inference_catalog`) or, for the advanced + /// free-text path, any HuggingFace model id. + pub model_id: String, + /// "cpu" or "gpu" — selects the engine/provider shape. Advanced/free-text + /// requests must pick "cpu" (with an explicit `image`) or "gpu". + pub tier: String, + /// Required for tier=cpu when `model_id` isn't one of the curated ids + /// (the llamacpp engine needs a pre-built AIKit/GGUF image — there's no + /// live HF→GGUF resolution path). + #[serde(default)] + pub image: Option<String>, + /// GPU count for tier=gpu. Default 1. + #[serde(default)] + pub gpu_count: Option<i64>, +} + +/// `POST /api/operator/local-inference/deployments` — create (or update, via +/// SSA) a `ModelDeployment`. Rejects tier=cpu requests with no resolvable +/// image rather than creating a ModelDeployment doomed to fail validation +/// with an opaque upstream error. +pub async fn create_local_model_deployment( + State(state): State<AppState>, + Json(req): Json<CreateLocalModelDeploymentRequest>, +) -> AppResult<Json<LocalModelDeploymentDto>> { + let cluster = require_cluster(&state)?; + if !is_dns1123_label(&req.name) { + return Err(AppError::BadRequest( + "name must be lowercase letters, digits, hyphens".into(), + )); + } + let spec = match req.tier.as_str() { + "cpu" => { + let image = req.image.as_deref().filter(|i| !i.trim().is_empty()) + .or_else(|| aikit_image_for(&req.model_id)) + .ok_or_else(|| AppError::BadRequest( + "a CPU deployment needs a pre-built AIKit image — pick a curated model or supply spec.image for an advanced/free-text one".into(), + ))?; + serde_json::json!({ + "model": {"id": req.model_id}, + "engine": {"type": "llamacpp"}, + "image": image, + }) + } + "gpu" => { + serde_json::json!({ + "model": {"id": req.model_id}, + "resources": {"gpu": {"count": req.gpu_count.unwrap_or(1), "type": "nvidia.com/gpu"}}, + }) + } + other => { + return Err(AppError::BadRequest(format!( + "tier must be \"cpu\" or \"gpu\", got {other:?}" + ))); + } + }; + let obj = cluster + .apply_model_deployment(&req.name, spec) + .await + .map_err(upstream)?; + Ok(Json(project_model_deployment(&obj))) +} + +/// `GET /api/operator/local-inference/deployments/:name/status` — rich LIVE +/// status for the deploy progress tracker: a milestone-derived percentage, +/// real pod/container state, and the actual Kubernetes event stream (image +/// pull, scheduling, container start/fail) for this deployment's pods. +pub async fn local_deployment_live_status( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<crate::kars::cluster::LocalDeployLiveStatus>> { + let cluster = require_cluster(&state)?; + let status = cluster + .local_deployment_live_status(&name) + .await + .map_err(upstream)?; + Ok(Json(status)) +} + +/// `DELETE /api/operator/local-inference/deployments/:name` — undeploy a +/// local model. The Bridge also removes it from the connected-providers list +/// if it had been auto-wired (see `auto_wire_local_provider` in routes/run.rs +/// or the corresponding poll path) — callers should not assume the +/// InferencePolicy-facing tag disappears atomically with the CR. +pub async fn delete_local_model_deployment( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + cluster + .delete_model_deployment(&name) + .await + .map_err(upstream)?; + // Best-effort: also drop it from the additional-providers secret if it + // was auto-wired. Not fatal if it wasn't (e.g. deleted before Ready). + let tag = format!("local-{name}"); + let tag_upper = tag.to_ascii_uppercase().replace('-', "_"); + let _ = cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_ENDPOINT")); + keys.remove(&format!("KARS_PROVIDER_{tag_upper}_MODELS")); + }) + .await; + Ok(Json(serde_json::json!({"deleted": true, "name": name}))) +} + +#[cfg(test)] +mod tests { + use super::project_model_deployment; + use kube::core::DynamicObject; + use serde_json::json; + + #[test] + fn projects_discovered_airunway_model_in_its_actual_namespace() { + let object: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "airunway.ai/v1alpha1", + "kind": "ModelDeployment", + "metadata": { + "name": "gpt-oss-120b", + "namespace": "default" + }, + "spec": { + "model": {"id": "openai/gpt-oss-120b"} + }, + "status": { + "phase": "Running", + "endpoint": {"service": "gpt-oss-120b", "port": 80}, + "engine": {"type": "vllm"}, + "provider": {"name": "kaito"} + } + })) + .expect("valid dynamic object"); + + let projected = project_model_deployment(&object); + + assert_eq!(projected.namespace, "default"); + assert!(!projected.managed); + assert_eq!( + projected.endpoint.as_deref(), + Some("http://gpt-oss-120b.default.svc.cluster.local:80") + ); + assert_eq!(projected.model_id.as_deref(), Some("openai/gpt-oss-120b")); + } +} diff --git a/bridge/bff/src/routes/operator/policies.rs b/bridge/bff/src/routes/operator/policies.rs new file mode 100644 index 000000000..a99d35768 --- /dev/null +++ b/bridge/bff/src/routes/operator/policies.rs @@ -0,0 +1,588 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use kube::core::DynamicObject; +use serde::Serialize; +use serde_json::Value; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::{created_of, name_of, ns_of, require_cluster, s, spec, status, upstream}; + +// ─── MCP servers (connected services) ──────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct McpServerDto { + pub name: String, + pub namespace: String, + pub url: Option<String>, + pub phase: Option<String>, + pub mode: Option<String>, + pub endpoint: Option<String>, + pub workload_ref: Option<String>, + pub discovered_tools: Vec<String>, + pub tool_schema_digest: Option<String>, + pub production: Option<bool>, + pub allowed_tools: Vec<String>, + pub created: Option<String>, + /// Raw `spec` for Edit-form prefill. + pub spec: serde_json::Value, +} + +fn to_mcp(o: &DynamicObject) -> McpServerDto { + let sp = spec(o); + let st = status(o); + let allowed_tools = sp + .get("allowedTools") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + McpServerDto { + name: name_of(o), + namespace: ns_of(o), + url: s(st, "endpoint").or_else(|| s(sp, "url")), + phase: s(st, "phase"), + mode: s(st, "mode"), + endpoint: s(st, "endpoint"), + workload_ref: s(st, "workloadRef"), + discovered_tools: st + .get("discoveredTools") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + tool_schema_digest: s(st, "toolSchemaDigest"), + production: sp.get("productionMode").and_then(|p| p.as_bool()), + allowed_tools, + created: created_of(o), + spec: sp.clone(), + } +} + +/// `GET /api/operator/mcpservers` — registered MCP servers (connected services). +pub async fn list_mcpservers(State(state): State<AppState>) -> AppResult<Json<Vec<McpServerDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_kind_all("McpServer").await.map_err(upstream)?; + let mut dtos: Vec<McpServerDto> = items.iter().map(to_mcp).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +// ─── Tool policies ─────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct ToolPolicyDto { + pub name: String, + pub namespace: String, + pub phase: Option<String>, + pub version_hash: Option<String>, + /// What this policy applies to (sandbox/tool scope), in plain terms. + pub applies_to: Option<String>, + /// Whether the policy carries an AGT governance profile (the rule set that + /// allows/denies/rate-limits capabilities). + pub has_governance_profile: bool, + /// Allowed tool / MCP identifiers, when expressed as a flat list. + pub allowed: Vec<String>, + pub created: Option<String>, + /// The raw `spec` object, so the console can prefill the Edit form with the + /// exact current spec (edit = re-apply with changed fields via SSA). + pub spec: serde_json::Value, +} + +fn to_toolpolicy(o: &DynamicObject) -> ToolPolicyDto { + let sp = spec(o); + let mut allowed: Vec<String> = Vec::new(); + if let Some(arr) = sp.get("allow").and_then(|a| a.as_array()) { + allowed.extend(arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string()))); + } + if let Some(arr) = sp.get("tools").and_then(|a| a.as_array()) { + allowed.extend(arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string()))); + } + // The real ToolPolicy scopes via `appliesTo` (sandbox labels + tool glob) + // and governs capabilities through an embedded AGT profile. Project that + // into a plain summary rather than an empty list. + let applies_to = sp.get("appliesTo").map(|a| { + let tool = a.get("tool").and_then(|t| t.as_str()).unwrap_or("*"); + // Render the FULL sandbox selector, not just the well-known sandbox + // label, so a policy scoped by other labels isn't misreported as "*". + let labels = a + .get("sandboxMatchLabels") + .and_then(|l| l.as_object()) + .map(|m| { + m.iter() + .map(|(k, v)| format!("{}={}", k, v.as_str().unwrap_or(""))) + .collect::<Vec<_>>() + .join(", ") + }) + .filter(|s| !s.is_empty()); + match labels { + Some(sel) => format!("sandbox [{sel}] · tools {tool}"), + None => format!("all sandboxes · tools {tool}"), + } + }); + let has_governance_profile = sp.get("agtProfile").is_some(); + ToolPolicyDto { + name: name_of(o), + namespace: ns_of(o), + phase: s(status(o), "phase"), + version_hash: s(status(o), "versionHash"), + applies_to, + has_governance_profile, + allowed, + created: created_of(o), + spec: sp.clone(), + } +} + +/// `GET /api/operator/toolpolicies` — tool/MCP authorization policies. +pub async fn list_toolpolicies( + State(state): State<AppState>, +) -> AppResult<Json<Vec<ToolPolicyDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("ToolPolicy") + .await + .map_err(upstream)?; + let mut dtos: Vec<ToolPolicyDto> = items.iter().map(to_toolpolicy).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +// ─── Inference policies ────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct InferencePolicyDto { + pub name: String, + pub namespace: String, + pub phase: Option<String>, + pub version_hash: Option<String>, + pub sandbox: Option<String>, + pub daily_token_budget: Option<i64>, + pub content_safety: bool, + pub created: Option<String>, + /// The raw spec, so the console's visual editor can pre-fill an edit + /// (name/sandbox/tokens/content-safety/model-preference) instead of a + /// hand-authored JSON blob. + pub spec: Value, +} + +fn to_inferencepolicy(o: &DynamicObject) -> InferencePolicyDto { + let sp = spec(o); + InferencePolicyDto { + name: name_of(o), + namespace: ns_of(o), + phase: s(status(o), "phase"), + version_hash: s(status(o), "versionHash"), + sandbox: sp + .get("appliesTo") + .and_then(|a| a.get("sandboxName")) + .and_then(|x| x.as_str()) + .map(|x| x.to_string()), + daily_token_budget: sp + .get("tokenBudget") + .and_then(|t| t.get("dailyTokens")) + .and_then(|x| x.as_i64()), + // Content safety is enforced when the floor actually sets a severity + // threshold or requires Prompt Shields — an empty `contentSafety: {}` + // object is not protection, so don't report it as enabled. + content_safety: sp + .get("contentSafety") + .map(|cs| { + ["hate", "selfHarm", "sexual", "violence"] + .iter() + .any(|k| cs.get(*k).and_then(|v| v.as_str()).is_some()) + || cs.get("requirePromptShields").and_then(|v| v.as_bool()) == Some(true) + }) + .unwrap_or(false), + created: created_of(o), + spec: sp.clone(), + } +} + +/// `GET /api/operator/inferencepolicies` — inference governance policies. +pub async fn list_inferencepolicies( + State(state): State<AppState>, +) -> AppResult<Json<Vec<InferencePolicyDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("InferencePolicy") + .await + .map_err(upstream)?; + let mut dtos: Vec<InferencePolicyDto> = items.iter().map(to_inferencepolicy).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +/// `POST /api/operator/inferencepolicies` — create (or Server-Side-Apply edit) a +/// standalone InferencePolicy the operator authors directly (e.g. a policy +/// scoped to a selector with a token budget + content-safety floor). The +/// per-sandbox `<task>-inference` policies remain controller-generated; this is +/// the "I should be able to create inference policies" capability. +pub async fn create_inferencepolicy( + State(state): State<AppState>, + Json(req): Json<ApplyCrdRequest>, +) -> AppResult<Json<serde_json::Value>> { + apply_governance(require_cluster(&state)?, "InferencePolicy", req).await +} + +#[derive(serde::Deserialize)] +pub struct PatchInferenceBudgetRequest { + /// New daily token cap for this policy (0 clears the cap). + pub daily_tokens: i64, +} + +/// `PATCH /api/operator/inferencepolicies/{name}` — edit a policy's daily token +/// budget in place (a merge patch on `spec.tokenBudget.dailyTokens`). For a +/// controller-generated policy the durable source is the mission's envelope +/// budget, so the reconciler may re-derive it; for an operator-authored policy +/// the edit sticks. +pub async fn patch_inferencepolicy( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, + Json(req): Json<PatchInferenceBudgetRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let budget = if req.daily_tokens > 0 { + serde_json::json!({ "dailyTokens": req.daily_tokens }) + } else { + serde_json::Value::Null + }; + let patch = serde_json::json!({ "spec": { "tokenBudget": budget } }); + cluster + .merge_patch_kind("kars-system", "InferencePolicy", &name, patch) + .await + .map_err(apply_err)?; + Ok(Json(serde_json::json!({ "patched": true, "name": name }))) +} + +/// `DELETE /api/operator/inferencepolicies/{name}` — remove an operator-authored +/// policy. (A controller-generated one will be recreated by the reconciler.) +pub async fn delete_inferencepolicy( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + delete_governance(require_cluster(&state)?, "InferencePolicy", &name, None).await +} + +// ─── Egress (allowlists + temporary approvals) ─────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct EgressApprovalDto { + pub name: String, + pub namespace: String, + pub sandbox: Option<String>, + pub phase: Option<String>, + pub reason: Option<String>, + pub hosts: Vec<String>, + pub expires_at: Option<String>, + pub created: Option<String>, +} + +fn to_egress_approval(o: &DynamicObject) -> EgressApprovalDto { + let sp = spec(o); + let hosts = sp + .get("hosts") + .and_then(|h| h.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host").and_then(|x| x.as_str())?; + let port = e.get("port").and_then(|x| x.as_i64()); + Some(match port { + Some(p) => format!("{host}:{p}"), + None => host.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + EgressApprovalDto { + name: name_of(o), + namespace: ns_of(o), + sandbox: s(sp, "sandbox"), + phase: s(status(o), "phase"), + reason: s(sp, "reason"), + hosts, + expires_at: s(status(o), "expiresAt"), + created: created_of(o), + } +} + +/// `GET /api/operator/egress` — temporary egress approvals across the fleet. +pub async fn list_egress(State(state): State<AppState>) -> AppResult<Json<Vec<EgressApprovalDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("EgressApproval") + .await + .map_err(upstream)?; + let mut dtos: Vec<EgressApprovalDto> = items.iter().map(to_egress_approval).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +// +// The Bridge is the author of the envelope's governance objects, not just a +// reader. Authoring uses Server-Side Apply (`cluster.apply_kind`) — the +// Kubernetes-native declarative upsert — so the SAME endpoint creates a new CRD +// and edits an existing one (re-apply with changed spec). The security boundary +// is RBAC on the Bridge ServiceAccount plus the CRD's admission/CEL validation; +// a rejected write surfaces the API server's own message via `AppError::Rejected`. + +/// Apply-a-governance-CRD request. `spec` is the kind's raw `spec` object so the +/// operator can author every field; `force` opts into taking field ownership on +/// a 409 conflict (default: surface the conflict instead of clobbering). +#[derive(Debug, serde::Deserialize)] +pub struct ApplyCrdRequest { + pub name: String, + #[serde(default)] + pub namespace: Option<String>, + pub spec: serde_json::Value, + #[serde(default)] + pub force: bool, +} + +/// Map a CRD-apply kube error to a client-safe AppError: admission/validation +/// (400/422) and field-ownership conflicts (409) are surfaced verbatim (safe — +/// they are the API server's own messages), RBAC denials (403) are made +/// actionable, everything else is an opaque upstream error. +pub(super) fn apply_err(e: kube::Error) -> AppError { + if let kube::Error::Api(ae) = &e { + match ae.code { + 400 | 422 => return AppError::Rejected(ae.message.clone()), + 409 => { + return AppError::Rejected(format!( + "field-ownership conflict: {} — another manager owns a field this apply sets; re-apply with force:true to take ownership", + ae.message + )); + } + 403 => { + return AppError::Rejected(format!( + "forbidden: {} — the Bridge ServiceAccount lacks RBAC to write this resource", + ae.message + )); + } + // Server-Side Apply surfaces schema-validation failures (an unknown + // or misspelled spec field) as a 500 whose message IS actionable and + // safe — e.g. "failed to create typed patch object (…): .spec.allow: + // field not declared in schema". Without this, an operator authoring + // a bad field gets an opaque "upstream dependency failed" instead of + // the field to fix. Surface it as a rejection with the real message. + 500 if ae.message.contains("field not declared in schema") + || ae.message.contains("failed to create typed patch object") + || ae.message.contains("unknown field") => + { + return AppError::Rejected(ae.message.clone()); + } + _ => {} + } + } + AppError::Upstream(e.to_string()) +} + +/// Shared apply path for the three governance kinds. Targets `kars-system` by +/// default (where the controller reads them). +pub(super) async fn apply_governance( + cluster: &crate::kars::cluster::Cluster, + kind: &str, + req: ApplyCrdRequest, +) -> AppResult<Json<serde_json::Value>> { + let name = req.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + if !req.spec.is_object() { + return Err(AppError::BadRequest("spec must be a JSON object".into())); + } + let ns = req + .namespace + .as_deref() + .unwrap_or("kars-system") + .to_string(); + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": kind, + "metadata": { + "name": name, + "namespace": ns, + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + }, + "spec": req.spec, + }); + let applied = cluster + .apply_kind(&ns, kind, body, req.force) + .await + .map_err(apply_err)?; + Ok(Json(serde_json::json!({ + "applied": true, + "kind": kind, + "name": name_of(&applied), + "namespace": ns, + "note": "Server-Side Apply (field manager kars-bridge): created on first apply, edited on re-apply." + }))) +} + +/// `PUT /api/operator/toolpolicies` — author/edit a `ToolPolicy` (SSA). +pub async fn put_toolpolicy( + State(state): State<AppState>, + Json(req): Json<ApplyCrdRequest>, +) -> AppResult<Json<serde_json::Value>> { + apply_governance(require_cluster(&state)?, "ToolPolicy", req).await +} + +/// `PUT /api/operator/mcpservers` — author/edit an `McpServer` (SSA). +pub async fn put_mcpserver( + State(state): State<AppState>, + Json(req): Json<ApplyCrdRequest>, +) -> AppResult<Json<serde_json::Value>> { + apply_governance(require_cluster(&state)?, "McpServer", req).await +} + +/// `PUT /api/operator/skills` — author/edit a `KarsSkill` (SSA). +pub async fn put_skill( + State(state): State<AppState>, + Json(req): Json<ApplyCrdRequest>, +) -> AppResult<Json<serde_json::Value>> { + apply_governance(require_cluster(&state)?, "KarsSkill", req).await +} + +/// Map a delete kube error: 404 → NotFound, 403 → actionable RBAC message, +/// everything else opaque upstream. +fn delete_err(e: kube::Error) -> AppError { + if let kube::Error::Api(ae) = &e { + match ae.code { + 404 => return AppError::NotFound, + 403 => { + return AppError::Rejected(format!( + "forbidden: {} — the Bridge ServiceAccount lacks RBAC to delete this resource", + ae.message + )); + } + _ => {} + } + } + AppError::Upstream(e.to_string()) +} + +/// Shared delete path for a governance kind. Targets `kars-system` by default. +pub(super) async fn delete_governance( + cluster: &crate::kars::cluster::Cluster, + kind: &str, + name: &str, + namespace: Option<&str>, +) -> AppResult<Json<serde_json::Value>> { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("name is required".into())); + } + let ns = namespace.unwrap_or("kars-system"); + cluster + .delete_kind(ns, kind, name) + .await + .map_err(delete_err)?; + Ok(Json(serde_json::json!({ + "deleted": true, + "kind": kind, + "name": name, + "namespace": ns, + "note": "Deleted with foreground propagation — the controller's finalizers revoke downstream state before removal." + }))) +} + +/// `DELETE /api/operator/toolpolicies/:name` — remove a `ToolPolicy`. +pub async fn delete_toolpolicy( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + delete_governance(require_cluster(&state)?, "ToolPolicy", &name, None).await +} + +/// `DELETE /api/operator/mcpservers/:name` — remove an `McpServer`. +pub async fn delete_mcpserver( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + delete_governance(require_cluster(&state)?, "McpServer", &name, None).await +} + +/// `DELETE /api/operator/skills/:name` — remove a `KarsSkill`. +pub async fn delete_skill( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + delete_governance(require_cluster(&state)?, "KarsSkill", &name, None).await +} + +/// `DELETE /api/operator/egress/:name` — revoke a temporary `EgressApproval`. +/// The EgressApproval model is create-to-grant / delete-to-revoke, so deleting +/// the object is the authoritative revoke action (the controller reconciles the +/// sandbox allowlist back to its signed baseline on removal). +pub async fn delete_egress( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + delete_governance(require_cluster(&state)?, "EgressApproval", &name, None).await +} + +#[cfg(test)] +mod tests { + use super::apply_err; + use crate::error::AppError; + + fn api_err(code: u16, message: &str) -> kube::Error { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: message.into(), + reason: "".into(), + code, + }) + } + + #[test] + fn apply_err_surfaces_ssa_schema_failure() { + // A Server-Side Apply schema rejection arrives as a 500 with an + // actionable message — it must become a Rejected (422) carrying that + // message, NOT an opaque Upstream (502). + let e = api_err( + 500, + "failed to create typed patch object (kars-system/qa; kars.azure.com/v1alpha1, Kind=ToolPolicy): .spec.allow: field not declared in schema", + ); + match apply_err(e) { + AppError::Rejected(m) => assert!(m.contains("field not declared in schema")), + other => panic!("expected Rejected, got {other:?}"), + } + } + + #[test] + fn apply_err_keeps_opaque_500_opaque() { + // A generic 500 with no actionable schema message stays Upstream. + match apply_err(api_err(500, "etcdserver: request timed out")) { + AppError::Upstream(_) => {} + other => panic!("expected Upstream, got {other:?}"), + } + } + + #[test] + fn apply_err_maps_validation_and_rbac() { + assert!(matches!( + apply_err(api_err(422, "bad")), + AppError::Rejected(_) + )); + assert!(matches!( + apply_err(api_err(403, "no")), + AppError::Rejected(_) + )); + assert!(matches!( + apply_err(api_err(409, "conflict")), + AppError::Rejected(_) + )); + } +} diff --git a/bridge/bff/src/routes/operator/providers.rs b/bridge/bff/src/routes/operator/providers.rs new file mode 100644 index 000000000..b43032bfa --- /dev/null +++ b/bridge/bff/src/routes/operator/providers.rs @@ -0,0 +1,698 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::additional_providers::{INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET}; +use super::{require_cluster, upstream}; + +// ─── Provider onboarding (model providers for missions + envelope gen) ─────── + +#[derive(Debug, serde::Deserialize)] +pub struct ProviderRequest { + /// "github-models" | "azure-openai" | "foundry". + pub kind: String, + /// Auth mode: "api" (key), "workload" (workload identity), "agentid". + pub auth: String, + pub endpoint: Option<String>, + /// Comma-separated deployment ids to expose in the catalog. + pub models: String, + /// Optional key when auth=api; stored write-only in kars-system. + pub key: Option<String>, +} + +/// `POST /api/operator/providers` — onboard a model provider. Sets the catalog +/// the controller serves, records the endpoint, and (for api auth) stores the +/// key as a write-only secret. Workload/agentid auth store no secret — the +/// controller authenticates via its identity. The catalog feeds both mission +/// models and envelope generation. Patches the controller deployment env. +pub async fn put_provider( + State(state): State<AppState>, + Json(req): Json<ProviderRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + // GUARD: this endpoint only ever wires FOUNDRY_ENDPOINT + AZURE_OPENAI_API_KEY + // onto the controller (set_controller_catalog below). GitHub Copilot needs a + // COPILOT_GITHUB_TOKEN (exchanged for a Copilot JWT by the router's copilot_auth + // path) and GitHub Models needs its catalog endpoint recognized by the router's + // is_github_models() host check — neither is wired by this route. Silently + // "succeeding" here would tell the operator the cluster default changed when it + // did not. Reject until real backend wiring exists; both kinds work correctly + // today via the "additional provider" flow (POST .../providers/additional), + // which does propagate a real per-provider tag + credential to every sandbox. + if req.kind == "github-copilot" || req.kind == "github-models" { + return Err(AppError::BadRequest(format!( + "{} can't be set as the cluster's default provider from this form yet \ + (it only wires an Azure-style endpoint/key). Add it as an additional \ + provider instead — every sandbox can already route to it per-request \ + via an InferencePolicy model preference.", + if req.kind == "github-copilot" { + "GitHub Copilot" + } else { + "GitHub Models" + } + ))); + } + let models: Vec<&str> = req + .models + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + if models.is_empty() { + return Err(AppError::BadRequest( + "at least one model deployment is required".into(), + )); + } + let mut key_secret: Option<(String, String)> = None; + if req.auth == "api" { + if let Some(k) = req.key.as_deref().filter(|k| !k.trim().is_empty()) { + let secret = format!("kars-provider-{}", req.kind); + cluster.upsert_secret("kars-system", &secret, serde_json::json!({ + "apiVersion": "v1", "kind": "Secret", "type": "Opaque", + "metadata": {"name": secret, "namespace": "kars-system", "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}}, + "stringData": {"API_KEY": k}, + })).await.map_err(upstream)?; + key_secret = Some((secret, "API_KEY".to_string())); + } else { + return Err(AppError::BadRequest( + "auth=api requires a provider API key".into(), + )); + } + } + let key_ref = key_secret.as_ref().map(|(s, k)| (s.as_str(), k.as_str())); + cluster + .set_controller_catalog(&models.join(","), req.endpoint.as_deref(), key_ref) + .await + .map_err(upstream)?; + Ok(Json( + serde_json::json!({"onboarded": true, "kind": req.kind, "auth": req.auth, "models": models, + "note": if key_secret.is_some() { + "Catalog updated and the API key wired into the controller via secretKeyRef (AZURE_OPENAI_API_KEY), which the controller propagates to sandbox pods. The controller is rolling to pick it up." + } else { + "Catalog updated; the controller is rolling. workload/agentid auth use the controller's own identity — no key stored." + }}), + )) +} + +/// One discoverable model, browser-facing. +#[derive(Debug, Serialize)] +pub struct DiscoveredModelDto { + /// The exact id to feed back into `ProviderRequest.models` (e.g. `openai/gpt-4o`). + pub id: String, + /// Human label, when richer than the id (e.g. "OpenAI GPT-4o"). + pub label: Option<String>, + /// True for a highlighted/pre-selected pick. For GitHub Copilot this is + /// every model in Copilot's own `powerful` picker category (the flagship + /// tier), derived LIVE from the `/models` endpoint — not a hand-picked id + /// that goes stale. Absent/false for GitHub Models / Azure OpenAI, which + /// have no "best pick" signal. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recommended: bool, + /// Short human detail (e.g. "Anthropic · 1.0M ctx · powerful"), when the + /// provider exposes it (GitHub Copilot's live catalog does). Shown in the + /// Model catalogue so a model isn't just an opaque id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option<String>, +} + +/// The same editor/integration headers the router's `copilot_auth` sends, so +/// the seat sees a consistent client identity across discovery and inference. +const COPILOT_EDITOR_VERSION: &str = "vscode/1.107.0"; +const COPILOT_INTEGRATION_ID: &str = "vscode-chat"; +/// Public OAuth client id for the GitHub Copilot device-flow integration — the +/// SAME id the CLI's `copilotDeviceLogin` uses (cli/src/github-copilot.ts). A +/// token minted through this flow is authorized for the `copilot_internal/v2/ +/// token` exchange, unlike a stock `gh auth login` token (which 404s there). +const COPILOT_OAUTH_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; + +/// `POST /api/operator/providers/copilot/login/start` — begin the GitHub +/// device-flow OAuth so the operator can sign in to Copilot properly (no +/// hand-pasted token). Returns the user code + verification URL to show, and +/// the device code the client polls with. +pub async fn copilot_login_start() -> AppResult<Json<serde_json::Value>> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .post("https://github.com/login/device/code") + .header("Accept", "application/json") + .header("User-Agent", "kars-bridge") + .json(&serde_json::json!({ "client_id": COPILOT_OAUTH_CLIENT_ID, "scope": "read:user" })) + .send() + .await + .map_err(|e| AppError::Upstream(format!("device-code request failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "GitHub device-code returned {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad device-code JSON: {e}")))?; + Ok(Json(serde_json::json!({ + "device_code": body.get("device_code").and_then(|v| v.as_str()).unwrap_or_default(), + "user_code": body.get("user_code").and_then(|v| v.as_str()).unwrap_or_default(), + "verification_uri": body.get("verification_uri").and_then(|v| v.as_str()).unwrap_or("https://github.com/login/device"), + "interval": body.get("interval").and_then(|v| v.as_u64()).unwrap_or(5), + "expires_in": body.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(900), + }))) +} + +#[derive(Debug, serde::Deserialize)] +pub struct CopilotLoginPollRequest { + pub device_code: String, +} + +/// `POST /api/operator/providers/copilot/login/poll` — poll the device flow. +/// While the user hasn't approved yet, returns `{status:"pending"}`. On +/// approval it: (1) verifies the minted token is Copilot-entitled, (2) stores +/// it server-side as the Copilot provider credential (COPILOT_GITHUB_TOKEN in +/// the shared providers secret) — the token NEVER returns to the browser, +/// (3) busts the live-catalog cache, and (4) returns the seat's live model +/// list so the wizard can show it immediately. +pub async fn copilot_login_poll( + State(state): State<AppState>, + Json(req): Json<CopilotLoginPollRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .header("User-Agent", "kars-bridge") + .json(&serde_json::json!({ + "client_id": COPILOT_OAUTH_CLIENT_ID, + "device_code": req.device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + })) + .send() + .await + .map_err(|e| AppError::Upstream(format!("device poll failed: {e}")))?; + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad poll JSON: {e}")))?; + + if let Some(token) = body + .get("access_token") + .and_then(|v| v.as_str()) + .filter(|t| !t.is_empty()) + { + // Verify the seat is genuinely Copilot-entitled before storing. + copilot_jwt(token).await?; + // Store server-side as the Copilot provider credential (never returned + // to the browser). Also refresh the controller's default credential so + // a cluster whose default IS Copilot starts working immediately. + cluster + .mutate_secret_keys(INFERENCE_PROVIDERS_NS, INFERENCE_PROVIDERS_SECRET, |keys| { + keys.insert("COPILOT_GITHUB_TOKEN".to_string(), token.to_string()); + }) + .await + .map_err(upstream)?; + // Fresh token → invalidate any cached catalog for the old one. + invalidate_copilot_catalog_cache(); + let models = copilot_catalog_cached(token).await; + return Ok(Json(serde_json::json!({ + "status": "authorized", + "models": models.iter().map(|(id, rec, detail)| serde_json::json!({"id": id, "recommended": rec, "detail": detail})).collect::<Vec<_>>(), + }))); + } + + match body.get("error").and_then(|v| v.as_str()) { + Some("authorization_pending") | Some("slow_down") => { + Ok(Json(serde_json::json!({ "status": "pending" }))) + } + Some("expired_token") => Err(AppError::Rejected( + "The sign-in code expired before it was approved. Start again.".into(), + )), + Some("access_denied") => Err(AppError::Rejected( + "Sign-in was cancelled on GitHub.".into(), + )), + Some(other) => Err(AppError::Upstream(format!( + "GitHub device flow error: {other}" + ))), + None => Ok(Json(serde_json::json!({ "status": "pending" }))), + } +} + +/// Exchange a GitHub OAuth token / PAT for a short-lived Copilot JWT — the +/// exact same endpoint (and `chat_enabled` eligibility semantics) the CLI's +/// `checkCopilotEligibility` and the router's `copilot_auth` use. A 200 with a +/// token and `chat_enabled != false` means the router will actually be able to +/// serve inference for this seat, not merely that the token parses. Returns +/// the JWT so the caller can immediately query the live `/models` catalog with +/// it (no second exchange). +pub(crate) async fn copilot_jwt(gh_token: &str) -> Result<String, AppError> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .get("https://api.github.com/copilot_internal/v2/token") + .header("Authorization", format!("token {gh_token}")) + .header("Accept", "application/json") + .header("User-Agent", "kars-bridge") + .send() + .await + .map_err(|e| AppError::Upstream(format!("Copilot eligibility check failed: {e}")))?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED + || resp.status() == reqwest::StatusCode::FORBIDDEN + { + return Err(AppError::Rejected( + "This GitHub token isn't entitled to Copilot. Enable Copilot at https://github.com/settings/copilot, or use a token from an account with an active seat.".into(), + )); + } + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "Copilot token endpoint returned {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad Copilot token response: {e}")))?; + if body.get("chat_enabled").and_then(|c| c.as_bool()) == Some(false) { + return Err(AppError::Rejected( + "Copilot subscription is active but Chat is disabled. Enable it at https://github.com/settings/copilot/features.".into(), + )); + } + body.get("token") + .and_then(|t| t.as_str()) + .map(str::to_string) + .ok_or_else(|| AppError::Upstream("Copilot token endpoint returned no token".into())) +} + +/// Parse GitHub Copilot's live `/models` response into the browser DTO. Pure +/// (no I/O) so it's unit-testable against a captured sample. Surfaces ONLY the +/// models a seat can actually reason with: +/// • `capabilities.type == "chat"` — excludes embeddings. +/// • `model_picker_enabled == true` — Copilot's own "show in picker" flag; +/// drops legacy/hidden aliases (gpt-4o, gpt-3.5-turbo, dated snapshots). +/// • policy absent, OR `policy.state == "enabled"` — a gated preview the +/// seat hasn't opted into is not usable, so it's hidden. +/// Ordering: Copilot's picker category (powerful → versatile → lightweight), +/// then context window desc, then id — so the flagship tier leads. Every +/// `powerful`-category model is marked `recommended` (pre-checked in the +/// wizard). This is entirely live: a new flagship (gpt-5.7, opus-4.9, …) +/// appears and is categorised by GitHub, with no code change here. +pub(crate) fn parse_copilot_models(body: &serde_json::Value) -> Vec<DiscoveredModelDto> { + fn category_rank(cat: &str) -> u8 { + match cat { + "powerful" => 0, + "versatile" => 1, + "lightweight" => 2, + _ => 3, + } + } + let mut rows: Vec<(u8, u64, String, DiscoveredModelDto)> = Vec::new(); + let Some(data) = body.get("data").and_then(|d| d.as_array()) else { + return Vec::new(); + }; + for m in data { + let caps = m.get("capabilities"); + let is_chat = caps.and_then(|c| c.get("type")).and_then(|t| t.as_str()) == Some("chat"); + let picker = m + .get("model_picker_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // policy absent => generally available; present => must be "enabled". + let policy_ok = match m.get("policy") { + None => true, + Some(p) => p.get("state").and_then(|s| s.as_str()) == Some("enabled"), + }; + if !(is_chat && picker && policy_ok) { + continue; + } + let Some(id) = m + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + else { + continue; + }; + let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); + let vendor = m.get("vendor").and_then(|v| v.as_str()).unwrap_or(""); + let category = m + .get("model_picker_category") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let ctx = caps + .and_then(|c| c.get("limits")) + .and_then(|l| l.get("max_context_window_tokens")) + .and_then(|v| v.as_u64()); + let ctx_label = ctx + .map(|c| { + if c >= 1_000_000 { + format!("{:.1}M ctx", c as f64 / 1_000_000.0) + } else { + format!("{}k ctx", c / 1000) + } + }) + .unwrap_or_default(); + let label = [vendor, &ctx_label, category] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::<Vec<_>>() + .join(" · "); + let detail = if label.is_empty() { + None + } else { + Some(label.clone()) + }; + rows.push(( + category_rank(category), + ctx.unwrap_or(0), + id.to_string(), + DiscoveredModelDto { + id: id.to_string(), + label: (name != id || !label.is_empty()).then(|| { + if label.is_empty() { + name.to_string() + } else { + format!("{name} — {label}") + } + }), + recommended: category == "powerful", + detail, + }, + )); + } + // Sort: powerful first, then largest context, then id desc (newer version + // numbers tend to sort higher) — purely presentational. + rows.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(b.2.cmp(&a.2))); + rows.into_iter().map(|(_, _, _, dto)| dto).collect() +} + +/// Fetch the live Copilot model catalog for a seat, given its exchanged JWT. +pub(crate) async fn fetch_copilot_models(jwt: &str) -> Result<Vec<DiscoveredModelDto>, AppError> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + let resp = client + .get("https://api.githubcopilot.com/models") + .header("Authorization", format!("Bearer {jwt}")) + .header("Editor-Version", COPILOT_EDITOR_VERSION) + .header("Copilot-Integration-Id", COPILOT_INTEGRATION_ID) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| AppError::Upstream(format!("Copilot /models request failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "Copilot /models returned {}", + resp.status() + ))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad Copilot /models JSON: {e}")))?; + Ok(parse_copilot_models(&body)) +} + +/// A short-TTL cache for the live Copilot catalog so `build_options` (hit on +/// every Configuration page load AND every orchestrator compose) doesn't do a +/// token-exchange + /models round-trip each time. Keyed by the token so a +/// changed seat re-fetches; 5-minute freshness is plenty for a model list. +type CopilotCatalog = Vec<(String, bool, Option<String>)>; +type CachedCopilotCatalog = (String, std::time::Instant, CopilotCatalog); +static COPILOT_CATALOG_CACHE: std::sync::Mutex<Option<CachedCopilotCatalog>> = + std::sync::Mutex::new(None); + +/// Drop the cached Copilot catalog — call after a fresh sign-in so the next +/// `build_options` re-fetches against the new token immediately. +pub(crate) fn invalidate_copilot_catalog_cache() { + *COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()) = None; +} + +/// Live (cached) Copilot model catalog for a seat token: `(deployment_id, +/// recommended, detail)` for every model the seat can actually use. Best-effort +/// — on any auth/network failure it returns the last good cache if still +/// present, else empty, so a transient Copilot outage never blanks the catalogue. +pub(crate) async fn copilot_catalog_cached(gh_token: &str) -> Vec<(String, bool, Option<String>)> { + const TTL: std::time::Duration = std::time::Duration::from_secs(300); + { + let guard = COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()); + if let Some((tok, at, models)) = guard.as_ref() + && tok == gh_token + && at.elapsed() < TTL + { + return models.clone(); + } + } + let fetched = async { + let jwt = copilot_jwt(gh_token).await.ok()?; + let models = fetch_copilot_models(&jwt).await.ok()?; + Some( + models + .into_iter() + .map(|m| (m.id, m.recommended, m.detail)) + .collect::<Vec<_>>(), + ) + } + .await; + match fetched { + Some(models) => { + let mut guard = COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()); + *guard = Some(( + gh_token.to_string(), + std::time::Instant::now(), + models.clone(), + )); + models + } + None => { + // Fetch failed — reuse a still-present cache entry (even if stale) + // rather than blanking the catalogue on a transient hiccup. + let guard = COPILOT_CATALOG_CACHE + .lock() + .unwrap_or_else(|p| p.into_inner()); + guard + .as_ref() + .filter(|(tok, _, _)| tok == gh_token) + .map(|(_, _, m)| m.clone()) + .unwrap_or_default() + } + } +} + +#[derive(Debug, serde::Deserialize)] +pub struct DiscoverModelsRequest { + /// "github-models" | "azure-openai" | "github-copilot". (Foundry already + /// discovers models via the existing /api/operator/foundry/verify.) + pub kind: String, + pub endpoint: Option<String>, + pub key: Option<String>, +} + +/// `POST /api/operator/providers/discover` — real, live model discovery so the +/// operator never hand-types a deployment id. GitHub Models queries the public +/// catalog (no auth). Azure OpenAI queries the data-plane `/openai/deployments` +/// endpoint using the operator-supplied endpoint + key (a live round-trip, so a +/// wrong key/endpoint surfaces as an immediate, actionable error). GitHub +/// Copilot exchanges the supplied token for a Copilot JWT (verifying the seat + +/// Chat entitlement live) and then queries the seat's LIVE `/models` catalog — +/// so the picker always reflects the models GitHub currently serves this seat +/// (gpt-5.6, claude-opus-4.8, gemini-3.1-pro, …), never a hand-maintained list +/// that goes stale. +pub async fn discover_models( + Json(req): Json<DiscoverModelsRequest>, +) -> AppResult<Json<Vec<DiscoveredModelDto>>> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + match req.kind.as_str() { + "github-models" => { + let resp = client + .get("https://models.github.ai/catalog/models") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| { + AppError::Upstream(format!("GitHub Models catalog request failed: {e}")) + })?; + if !resp.status().is_success() { + return Err(AppError::Upstream(format!( + "GitHub Models catalog returned {}", + resp.status() + ))); + } + let body: Vec<serde_json::Value> = resp + .json() + .await + .map_err(|e| AppError::Upstream(format!("bad catalog JSON: {e}")))?; + let models = body + .iter() + .filter_map(|m| { + let id = m.get("id").and_then(|v| v.as_str())?.to_string(); + let name = m.get("name").and_then(|v| v.as_str()).map(str::to_string); + Some(DiscoveredModelDto { + id, + label: name, + recommended: false, + detail: None, + }) + }) + .collect(); + Ok(Json(models)) + } + "azure-openai" => { + let endpoint = req + .endpoint + .as_deref() + .map(|e| e.trim().trim_end_matches('/')) + .filter(|e| !e.is_empty()) + .ok_or_else(|| { + AppError::BadRequest( + "endpoint is required to discover Azure OpenAI deployments".into(), + ) + })?; + let key = req + .key + .as_deref() + .filter(|k| !k.trim().is_empty()) + .ok_or_else(|| AppError::BadRequest( + "an API key is required to discover deployments (workload/agentid auth can't be exercised from the browser — enter deployment ids manually, or discover once with a temporary key)".into(), + ))?; + let url = format!("{endpoint}/openai/deployments?api-version=2023-05-15"); + let resp = client + .get(&url) + .header("api-key", key) + .send() + .await + .map_err(|e| AppError::Upstream(format!("Azure OpenAI request failed: {e}")))?; + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(AppError::Rejected(format!( + "Azure OpenAI rejected the discovery request ({status}) — check the endpoint and key: {body_text}" + ))); + } + let body: serde_json::Value = serde_json::from_str(&body_text) + .map_err(|e| AppError::Upstream(format!("bad deployments JSON: {e}")))?; + let models = body + .get("data") + .and_then(|d| d.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|d| { + let id = d.get("id").and_then(|v| v.as_str())?.to_string(); + let base = d + .get("model") + .and_then(|v| v.as_str()) + .map(|m| format!("deployment of {m}")); + Some(DiscoveredModelDto { + id, + label: base, + recommended: false, + detail: None, + }) + }) + .collect() + }) + .unwrap_or_default(); + Ok(Json(models)) + } + "github-copilot" => { + let token = req + .key + .as_deref() + .map(str::trim) + .filter(|k| !k.is_empty()) + .ok_or_else(|| AppError::BadRequest( + "a GitHub token (OAuth token or PAT with Copilot access) is required to verify the seat before showing the model catalog".into(), + ))?; + let jwt = copilot_jwt(token).await?; + let models = fetch_copilot_models(&jwt).await?; + Ok(Json(models)) + } + other => Err(AppError::BadRequest(format!( + "unknown provider kind '{other}' for discovery" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::parse_copilot_models; + use serde_json::json; + + #[test] + fn parse_copilot_models_filters_and_categorises() { + // A trimmed but faithful sample of the real /models response shape + // (captured live 2026-07): a flagship chat model, a versatile one, an + // embeddings model (must be dropped), a legacy non-picker chat model + // (must be dropped), and a gated preview the seat hasn't enabled + // (must be dropped). + let body = json!({"data": [ + { + "id": "claude-opus-4.8", "name": "Claude Opus 4.8", "vendor": "Anthropic", + "model_picker_enabled": true, "model_picker_category": "powerful", + "policy": {"state": "enabled"}, + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 1_000_000}} + }, + { + "id": "gpt-5.6-terra", "name": "GPT-5.6 Terra", "vendor": "OpenAI", + "model_picker_enabled": true, "model_picker_category": "versatile", + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 1_050_000}} + }, + { + "id": "text-embedding-3-small", "name": "Embedding V3 small", "vendor": "Azure OpenAI", + "model_picker_enabled": false, "capabilities": {"type": "embeddings"} + }, + { + "id": "gpt-4o", "name": "GPT-4o", "vendor": "Azure OpenAI", + "model_picker_enabled": false, + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 128_000}} + }, + { + "id": "some-preview", "name": "Gated Preview", "vendor": "OpenAI", + "model_picker_enabled": true, "model_picker_category": "powerful", + "policy": {"state": "unconfigured"}, + "capabilities": {"type": "chat", "limits": {"max_context_window_tokens": 200_000}} + } + ]}); + let out = parse_copilot_models(&body); + let ids: Vec<&str> = out.iter().map(|m| m.id.as_str()).collect(); + // Only the two enabled, picker-enabled chat models survive — embeddings, + // the legacy non-picker gpt-4o, and the un-enabled preview are dropped. + assert_eq!(ids, vec!["claude-opus-4.8", "gpt-5.6-terra"]); + // powerful sorts before versatile. + assert!(out[0].recommended, "powerful model must be recommended"); + assert!( + !out[1].recommended, + "versatile model must not be recommended" + ); + // Label carries the human name + context. + assert!(out[0].label.as_deref().unwrap().contains("Claude Opus 4.8")); + assert!(out[0].label.as_deref().unwrap().contains("1.0M ctx")); + } + + #[test] + fn parse_copilot_models_empty_on_missing_data() { + assert!(parse_copilot_models(&json!({})).is_empty()); + assert!(parse_copilot_models(&json!({"data": []})).is_empty()); + } +} diff --git a/bridge/bff/src/routes/operator/sandboxes.rs b/bridge/bff/src/routes/operator/sandboxes.rs new file mode 100644 index 000000000..14e80caf7 --- /dev/null +++ b/bridge/bff/src/routes/operator/sandboxes.rs @@ -0,0 +1,571 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::State; +use kube::core::DynamicObject; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; + +use crate::error::AppResult; +use crate::state::AppState; + +use super::{ + created_of, has_task_owner, label, name_of, ns_of, require_cluster, s, spec, status, upstream, +}; + +// ─── Sandbox fleet ─────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct SandboxDto { + pub name: String, + pub namespace: String, + pub runtime_namespace: Option<String>, + pub phase: Option<String>, + pub runtime: Option<String>, + pub isolation: Option<String>, + /// The governing ToolPolicy (AGT capability bounds) this agent runs under — + /// registry inventory: "which policy governs this agent". From + /// spec.governance.toolPolicyRef. + pub tool_policy: Option<String>, + /// The InferencePolicy binding its model route + token budget. From + /// spec.inferenceRef. + pub inference_policy: Option<String>, + /// Whether AGT governance is enabled (fails closed on an empty policy set). + pub governed: bool, + /// Owning standing team (label), if any — the "who owns this" registry column. + pub team: Option<String>, + /// Parent sandbox name when this is a spawned sub-agent (label-derived). + pub parent: Option<String>, + pub message: Option<String>, + pub created: Option<String>, + /// For a Running sandbox: whether its run has produced ANY real activity + /// (model rounds / tool calls). `Some(false)` = the pod is Running but idle + /// — e.g. a chat-gateway harness waiting for input, or a hung run. Surfaced + /// so a green "Running" never masks a stalled agent (audit f23). `None` when + /// the sandbox isn't Running (the signal doesn't apply). + pub working: Option<bool>, + /// Whether this sandbox is CURRENTLY executing a task (Running AND no + /// terminal mission-output yet) — the same "live" test the Workspace's + /// Active-agents page uses. Distinct from `working` above: a sandbox can + /// have `working: true` (it did real work) and still be `executing: false` + /// (it already delivered and is simply lingering before teardown/ + /// retention) — the exact case that made "Sandboxes: 2 running" and + /// "Active agents: 0 working" look contradictory when they're both true. + pub executing: Option<bool>, + pub cpu_millicores: Option<f64>, + pub memory_bytes: Option<u64>, + /// Standard K8s conditions, surfaced for the troubleshooting table. + pub conditions: Vec<ConditionDto>, +} + +#[derive(Debug, Serialize)] +pub struct ConditionDto { + pub type_: String, + pub status: String, + pub reason: Option<String>, + pub message: Option<String>, +} + +fn conditions_of(o: &DynamicObject) -> Vec<ConditionDto> { + status(o) + .get("conditions") + .and_then(|c| c.as_array()) + .map(|arr| { + arr.iter() + .map(|c| ConditionDto { + type_: s(c, "type").unwrap_or_default(), + status: s(c, "status").unwrap_or_default(), + reason: s(c, "reason"), + message: s(c, "message"), + }) + .collect() + }) + .unwrap_or_default() +} + +fn to_sandbox(o: &DynamicObject) -> SandboxDto { + let sp = spec(o); + SandboxDto { + name: name_of(o), + namespace: ns_of(o), + runtime_namespace: s(status(o), "namespace"), + phase: s(status(o), "phase"), + runtime: sp + .get("runtime") + .and_then(|r| r.get("kind")) + .and_then(|k| k.as_str()) + .map(|x| x.to_string()), + isolation: sp + .get("sandbox") + .and_then(|sb| sb.get("isolation")) + .and_then(|i| i.as_str()) + .map(|x| x.to_string()), + tool_policy: sp + .get("governance") + .and_then(|g| g.get("toolPolicyRef")) + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|x| x.to_string()), + inference_policy: sp + .get("inferenceRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.as_str()) + .map(|x| x.to_string()), + governed: sp + .get("governance") + .and_then(|g| g.get("enabled")) + .and_then(|e| e.as_bool()) + .unwrap_or(false), + team: label(o, "kars.azure.com/team"), + parent: label(o, "kars.azure.com/parent").or_else(|| s(sp, "parentSandbox")), + message: s(status(o), "message"), + created: created_of(o), + working: None, + executing: None, + cpu_millicores: None, + memory_bytes: None, + conditions: conditions_of(o), + } +} + +fn cpu_millicores(raw: &str) -> Option<f64> { + let raw = raw.trim(); + if let Some(value) = raw.strip_suffix('n') { + return value.parse::<f64>().ok().map(|value| value / 1_000_000.0); + } + if let Some(value) = raw.strip_suffix('u') { + return value.parse::<f64>().ok().map(|value| value / 1_000.0); + } + if let Some(value) = raw.strip_suffix('m') { + return value.parse::<f64>().ok(); + } + raw.parse::<f64>().ok().map(|value| value * 1_000.0) +} + +fn memory_bytes(raw: &str) -> Option<u64> { + let raw = raw.trim(); + for (suffix, multiplier) in [ + ("Ki", 1_024_f64), + ("Mi", 1_048_576_f64), + ("Gi", 1_073_741_824_f64), + ("Ti", 1_099_511_627_776_f64), + ("K", 1_000_f64), + ("M", 1_000_000_f64), + ("G", 1_000_000_000_f64), + ] { + if let Some(value) = raw.strip_suffix(suffix) { + return value + .parse::<f64>() + .ok() + .map(|value| (value * multiplier) as u64); + } + } + raw.parse::<u64>().ok() +} + +fn metric_usage(metric: &DynamicObject) -> (f64, u64) { + metric + .data + .get("containers") + .and_then(Value::as_array) + .map(|containers| { + containers + .iter() + .fold((0.0, 0_u64), |(cpu, memory), container| { + let usage = container.get("usage").unwrap_or(&Value::Null); + ( + cpu + usage + .get("cpu") + .and_then(Value::as_str) + .and_then(cpu_millicores) + .unwrap_or(0.0), + memory + + usage + .get("memory") + .and_then(Value::as_str) + .and_then(memory_bytes) + .unwrap_or(0), + ) + }) + }) + .unwrap_or((0.0, 0)) +} + +fn inherit_sandbox_context(sandboxes: &mut [SandboxDto]) { + let by_name: HashMap<(String, String), usize> = sandboxes + .iter() + .enumerate() + .map(|(index, sandbox)| ((sandbox.namespace.clone(), sandbox.name.clone()), index)) + .collect(); + let resolved: Vec<(Option<String>, Option<bool>)> = sandboxes + .iter() + .enumerate() + .map(|(index, sandbox)| { + let mut team = sandbox.team.clone(); + let mut executing = sandbox.executing; + let mut cursor = index; + let mut visited = vec![false; sandboxes.len()]; + visited[cursor] = true; + + while let Some(parent) = sandboxes[cursor].parent.as_ref() { + let parent_key = (sandboxes[cursor].namespace.clone(), parent.clone()); + let Some(parent_index) = by_name.get(&parent_key).copied() else { + break; + }; + if visited[parent_index] { + break; + } + visited[parent_index] = true; + let parent = &sandboxes[parent_index]; + if team.is_none() { + team = parent.team.clone(); + } + if parent.executing.is_some() { + executing = parent.executing; + } + cursor = parent_index; + } + (team, executing) + }) + .collect(); + + for (sandbox, (team, executing)) in sandboxes.iter_mut().zip(resolved) { + sandbox.team = team; + if sandbox.parent.is_some() { + let observed_working = + sandbox.phase.as_deref() == Some("Running") && sandbox.working == Some(true); + sandbox.executing = + executing.map(|parent_executing| parent_executing && observed_working); + } + } +} + +#[derive(Debug, Serialize)] +pub struct NodeCapacityDto { + pub name: String, + pub cpu_usage_millicores: Option<f64>, + pub cpu_allocatable_millicores: Option<f64>, + pub memory_usage_bytes: Option<u64>, + pub memory_allocatable_bytes: Option<u64>, + pub cpu_percent: Option<f64>, + pub memory_percent: Option<f64>, +} + +#[derive(Debug, Serialize)] +pub struct CapacityDto { + pub metrics_available: bool, + pub metrics_error: Option<String>, + pub team_max_concurrent_runs: usize, + pub global_active_runs_limit: usize, + pub active_team_runs: usize, + pub pod_metrics_available: bool, + pub pod_metrics_error: Option<String>, + pub nodes: Vec<NodeCapacityDto>, +} + +/// `GET /api/operator/sandboxes` — the fleet, across all namespaces. +pub async fn list_sandboxes(State(state): State<AppState>) -> AppResult<Json<Vec<SandboxDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("KarsSandbox") + .await + .map_err(upstream)?; + let mut dtos: Vec<SandboxDto> = items.iter().map(to_sandbox).collect(); + let task_teams: HashMap<(String, String), String> = cluster + .list_kind_all("KarsTask") + .await + .unwrap_or_default() + .into_iter() + .filter_map(|task| { + label(&task, "kars.azure.com/team").map(|team| ((ns_of(&task), name_of(&task)), team)) + }) + .collect(); + let pod_metrics = cluster + .list_metrics_all("PodMetrics", "pods") + .await + .unwrap_or_default(); + let usage: HashMap<(String, String), (f64, u64)> = pod_metrics + .iter() + .map(|metric| ((ns_of(metric), name_of(metric)), metric_usage(metric))) + .collect(); + // Stall signal (audit f23): for each Running sandbox, check whether its run + // has produced any real activity. A Running-but-empty sandbox is idle or + // hung (a chat-gateway harness waiting for input, or a stalled loop) — the + // operator must be able to tell that apart from a green "Running". + for (o, d) in items.iter().zip(dtos.iter_mut()) { + if d.team.is_none() + && let Some(task_name) = label(o, "kars.azure.com/karstask") + { + d.team = task_teams.get(&(d.namespace.clone(), task_name)).cloned(); + } + if let Some(runtime_namespace) = d.runtime_namespace.as_deref() { + let (cpu, memory) = usage + .iter() + .filter(|((namespace, pod), _)| { + namespace == runtime_namespace && pod.starts_with(&d.name) + }) + .fold((0.0, 0_u64), |(cpu, memory), (_, usage)| { + (cpu + usage.0, memory + usage.1) + }); + if cpu > 0.0 || memory > 0 { + d.cpu_millicores = Some(cpu); + d.memory_bytes = Some(memory); + } + } + if d.phase.as_deref() == Some("Running") { + let persisted_activity = cluster + .read_mission_trace(&d.name) + .await + .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok()) + .map(|v| !v.is_empty()) + .unwrap_or(false); + let has_activity = + persisted_activity || !cluster.sandbox_live_trace(&d.name).await.is_empty(); + d.working = Some(has_activity); + // "Executing right now" — the same test the Workspace's Active + // agents page uses (live iff Running AND no terminal mission-output + // yet). A sandbox that already delivered still shows `working: true` + // (it DID real work) but `executing: false` (nothing left to do, + // just lingering before teardown/retention) — this is what makes + // "Sandboxes: N running" and "Active agents: 0 working" both + // correct at once instead of reading as a contradiction. Only + // applies to a task-owned sandbox — a standing sandbox with no + // KarsTask (e.g. the Bridge's own orchestrator) never gets a + // mission-output ConfigMap, so it would otherwise look permanently + // "not yet delivered" and inflate this count. + if has_task_owner(o) { + let delivered = cluster.read_mission_output(&d.name).await.is_some(); + d.executing = Some(!delivered); + } + } + } + inherit_sandbox_context(&mut dtos); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +pub async fn capacity(State(state): State<AppState>) -> AppResult<Json<CapacityDto>> { + let cluster = require_cluster(&state)?; + let nodes = cluster.list_nodes().await.map_err(upstream)?; + let metrics = cluster.list_metrics_all("NodeMetrics", "nodes").await; + let (metrics_api_available, metrics_api_error, metric_map) = match metrics { + Ok(items) => ( + true, + None, + items + .into_iter() + .map(|metric| { + let usage = metric.data.get("usage").cloned().unwrap_or(Value::Null); + ( + name_of(&metric), + ( + usage + .get("cpu") + .and_then(Value::as_str) + .and_then(cpu_millicores), + usage + .get("memory") + .and_then(Value::as_str) + .and_then(memory_bytes), + ), + ) + }) + .collect::<HashMap<_, _>>(), + ), + Err(error) => (false, Some(error.to_string()), HashMap::new()), + }; + let node_count = nodes.len(); + let covered_nodes = nodes + .iter() + .filter(|node| { + node.metadata + .name + .as_ref() + .is_some_and(|name| metric_map.contains_key(name)) + }) + .count(); + let metrics_available = metrics_api_available && node_count > 0 && covered_nodes == node_count; + let metrics_error = if !metrics_api_available { + metrics_api_error + } else if node_count == 0 { + Some("the cluster reported no nodes".into()) + } else if covered_nodes != node_count { + Some(format!( + "node metrics coverage is partial ({covered_nodes}/{node_count})" + )) + } else { + None + }; + let nodes = nodes + .into_iter() + .map(|node| { + let name = node.metadata.name.unwrap_or_default(); + let allocatable = node.status.and_then(|status| status.allocatable); + let cpu_allocatable = allocatable + .as_ref() + .and_then(|values| values.get("cpu")) + .and_then(|value| cpu_millicores(&value.0)); + let memory_allocatable = allocatable + .as_ref() + .and_then(|values| values.get("memory")) + .and_then(|value| memory_bytes(&value.0)); + let (cpu_usage, memory_usage) = metric_map.get(&name).cloned().unwrap_or((None, None)); + NodeCapacityDto { + name, + cpu_usage_millicores: cpu_usage, + cpu_allocatable_millicores: cpu_allocatable, + memory_usage_bytes: memory_usage, + memory_allocatable_bytes: memory_allocatable, + cpu_percent: cpu_usage + .zip(cpu_allocatable) + .filter(|(_, allocatable)| *allocatable > 0.0) + .map(|(usage, allocatable)| usage / allocatable * 100.0), + memory_percent: memory_usage + .zip(memory_allocatable) + .filter(|(_, allocatable)| *allocatable > 0) + .map(|(usage, allocatable)| usage as f64 / allocatable as f64 * 100.0), + } + }) + .collect(); + let team_max_concurrent_runs = cluster + .controller_env_value("KARS_TEAM_MAX_CONCURRENT_RUNS") + .await + .and_then(|value| value.parse().ok()) + .unwrap_or(2); + let global_active_runs_limit = cluster + .controller_env_value("KARS_TEAM_GLOBAL_ACTIVE_RUNS_LIMIT") + .await + .and_then(|value| value.parse().ok()) + .unwrap_or(6); + let active_team_runs = cluster + .list_kind_all("KarsTask") + .await + .unwrap_or_default() + .iter() + .filter(|task| { + let annotations = task.metadata.annotations.as_ref(); + let taskforce = annotations + .and_then(|values| values.get("kars.azure.com/team-role")) + .is_some_and(|role| role == "taskforce"); + let launched = task + .data + .pointer("/spec/execution/launch") + .and_then(Value::as_bool) + .unwrap_or(false); + if !taskforce || !launched { + return false; + } + let requested = + annotations.and_then(|values| values.get("kars.azure.com/run-requested")); + let completed = + annotations.and_then(|values| values.get("kars.azure.com/run-completed")); + let delivery_pending = requested.is_some() && requested != completed; + let assignment_active = task + .data + .pointer("/status/assignment/state") + .and_then(Value::as_str) + .is_some_and(|state| matches!(state, "Assigned" | "Running")); + let execution_active = task + .data + .pointer("/status/executionPhase") + .and_then(Value::as_str) + .is_some_and(|phase| matches!(phase, "Launching" | "Running")); + delivery_pending || assignment_active || execution_active + }) + .count(); + let (pod_metrics_available, pod_metrics_error) = + match cluster.list_metrics_all("PodMetrics", "pods").await { + Ok(items) if !items.is_empty() => (true, None), + Ok(_) => ( + false, + Some("the metrics API returned no pod samples".into()), + ), + Err(error) => (false, Some(error.to_string())), + }; + Ok(Json(CapacityDto { + metrics_available, + metrics_error, + team_max_concurrent_runs, + global_active_runs_limit, + active_team_runs, + pod_metrics_available, + pod_metrics_error, + nodes, + })) +} + +#[cfg(test)] +mod tests { + use super::{SandboxDto, inherit_sandbox_context}; + + fn sandbox( + name: &str, + parent: Option<&str>, + team: Option<&str>, + executing: Option<bool>, + ) -> SandboxDto { + SandboxDto { + name: name.to_string(), + namespace: "kars-system".to_string(), + runtime_namespace: None, + phase: Some("Running".to_string()), + runtime: None, + isolation: None, + tool_policy: None, + inference_policy: None, + governed: true, + team: team.map(str::to_string), + parent: parent.map(str::to_string), + message: None, + created: None, + working: Some(false), + executing, + cpu_millicores: None, + memory_bytes: None, + conditions: Vec::new(), + } + } + + #[test] + fn nested_subagents_inherit_root_team_and_execution() { + let mut sandboxes = vec![ + sandbox("lead", None, Some("maintenance"), Some(true)), + sandbox("specialist", Some("lead"), None, None), + sandbox("worker", Some("specialist"), None, None), + ]; + sandboxes[1].working = Some(true); + sandboxes[2].working = Some(true); + + inherit_sandbox_context(&mut sandboxes); + + assert_eq!(sandboxes[0].team.as_deref(), Some("maintenance")); + assert_eq!(sandboxes[0].executing, Some(true)); + assert_eq!(sandboxes[0].working, Some(false)); + for sandbox in &sandboxes[1..] { + assert_eq!(sandbox.team.as_deref(), Some("maintenance")); + assert_eq!(sandbox.executing, Some(true)); + assert_eq!(sandbox.working, Some(true)); + } + } + + #[test] + fn sandbox_context_does_not_cross_namespaces() { + let mut first_lead = sandbox("lead", None, Some("team-a"), Some(true)); + first_lead.namespace = "namespace-a".into(); + let mut first_child = sandbox("worker", Some("lead"), None, None); + first_child.namespace = "namespace-a".into(); + let mut second_lead = sandbox("lead", None, Some("team-b"), Some(false)); + second_lead.namespace = "namespace-b".into(); + let mut second_child = sandbox("worker", Some("lead"), None, None); + second_child.namespace = "namespace-b".into(); + let mut sandboxes = vec![first_lead, first_child, second_lead, second_child]; + + inherit_sandbox_context(&mut sandboxes); + + assert_eq!(sandboxes[1].team.as_deref(), Some("team-a")); + assert_eq!(sandboxes[1].executing, Some(false)); + assert_eq!(sandboxes[3].team.as_deref(), Some("team-b")); + assert_eq!(sandboxes[3].executing, Some(false)); + } +} diff --git a/bridge/bff/src/routes/operator/skills_profiles.rs b/bridge/bff/src/routes/operator/skills_profiles.rs new file mode 100644 index 000000000..9f524bc58 --- /dev/null +++ b/bridge/bff/src/routes/operator/skills_profiles.rs @@ -0,0 +1,557 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, State}; +use kube::core::DynamicObject; +use serde::Serialize; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::policies::{ApplyCrdRequest, apply_err, apply_governance, delete_governance}; +use super::{ + annotation, is_dns1123_label, name_of, ns_of, require_cluster, s, spec, status, upstream, +}; + +// Skill-admission annotation keys — the operator trust gate (§ skills workflow): +// a user-uploaded skill is only usable once an operator has scanned + approved +// it, which LOCKS the approval to the exact version digest at approval time. +// Any later change to the skill breaks the lock and returns it to review. +const ANN_REVIEW: &str = "kars.azure.com/skill-review"; +const ANN_LOCKED_DIGEST: &str = "kars.azure.com/skill-locked-digest"; +const ANN_APPROVED_BY: &str = "kars.azure.com/skill-approved-by"; +const ANN_APPROVED_AT: &str = "kars.azure.com/skill-approved-at"; + +// ─── Skills & Profiles (predefined building blocks for customers) ──────────── + +#[derive(Debug, Serialize)] +pub struct SkillDto { + pub name: String, + pub namespace: String, + pub version: Option<String>, + pub summary: Option<String>, + pub bounding_policy: Option<String>, + pub phase: Option<String>, + pub version_digest: Option<String>, + pub attestation_verified: Option<bool>, + // ── Operator trust gate ────────────────────────────────────────────────── + /// Admission verdict: "approved" once an operator has signed off, else the + /// skill is treated as pending review. + pub review: String, + /// The version digest the approval is locked to (from status at approval). + pub locked_digest: Option<String>, + pub approved_by: Option<String>, + pub approved_at: Option<String>, + /// True when approved AND the locked digest still matches the current + /// version digest — i.e. usable by users. False if never approved or the + /// skill changed since approval (lock broken → back to review). + pub usable: bool, + /// Raw `spec` for Edit-form prefill. + pub spec: serde_json::Value, +} + +fn to_skill(o: &DynamicObject) -> SkillDto { + let sp = spec(o); + let version_digest = s(status(o), "versionDigest"); + let review = annotation(o, ANN_REVIEW).unwrap_or_else(|| "pending".into()); + let locked_digest = annotation(o, ANN_LOCKED_DIGEST); + // Usable only when explicitly approved and the lock still matches the live + // digest. When the skill has no digest yet (not scanned), it can't be usable. + let usable = review == "approved" && locked_digest.is_some() && locked_digest == version_digest; + SkillDto { + name: name_of(o), + namespace: ns_of(o), + version: s(sp, "version"), + summary: s(sp, "summary"), + bounding_policy: s(sp, "boundingPolicy"), + phase: s(status(o), "phase"), + version_digest, + attestation_verified: status(o) + .get("attestationVerified") + .and_then(|v| v.as_bool()), + review, + locked_digest, + approved_by: annotation(o, ANN_APPROVED_BY), + approved_at: annotation(o, ANN_APPROVED_AT), + usable, + spec: sp.clone(), + } +} + +#[derive(Debug, Serialize, serde::Deserialize, Clone)] +pub struct McpProfileDto { + pub name: String, + #[serde(default)] + pub summary: Option<String>, + /// Names of the operator-vetted McpServers this profile bundles. + pub servers: Vec<String>, +} + +/// `GET /api/operator/mcp-profiles` — the operator-curated MCP bundles users +/// can pick from (a named, vetted set of McpServers, so users compose from +/// approved groupings rather than assembling servers one by one). +pub async fn list_mcp_profiles( + State(state): State<AppState>, +) -> AppResult<Json<Vec<McpProfileDto>>> { + let cluster = require_cluster(&state)?; + let raw = cluster.read_mcp_profiles().await; + let profiles: Vec<McpProfileDto> = serde_json::from_str(&raw).unwrap_or_default(); + Ok(Json(profiles)) +} + +/// `PUT /api/operator/mcp-profiles` — upsert a profile by name. Validates that +/// every referenced server is a real McpServer on the cluster, so a profile can +/// never bundle a non-existent (unvetted) server. +pub async fn put_mcp_profile( + State(state): State<AppState>, + Json(req): Json<McpProfileDto>, +) -> AppResult<Json<Vec<McpProfileDto>>> { + let cluster = require_cluster(&state)?; + if req.name.trim().is_empty() { + return Err(AppError::BadRequest("profile name is required".into())); + } + // Real McpServers on the cluster — the vetted universe a profile may draw from. + let known: std::collections::BTreeSet<String> = cluster + .list_kind_all("McpServer") + .await + .map_err(upstream)? + .iter() + .map(name_of) + .collect(); + for s in &req.servers { + if !known.contains(s) { + return Err(AppError::BadRequest(format!( + "server '{s}' is not a registered McpServer — vet it first" + ))); + } + } + let raw = cluster.read_mcp_profiles().await; + let mut profiles: Vec<McpProfileDto> = serde_json::from_str(&raw).unwrap_or_default(); + profiles.retain(|p| p.name != req.name); + profiles.push(req); + profiles.sort_by(|a, b| a.name.cmp(&b.name)); + let json = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".into()); + cluster + .write_mcp_profiles(&json) + .await + .map_err(AppError::Internal)?; + Ok(Json(profiles)) +} + +/// `DELETE /api/operator/mcp-profiles/:name` — remove a profile. +pub async fn delete_mcp_profile( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<Vec<McpProfileDto>>> { + let cluster = require_cluster(&state)?; + let raw = cluster.read_mcp_profiles().await; + let mut profiles: Vec<McpProfileDto> = serde_json::from_str(&raw).unwrap_or_default(); + profiles.retain(|p| p.name != name); + let json = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".into()); + cluster + .write_mcp_profiles(&json) + .await + .map_err(AppError::Internal)?; + Ok(Json(profiles)) +} + +pub async fn list_skills(State(state): State<AppState>) -> AppResult<Json<Vec<SkillDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster.list_kind_all("KarsSkill").await.map_err(upstream)?; + let mut dtos: Vec<SkillDto> = items.iter().map(to_skill).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +/// Annotation recording who uploaded a user-submitted skill (provenance for the +/// operator reviewing it). +const ANN_UPLOADED_BY: &str = "kars.azure.com/skill-uploaded-by"; +const ANN_UPLOADED_BY_SUB: &str = "kars.azure.com/skill-uploaded-by-sub"; + +#[derive(serde::Deserialize)] +pub struct SubmitSkillRequest { + /// DNS-1123 object name (kebab-case). + pub name: String, + pub display_name: Option<String>, + pub version: String, + pub summary: String, + /// The bounding tool policy — must be one the operator already vetted; it + /// caps what the skill's recipe can do. Users pick from the approved set. + pub bounding_policy: String, + pub recipe: Option<String>, + #[serde(default)] + pub mcp_servers: Vec<String>, + /// The skill PACKAGE files — flat filenames (SKILL.md + scripts). Stored as + /// the `karsskill-<name>` ConfigMap and mounted into a granting sandbox. + #[serde(default)] + pub files: Vec<SkillFile>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct SkillFile { + /// Flat filename (no path separators) — e.g. `SKILL.md`, `triage.sh`. + pub name: String, + pub content: String, +} + +/// `POST /api/skills` — USER skill submission. A team member uploads a skill +/// package; it lands as a `KarsSkill` that starts life PENDING REVIEW (never +/// usable until an operator scans + approves it). This is the user side of the +/// trust gate: users propose capability, operators vet + sign, then it's +/// grantable. The BFF never marks a user-submitted skill approved. +pub async fn submit_skill( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Json(req): Json<SubmitSkillRequest>, +) -> AppResult<Json<SkillDto>> { + let cluster = require_cluster(&state)?; + let name = req.name.trim(); + if !is_dns1123_label(name) { + return Err(AppError::BadRequest( + "skill name must be a DNS-1123 label (lowercase letters, digits, hyphens)".into(), + )); + } + if req.version.trim().is_empty() { + return Err(AppError::BadRequest("version is required".into())); + } + if req.summary.trim().len() < 8 { + return Err(AppError::BadRequest("a real summary is required".into())); + } + if req.bounding_policy.trim().is_empty() { + return Err(AppError::BadRequest( + "a bounding tool policy is required — it caps what the skill may do".into(), + )); + } + let ns = "kars-system".to_string(); + let mut spec = serde_json::json!({ + "version": req.version.trim(), + "summary": req.summary.trim(), + "boundingPolicy": req.bounding_policy.trim(), + }); + if let Some(dn) = req.display_name.as_ref().filter(|s| !s.trim().is_empty()) { + spec["displayName"] = serde_json::json!(dn.trim()); + } + if let Some(r) = req.recipe.as_ref().filter(|s| !s.trim().is_empty()) { + spec["recipe"] = serde_json::json!(r.trim()); + } + if !req.mcp_servers.is_empty() { + spec["mcpServers"] = serde_json::json!(req.mcp_servers); + } + // Validate + collect the package files. Standard Agent Skills use + // subdirectories (scripts/, references/, assets/) referenced relatively from + // SKILL.md. ConfigMap keys can't contain '/', so we accept relative paths + // here and path-encode '/'→'__' only when writing the ConfigMap; the sandbox + // entrypoint decodes them back on mount so the on-disk tree matches exactly. + // A real skill package is at least a SKILL.md at the root. + let mut files: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new(); + for f in &req.files { + let fname = f.name.trim(); + let bad_segments = fname.split('/').any(|seg| { + seg.is_empty() + || !seg + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }); + if fname.is_empty() + || fname.starts_with('/') + || fname.ends_with('/') + || fname.contains("..") + || fname.contains("__") // reserved as the CM path separator + || fname.len() > 253 + || bad_segments + { + return Err(AppError::BadRequest(format!( + "invalid skill file path '{fname}': use relative paths (letters, digits, . _ - and / for subdirs); no '..', no '__', no leading/trailing '/'" + ))); + } + files.insert(fname.to_string(), f.content.clone()); + } + if !files.is_empty() { + // A package the agent can actually USE must carry a SKILL.md — OpenClaw + // auto-discovers `<name>/SKILL.md` and reads its frontmatter `description` + // to know when to invoke the skill. Without it the files are dead weight. + let skill_md = files.get("SKILL.md"); + match skill_md { + None => { + return Err(AppError::BadRequest( + "a skill package must include a SKILL.md — the agent discovers the skill from it".into(), + )); + } + Some(md) if !md.contains("description:") => { + return Err(AppError::BadRequest( + "SKILL.md must have YAML frontmatter with a `description:` — that's how the agent knows when to use the skill".into(), + )); + } + _ => {} + } + spec["package"] = serde_json::json!(true); + spec["files"] = serde_json::json!(files.keys().cloned().collect::<Vec<_>>()); + use sha2::{Digest, Sha256}; + let configmap_data: std::collections::BTreeMap<String, String> = files + .iter() + .map(|(path, content)| (path.replace('/', "__"), content.clone())) + .collect(); + let canonical = serde_json::to_vec(&configmap_data) + .map_err(|e| AppError::Internal(anyhow::Error::new(e)))?; + spec["packageDigest"] = serde_json::json!(format!( + "sha256:{}", + hex::encode(Sha256::digest(&canonical)) + )); + } + let uploader = principal.name; + let uploader_sub = principal.sub; + let body = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSkill", + "metadata": { + "name": name, + "namespace": ns, + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + // Explicitly PENDING — the operator trust gate must approve it before + // it is usable. Never set review=approved on the user path. + "annotations": { + ANN_REVIEW: "pending", + ANN_UPLOADED_BY: uploader, + ANN_UPLOADED_BY_SUB: uploader_sub, + }, + }, + "spec": spec, + }); + let applied = cluster + .apply_kind(&ns, "KarsSkill", body, false) + .await + .map_err(apply_err)?; + // Persist the package files as the karsskill-<name> ConfigMap so the + // controller can mount them into a granting sandbox. ConfigMap keys can't + // contain '/', so subdirectory paths are encoded '/'→'__'; the sandbox + // entrypoint decodes them back to the real tree on mount. + if !files.is_empty() { + let cm_files: std::collections::BTreeMap<String, String> = files + .iter() + .map(|(path, content)| (path.replace('/', "__"), content.clone())) + .collect(); + let package_digest = spec + .get("packageDigest") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("package digest missing")))?; + cluster + .write_skill_package(name, &cm_files, package_digest) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + } + Ok(Json(to_skill(&applied))) +} + +/// Locate a skill by name across namespaces, returning `(namespace, object)`. +async fn find_skill( + cluster: &crate::kars::cluster::Cluster, + name: &str, +) -> AppResult<(String, DynamicObject)> { + let items = cluster.list_kind_all("KarsSkill").await.map_err(upstream)?; + items + .into_iter() + .find(|o| name_of(o) == name) + .map(|o| (ns_of(&o), o)) + .ok_or(AppError::NotFound) +} + +/// `POST /api/operator/skills/:name/approve` — the operator admission gate. +/// Records the operator's approval and LOCKS it to the skill's current version +/// digest, after which users can assign the skill. Requires the skill to have +/// been scanned (a version digest present) and its attestation to have verified +/// — an operator can't approve a skill the controller hasn't validated. Any +/// later change to the skill breaks the lock and returns it to review. +pub async fn approve_skill( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + axum::extract::Path(name): axum::extract::Path<String>, + Json(_req): Json<ApproveSkillRequest>, +) -> AppResult<Json<SkillDto>> { + let cluster = require_cluster(&state)?; + let (ns, obj) = find_skill(cluster, &name).await?; + let uploader_subject = obj + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(ANN_UPLOADED_BY_SUB)) + .cloned(); + if uploader_subject.as_deref() == Some(principal.sub.as_str()) { + return Err(AppError::Forbidden( + "skill submitter cannot approve their own package".into(), + )); + } + let generation = obj.metadata.generation.unwrap_or_default(); + let observed_generation = status(&obj) + .get("observedGeneration") + .and_then(|v| v.as_i64()) + .unwrap_or_default(); + if observed_generation != generation { + return Err(AppError::Conflict( + "skill changed after its last controller scan; wait for the current generation".into(), + )); + } + let digest = s(status(&obj), "versionDigest").ok_or_else(|| { + AppError::BadRequest( + "skill has not been scanned yet (no version digest) — the controller must validate it before approval".into(), + ) + })?; + // Honest gate: don't let an operator approve a skill whose attestation the + // controller could not verify. + if status(&obj) + .get("attestationVerified") + .and_then(|v| v.as_bool()) + == Some(false) + { + return Err(AppError::BadRequest( + "skill attestation did not verify — cannot approve until the scan passes".into(), + )); + } + let by = principal.name; + let now = chrono::Utc::now().to_rfc3339(); + let updated = cluster + .annotate_kind( + &ns, + "KarsSkill", + &name, + &[ + (ANN_REVIEW, Some("approved".into())), + (ANN_LOCKED_DIGEST, Some(digest)), + (ANN_APPROVED_BY, Some(by)), + (ANN_APPROVED_AT, Some(now)), + ], + ) + .await + .map_err(upstream)?; + Ok(Json(to_skill(&updated))) +} + +/// `POST /api/operator/skills/:name/revoke` — withdraw approval, returning the +/// skill to review (users immediately stop seeing it). +pub async fn revoke_skill( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<SkillDto>> { + let cluster = require_cluster(&state)?; + let (ns, _) = find_skill(cluster, &name).await?; + let updated = cluster + .annotate_kind( + &ns, + "KarsSkill", + &name, + &[ + (ANN_REVIEW, Some("pending".into())), + (ANN_LOCKED_DIGEST, None), + (ANN_APPROVED_BY, None), + (ANN_APPROVED_AT, None), + ], + ) + .await + .map_err(upstream)?; + Ok(Json(to_skill(&updated))) +} + +#[derive(Debug, serde::Deserialize)] +pub struct ApproveSkillRequest {} + +#[derive(Debug, Serialize)] +pub struct ProfileRoleDto { + pub name: String, + pub system_prompt: Option<String>, + pub skills: Vec<String>, +} + +#[derive(Debug, Serialize)] +pub struct ProfileDto { + pub name: String, + pub namespace: String, + pub domain: Option<String>, + pub phase: Option<String>, + pub template_digest: Option<String>, + // Instantiation fields — so the team composer can prefill a whole team from + // a profile (the profile is a vetted org template, not a dead-end record). + pub display_name: Option<String>, + pub charter_template: Option<String>, + pub tier: Option<i32>, + pub tool_policy: Option<String>, + pub knowledge_commons: Option<String>, + pub roles: Vec<ProfileRoleDto>, + /// Raw spec for Edit-form prefill. + pub spec: serde_json::Value, +} + +fn to_profile(o: &DynamicObject) -> ProfileDto { + let sp = spec(o); + let roles = sp + .get("roles") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| { + let name = r.get("name")?.as_str()?.to_string(); + Some(ProfileRoleDto { + name, + system_prompt: r + .get("systemPrompt") + .and_then(|s| s.as_str()) + .map(String::from), + skills: r + .get("skills") + .and_then(|s| s.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + }) + }) + .collect() + }) + .unwrap_or_default(); + ProfileDto { + name: name_of(o), + namespace: ns_of(o), + domain: s(sp, "domain"), + phase: s(status(o), "phase"), + template_digest: s(status(o), "templateDigest"), + display_name: s(sp, "displayName"), + charter_template: s(sp, "charterTemplate"), + tier: sp + .get("defaultEnvelope") + .and_then(|e| e.get("tier")) + .and_then(|t| t.as_i64()) + .map(|t| t as i32), + tool_policy: s(sp, "toolPolicy"), + knowledge_commons: s(sp, "knowledgeCommons"), + roles, + spec: sp.clone(), + } +} + +pub async fn list_profiles(State(state): State<AppState>) -> AppResult<Json<Vec<ProfileDto>>> { + let cluster = require_cluster(&state)?; + let items = cluster + .list_kind_all("KarsProfile") + .await + .map_err(upstream)?; + let mut dtos: Vec<ProfileDto> = items.iter().map(to_profile).collect(); + dtos.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(Json(dtos)) +} + +/// `PUT /api/operator/profiles` — author/edit a `KarsProfile` (SSA). +pub async fn put_profile( + State(state): State<AppState>, + Json(req): Json<ApplyCrdRequest>, +) -> AppResult<Json<serde_json::Value>> { + apply_governance(require_cluster(&state)?, "KarsProfile", req).await +} + +/// `DELETE /api/operator/profiles/:name` — remove a `KarsProfile`. +pub async fn delete_profile( + State(state): State<AppState>, + axum::extract::Path(name): axum::extract::Path<String>, +) -> AppResult<Json<serde_json::Value>> { + delete_governance(require_cluster(&state)?, "KarsProfile", &name, None).await +} From 1ef457d4857d896c702c4cbbbe5a1f89b527577c Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 18:21:37 +0200 Subject: [PATCH 010/111] Add permanent core independence and Bridge contract CI gates Build/test core without the Bridge directory, require locked CLI validation, and classify all runtime/shared/unknown changes without rename or missing-diff bypasses. Emit stable component/native aggregates and reject unqualified skips. Preserve full reusable-release qualification. Hosted validation and required branch-policy activation remain pending; no protection or release channel is changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 15 ++- .github/workflows/bridge-native.yml | 54 +++++--- .github/workflows/ci.yml | 79 +++++------ bridge/docs/contributing.md | 30 +++++ .../tests/native-qualification.test.ts | 9 +- ci/bridge-contract-result.sh | 31 +++++ ci/bridge_component_results.py | 19 +++ ci/bridge_contracts.py | 56 ++++++++ ci/tests/bridge_contracts_test.py | 126 ++++++++++++++++++ ci/tests/git_fixture.py | 34 +++++ ci/tests/no_stubs_test.py | 29 +--- cli/src/lib/bridge-contract-ci.test.ts | 105 +++++++++++++++ .../2026-09-11-bridge-application.md | 29 ++++ tests/e2e/sre_authority/harness_test.py | 16 ++- 14 files changed, 539 insertions(+), 93 deletions(-) create mode 100644 ci/bridge-contract-result.sh create mode 100644 ci/bridge_component_results.py create mode 100644 ci/bridge_contracts.py create mode 100644 ci/tests/bridge_contracts_test.py create mode 100644 ci/tests/git_fixture.py create mode 100644 cli/src/lib/bridge-contract-ci.test.ts diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index d29badd0e..1c0d6efd5 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -3,10 +3,8 @@ name: Bridge CI on: pull_request: branches: [main, dev, kars-bridge] - paths: ['bridge/**', '.github/workflows/bridge-ci.yml', '.github/workflows/bridge-native.yml', 'Cargo.toml', 'ci/npm-audit-bulk.mjs'] push: branches: [main, kars-bridge] - paths: ['bridge/**', '.github/workflows/bridge-ci.yml', '.github/workflows/bridge-native.yml', 'Cargo.toml', 'ci/npm-audit-bulk.mjs'] workflow_dispatch: permissions: @@ -17,6 +15,19 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + bridge-required-gates: + name: Bridge component acceptance + needs: [addon, bff, dependencies, lockfiles, rust-dependencies, secrets, security, web] + if: always() + runs-on: ubuntu-22.04 + env: + COMPONENT_RESULTS: ${{ toJSON(needs) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: python3 ci/bridge_component_results.py + bff: name: BFF build and test runs-on: ubuntu-latest diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml index 395288da9..9ef45f18f 100644 --- a/.github/workflows/bridge-native.yml +++ b/.github/workflows/bridge-native.yml @@ -3,15 +3,8 @@ name: Bridge native qualification on: pull_request: branches: [main, dev, kars-bridge] - paths: - - '.github/workflows/bridge-native.yml' - - 'bridge/**' - - 'controller/**' - - 'inference-router/**' - - 'shared/**' - - 'deploy/helm/kars/**' - - 'Cargo.toml' - - 'Cargo.lock' + push: + branches: [main, kars-bridge] workflow_dispatch: permissions: @@ -31,8 +24,33 @@ defaults: working-directory: bridge jobs: + contract-scope: + name: Core and Bridge contract scope + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: . + outputs: + required: ${{ steps.scope.outputs.required }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Verify contract scope and aggregate behavior + run: python3 -m unittest discover -s ci/tests -p bridge_contracts_test.py + - id: scope + run: | + python3 ci/bridge_contracts.py \ + --event "${{ github.event_name }}" \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha || github.sha }}" >> "$GITHUB_OUTPUT" + api-admission: name: Native API and admission (no active SRE) + needs: contract-scope + if: needs.contract-scope.outputs.required == 'true' runs-on: ubuntu-22.04 timeout-minutes: 15 steps: @@ -74,6 +92,8 @@ jobs: native-runtime: name: Native BFF grants, lifecycle, TLS and CNI (no active SRE) + needs: contract-scope + if: needs.contract-scope.outputs.required == 'true' # Collect actual controller/BFF evidence independently of schema diagnostics. # Both jobs remain mandatory in the final gate; core readiness is unchanged. runs-on: ubuntu-22.04 @@ -165,16 +185,20 @@ jobs: native-required-gates: name: Require both native API and runtime acceptance - needs: [api-admission, native-runtime] + needs: [contract-scope, api-admission, native-runtime] if: always() runs-on: ubuntu-22.04 timeout-minutes: 2 env: + SCOPE_RESULT: ${{ needs.contract-scope.result }} + NATIVE_REQUIRED: ${{ needs.contract-scope.outputs.required }} API_RESULT: ${{ needs.api-admission.result }} RUNTIME_RESULT: ${{ needs.native-runtime.result }} steps: - - name: Fail if either independent prerequisite failed or was skipped - working-directory: ${{ github.workspace }} - run: | - test "$API_RESULT" = success - test "$RUNTIME_RESULT" = success + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Require scoped native contract outcomes + working-directory: . + run: bash ci/bridge-contract-result.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfa17367a..8e357d7df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,11 @@ concurrency: jobs: # ── Change classifier ───────────────────────────────────────────── # Emits `code=true` when anything other than docs/assets is touched. - # Docs-only PRs (markdown, docs/, screencast .cast/.gif) skip the + # Root documentation-only PRs skip the # ~19min Rust build, E2E, chaos and bench rows. Required jobs still # RUN (so their status reports) but short-circuit their expensive - # steps. Non-PR events (push/dispatch/call) always run the full set. + # steps. Shipped skills and unknown source paths remain in scope. + # Non-PR events (push/dispatch/call) always run the full set. changes: name: Detect change scope runs-on: ubuntu-latest @@ -35,23 +36,14 @@ jobs: code: ${{ steps.scope.outputs.code }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - id: scope run: | - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 - fi - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - git fetch --no-tags --depth=50 origin "$base" "$head" 2>/dev/null || true - changed="$(git diff --name-only "$base" "$head" 2>/dev/null)" - # Strip docs/asset-only paths; anything left means code changed. - code="$(echo "$changed" | grep -vE '^(docs/|.*\.md$|README\.md|mkdocs\.ya?ml|book\.toml)' || true)" - if [ -n "$code" ]; then - echo "code=true" >> "$GITHUB_OUTPUT" - else - echo "code=false" >> "$GITHUB_OUTPUT" - echo "::notice::Docs/asset-only change — skipping Rust build, E2E, chaos, bench" - fi + python3 ci/bridge_contracts.py --output code \ + --event "${{ github.event_name }}" \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha || github.sha }}" >> "$GITHUB_OUTPUT" # ── ONE Rust compile per CI run ─────────────────────────────────── # This job is the SINGLE source of compiled Rust artefacts for the @@ -72,6 +64,14 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + /* + !/bridge/ + sparse-checkout-cone-mode: false + - name: Require standalone core checkout + working-directory: . + run: test ! -e bridge - if: needs.changes.outputs.code == 'true' uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: @@ -254,10 +254,18 @@ jobs: working-directory: cli steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: | + /* + !/bridge/ + sparse-checkout-cone-mode: false + - name: Require standalone core checkout + working-directory: . + run: test ! -e bridge - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - run: npm install + - run: npm ci - run: npm run typecheck - run: npm run lint - run: npm run build @@ -656,33 +664,26 @@ jobs: # Required by docker/build-push-action GHA cache backend # (cache-to: type=gha) used by the image pre-build steps below. actions: write - if: | - needs.changes.outputs.code == 'true' && ( - github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - github.event_name == 'workflow_call' || - github.event_name == 'pull_request' ) + if: needs.changes.outputs.code == 'true' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + sparse-checkout: | + /* + !/bridge/ + sparse-checkout-cone-mode: false + - name: Require standalone core checkout + working-directory: . + run: test ! -e bridge - name: Detect runtime-affecting changes id: paths run: | - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - base="${{ github.event.pull_request.base.sha }}" - head="${{ github.event.pull_request.head.sha }}" - # Fetch enough history to diff. - git fetch --no-tags --depth=50 origin "$base" "$head" 2>/dev/null || true - if git diff --name-only "$base" "$head" 2>/dev/null \ - | grep -E '^(controller/|inference-router/|a2a-gateway/|kars-a2a-core/|deploy/helm/|sandbox-images/|tests/e2e/|shared/|runtimes/hermes/src/kars_runtime_hermes/plugin/sre|cli/src/(commands/(sre|budget)|lib/sre|lib/namespace-ownership)|Cargo\.toml|Cargo\.lock|Makefile)' >/dev/null; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice::No runtime-affecting paths changed — skipping e2e" - fi + python3 ci/bridge_contracts.py --output run --core-only \ + --event "${{ github.event_name }}" \ + --base "${{ github.event.pull_request.base.sha }}" \ + --head "${{ github.event.pull_request.head.sha || github.sha }}" >> "$GITHUB_OUTPUT" - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable if: steps.paths.outputs.run == 'true' diff --git a/bridge/docs/contributing.md b/bridge/docs/contributing.md index 0c149666a..0050f5562 100644 --- a/bridge/docs/contributing.md +++ b/bridge/docs/contributing.md @@ -36,3 +36,33 @@ helm template kars-bridge deploy/helm/kars-bridge \ Any documented write path must also be tested through the deployed `kars-bridge` ServiceAccount rather than only through a cluster-admin kubeconfig. + +## Permanent core/Bridge CI boundary + +The integration candidate's core Rust, CLI and Kind jobs check out the repository +with `bridge/` physically absent. Core builds and runtime acceptance must not +acquire a mandatory dependency on the add-on. + +Bridge native qualification emits its aggregate status for every PR targeting +the supported integration branches. Only root documentation-only changes can +skip native execution; CLI, runtime, mesh, chart, dependency, shipped-skill and +unknown source changes require it. Core-only Kind scope excludes Bridge-only +changes, which still require paired Bridge qualification. Pushes, manual runs +and reusable CI callers retain full qualification. + +`Bridge component acceptance` aggregates every BFF, web, audit and add-on job +and runs even for core-only PRs. Failed, cancelled or skipped component jobs +cannot satisfy it. Together with `Require both native API and runtime acceptance`, +it provides stable check names for the integration merge policy rather than +relying on path-filtered jobs that may never report. + +The aggregate rejects failed scope selection, missing outputs, and failed, +cancelled or unexpectedly skipped required jobs. An intentional documentation +skip is reported as not executed, never as runtime evidence. Existing real +add-on install/upgrade/uninstall checks retain their core resource/data +preservation assertions. + +These workflow changes still require hosted qualification and integration into +the required merge-check policy. Complete supported-version and standing-Team +workflow coverage remains a separate acceptance requirement; passing scope or +template checks alone does not establish compatibility. diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts index 462f68e19..a8e5f6def 100644 --- a/bridge/teams-gateway/tests/native-qualification.test.ts +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -76,9 +76,12 @@ describe("Monorepo native prerequisite", () => { it("qualifies actual CNI traffic and never treats API existence as enforcement", () => { expect(workflow).toContain("--version 1.18.5"); - expect(workflow).toContain("needs: [api-admission, native-runtime]"); - expect(workflow).toContain('test "$API_RESULT" = success'); - expect(workflow).toContain('test "$RUNTIME_RESULT" = success'); + expect(workflow).toContain("needs: [contract-scope, api-admission, native-runtime]"); + expect(workflow).toContain("bash ci/bridge-contract-result.sh"); + const aggregate = readFileSync(new URL("../../../ci/bridge-contract-result.sh", import.meta.url), "utf8"); + expect(aggregate).toContain('${API_RESULT:?Missing API result}'); + expect(aggregate).toContain('${RUNTIME_RESULT:?Missing runtime result}'); + expect(aggregate).toContain("Both native API and runtime acceptance must succeed"); expect(workflow).not.toContain("continue-on-error:"); expect(read("tests/native-credentials/kind_config.py")).toContain('"disableDefaultCNI": True'); const observations = read("tests/native-credentials/observation_cases.py"); diff --git a/ci/bridge-contract-result.sh b/ci/bridge-contract-result.sh new file mode 100644 index 000000000..006a612fc --- /dev/null +++ b/ci/bridge-contract-result.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +set -euo pipefail + +if [ "${SCOPE_RESULT:?Missing scope result}" != success ]; then + echo "Native contract scope selection did not succeed" >&2 + exit 1 +fi + +case "${NATIVE_REQUIRED:?Missing native contract decision}" in + true) + if [ "${API_RESULT:?Missing API result}" != success ] || + [ "${RUNTIME_RESULT:?Missing runtime result}" != success ]; then + echo "Both native API and runtime acceptance must succeed" >&2 + exit 1 + fi + ;; + false) + if [ "${API_RESULT:?Missing API result}" != skipped ] || + [ "${RUNTIME_RESULT:?Missing runtime result}" != skipped ]; then + echo "Unexpected native job outcome for a documentation-only change" >&2 + exit 1 + fi + echo "Documentation-only change: native execution not required, not claimed as tested" + ;; + *) + echo "Invalid native contract scope decision" >&2 + exit 1 + ;; +esac diff --git a/ci/bridge_component_results.py b/ci/bridge_component_results.py new file mode 100644 index 000000000..1360df685 --- /dev/null +++ b/ci/bridge_component_results.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Require every declared Bridge component dependency to have succeeded.""" + +import json +import os + + +def require_success(results): + if not isinstance(results, dict) or not results: + raise ValueError("Bridge component results are missing or malformed") + if any(not isinstance(value, dict) or value.get("result") != "success" + for value in results.values()): + raise ValueError("Every Bridge component, audit and add-on job must succeed") + + +if __name__ == "__main__": + require_success(json.loads(os.environ["COMPONENT_RESULTS"])) diff --git a/ci/bridge_contracts.py b/ci/bridge_contracts.py new file mode 100644 index 000000000..7eb517cb6 --- /dev/null +++ b/ci/bridge_contracts.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Fail-closed native-contract scope selection from the actual Git tree diff.""" + +import argparse +from pathlib import PurePosixPath +import re +import subprocess + + +ROOT_DOCUMENTS = {"README.md", "CHANGELOG.md", "CONTRIBUTING.md", "LICENSE", "NOTICE"} +DOCUMENT_SUFFIXES = {".md", ".txt", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".mmd"} + + +def native_required(paths, core_only=False): + for path in paths: + if core_only and path.startswith("bridge/"): + continue + if path in ROOT_DOCUMENTS: + continue + if path.startswith("docs/") and PurePosixPath(path).suffix in DOCUMENT_SUFFIXES: + continue + # New/shared packages, shipped skills, and unknown paths require proof. + return True + return False + + +def classify(event, base, head, core_only=False): + if not event or event == "pull_request_target": + raise ValueError("Unsupported native contract event") + # Reusable CI retains its caller's event, including release/schedule. + # Every non-PR event must keep the existing full-qualification behavior. + if event != "pull_request": + return True + if not all(re.fullmatch(r"[a-f0-9]{40}", value) for value in (base, head)): + raise ValueError("Native contract scope requires exact base and head revisions") + result = subprocess.run( + ["git", "diff", "--no-renames", "--name-only", "-z", base, head, "--"], + check=True, capture_output=True, timeout=30, + ) + # Keep both sides of moves: moving a runtime file into docs is still code. + return native_required( + (path.decode("utf-8") for path in result.stdout.split(b"\0") if path), core_only, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--event", required=True) + parser.add_argument("--base", default="") + parser.add_argument("--head", default="") + parser.add_argument("--output", choices=("required", "code", "run"), default="required") + parser.add_argument("--core-only", action="store_true") + args = parser.parse_args() + print(args.output + "=" + str(classify(args.event, args.base, args.head, args.core_only)).lower()) diff --git a/ci/tests/bridge_contracts_test.py b/ci/tests/bridge_contracts_test.py new file mode 100644 index 000000000..511c646a4 --- /dev/null +++ b/ci/tests/bridge_contracts_test.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import importlib.util +import os +from pathlib import Path +import runpy +import subprocess +import unittest + +from git_fixture import GitFixture + +CI = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("bridge_contracts", CI / "bridge_contracts.py") +scope = importlib.util.module_from_spec(spec) +spec.loader.exec_module(scope) + + +class ContractScopeTests(GitFixture): + def invoke(self, event="pull_request", base=None, head=None, extra=()): + return subprocess.run( + ["python3", str(CI / "bridge_contracts.py"), "--event", event, + "--base", self.base if base is None else base, + "--head", self.git("rev-parse", "HEAD").strip() if head is None else head, *extra], + cwd=self.root, text=True, capture_output=True, timeout=30, + ) + + def test_unknown_and_every_runtime_surface_require_native_contracts(self): + for path in ( + "controller/src/lib.rs", "inference-router/src/lib.rs", "bridge/bff/src/main.rs", + "bridge/web/src/lib/types.ts", "cli/src/commands/credential-grants.ts", + "cli/package-lock.json", "mesh-plugin/src/index.ts", "shared/protocol.json", + "runtimes/openclaw/skills/SKILL.md", "sandbox-images/openclaw/entrypoint.sh", + "deploy/agentmesh-agt.yaml", "tests/e2e/run.sh", "new-package/src/lib.rs", + "Cargo.toml", "Cargo.lock", "Makefile", ".github/workflows/bridge-native.yml", + "ci/bridge_contracts.py", "docs/runtime-policy.json", "bridge/docs/compatibility.md", + ): + with self.subTest(path=path): + self.assertTrue(scope.native_required([path])) + self.assertFalse(scope.native_required(["README.md", "docs/guide.md", "docs/image.svg"])) + self.assertTrue(scope.native_required(["docs/guide.md", "cli/src/index.ts"])) + self.assertFalse(scope.native_required(["bridge/web/src/page.tsx"], core_only=True)) + self.assertTrue(scope.native_required(["bridge/web/src/page.tsx", "cli/src/index.ts"], core_only=True)) + + def test_actual_diff_keeps_moves_and_deletions_in_scope(self): + self.write("controller/src/fixture.rs", "fn fixture() {}\n") + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + (self.root / "docs").mkdir() + self.git("mv", "controller/src/fixture.rs", "docs/fixture.md") + self.commit() + result = self.invoke() + self.assertEqual((result.returncode, result.stdout), (0, "required=true\n")) + + def test_empty_and_documentation_only_diffs_do_not_claim_native_execution(self): + self.assertEqual(self.invoke().stdout, "required=false\n") + self.write("docs/guide with\nnewline.md", "documentation\n") + self.commit() + result = self.invoke() + self.assertEqual((result.returncode, result.stdout), (0, "required=false\n")) + + def test_invalid_revision_diff_or_event_fails_instead_of_skipping(self): + for arguments in ({"base": ""}, {"head": "HEAD; false"}, + {"base": "0" * 40}, {"event": "pull_request_target"}, {"event": ""}): + with self.subTest(arguments=arguments): + result = self.invoke(**arguments) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + for event in ("push", "workflow_dispatch", "workflow_call", "release", "schedule"): + self.assertEqual(self.invoke(event=event).stdout, "required=true\n") + + def test_core_and_pairing_outputs_have_distinct_bridge_only_scope(self): + self.write("bridge/web/src/page.tsx", "export const page = 1;\n") + self.commit() + self.assertEqual(self.invoke(extra=("--output", "code")).stdout, "code=true\n") + self.assertEqual(self.invoke(extra=("--output", "run", "--core-only")).stdout, "run=false\n") + self.assertEqual(self.invoke(event="push", extra=("--output", "run", "--core-only")).stdout, + "run=true\n") + + def test_sparse_core_checkout_really_removes_only_bridge(self): + for path in ("Cargo.toml", "cli/src/index.ts", "controller/src/lib.rs", + ".github/workflows/bridge-native.yml", "bridge/bff/Cargo.toml"): + self.write(path, "fixture\n") + self.commit() + self.git("sparse-checkout", "set", "--no-cone", "/*", "!/bridge/") + self.assertFalse((self.root / "bridge").exists()) + for path in ("Cargo.toml", "cli/src/index.ts", "controller/src/lib.rs", + ".github/workflows/bridge-native.yml"): + self.assertTrue((self.root / path).is_file(), path) + + +class ContractAggregateTests(unittest.TestCase): + def test_component_aggregate_rejects_missing_failed_or_skipped_jobs(self): + check = runpy.run_path(str(CI / "bridge_component_results.py"))["require_success"] + check({"bff": {"result": "success"}, "web": {"result": "success"}}) + for result in ({}, [], None, {"bff": {}}, {"bff": None}, + {"bff": {"result": "success"}, "web": {"result": "failure"}}, + {"bff": {"result": "skipped"}}, {"bff": {"result": "cancelled"}}): + with self.subTest(result=result), self.assertRaises(ValueError): + check(result) + + def test_only_required_success_or_explicit_docs_skip_passes(self): + for required, scope_result, api, runtime, expected in ( + ("true", "success", "success", "success", 0), + ("false", "success", "skipped", "skipped", 0), + ("true", "success", "success", "skipped", 1), + ("true", "success", "failure", "success", 1), + ("true", "success", "cancelled", "success", 1), + ("false", "failure", "skipped", "skipped", 1), + ("false", "success", "failure", "skipped", 1), + ("false", "success", "success", "success", 1), + ("unknown", "success", "skipped", "skipped", 1), + ("", "success", "skipped", "skipped", 1), + ): + with self.subTest(required=required, scope=scope_result, api=api, runtime=runtime): + result = subprocess.run( + ["bash", str(CI / "bridge-contract-result.sh")], + text=True, capture_output=True, timeout=10, + env={**os.environ, "NATIVE_REQUIRED": required, "SCOPE_RESULT": scope_result, + "API_RESULT": api, "RUNTIME_RESULT": runtime}, + ) + self.assertEqual(result.returncode, expected, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/ci/tests/git_fixture.py b/ci/tests/git_fixture.py new file mode 100644 index 000000000..4704e7f0b --- /dev/null +++ b/ci/tests/git_fixture.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class GitFixture(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="kars-ci-git-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.git("init", "-q") + self.git("config", "user.name", "Gate Fixture") + self.git("config", "user.email", "gate@example.invalid") + self.git("config", "commit.gpgsign", "false") + self.git("config", "core.hooksPath", str(self.root / "empty-hooks")) + self.git("commit", "--allow-empty", "-qm", "base") + self.base = self.git("rev-parse", "HEAD").strip() + + def git(self, *args): + return subprocess.check_output(["git", *args], cwd=self.root, text=True, + stderr=subprocess.PIPE) + + def write(self, name, text): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + def commit(self): + self.git("add", ".") + self.git("commit", "-qm", "fixture") diff --git a/ci/tests/no_stubs_test.py b/ci/tests/no_stubs_test.py index b3ff64c91..8e84796bd 100644 --- a/ci/tests/no_stubs_test.py +++ b/ci/tests/no_stubs_test.py @@ -8,39 +8,14 @@ import shlex import shutil import subprocess -import tempfile import unittest +from git_fixture import GitFixture GATE = Path(__file__).resolve().parents[1] / "no-stubs.sh" -class NoStubsTests(unittest.TestCase): - def setUp(self): - self.directory = tempfile.TemporaryDirectory(prefix="kars-stub-gate-") - self.addCleanup(self.directory.cleanup) - self.root = Path(self.directory.name) - self.git("init", "-q") - self.git("config", "user.name", "Gate Fixture") - self.git("config", "user.email", "gate@example.invalid") - self.git("config", "commit.gpgsign", "false") - self.git("config", "core.hooksPath", str(self.root / "empty-hooks")) - self.git("commit", "--allow-empty", "-qm", "base") - self.base = self.git("rev-parse", "HEAD").strip() - - def git(self, *args): - return subprocess.check_output(["git", *args], cwd=self.root, text=True, - stderr=subprocess.PIPE) - - def write(self, name, text): - path = self.root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text) - - def commit(self): - self.git("add", ".") - self.git("commit", "-qm", "fixture") - +class NoStubsTests(GitFixture): def gate(self, extra_env=None): return subprocess.run(["bash", str(GATE)], cwd=self.root, text=True, capture_output=True, timeout=30, diff --git a/cli/src/lib/bridge-contract-ci.test.ts b/cli/src/lib/bridge-contract-ci.test.ts new file mode 100644 index 000000000..8d4f8c289 --- /dev/null +++ b/cli/src/lib/bridge-contract-ci.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +function mapping(value: unknown): Record<string, unknown> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Workflow contract requires a mapping"); + } + return value as Record<string, unknown>; +} + +function workflow(name: string): Record<string, unknown> { + return mapping(parse(readFileSync(new URL(`../../../.github/workflows/${name}`, import.meta.url), "utf8"))); +} + +function jobSteps(document: Record<string, unknown>, name: string): Record<string, unknown>[] { + const value = mapping(mapping(document.jobs)[name]).steps; + if (!Array.isArray(value)) throw new Error("Workflow contract requires steps"); + return value.map(mapping); +} + +describe("permanent core and Bridge CI boundary", () => { + it("always reports component acceptance and includes every component job", () => { + const bridge = workflow("bridge-ci.yml"); + const events = mapping(bridge.on); + expect(mapping(events.pull_request).paths).toBeUndefined(); + expect(mapping(events.pull_request)["paths-ignore"]).toBeUndefined(); + expect(mapping(events.push).paths).toBeUndefined(); + const jobs = mapping(bridge.jobs); + const aggregate = mapping(jobs["bridge-required-gates"]); + const expected = Object.keys(jobs).filter(name => name !== "bridge-required-gates").sort(); + expect(aggregate.needs).toEqual(expected); + for (const name of expected) { + expect(mapping(jobs[name])["continue-on-error"], name).toBeUndefined(); + for (const step of jobSteps(bridge, name)) { + expect(step["continue-on-error"], name).toBeUndefined(); + } + } + expect(aggregate.if).toBe("always()"); + expect(mapping(aggregate.env).COMPONENT_RESULTS).toBe("${{ toJSON(needs) }}"); + expect(jobSteps(bridge, "bridge-required-gates").some(step => + step.run === "python3 ci/bridge_component_results.py")).toBe(true); + }); + + it("builds core Rust and CLI and exercises core Kind with Bridge physically absent", () => { + const core = workflow("ci.yml"); + for (const name of ["build-rust", "cli-build", "e2e-kind"]) { + const steps = jobSteps(core, name); + const checkout = steps.find(step => String(step.uses).startsWith("actions/checkout@")); + expect(checkout, name).toBeDefined(); + const options = mapping(checkout?.with); + expect(options["sparse-checkout-cone-mode"], name).toBe(false); + expect(String(options["sparse-checkout"]).trim().split("\n"), name).toEqual(["/*", "!/bridge/"]); + const guard = steps.find(step => step.name === "Require standalone core checkout"); + expect(guard?.run, name).toContain("test ! -e bridge"); + expect(guard?.["working-directory"], name).toBe("."); + } + expect(jobSteps(core, "cli-build").some(step => step.run === "npm ci")).toBe(true); + expect(jobSteps(core, "changes").some(step => String(step.run) + .includes("ci/bridge_contracts.py --output code"))).toBe(true); + expect(jobSteps(core, "e2e-kind").some(step => String(step.run) + .includes("ci/bridge_contracts.py --output run --core-only"))).toBe(true); + expect(mapping(mapping(core.jobs)["e2e-kind"]).if).toBe("needs.changes.outputs.code == 'true'"); + }); + + it("always creates the native contract status for PRs and qualifies merged tips", () => { + const native = workflow("bridge-native.yml"); + const events = mapping(native.on); + const pullRequest = mapping(events.pull_request); + expect(pullRequest.branches).toEqual(expect.arrayContaining(["main", "kars-bridge"])); + expect(pullRequest.paths).toBeUndefined(); + expect(pullRequest["paths-ignore"]).toBeUndefined(); + expect(mapping(events.push).branches).toEqual(expect.arrayContaining(["main", "kars-bridge"])); + const scope = mapping(mapping(native.jobs)["contract-scope"]); + expect(mapping(scope.outputs).required).toBe("${{ steps.scope.outputs.required }}"); + const steps = jobSteps(native, "contract-scope"); + expect(steps.some(step => String(step.run).includes("ci/bridge_contracts.py"))).toBe(true); + const checkout = steps.find(step => String(step.uses).startsWith("actions/checkout@")); + expect(mapping(checkout?.with)["fetch-depth"]).toBe(0); + }); + + it("cannot turn skipped or failed required native jobs into a passing aggregate", () => { + const native = workflow("bridge-native.yml"); + const jobs = mapping(native.jobs); + for (const name of ["api-admission", "native-runtime"]) { + const job = mapping(jobs[name]); + expect(job.needs).toBe("contract-scope"); + expect(job.if).toBe("needs.contract-scope.outputs.required == 'true'"); + } + const aggregate = mapping(jobs["native-required-gates"]); + expect(aggregate.needs).toEqual(["contract-scope", "api-admission", "native-runtime"]); + expect(aggregate.if).toBe("always()"); + const env = mapping(aggregate.env); + expect(env.SCOPE_RESULT).toBe("${{ needs.contract-scope.result }}"); + expect(env.NATIVE_REQUIRED).toBe("${{ needs.contract-scope.outputs.required }}"); + expect(env.API_RESULT).toBe("${{ needs.api-admission.result }}"); + expect(env.RUNTIME_RESULT).toBe("${{ needs.native-runtime.result }}"); + const steps = jobSteps(native, "native-required-gates"); + expect(steps.some(step => step.run === "bash ci/bridge-contract-result.sh" + && step["working-directory"] === ".")).toBe(true); + }); +}); diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 24cbf7b51..42426ff07 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -89,6 +89,35 @@ unused task-module import and incorrectly nested anchor tests were rejected. The correction removes the import, places the tests at module scope and requires their exact registration in the hosted test inventory before running the complete suite. No lint suppression or assertion removal is used. +The corrected `09954106` passed actual BFF Clippy, required regression +registration and the complete Cargo suite in Azure run 34614267072. + +## Permanent integration CI + +Core Rust, CLI and Kind checkout configurations now omit `bridge/` and assert +its absence. CLI validation uses its committed lockfile. A disposable sparse +checkout of public `09954106` also resolved all eight core workspace packages +with locked offline metadata and no Bridge directory; this is not local +compilation evidence. + +Shared change classification is fail-closed, includes both sides of renames, +and covers CLI/runtime/mesh/shipped-skill and unknown source paths. Only root +documentation can bypass native execution. Reusable CI retains full non-PR +qualification, including release caller events. Core-only Kind can omit +Bridge-only changes while paired native qualification still requires them. + +Both component and native workflows now report stable aggregates. Component +acceptance includes every build, dependency/lock audit, secret/configuration +scan and real add-on job. Native acceptance rejects failed scope selection, +missing outputs and failed/cancelled/unexpectedly skipped required lanes; +documentation-only skips explicitly do not claim runtime execution. + +Local Git/scope/aggregate regressions and the core Python harness passed. +Structural CLI checks are provisional because the shared local Vitest/YAML +cache differs from the committed lock; the new hosted locked CLI job must +qualify them. Required branch-check policy integration, complete supported-pair +contracts and standing-Team acceptance remain open. No protection was weakened +or changed to accept these unqualified workflows. Other findings remain open: credential-review V1 derives a secret key with a custom versioned SHA-256 construction and cannot inherit a plain content-digest diff --git a/tests/e2e/sre_authority/harness_test.py b/tests/e2e/sre_authority/harness_test.py index a45c66113..1cc9a662f 100644 --- a/tests/e2e/sre_authority/harness_test.py +++ b/tests/e2e/sre_authority/harness_test.py @@ -7,6 +7,7 @@ import json from pathlib import Path import re +import runpy import subprocess import tempfile import time @@ -499,16 +500,17 @@ def test_kind_runtime_filter_includes_all_shared_security_modules(self): root = Path(__file__).resolve().parents[3] workflow = (root / ".github/workflows/ci.yml").read_text() kind = workflow.split(" e2e-kind:", 1)[1].split(" bench-regression:", 1)[0] - expression = re.search(r"\| grep -E '([^']+)'", kind) - self.assertIsNotNone(expression) - pattern = expression.group(1) + self.assertIn("ci/bridge_contracts.py --output run --core-only", kind) + required = runpy.run_path(str(root / "ci/bridge_contracts.py"))["native_required"] for path in ("shared/sre_privacy.rs", "shared/another_security_module.rs", - "controller/src/sre_authority.rs", "cli/src/lib/sre-authority.ts"): + "controller/src/sre_authority.rs", "cli/src/lib/sre-authority.ts", + "cli/src/lib/private-activation.ts", "runtimes/openclaw/skills/SKILL.md", + "unrelated/shared/sre_privacy.rs"): with self.subTest(path=path): - self.assertRegex(path, pattern) - for path in ("docs/how-to/sre-authority.md", "unrelated/shared/sre_privacy.rs"): + self.assertTrue(required([path], core_only=True)) + for path in ("docs/how-to/sre-authority.md", "bridge/web/src/page.tsx"): with self.subTest(path=path): - self.assertNotRegex(path, pattern) + self.assertFalse(required([path], core_only=True)) if __name__ == "__main__": From 183eb5ef7e3c31ab895556162f141cd8e0d7892c Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 18:31:05 +0200 Subject: [PATCH 011/111] Split Bridge composition routes into bounded modules Preserve24exports,18tests, proposal/approval semantics and exact orchestration prompt content through mechanical extraction. All resulting compose files remain below800lines; hosted compilation is pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/compose.rs | 4129 +---------------- .../src/routes/compose/capability_tests.rs | 476 ++ bridge/bff/src/routes/compose/client.rs | 224 + bridge/bff/src/routes/compose/egress.rs | 89 + bridge/bff/src/routes/compose/execution.rs | 376 ++ bridge/bff/src/routes/compose/loops.rs | 197 + bridge/bff/src/routes/compose/mission.rs | 416 ++ .../src/routes/compose/mission_proposal.rs | 339 ++ bridge/bff/src/routes/compose/models.rs | 69 + bridge/bff/src/routes/compose/prompts.rs | 597 +++ bridge/bff/src/routes/compose/routing.rs | 172 + bridge/bff/src/routes/compose/team.rs | 319 ++ .../bff/src/routes/compose/team_proposal.rs | 524 +++ .../src/routes/compose/team_qualification.rs | 368 ++ 14 files changed, 4194 insertions(+), 4101 deletions(-) create mode 100644 bridge/bff/src/routes/compose/capability_tests.rs create mode 100644 bridge/bff/src/routes/compose/client.rs create mode 100644 bridge/bff/src/routes/compose/egress.rs create mode 100644 bridge/bff/src/routes/compose/execution.rs create mode 100644 bridge/bff/src/routes/compose/loops.rs create mode 100644 bridge/bff/src/routes/compose/mission.rs create mode 100644 bridge/bff/src/routes/compose/mission_proposal.rs create mode 100644 bridge/bff/src/routes/compose/models.rs create mode 100644 bridge/bff/src/routes/compose/prompts.rs create mode 100644 bridge/bff/src/routes/compose/routing.rs create mode 100644 bridge/bff/src/routes/compose/team.rs create mode 100644 bridge/bff/src/routes/compose/team_proposal.rs create mode 100644 bridge/bff/src/routes/compose/team_qualification.rs diff --git a/bridge/bff/src/routes/compose.rs b/bridge/bff/src/routes/compose.rs index f17aab935..45cc77536 100644 --- a/bridge/bff/src/routes/compose.rs +++ b/bridge/bff/src/routes/compose.rs @@ -18,17 +18,35 @@ // not set the endpoint reports `available: false` and the UI falls back to the // manual composer — never a fabricated package. -use axum::{ - Json, - extract::{Extension, Path, State}, -}; -use serde::{Deserialize, Serialize}; +mod client; +mod egress; +mod execution; +mod loops; +mod mission; +mod mission_proposal; +mod models; +mod prompts; +mod routing; +mod team; +mod team_proposal; +mod team_qualification; -use crate::auth::Principal; -use crate::error::{AppError, AppResult}; -use crate::routes::options::ModelOption; -use crate::routes::options::build_options; -use crate::state::AppState; +#[cfg(test)] +mod capability_tests; + +pub(crate) use execution::{ + delegation_budget_allocation, validate_delegation, validate_execution_plan, +}; +pub use loops::{ProposeLoopRequest, ProposeLoopResponse, propose_loop}; +pub use mission::compose; +pub use models::{ + ComposeDelegation, ComposeDelegationRole, ComposeEgress, ComposeModel, ComposeProposal, + ComposeRequest, ComposeResponse, +}; +pub use team::{ + ComposeTeamMilestone, ComposeTeamProposal, ComposeTeamRequest, ComposeTeamResponse, + ComposeTeamRole, compose_team, +}; /// The cluster-default governance policy. Assigned to any envelope the /// orchestrator (or operator) leaves un-governed, so the sandbox is BOTH @@ -37,12 +55,6 @@ use crate::state::AppState; /// otherwise yields a sandbox that hangs (no tool/inference/mesh permitted). /// `kars-default` allows inference/tool/mesh/spawn and denies dangerous shell. pub const DEFAULT_TOOL_POLICY: &str = "kars-default"; -const MISSION_COMPOSE_MAX_TOKENS: u32 = 4_096; -const LOOP_COMPOSE_MAX_TOKENS: u32 = 1_200; -// A team proposal can contain eight milestone contracts plus four role contracts. -// Keep enough output room for the model's complete JSON rather than accepting a -// syntactically truncated proposal and wasting the single repair attempt. -const TEAM_COMPOSE_MAX_TOKENS: u32 = 8_192; /// Resolve a governance policy for an envelope the orchestrator left /// un-governed: prefer `kars-default` when the cluster has it, otherwise the @@ -58,1360 +70,6 @@ pub fn default_tool_policy(o: &crate::routes::options::Options) -> Option<String o.tool_policies.first().map(|tp| tp.name.clone()) } -fn orchestrator_quality_score(deployment: &str) -> Option<i64> { - let model = deployment.to_ascii_lowercase().replace(['.', '_'], "-"); - if model.contains("embedding") - || model.contains("image") - || model.contains("flux") - || model.contains("dall-e") - { - return None; - } - let score = if model.contains("gpt-5-6") || model.contains("gpt-5.6") { - 1_000 - } else if model.contains("claude-opus-4-8") { - 990 - } else if model.contains("claude-opus-4-7") { - 980 - } else if model.contains("gpt-5-4-pro") { - 970 - } else if model.contains("gpt-5-4") { - 950 - } else if model.contains("claude-sonnet-5") { - 940 - } else if model.contains("gpt-4-1") { - 900 - } else if model.contains("gpt-oss-120b") { - 850 - } else if model.contains("gpt-5") || model.contains("claude") { - 800 - } else { - 500 - }; - Some(score) -} - -fn catalogue_has_model(models: &[ModelOption], provider: &str, deployment: &str) -> bool { - models - .iter() - .any(|model| model.provider == provider && model.deployment == deployment) -} - -fn catalogue_has_model_key(models: &[ModelOption], key: &str) -> bool { - key.split_once("::") - .is_some_and(|(provider, deployment)| catalogue_has_model(models, provider, deployment)) -} - -fn recommendation_is_actionable(recommended: Option<&str>, low_confidence: bool) -> bool { - recommended.is_some() && !low_confidence -} - -fn select_orchestrator_route( - options: &crate::routes::options::Options, - efficiency: &crate::routes::efficiency::EfficiencyDto, -) -> Option<(String, String, String)> { - let actionable_recommendation = efficiency.recommended.as_deref().filter(|_| { - recommendation_is_actionable( - efficiency.recommended.as_deref(), - efficiency.recommended_low_confidence, - ) - }); - options - .models - .iter() - .filter_map(|model| { - let quality = orchestrator_quality_score(&model.deployment)?; - let route = efficiency - .routes - .iter() - .find(|route| route.route == model.deployment); - let frontier_bonus = if actionable_recommendation == Some(model.deployment.as_str()) { - 80 - } else { - 0 - }; - let evidence_bonus = route - .map(|route| (route.acceptance_rate * 50.0).round() as i64) - .unwrap_or(0); - Some((quality + frontier_bonus + evidence_bonus, model)) - }) - .max_by_key(|(score, _)| *score) - .map(|(_, model)| { - let basis = if actionable_recommendation == Some(model.deployment.as_str()) { - format!( - "Selected {} from {} as the strongest orchestration-capable model and current efficiency-frontier recommendation.", - model.deployment, model.provider - ) - } else { - format!( - "Selected {} from {} as the strongest orchestration-capable model in the configured catalogue.", - model.deployment, model.provider - ) - }; - (model.provider.clone(), model.deployment.clone(), basis) - }) -} - -#[derive(Debug, Deserialize)] -pub struct ComposeRequest { - pub objective: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeModel { - pub provider: String, - pub deployment: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeEgress { - pub host: String, - pub port: Option<u16>, -} - -fn complete_egress_recommendation( - egress: Vec<ComposeEgress>, - intent: &str, - mcp_servers: &[String], -) -> Vec<ComposeEgress> { - let mut endpoints = std::collections::BTreeMap::<String, Option<u16>>::new(); - let lower = intent.to_ascii_lowercase(); - let npm_intent = ["npm", "node", "javascript", "typescript", "package.json"] - .iter() - .any(|term| lower.contains(term)); - let python_intent = ["python", "pip", "pypi", "requirements.txt"] - .iter() - .any(|term| lower.contains(term)); - for endpoint in egress { - let host = endpoint.host.to_ascii_lowercase(); - if host.contains("githubcopilot.com") - || host.ends_with(".openai.azure.com") - || host.ends_with(".services.ai.azure.com") - { - continue; - } - if host == "registry.npmjs.org" && !npm_intent { - continue; - } - if matches!(host.as_str(), "pypi.org" | "files.pythonhosted.org") && !python_intent { - continue; - } - endpoints.insert(host, endpoint.port.or(Some(443))); - } - let github = mcp_servers - .iter() - .any(|server| server.to_ascii_lowercase().contains("github")) - || [ - "github", - "repository", - "pull request", - "dependabot", - "code scanning", - ] - .iter() - .any(|term| lower.contains(term)); - let mut add = |host: &str| { - endpoints.entry(host.to_string()).or_insert(Some(443)); - }; - if github { - for host in [ - "api.github.com", - "github.com", - "raw.githubusercontent.com", - "codeload.github.com", - "objects.githubusercontent.com", - "patch-diff.githubusercontent.com", - ] { - add(host); - } - } - if npm_intent { - add("registry.npmjs.org"); - } - if python_intent { - add("pypi.org"); - add("files.pythonhosted.org"); - } - if [ - "security advisory", - "security advisories", - "vulnerability", - "vulnerabilities", - "cve", - ] - .iter() - .any(|term| lower.contains(term)) - { - add("api.osv.dev"); - } - if ["rust", "cargo", "crates.io"] - .iter() - .any(|term| lower.contains(term)) - { - add("index.crates.io"); - add("static.crates.io"); - } - endpoints - .into_iter() - .map(|(host, port)| ComposeEgress { host, port }) - .collect() -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ComposeDelegationRole { - pub name: String, - pub objective: String, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ComposeDelegation { - pub mode: String, - pub roles: Vec<ComposeDelegationRole>, - pub max_parallel: i32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeProposal { - pub tier: i32, - pub model: Option<ComposeModel>, - /// Ordered routes that independently qualify the complete Mission package. - pub model_fallbacks: Vec<ComposeModel>, - /// Plain-language basis for the model choice, so the reviewer sees WHY this - /// model was proposed — the learned efficiency frontier, the orchestrator's - /// objective-driven pick, or the cluster default. Never fabricated. - pub model_basis: Option<String>, - pub runtime: String, - pub instructions: String, - pub tool_policy: Option<String>, - pub mcp_servers: Vec<String>, - pub skills: Vec<String>, - pub egress: Vec<ComposeEgress>, - pub isolation: String, - pub memory: Option<String>, - pub budget_tokens: Option<i64>, - pub execution_plan: Option<crate::routes::tasks::ExecutionPlanDto>, - pub delegation: ComposeDelegation, -} - -#[derive(Debug, Serialize)] -pub struct ComposeResponse { - /// Whether the orchestrator is configured + reachable on this deployment. - pub available: bool, - /// Why it isn't available (only set when `available` is false), so the UI - /// can explain the manual-composer fallback honestly. - pub reason: Option<String>, - /// The composed, validated launch package — ready to review and edit. - pub proposal: Option<ComposeProposal>, - /// A short, plain-language rationale for the choices (for the reviewer). - pub rationale: Option<String>, - /// The model that composed the package (provenance). - pub source: Option<String>, -} - -fn mission_proposal_blueprint( - proposal: &ComposeProposal, -) -> Result< - ( - crate::routes::tasks::BlueprintDto, - &ComposeModel, - std::collections::BTreeSet<String>, - i32, - ), - String, -> { - let model = proposal - .model - .as_ref() - .ok_or_else(|| "the proposal has no model route".to_string())?; - let blueprint = crate::routes::tasks::BlueprintDto { - runtime: Some(proposal.runtime.clone()), - model: Some(crate::routes::tasks::ModelDto { - provider: model.provider.clone(), - deployment: model.deployment.clone(), - }), - model_fallbacks: proposal - .model_fallbacks - .iter() - .map(|model| crate::routes::tasks::ModelDto { - provider: model.provider.clone(), - deployment: model.deployment.clone(), - }) - .collect(), - instructions: Some(proposal.instructions.clone()), - tool_policy: proposal.tool_policy.clone(), - mcp_servers: proposal.mcp_servers.clone(), - egress: proposal - .egress - .iter() - .map(|endpoint| crate::routes::tasks::EgressDto { - host: endpoint.host.clone(), - port: endpoint.port.map(i32::from), - }) - .collect(), - egress_mode: Some("strict".into()), - isolation: Some(proposal.isolation.clone()), - memory: proposal.memory.clone(), - skills: proposal.skills.clone(), - execution_plan: proposal.execution_plan.clone(), - }; - let (required, max_parallel) = - crate::routes::validate::qualification_requirements(&blueprint, None); - Ok((blueprint, model, required, max_parallel)) -} - -fn apply_mission_budget_floor(proposal: &mut ComposeProposal) -> Result<Option<i64>, String> { - let (_, model, required, max_parallel) = mission_proposal_blueprint(proposal)?; - let minimum = crate::routes::options::route_minimum_tokens( - &proposal.runtime, - &model.provider, - &model.deployment, - &required, - max_parallel, - )?; - let Some(minimum) = minimum else { - return Ok(None); - }; - - let mut changed = false; - let mut required_total = minimum; - if let Some(plan) = proposal.execution_plan.as_mut() - && !plan.roles.is_empty() - { - let (role_budgets_changed, role_budget_total) = - apply_weighted_role_budget_floors(plan, minimum.max(plan.roles.len() as i64)); - changed |= role_budgets_changed; - required_total = required_total.max(role_budget_total); - } - if proposal - .budget_tokens - .is_none_or(|current| current < required_total) - { - proposal.budget_tokens = Some(required_total); - changed = true; - } - Ok(changed.then_some(required_total)) -} - -fn mission_proposal_qualification(proposal: &ComposeProposal) -> Result<(), String> { - let (_, model, required, max_parallel) = mission_proposal_blueprint(proposal)?; - let qualified = crate::routes::options::route_qualification( - &proposal.runtime, - &model.provider, - &model.deployment, - &required, - max_parallel, - proposal.budget_tokens, - )?; - if qualified { - return Ok(()); - } - let missing = crate::routes::options::route_qualification_gap( - &proposal.runtime, - &model.provider, - &model.deployment, - &required, - max_parallel, - proposal.budget_tokens, - )?; - Err(format!( - "{} · {}::{} lacks retained qualification for [{}] at max_parallel={max_parallel}", - proposal.runtime, - model.provider, - model.deployment, - missing.into_iter().collect::<Vec<_>>().join(", "), - )) -} - -fn option_named<'a>( - options: &'a [crate::routes::options::RefOption], - name: &str, -) -> Option<&'a crate::routes::options::RefOption> { - options.iter().find(|option| option.name == name) -} - -fn mission_resource_qualification( - proposal: &ComposeProposal, - options: &crate::routes::options::Options, -) -> Result<(), String> { - let (_, model, _, _) = mission_proposal_blueprint(proposal)?; - let route = - crate::routes::options::route_label(&proposal.runtime, &model.provider, &model.deployment); - for server in &proposal.mcp_servers { - let option = option_named(&options.mcp_servers, server) - .ok_or_else(|| format!("MCP server `{server}` is not in the live options catalogue"))?; - if !crate::routes::options::mcp_server_qualified_for_route( - &proposal.runtime, - &model.provider, - &model.deployment, - option, - )? { - return Err(format!( - "MCP server `{server}` lacks retained resource qualification for {route} at current schema {}. Generic route records do not prove this server.", - option.tool_schema_digest.as_deref().unwrap_or("missing"), - )); - } - } - if let Some(memory) = proposal.memory.as_deref() { - let option = option_named(&options.memories, memory) - .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; - if !crate::routes::options::memory_binding_qualified_for_route( - &proposal.runtime, - &model.provider, - &model.deployment, - option, - )? { - return Err(format!( - "Memory `{memory}` lacks retained resource qualification for {route} at backend {} / compiled digest {}. Generic route records do not prove this binding.", - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing"), - )); - } - } - for skill in &proposal.skills { - let option = option_named(&options.skills, skill) - .ok_or_else(|| format!("skill `{skill}` is not in the approved live catalogue"))?; - if !crate::routes::options::skill_version_qualified_for_route( - &proposal.runtime, - &model.provider, - &model.deployment, - option, - )? { - return Err(format!( - "Skill `{skill}` lacks retained resource qualification for {route} at current version digest {}. Generic route records do not prove this approved version.", - option.version_digest.as_deref().unwrap_or("missing"), - )); - } - } - Ok(()) -} - -fn mission_proposal_launchability( - proposal: &ComposeProposal, - options: &crate::routes::options::Options, -) -> Result<(), String> { - mission_proposal_qualification(proposal)?; - mission_resource_qualification(proposal, options) -} - -/// `POST /api/namespaces/:ns/compose` — orchestrate a launch package from an -/// objective. The `ns` is accepted for symmetry with the other task routes but -/// composition reads cluster-wide building blocks. -pub async fn compose( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Json(req): Json<ComposeRequest>, -) -> AppResult<Json<ComposeResponse>> { - let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; - - let objective = req.objective.trim(); - if objective.is_empty() { - return Err(AppError::BadRequest("objective is required".into())); - } - - let options = build_options(cluster).await?; - let efficiency = crate::routes::efficiency::compute_efficiency_for_owner( - cluster, - Some(principal.sub.as_str()), - ) - .await; - let orchestrator_route = select_orchestrator_route(&options, &efficiency); - - let qualification_constraints = crate::routes::options::qualification_constraints_summary() - .unwrap_or_else(|error| format!(" (qualification records unavailable: {error})")); - let resource_qualification_constraints = - crate::routes::options::resource_qualification_summary(&options).unwrap_or_else(|error| { - format!(" (resource qualification records unavailable: {error})") - }); - let system = build_system_prompt( - &options, - &efficiency, - &qualification_constraints, - &resource_qualification_constraints, - ); - let user = format!( - "Objective:\n{objective}\n\nCompose the launch package now. Respond with ONLY the JSON object." - ); - - // Resolve the model used to CALL the orchestrator. An explicit - // BRIDGE_ORCHESTRATOR_* env triple overrides (and carries its own model), so - // it makes `default_model` irrelevant. Otherwise we need a real cluster - // model — never a fabricated one, which would fail opaquely on a non-Anthropic - // cluster. When there is neither, say so honestly instead of guessing. - let env_orchestrator = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") - .is_ok_and(|v| !v.trim().is_empty()) - && std::env::var("BRIDGE_ORCHESTRATOR_TOKEN").is_ok_and(|v| !v.trim().is_empty()) - && std::env::var("BRIDGE_ORCHESTRATOR_MODEL").is_ok_and(|v| !v.trim().is_empty()); - let pinned_orchestrator_model = cluster.bridge_orchestrator_model().await; - let resolved_model = orchestrator_route - .as_ref() - .map(|(_, deployment, _)| deployment.clone()) - .or(pinned_orchestrator_model) - .or_else(|| { - options - .models - .iter() - .find(|m| m.is_default) - .or_else(|| options.models.first()) - .map(|m| m.deployment.clone()) - }); - if resolved_model.is_none() && !env_orchestrator { - return Ok(Json(ComposeResponse { - available: false, - reason: Some( - "No inference models are configured on this cluster, so the AI composer can't run. Add an inference provider in the Operator Console, or compose the package manually below.".into(), - ), - proposal: None, - rationale: None, - source: None, - })); - } - let default_model = resolved_model.unwrap_or_default(); - if !env_orchestrator - && let Some((provider, deployment, _)) = &orchestrator_route - && let Err(error) = cluster - .configure_bridge_orchestrator_model(provider, deployment) - .await - { - return Ok(Json(ComposeResponse { - available: false, - reason: Some(format!( - "The best orchestrator route ({provider}/{deployment}) could not be configured: {error}" - )), - proposal: None, - rationale: None, - source: None, - })); - } - - let (raw, mut source) = match orchestrator_complete( - cluster, - &system, - &user, - &default_model, - MISSION_COMPOSE_MAX_TOKENS, - ) - .await - { - Ok(r) => r, - Err(e) => { - // A reachable-but-failing orchestrator is reported honestly, not - // papered over with a fabricated package. - return Ok(Json(ComposeResponse { - available: false, - reason: Some(format!( - "The orchestrator could not compose a package ({e}). Compose it manually below — every field is the same one the orchestrator would propose." - )), - proposal: None, - rationale: None, - source: None, - })); - } - }; - - let (mut proposal, mut rationale) = parse_and_validate(&raw, &options, &efficiency, objective); - if let Ok(Some(minimum)) = apply_mission_budget_floor(&mut proposal) { - let note = format!( - "The token budget was raised to the retained qualification floor of {minimum} tokens." - ); - rationale = Some(match rationale { - Some(existing) if !existing.trim().is_empty() => format!("{existing} {note}"), - _ => note, - }); - } - if let Err(error) = mission_proposal_launchability(&proposal, &options) { - let repair_user = format!( - "{user}\n\nYour previous proposal was not launchable: {error}\n\ - Recompose it so the complete runtime/model/capability/max_parallel requirement fits \ - ONE qualified execution record below. Qualification records do not compose. If a \ - selected MCP server, memory binding, or approved skill is used, it MUST have a \ - retained resource-scoped qualification record at the CURRENT digest on the chosen \ - route — generic route records do not count. If a requested binary or file-writing \ - deliverable needs an unqualified capability, choose a launchable text/JSON \ - alternative and represent diagrams inline with quoted Mermaid flowchart labels \ - whenever they contain parser-sensitive punctuation.\n\n\ - QUALIFIED EXECUTION RECORDS:\n{qualification_constraints}\n\n\ - RESOURCE QUALIFICATION RECORDS:\n{resource_qualification_constraints}\n\n\ - Return ONLY the complete JSON object." - ); - if let Ok((repair_raw, repair_source)) = orchestrator_complete( - cluster, - &system, - &repair_user, - &default_model, - MISSION_COMPOSE_MAX_TOKENS, - ) - .await - { - (proposal, rationale) = - parse_and_validate(&repair_raw, &options, &efficiency, objective); - let _ = apply_mission_budget_floor(&mut proposal); - source = repair_source; - } - } - if let Err(error) = mission_proposal_launchability(&proposal, &options) { - return Ok(Json(ComposeResponse { - available: false, - reason: Some(format!( - "The orchestrator could not produce a launchable package after retry: {error}" - )), - proposal: None, - rationale: None, - source: Some(source), - })); - } - - proposal.model_fallbacks = qualified_mission_fallbacks(&proposal, &options); - Ok(Json(ComposeResponse { - available: true, - reason: None, - proposal: Some(proposal), - rationale, - source: Some(source), - })) -} - -fn qualified_mission_fallbacks( - proposal: &ComposeProposal, - options: &crate::routes::options::Options, -) -> Vec<ComposeModel> { - let primary = proposal - .model - .as_ref() - .map(|model| format!("{}::{}", model.provider, model.deployment)); - let mut candidates = options - .models - .iter() - .map(|model| (model.provider.clone(), model.deployment.clone())) - .collect::<Vec<_>>(); - candidates.sort(); - candidates.dedup(); - candidates - .into_iter() - .filter(|(provider, deployment)| { - primary.as_deref() != Some(format!("{provider}::{deployment}").as_str()) - }) - .filter_map(|(provider, deployment)| { - let mut trial = proposal.clone(); - trial.model = Some(ComposeModel { - provider: provider.clone(), - deployment: deployment.clone(), - }); - trial.model_fallbacks.clear(); - mission_proposal_launchability(&trial, options) - .is_ok() - .then_some(ComposeModel { - provider, - deployment, - }) - }) - .take(8) - .collect() -} - -/// `POST /api/namespaces/:ns/propose-loop` — the orchestrator turns a raw intent -/// into a PROPOSED loop (2026 loop engineering): it picks the feedback-loop -/// pattern that fits and drafts the goal + success criteria. The web then shows -/// this in the Loop Designer for the user to REVIEW and tweak before executing — -/// so the loop is orchestrator-defined, human-reviewed, then run. Falls back to a -/// keyword heuristic when the orchestrator is unreachable (never a dead end). -#[derive(Debug, serde::Deserialize)] -pub struct ProposeLoopRequest { - pub intent: String, - /// "mission" (single run) or "team" (standing cadence loop). - #[serde(default)] - pub surface: String, -} - -#[derive(Debug, Serialize)] -pub struct ProposeLoopResponse { - /// Chosen loop pattern id (matches the web catalog: react, reflect, - /// plan-execute, eval-iterate, explore-branch, standing-watch). - pub pattern: String, - /// The goal the orchestrator distilled from the intent. - pub goal: String, - /// Draft success criteria (one per line). - pub criteria: String, - /// One-line why-this-pattern rationale. - pub rationale: String, - /// "orchestrator" when the model chose it, "heuristic" on fallback. - pub source: String, -} - -const LOOP_PATTERN_IDS: [&str; 6] = [ - "react", - "reflect", - "plan-execute", - "eval-iterate", - "explore-branch", - "standing-watch", -]; - -/// Keyword heuristic used both to seed the orchestrator and as the fallback. -fn heuristic_pattern(intent: &str, surface: &str) -> &'static str { - let t = intent.to_ascii_lowercase(); - if surface == "team" - || t.contains("watch") - || t.contains("monitor") - || t.contains("keep an eye") - || t.contains("on cadence") - || t.contains("every ") - { - return "standing-watch"; - } - if t.contains("test") - || t.contains("verify") - || t.contains("pass") - || t.contains("acceptance") - || t.contains("ci") - { - return "eval-iterate"; - } - if t.contains("research") - || t.contains("investigate") - || t.contains("browse") - || t.contains("search") - || t.contains("find ") - { - return "react"; - } - if t.contains("write") - || t.contains("draft") - || t.contains("report") - || t.contains("polish") - || t.contains("review") - { - return "reflect"; - } - if t.contains("design") - || t.contains("compare") - || t.contains("options") - || t.contains("approach") - || t.contains("brainstorm") - { - return "explore-branch"; - } - "plan-execute" -} - -pub async fn propose_loop( - State(state): State<AppState>, - Json(req): Json<ProposeLoopRequest>, -) -> AppResult<Json<ProposeLoopResponse>> { - let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; - let intent = req.intent.trim(); - if intent.is_empty() { - return Err(AppError::BadRequest("intent is required".into())); - } - let surface = if req.surface == "team" { - "team" - } else { - "mission" - }; - let heuristic = heuristic_pattern(intent, surface); - - let options = build_options(cluster).await?; - // Don't fabricate a specific model when none is configured — an empty model - // makes the orchestrator call fail cleanly and fall back to the heuristic - // below, rather than pretending a named model exists on this cluster. - let default_model = options - .models - .iter() - .find(|m| m.is_default) - .or_else(|| options.models.first()) - .map(|m| m.deployment.clone()) - .unwrap_or_default(); - - let system = format!( - "You are a loop-engineering orchestrator. Given a user's intent, pick the ONE feedback-loop \ - pattern that best fits and draft the loop. Patterns: react (reason+act with tools), reflect \ - (draft, self-critique, revise), plan-execute (plan then do), eval-iterate (define acceptance \ - checks first, loop until they pass), explore-branch (generate candidates, prune), \ - standing-watch (periodic observe->detect change->act, for standing {surface} work). \ - Respond with ONLY a JSON object: {{\"pattern\": one of [{}], \"goal\": string, \ - \"criteria\": string with one success criterion per line, \"rationale\": one short sentence}}.", - LOOP_PATTERN_IDS.join(", ") - ); - let user = format!( - "Surface: {surface}\nIntent:\n{intent}\n\nA reasonable default pattern is '{heuristic}', but \ - choose the best fit. Respond with ONLY the JSON object." - ); - - // Ask the orchestrator; parse its JSON. Any failure → honest heuristic. - match orchestrator_complete( - cluster, - &system, - &user, - &default_model, - LOOP_COMPOSE_MAX_TOKENS, - ) - .await - { - Ok((raw, _src)) => { - if let Some(v) = extract_json_object(&raw) { - let pattern = v - .get("pattern") - .and_then(|p| p.as_str()) - .filter(|p| LOOP_PATTERN_IDS.contains(p)) - .unwrap_or(heuristic) - .to_string(); - let goal = v - .get("goal") - .and_then(|g| g.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| intent.to_string()); - let criteria = v - .get("criteria") - .and_then(|c| c.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let rationale = v - .get("rationale") - .and_then(|r| r.as_str()) - .unwrap_or("Best fit for this intent.") - .trim() - .to_string(); - return Ok(Json(ProposeLoopResponse { - pattern, - goal, - criteria, - rationale, - source: "orchestrator".into(), - })); - } - // Unparseable model output → heuristic. - } - Err(_) => { /* orchestrator unreachable → heuristic */ } - } - - Ok(Json(ProposeLoopResponse { - pattern: heuristic.to_string(), - goal: intent.to_string(), - criteria: String::new(), - rationale: "Chosen from your intent's keywords (orchestrator unavailable).".into(), - source: "heuristic".into(), - })) -} - -/// Find and parse the first top-level JSON object in a model response (it may be -/// fenced or prefixed with prose). Returns `None` when there's no parseable object. -fn extract_json_object(raw: &str) -> Option<serde_json::Value> { - let start = raw.find('{')?; - let end = raw.rfind('}')?; - if end <= start { - return None; - } - serde_json::from_str(&raw[start..=end]).ok() -} - -/// Complete the orchestrator prompt, returning `(raw_model_output, source)`. -/// -/// Two reachable paths, in priority order: -/// 1. **Ops override** — an explicit `BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL}` -/// triple (a dedicated composer endpoint the operator configured). -/// 2. **Native** — route through a Running sandbox's inference router via the -/// `pods/proxy` subresource. The router injects the provider's auth + -/// integration headers (Copilot/Foundry) and enforces governance, so this -/// works on workload-identity clusters with NO static token in the Bridge — -/// reusing exactly the secure path agents use. -async fn orchestrator_complete( - cluster: &crate::kars::cluster::Cluster, - system: &str, - user: &str, - default_model: &str, - max_tokens: u32, -) -> anyhow::Result<(String, String)> { - // 1. Ops override — direct endpoint/token/model. - if let (Ok(endpoint), Ok(token), Ok(model)) = ( - std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT"), - std::env::var("BRIDGE_ORCHESTRATOR_TOKEN"), - std::env::var("BRIDGE_ORCHESTRATOR_MODEL"), - ) && !endpoint.trim().is_empty() - && !token.trim().is_empty() - && !model.trim().is_empty() - { - let raw = call_llm(&endpoint, &token, &model, system, user, max_tokens).await?; - return Ok((raw, model)); - } - - // 2. Native — through a running sandbox's secure inference router. Try each - // stable candidate in turn so a sandbox with stale provider auth or a - // warming router is skipped rather than failing the whole compose. - // Claude models use the native Anthropic `/v1/messages` path (the - // OpenAI-compat path returns empty content for Claude). - let candidates = cluster.running_sandbox_candidates().await; - if candidates.is_empty() { - anyhow::bail!( - "orchestrator has no inference path yet — the standing `bridge-orchestrator` sandbox is still starting (retry shortly), or set BRIDGE_ORCHESTRATOR_{{ENDPOINT,TOKEN,MODEL}} to route directly at Azure AI Foundry / Azure OpenAI (scales better for many teams)" - ); - } - let is_claude = default_model.to_ascii_lowercase().contains("claude"); - let mut last_err = String::from("no candidate router returned content"); - for (ns, pod) in candidates.iter().take(4) { - match orchestrator_via_router( - cluster, - ns, - pod, - default_model, - OrchestratorPrompt { system, user }, - is_claude, - max_tokens, - ) - .await - { - Ok(content) if !content.trim().is_empty() => { - return Ok((content, format!("{default_model} (cluster router)"))); - } - Ok(_) => last_err = "router returned empty content".into(), - Err(e) => last_err = e.to_string(), - } - } - anyhow::bail!("{last_err}") -} - -struct OrchestratorPrompt<'a> { - system: &'a str, - user: &'a str, -} - -/// Single orchestrator completion against one sandbox router (Anthropic -/// `/v1/messages` for Claude, OpenAI `/chat/completions` otherwise). -async fn orchestrator_via_router( - cluster: &crate::kars::cluster::Cluster, - ns: &str, - pod: &str, - model: &str, - prompt: OrchestratorPrompt<'_>, - is_claude: bool, - max_tokens: u32, -) -> anyhow::Result<String> { - let OrchestratorPrompt { system, user } = prompt; - if is_claude { - let body = serde_json::json!({ - "model": model, - "system": system, - "messages": [{ "role": "user", "content": user }], - "max_tokens": max_tokens, - }); - let text = cluster.router_messages(ns, pod, &body).await?; - let parsed: serde_json::Value = serde_json::from_str(&text) - .map_err(|e| anyhow::anyhow!("router returned non-JSON: {e}"))?; - let content: String = parsed - .get("content") - .and_then(|c| c.as_array()) - .map(|blocks| { - blocks - .iter() - .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) - .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .collect::<Vec<_>>() - .join("") - }) - .unwrap_or_default(); - return Ok(content); - } - - let body = serde_json::json!({ - "model": model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - "max_tokens": max_tokens, - }); - let text = cluster.router_chat(ns, pod, &body).await?; - let parsed: serde_json::Value = serde_json::from_str(&text) - .map_err(|e| anyhow::anyhow!("router returned non-JSON: {e}"))?; - Ok(parsed - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - .and_then(|c| c.as_str()) - .unwrap_or_default() - .to_string()) -} - -/// Build the system prompt enumerating the real building blocks + the strict -/// JSON contract. The model is told it may ONLY use these exact identifiers, -/// and is given the learned efficiency frontier so its model choice is grounded -/// in what actually performs on this cluster — not a blind pick. -fn build_system_prompt( - o: &crate::routes::options::Options, - eff: &crate::routes::efficiency::EfficiencyDto, - qualification_constraints: &str, - resource_qualification_constraints: &str, -) -> String { - let models = o - .models - .iter() - .map(|m| { - format!( - " - deployment=\"{}\" provider=\"{}\"{}", - m.deployment, - m.provider, - if m.is_default { " (default)" } else { "" } - ) - }) - .collect::<Vec<_>>() - .join("\n"); - let runtimes = o - .runtimes - .iter() - .filter(|r| r.wired && r.kind != "BYO") - .map(|r| format!(" - \"{}\"", r.kind)) - .collect::<Vec<_>>() - .join("\n"); - let isolation = o - .isolation - .iter() - .map(|i| format!(" - \"{}\" — {}", i.value, i.note)) - .collect::<Vec<_>>() - .join("\n"); - let policies = if o.tool_policies.is_empty() { - " (none)".to_string() - } else { - o.tool_policies - .iter() - .map(|p| { - format!( - " - \"{}\"{}", - p.name, - p.summary - .as_deref() - .map(|s| format!(" — {s}")) - .unwrap_or_default() - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - let mcp = if o.mcp_servers.is_empty() { - " (none)".to_string() - } else { - o.mcp_servers - .iter() - .map(|m| { - format!( - " - \"{}\"{}{}{}{}{}", - m.name, - m.summary - .as_deref() - .map(|s| format!(" — {s}")) - .unwrap_or_default(), - m.mode - .as_deref() - .map(|mode| format!(" · mode={mode}")) - .unwrap_or_default(), - if m.discovered_tools.is_empty() { - String::new() - } else { - format!(" · tools=[{}]", m.discovered_tools.join(", ")) - }, - m.tool_schema_digest - .as_deref() - .map(|digest| format!(" · schema_digest={digest}")) - .unwrap_or_else(|| " · schema_digest=missing".into()), - m.readiness - .as_deref() - .map(|readiness| format!(" · readiness={readiness}")) - .unwrap_or_default() - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - let memories = if o.memories.is_empty() { - " (none)".to_string() - } else { - o.memories - .iter() - .map(|m| { - format!( - " - \"{}\"{}{}{}{}", - m.name, - m.summary - .as_deref() - .map(|summary| format!(" — {summary}")) - .unwrap_or_default(), - m.backend - .as_deref() - .map(|backend| format!(" · backend={backend}")) - .unwrap_or_else(|| " · backend=missing".into()), - m.compiled_digest - .as_deref() - .map(|digest| format!(" · compiled_digest={digest}")) - .unwrap_or_else(|| " · compiled_digest=missing".into()), - m.readiness - .as_deref() - .map(|readiness| format!(" · readiness={readiness}")) - .unwrap_or_default(), - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - let skills = if o.skills.is_empty() { - " (none)".to_string() - } else { - o.skills - .iter() - .map(|s| { - format!( - " - \"{}\"{}{}{}{}{}", - s.name, - s.summary - .as_deref() - .map(|v| format!(" — {v}")) - .unwrap_or_default(), - s.version - .as_deref() - .map(|version| format!(" · version={version}")) - .unwrap_or_default(), - s.version_digest - .as_deref() - .map(|digest| format!(" · version_digest={digest}")) - .unwrap_or_else(|| " · version_digest=missing".into()), - s.recipe - .as_deref() - .map(|recipe| { - format!(" · recipe={}", recipe.chars().take(180).collect::<String>()) - }) - .unwrap_or_default(), - s.readiness - .as_deref() - .map(|readiness| format!(" · readiness={readiness}")) - .unwrap_or_default() - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - - // The learned efficiency frontier — grounds the model choice in real - // outcomes. Honest: when no runs have completed yet, say so rather than - // inventing a recommendation. - let efficiency = if eff.routes.is_empty() { - " (no completed runs yet — choose the default model unless the objective clearly warrants another)".to_string() - } else { - let mut lines = eff - .routes - .iter() - .take(6) - .map(|r| { - // Structured, honest per-route signal. Absent metrics (pass^k - // with no repeats, USD with no price table) are omitted rather - // than faked, so the model never reasons over invented numbers. - let reliability = match (r.reliability_rate, r.reliability_k) { - (Some(rate), Some(k)) => { - format!(", pass^{k} reliability {:.0}% (n={})", rate * 100.0, r.reliability_samples) - } - _ => String::new(), - }; - let latency = if r.avg_wall_ms > 0 { - format!(", ~{:.0}s wall (p95 {:.0}s)", r.avg_wall_ms as f64 / 1000.0, r.p95_wall_ms as f64 / 1000.0) - } else { - String::new() - }; - let toolfail = if r.avg_tool_calls > 0.0 { - format!(", {:.0}% tool-fail", r.tool_fail_rate * 100.0) - } else { - String::new() - }; - let usd = match r.usd_per_outcome { - Some(u) => format!(", ${:.3}/outcome", u), - None => String::new(), - }; - let fault = if r.top_fault.is_empty() { - String::new() - } else { - format!(", top miss: {}", r.top_fault) - }; - format!( - " - route \"{}\": {:.0}% accepted, {:.0}% delivered, {} tokens/outcome{usd}{reliability}{latency}{toolfail}{fault} over {} run(s){}", - r.route, - r.acceptance_rate * 100.0, - r.success_rate * 100.0, - r.tokens_per_outcome, - r.runs, - if !eff.recommended_low_confidence - && eff.recommended.as_deref() == Some(r.route.as_str()) - { - " ← recommended" - } else if eff.recommended_low_confidence - && eff.recommended.as_deref() == Some(r.route.as_str()) - { - " ← best observed, insufficient evidence" - } else { - "" - } - ) - }) - .collect::<Vec<_>>() - .join("\n"); - if !eff.recommended_low_confidence - && let Some(rec) = &eff.recommended - { - lines.push_str(&format!( - "\n Prefer the recommended route's model (route contains its deployment: \"{rec}\") unless the objective clearly needs a stronger or cheaper model." - )); - } else if eff.recommended_low_confidence { - lines.push_str( - "\n The retained route history is too sparse for automatic model selection. Use the cluster default unless the objective itself clearly requires a stronger or more specialized model.", - ); - } - lines - }; - - format!( - r#"You are the kars launch-package orchestrator. You turn a user's plain-language objective into a single, well-governed launch package for a sandboxed AI agent on the kars runtime. You propose; a human reviews and approves before anything runs. - -You MUST only use the building blocks listed below — never invent a model, tool policy, MCP server, isolation level, or memory store that is not listed. - -AVAILABLE MODELS (pick exactly one by its deployment string): -{models} - -EFFICIENCY FRONTIER (learned from completed runs on THIS cluster — the honest signal is human ACCEPTANCE, not emitted tokens): -{efficiency} - -QUALIFIED EXECUTION RECORDS (the complete proposed package MUST fit one record; records do not compose): -{qualification_constraints} - -RESOURCE QUALIFICATION RECORDS (selected MCP servers, memory bindings, and skills MUST match one current-digest record on the chosen route; generic route records do not count): -{resource_qualification_constraints} - -MODEL ROUTING: the model running this composer is not automatically the model that should execute the mission. For routine, bounded, low-risk work, prefer the cluster default or a proven efficient route. Reserve the strongest model for objectives with substantial ambiguity, synthesis, security impact, long context, or difficult tool orchestration. Sparse history with few or zero accepted outcomes is not a recommendation. - -HARNESSES (pick exactly one): -{runtimes} - -ISOLATION LEVELS (pick exactly one): -{isolation} - -TOOL POLICIES (optional; pick one name or null): -{policies} - -MCP SERVERS (optional; pick zero or more names; if you pick any, you MUST also set a tool_policy): -{mcp} -Foundry-native web search, file search, memory, and code execution are Kars plugin tools and do not require MCP. If the customer explicitly requests an installed MCP server, select it and declare `mcp`; the complete capability combination must match one atomic qualification record. - -APPROVED SKILLS (optional; pick zero or more names): -{skills} - -SHARED MEMORY STORES (optional; pick one name or null): -{memories} - -AUTONOMY TIERS (pick the lowest tier that fits the objective): - 1 = Manual (proposes every step, acts on nothing) - 2 = Shared (acts only on low-risk steps) - 3 = Conditional (acts, but pauses before anything costly/external/irreversible) - 4 = Supervised (autonomous with periodic checkpoints) - 5 = Full (fully autonomous within budget) -Default to tier 3 unless the objective clearly warrants more or less. - -EGRESS: list the external network hosts the agent legitimately needs (e.g. an API host), as objects {{"host": "...", "port": 443}}. Prefer an empty list — the model path is always allowed; only add hosts the task truly requires. - -INSTRUCTIONS: write a concise, specific system prompt (2–5 sentences) framing the agent's role and standards for THIS objective. - -EXECUTION PLAN: when the objective benefits from decomposition, propose a workload-neutral typed execution plan. Choose arbitrary role names from the objective — never use a fixed role template. Each role has dependency-aware phases. Each phase declares only the generic capabilities it needs: filesystem-read, filesystem-write, shell, network, web-search, mcp, memory. `min_tool_calls` is the minimum successful evidence-producing calls required; set it to at least 1 whenever the phase outcome depends on tools or external evidence. `max_tool_calls` is the explicit upper bound. Set `fresh_context=true` when a phase should consume only prior handbacks instead of the full earlier transcript. Use null for a small single-agent objective. - -LAUNCHABILITY: every required capability, the runtime/model route, and max_parallel MUST fit one qualified execution record above. Never merge capabilities from separate records. If you select an MCP server, memory binding, or approved skill, state in the rationale which current-digest resource qualification record makes it launchable. Generic route records do not prove a specific server, backend, or skill version. If the requested output format requires an unqualified capability, propose a supported alternative (for example a Markdown report with inline Mermaid diagrams instead of generated binary images) and explain that choice in the rationale. When you emit Mermaid flowcharts, quote every label that contains parser-sensitive punctuation such as :, (), [], {{}}, or /. - -RESEARCH EVIDENCE: for current-events, incident, or authoritative-source research, declare `web-search` on the source-discovery phase and `network` on the exact-URL fetch phase (or declare both on one combined phase). The first fetch-capable phase must discover exact source URLs with an available search tool (`foundry_web_search` or `web_search`) before fetching pages. Never invent article paths. A timeout, non-success response, blocked page, or search snippet is not evidence for a factual claim. Later phases and synthesis may cite only URLs and facts retained from successful source-discovery/fetch tool results; if authoritative evidence is unavailable, report the gap instead of reconstructing unsupported details. - -BUDGET: optionally propose a token budget (integer) appropriate to the scope, or null for no cap. - -Respond with ONLY a JSON object (no prose, no code fences) of exactly this shape: -{{ - "tier": <int 1-5>, - "model": {{"provider": "<provider>", "deployment": "<deployment>"}}, - "runtime": "<harness>", - "instructions": "<system prompt>", - "tool_policy": "<name or null>", - "mcp_servers": ["<name>", ...], - "skills": ["<name>", ...], - "egress": [{{"host": "<host>", "port": <int or null>}}], - "isolation": "<level>", - "memory": "<name or null>", - "budget_tokens": <int or null>, - "execution_plan": {{ - "schema": "kars.execution-plan/v1", - "roles": [{{ - "name": "<short-kebab-role>", - "objective": "<narrow assignment>", - "depends_on": ["<earlier-role>", ...], - "budget_tokens": <int or null>, - "phases": [{{ - "name": "<short-kebab-phase>", - "objective": "<phase outcome>", - "capabilities": ["<filesystem-read|filesystem-write|shell|network|web-search|mcp|memory>", ...], - "min_tool_calls": <int 0-32, <= max_tool_calls>, - "max_tool_calls": <int 0-32>, - "fresh_context": <bool> - }}] - }}], - "max_parallel": <int 1-8>, - "synthesis": {{ - "objective": "<how the principal should reconcile handbacks>", - "capabilities": [], - "max_tool_calls": 0 - }}, - "deliverables": [{{"name":"<safe filename>","media_type":"<optional MIME>"}}] - }} | null, - "rationale": "<1-3 sentences explaining the key choices for the reviewer>" -}}"# - ) -} - -/// Call the orchestrator LLM (OpenAI-compatible chat/completions) and return -/// the assistant's text content. -async fn call_llm( - endpoint: &str, - token: &str, - model: &str, - system: &str, - user: &str, - max_tokens: u32, -) -> anyhow::Result<String> { - let url = format!("{}/chat/completions", endpoint.trim_end_matches('/')); - let body = serde_json::json!({ - "model": model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - "max_tokens": max_tokens, - }); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(45)) - .build()?; - let resp = client - .post(&url) - .bearer_auth(token) - .json(&body) - .send() - .await?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - if !status.is_success() { - anyhow::bail!( - "HTTP {status}: {}", - text.chars().take(200).collect::<String>() - ); - } - let parsed: serde_json::Value = serde_json::from_str(&text)?; - let content = parsed - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - .and_then(|c| c.as_str()) - .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("no completion content"))?; - Ok(content) -} - /// Parse the model's JSON (tolerating code fences / surrounding prose) and /// Whether a harness runs a PRODUCTIVE one-shot / cadence autonomous session — /// i.e. it consumes a delivered objective (over the mesh) and returns a real @@ -1442,2734 +100,3 @@ pub(crate) fn is_autonomous_harness(kind: &str) -> bool { pub(crate) fn is_non_autonomous_harness(kind: &str) -> bool { !kind.trim().is_empty() && !is_autonomous_harness(kind) } - -/// validate every field against the real options — the server is the authority, -/// not the model. Anything invalid is dropped or normalized to a safe default. -fn parse_and_validate( - raw: &str, - o: &crate::routes::options::Options, - eff: &crate::routes::efficiency::EfficiencyDto, - intent: &str, -) -> (ComposeProposal, Option<String>) { - let json = extract_json(raw).unwrap_or_else(|| serde_json::json!({})); - - // Tier: clamp to 1..=5, default 3. - let tier = json - .get("tier") - .and_then(|v| v.as_i64()) - .map(|t| t.clamp(1, 5) as i32) - .unwrap_or(3); - - // ── Model selection (efficiency-driven) ───────────────────────────────── - // Priority: (1) the orchestrator's explicit, valid choice — objective-aware; - // (2) the learned efficiency frontier's recommended route — grounded in real - // accepted outcomes; (3) the cluster default; (4) the first catalogue model. - // `model_basis` records which of these fired so the reviewer sees WHY. - let orchestrator_pick = json.get("model").and_then(|m| { - let provider = m.get("provider").and_then(|p| p.as_str())?; - let dep = m.get("deployment").and_then(|d| d.as_str())?; - o.models - .iter() - .find(|mo| mo.provider == provider && mo.deployment == dep) - .map(|mo| ComposeModel { - provider: mo.provider.clone(), - deployment: mo.deployment.clone(), - }) - }); - - let recommended_model = eff - .recommended - .as_ref() - .filter(|_| { - recommendation_is_actionable(eff.recommended.as_deref(), eff.recommended_low_confidence) - }) - .and_then(|route| { - o.models - .iter() - .find(|mo| mo.deployment == *route) - .map(|mo| ComposeModel { - provider: mo.provider.clone(), - deployment: mo.deployment.clone(), - }) - }); - - let (model, mut model_basis, model_from_reco) = if let Some(m) = orchestrator_pick { - let is_reco = recommendation_is_actionable( - eff.recommended.as_deref(), - eff.recommended_low_confidence, - ) && eff.recommended.as_ref().is_some_and(|r| *r == m.deployment); - let basis = if is_reco { - efficiency_basis(eff, &m.deployment) - } else { - "Chosen by the orchestrator for this objective.".to_string() - }; - (Some(m), Some(basis), is_reco) - } else if let Some(m) = recommended_model { - let basis = efficiency_basis(eff, &m.deployment); - (Some(m), Some(basis), true) - } else { - let m = o - .models - .iter() - .find(|m| m.is_default) - .or_else(|| o.models.first()) - .map(|m| ComposeModel { - provider: m.provider.clone(), - deployment: m.deployment.clone(), - }); - let basis = m.as_ref().map(|_| { - if eff.total_runs == 0 { - "Cluster default — no completed runs yet to learn a better route.".to_string() - } else if eff.recommended.is_some() { - // There IS a learned recommendation, but it doesn't map to a live - // model — be honest rather than implying the default was "chosen". - "Cluster default — the recommended route is no longer in the catalogue.".to_string() - } else { - "Cluster default — the objective didn't clearly warrant another route.".to_string() - } - }); - (m, basis, false) - }; - - // Runtime: the orchestrator's valid choice wins; else, when the model came - // from the efficiency frontier, adopt the harness that ACTUALLY won on that - // route (so we propose the whole winning route, not the model on a default - // harness); else OpenClaw. Any adopted harness must be a wired runtime. - let mut runtime = json - .get("runtime") - .and_then(|v| v.as_str()) - .filter(|r| o.runtimes.iter().any(|ro| ro.wired && ro.kind == *r)) - .map(str::to_string) - .or_else(|| { - if model_from_reco { - eff.recommended_harness - .as_ref() - .filter(|h| o.runtimes.iter().any(|ro| ro.wired && ro.kind == **h)) - .cloned() - } else { - None - } - }) - .unwrap_or_else(|| "OpenClaw".to_string()); - - // Honesty guard (B1): the model can come from the recommended route while - // the orchestrator proposes a DIFFERENT harness. In that case the basis must - // NOT imply we adopted the whole recommended route — the reviewer was seeing - // "Best learned route … on OpenClaw" next to a package that actually ran on - // Hermes. Rewrite the basis to name the real divergence. - if model_from_reco - && let Some(rec_h) = eff.recommended_harness.as_deref() - && !rec_h.is_empty() - && rec_h != runtime - { - let dep = model - .as_ref() - .map(|m| m.deployment.clone()) - .unwrap_or_else(|| "the recommended model".to_string()); - model_basis = Some(format!( - "Recommended model ({dep}), proposed on the {runtime} harness — \ - note the learned best route ran on {rec_h}, so this is not the \ - full recommended route." - )); - } - - // ── Hard capability match (0.4) ───────────────────────────────────────── - // A one-shot MISSION requires an autonomous harness — one that consumes a - // delivered objective and returns a deliverable. A bootstrap-only adapter - // (Anthropic/OpenAIAgents/MAF/LangGraph/PydanticAi) has no task-execution - // loop and delivers nothing autonomously, so routing a mission to one is a - // silent no-op. This is a capability mismatch, not a preference, so we BLOCK - // it rather than soft-warn: it's corrected to OpenClaw (the autonomous - // default) and the decision is recorded in the rationale + stamped on the - // task at launch (kars.azure.com/harness-corrected). (Hermes and BYO are - // autonomous and pass through unchanged.) - let harness_correction: Option<String> = if is_non_autonomous_harness(&runtime) { - let note = format!( - "Capability match: {runtime} is a bootstrap-only adapter with no autonomous \ - task-execution loop, so it cannot run a one-shot mission — routed to OpenClaw \ - (autonomous harness).", - ); - runtime = "OpenClaw".to_string(); - Some(note) - } else { - None - }; - - // Isolation: must be a real level; else standard. - let isolation = json - .get("isolation") - .and_then(|v| v.as_str()) - .filter(|i| o.isolation.iter().any(|io| io.value == *i)) - .unwrap_or("standard") - .to_string(); - - // Tool policy: must be a real policy name. Every envelope MUST carry a - // governance policy — the agent runtime always initializes its AGT engine - // and fails closed on an empty policy set, so an envelope with no tool - // policy yields a sandbox that hangs (no tool/inference/mesh is allowed). - // When the orchestrator doesn't name one, fall back to the cluster default - // governance policy (`kars-default`, which allows inference/tool/mesh/spawn - // and denies dangerous shell) so the sandbox is governed AND functional. - let requested_tool_policy = json - .get("tool_policy") - .and_then(|v| v.as_str()) - .filter(|p| !p.is_empty() && o.tool_policies.iter().any(|tp| tp.name == *p)) - .map(str::to_string) - .or_else(|| default_tool_policy(o)); - let (tool_policy, policy_correction) = if requested_tool_policy.as_deref() - == Some("kars-team-member") - { - ( - default_tool_policy(o), - Some( - "Capability match: kars-team-member is reserved for declared standing-team specialists; routed this standalone mission to kars-default." - .to_string(), - ), - ) - } else { - (requested_tool_policy, None) - }; - - // MCP servers: subset of real servers. - let mut mcp_servers: Vec<String> = json - .get("mcp_servers") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .filter(|name| o.mcp_servers.iter().any(|m| m.name == *name)) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - // Least privilege: a model sometimes lists the same server more than once — - // dedupe (order-preserving) so the package never carries a redundant MCP - // grant (the audit saw the same Playwright server selected twice). - { - let mut seen = std::collections::HashSet::new(); - mcp_servers.retain(|s| seen.insert(s.clone())); - } - // Governance invariant: MCP access requires a bounding tool policy. If the - // model asked for MCP without one, drop the MCP servers rather than emit an - // un-admittable package (the reviewer can re-add with a policy). - if !mcp_servers.is_empty() && tool_policy.is_none() { - mcp_servers.clear(); - } - - let requested_skills: Vec<String> = json - .get("skills") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .filter(|name| o.skills.iter().any(|s| s.name == *name)) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - let (skills, skill_correction) = if runtime == "OpenClaw" { - (requested_skills, None) - } else if requested_skills.is_empty() { - (Vec::new(), None) - } else { - ( - Vec::new(), - Some(format!( - "Capability match: {runtime} does not support controller-mounted file skills; omitted them instead of claiming they would be installed." - )), - ) - }; - - // Memory: must be a real store; else None. - let memory = json - .get("memory") - .and_then(|v| v.as_str()) - .filter(|m| !m.is_empty() && o.memories.iter().any(|mo| mo.name == *m)) - .map(str::to_string); - - // Egress: sanitized host list. - let egress = json - .get("egress") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|e| { - let host = e.get("host").and_then(|h| h.as_str())?.trim().to_string(); - if host.is_empty() { - return None; - } - let port = e - .get("port") - .and_then(|p| p.as_u64()) - .and_then(|p| u16::try_from(p).ok()); - Some(ComposeEgress { host, port }) - }) - .take(20) - .collect() - }) - .unwrap_or_default(); - let egress = complete_egress_recommendation(egress, intent, &mcp_servers); - - let instructions = json - .get("instructions") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - - let execution_plan = parse_execution_plan(&json); - let delegation = execution_plan - .as_ref() - .map(delegation_from_execution_plan) - .unwrap_or_else(single_agent_delegation); - let proposed_budget = json - .get("budget_tokens") - .and_then(|v| v.as_i64()) - .filter(|t| *t > 0) - .filter(|tokens| *tokens > 0); - let budget_tokens = proposed_budget; - - let rationale = json - .get("rationale") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - // Surface the capability correction to the reviewer alongside the rationale so - // the harness swap is never silent. - let corrections = [ - harness_correction.as_deref(), - policy_correction.as_deref(), - skill_correction.as_deref(), - ] - .into_iter() - .flatten() - .collect::<Vec<_>>() - .join(" "); - let rationale = match (rationale, corrections.is_empty()) { - (Some(r), false) => Some(format!("{r} {corrections}")), - (None, false) => Some(corrections), - (r, true) => r, - }; - - ( - ComposeProposal { - tier, - model, - model_fallbacks: Vec::new(), - model_basis, - runtime, - instructions, - tool_policy, - mcp_servers, - skills, - egress, - isolation, - memory, - budget_tokens, - execution_plan, - delegation, - }, - rationale, - ) -} - -fn valid_delegation_role(value: &serde_json::Value) -> Option<ComposeDelegationRole> { - let name = value.get("name")?.as_str()?.trim().to_ascii_lowercase(); - let objective = value.get("objective")?.as_str()?.trim().to_string(); - if name.is_empty() - || name.len() > 48 - || objective.len() < 20 - || objective.len() > 600 - || !name - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - || name.starts_with('-') - || name.ends_with('-') - { - return None; - } - Some(ComposeDelegationRole { name, objective }) -} - -fn single_agent_delegation() -> ComposeDelegation { - ComposeDelegation { - mode: "single-agent".into(), - roles: Vec::new(), - max_parallel: 1, - } -} - -fn delegation_from_execution_plan( - plan: &crate::routes::tasks::ExecutionPlanDto, -) -> ComposeDelegation { - ComposeDelegation { - mode: "principal-specialists".into(), - roles: plan - .roles - .iter() - .map(|role| ComposeDelegationRole { - name: role.name.clone(), - objective: role.objective.clone(), - }) - .collect(), - max_parallel: plan.max_parallel, - } -} - -const EXECUTION_CAPABILITIES: &[&str] = &[ - "filesystem-read", - "filesystem-write", - "shell", - "network", - "web-search", - "mcp", - "memory", -]; - -const EXECUTION_ROLE_PHASE_BASE_WEIGHT: i64 = 4; - -fn valid_plan_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 48 - && name - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - && !name.starts_with('-') - && !name.ends_with('-') -} - -fn valid_capabilities(capabilities: &[String]) -> bool { - let mut seen = std::collections::HashSet::new(); - capabilities.iter().all(|capability| { - EXECUTION_CAPABILITIES.contains(&capability.as_str()) && seen.insert(capability) - }) -} - -fn role_budget_weight(role: &crate::routes::tasks::ExecutionRoleDto) -> i64 { - role.phases - .iter() - .map(|phase| EXECUTION_ROLE_PHASE_BASE_WEIGHT + i64::from(phase.max_tool_calls)) - .sum::<i64>() - .max(1) -} - -fn weighted_role_budget_floors( - total_tokens: i64, - plan: &crate::routes::tasks::ExecutionPlanDto, -) -> Vec<i64> { - let weights = plan - .roles - .iter() - .map(role_budget_weight) - .collect::<Vec<_>>(); - let total_weight = weights - .iter() - .map(|weight| i128::from(*weight)) - .sum::<i128>(); - let total_tokens_i128 = i128::from(total_tokens); - let mut floors = weights - .iter() - .map(|weight| ((total_tokens_i128 * i128::from(*weight)) / total_weight) as i64) - .collect::<Vec<_>>(); - let assigned = floors.iter().sum::<i64>(); - let mut remainders = weights - .iter() - .enumerate() - .map(|(index, weight)| { - ( - (total_tokens_i128 * i128::from(*weight)) % total_weight, - index, - ) - }) - .collect::<Vec<_>>(); - remainders.sort_by( - |(left_remainder, left_index), (right_remainder, right_index)| { - right_remainder - .cmp(left_remainder) - .then(left_index.cmp(right_index)) - }, - ); - for (_, index) in remainders - .into_iter() - .take((total_tokens - assigned) as usize) - { - floors[index] += 1; - } - floors -} - -fn apply_weighted_role_budget_floors( - plan: &mut crate::routes::tasks::ExecutionPlanDto, - total_tokens: i64, -) -> (bool, i64) { - let mut changed = false; - let floors = weighted_role_budget_floors(total_tokens, plan); - for (role, floor) in plan.roles.iter_mut().zip(floors) { - let floor = floor.max(1); - if role.budget_tokens.is_none_or(|current| current < floor) { - role.budget_tokens = Some(floor); - changed = true; - } - } - let total = plan - .roles - .iter() - .filter_map(|role| role.budget_tokens) - .sum(); - (changed, total) -} - -fn valid_deliverable_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 128 - && !name.starts_with('.') - && !name.contains('/') - && !name.contains('\\') - && name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) -} - -fn execution_plan_is_acyclic(plan: &crate::routes::tasks::ExecutionPlanDto) -> bool { - let dependencies = plan - .roles - .iter() - .map(|role| (role.name.as_str(), role.depends_on.as_slice())) - .collect::<std::collections::HashMap<_, _>>(); - fn visit<'a>( - role: &'a str, - dependencies: &std::collections::HashMap<&'a str, &'a [String]>, - visiting: &mut std::collections::HashSet<&'a str>, - visited: &mut std::collections::HashSet<&'a str>, - ) -> bool { - if visited.contains(role) { - return true; - } - if !visiting.insert(role) { - return false; - } - for dependency in dependencies.get(role).copied().unwrap_or_default() { - if !visit(dependency, dependencies, visiting, visited) { - return false; - } - } - visiting.remove(role); - visited.insert(role); - true - } - let mut visiting = std::collections::HashSet::new(); - let mut visited = std::collections::HashSet::new(); - plan.roles - .iter() - .all(|role| visit(&role.name, &dependencies, &mut visiting, &mut visited)) -} - -pub(crate) fn validate_execution_plan( - plan: &crate::routes::tasks::ExecutionPlanDto, -) -> Result<(), String> { - if plan.schema != "kars.execution-plan/v1" { - return Err("execution plan schema must be kars.execution-plan/v1".into()); - } - if plan.roles.is_empty() || plan.roles.len() > 8 { - return Err("execution plan requires 1-8 roles".into()); - } - if plan.max_parallel < 1 || plan.max_parallel > plan.roles.len() as i32 { - return Err("execution plan max_parallel must be within the role count".into()); - } - let role_names = plan - .roles - .iter() - .map(|role| role.name.as_str()) - .collect::<std::collections::HashSet<_>>(); - if role_names.len() != plan.roles.len() || role_names.iter().any(|name| !valid_plan_name(name)) - { - return Err("execution plan role names must be unique DNS-safe labels".into()); - } - for role in &plan.roles { - if !(20..=1200).contains(&role.objective.len()) { - return Err(format!("role {} has an invalid objective", role.name)); - } - if role.phases.is_empty() || role.phases.len() > 8 { - return Err(format!("role {} requires 1-8 phases", role.name)); - } - if role.budget_tokens.is_some_and(|budget| budget <= 0) { - return Err(format!("role {} has an invalid budget", role.name)); - } - let mut phase_names = std::collections::HashSet::new(); - for phase in &role.phases { - if !valid_plan_name(&phase.name) || !phase_names.insert(phase.name.as_str()) { - return Err(format!("role {} has invalid phase names", role.name)); - } - if !(20..=1200).contains(&phase.objective.len()) - || phase.min_tool_calls < 0 - || phase.min_tool_calls > phase.max_tool_calls - || !(0..=32).contains(&phase.max_tool_calls) - || !valid_capabilities(&phase.capabilities) - { - return Err(format!( - "role {} phase {} is invalid", - role.name, phase.name - )); - } - if phase.required_tool_calls.len() > 8 - || phase.required_tool_calls.len() as i32 > phase.max_tool_calls - { - return Err(format!( - "role {} phase {} has invalid required tool calls", - role.name, phase.name - )); - } - for call in &phase.required_tool_calls { - if call.name != "github_actions_job_logs" - || !phase - .capabilities - .iter() - .any(|capability| capability == "mcp") - || ["owner", "repo", "job_id"].iter().any(|key| { - call.arguments - .get(*key) - .is_none_or(|value| value.trim().is_empty()) - }) - || call.arguments.get("tail_lines").is_some_and(|lines| { - lines - .parse::<u32>() - .ok() - .is_none_or(|value| !(1..=2000).contains(&value)) - }) - { - return Err(format!( - "role {} phase {} has an unsupported required tool call", - role.name, phase.name - )); - } - } - } - let mut dependencies = std::collections::HashSet::new(); - if role.depends_on.iter().any(|dependency| { - dependency == &role.name - || !role_names.contains(dependency.as_str()) - || !dependencies.insert(dependency) - }) { - return Err(format!("role {} has invalid dependencies", role.name)); - } - } - if !(20..=1200).contains(&plan.synthesis.objective.len()) - || !(0..=32).contains(&plan.synthesis.max_tool_calls) - || !valid_capabilities(&plan.synthesis.capabilities) - { - return Err("execution plan synthesis is invalid".into()); - } - let mut deliverables = std::collections::HashSet::new(); - if plan.deliverables.len() > 16 - || plan.deliverables.iter().any(|deliverable| { - !valid_deliverable_name(&deliverable.name) - || !deliverables.insert(deliverable.name.as_str()) - }) - { - return Err("execution plan deliverables are invalid".into()); - } - execution_plan_is_acyclic(plan) - .then_some(()) - .ok_or_else(|| "execution plan dependencies must be acyclic".into()) -} - -fn parse_execution_plan_result( - json: &serde_json::Value, -) -> Result<crate::routes::tasks::ExecutionPlanDto, String> { - let value = json - .get("execution_plan") - .ok_or_else(|| "execution_plan is missing".to_string())?; - let plan = serde_json::from_value(value.clone()) - .map_err(|error| format!("execution_plan does not match the required schema: {error}"))?; - validate_execution_plan(&plan)?; - Ok(plan) -} - -fn parse_execution_plan( - json: &serde_json::Value, -) -> Option<crate::routes::tasks::ExecutionPlanDto> { - parse_execution_plan_result(json).ok() -} - -fn execution_plan_error_from_raw(raw: &str) -> Option<String> { - let json = - extract_json(raw).ok_or_else(|| "response did not contain a JSON object".to_string()); - match json { - Ok(json) => parse_execution_plan_result(&json).err(), - Err(error) => Some(error), - } -} - -pub(crate) fn validate_delegation(delegation: &ComposeDelegation) -> Result<(), String> { - match delegation.mode.as_str() { - "single-agent" if delegation.roles.is_empty() && delegation.max_parallel == 1 => Ok(()), - "principal-specialists" - if (2..=4).contains(&delegation.roles.len()) - && delegation.max_parallel >= 1 - && delegation.max_parallel <= delegation.roles.len() as i32 - && delegation.roles.iter().all(|role| { - let value = serde_json::json!({ - "name": role.name, - "objective": role.objective, - }); - valid_delegation_role(&value).is_some() - }) => - { - let unique = delegation - .roles - .iter() - .map(|role| role.name.as_str()) - .collect::<std::collections::HashSet<_>>(); - (unique.len() == delegation.roles.len()) - .then_some(()) - .ok_or_else(|| "delegation role names must be unique".to_string()) - } - "single-agent" => { - Err("single-agent delegation must have no roles and max_parallel=1".into()) - } - "principal-specialists" => Err( - "principal-specialists delegation requires 2–4 unique valid leaf roles and a bounded max_parallel" - .into(), - ), - _ => Err("delegation mode must be single-agent or principal-specialists".into()), - } -} - -pub(crate) fn delegation_budget_allocation( - total_tokens: i64, - role_count: usize, -) -> Result<(i64, i64), String> { - if role_count == 0 || total_tokens < role_count as i64 { - return Err( - "execution plans require a positive total budget with capacity for every role".into(), - ); - } - Ok((total_tokens, total_tokens / role_count as i64)) -} - -/// A one-line, plain-language basis for recommending `deployment`, drawn from -/// the learned efficiency frontier — real accepted-outcome counts, never -/// fabricated. Falls back to a generic line if the route has no stats yet. -fn efficiency_basis(eff: &crate::routes::efficiency::EfficiencyDto, deployment: &str) -> String { - if let Some(r) = eff.routes.iter().find(|r| r.route == deployment) { - let acc = (r.acceptance_rate * 100.0).round() as i64; - // Distinguish a CONFIDENT recommendation (enough runs, a real acceptance - // rate) from the best of a sparse/weak set. Without this, a route that is - // merely "least-bad" — e.g. 7% accepted over a handful of runs — read as a - // glowing endorsement next to the word "Recommended", which is dishonest. - let strong = r.runs >= 5 && r.acceptance_rate >= 0.5; - let mut s = if strong { - format!( - "Best learned route — {acc}% accepted over {} run{}", - r.runs, - if r.runs == 1 { "" } else { "s" } - ) - } else { - format!( - "Best available route so far (limited signal) — {acc}% accepted over {} run{}", - r.runs, - if r.runs == 1 { "" } else { "s" } - ) - }; - if !r.harness.is_empty() { - s.push_str(&format!(" on {}", r.harness)); - } - // Reliability of 0% over a tiny sample is "not yet established", not a - // meaningful "0%" — report it honestly so it doesn't read as "0% reliable". - match (r.reliability_rate, r.reliability_k, r.reliability_samples) { - (Some(rel), Some(k), samples) if samples >= 3 && rel > 0.0 => { - s.push_str(&format!( - ", pass^{k} reliability {}%", - (rel * 100.0).round() as i64 - )); - } - (Some(_), _, samples) => { - s.push_str(&format!(", reliability not yet established (n={samples})")); - } - _ => {} - } - if let Some(usd) = r.usd_per_outcome { - s.push_str(&format!(", ${usd:.2}/outcome")); - } - s.push('.'); - s - } else { - "Recommended by the learned efficiency frontier.".to_string() - } -} - -/// Extract the first balanced JSON object from a string, tolerating code fences -/// and leading/trailing prose that some models add despite instructions. -fn extract_json(raw: &str) -> Option<serde_json::Value> { - if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw.trim()) { - return Some(v); - } - let bytes = raw.as_bytes(); - let start = raw.find('{')?; - let mut depth = 0i32; - let mut in_str = false; - let mut esc = false; - for i in start..bytes.len() { - let c = bytes[i] as char; - if in_str { - if esc { - esc = false; - } else if c == '\\' { - esc = true; - } else if c == '"' { - in_str = false; - } - continue; - } - match c { - '"' => in_str = true, - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return serde_json::from_str(&raw[start..=i]).ok(); - } - } - _ => {} - } - } - None -} - -// ─── Team orchestrator: charter → org chart ────────────────────────────────── -// -// The symmetric counterpart to the mission orchestrator. From a standing-team -// charter it proposes a full org chart — a roster of member roles, each with a -// purpose-fit harness and model — informed by the SAME efficiency frontier the -// mission composer uses. This is the bread-and-butter: different roles can run -// different harnesses/models, chosen by what actually performs. A human reviews -// and edits before the team is created. - -#[derive(Debug, Deserialize)] -pub struct ComposeTeamRequest { - pub charter: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeTeamRole { - pub name: String, - pub system_prompt: String, - /// Harness kind (validated against real wired runtimes) or empty for the - /// team default. - pub runtime: String, - /// Model as `provider::deployment` (validated) or empty for team default. - pub model: String, - pub skills: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeTeamMilestone { - pub id: String, - pub title: String, - pub description: String, - pub owner_role: Option<String>, - pub depends_on: Vec<String>, - pub acceptance_criteria: Vec<String>, - pub review_required: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeTeamProposal { - pub tier: i32, - pub cadence_minutes: i64, - pub instructions: String, - /// Principal/default model as `provider::deployment`. - pub model: String, - /// Ordered routes that independently qualify the complete Team contract. - pub model_fallbacks: Vec<String>, - /// Evidence-backed reason for the principal model choice. - pub model_basis: Option<String>, - /// Historical cost of the selected route, when the efficiency graph has - /// enough retained outcomes to measure it. - pub expected_tokens_per_outcome: Option<i64>, - pub efficiency_sample_runs: i64, - pub mcp_servers: Vec<String>, - pub memory: Option<String>, - pub egress: Vec<ComposeEgress>, - pub egress_mode: String, - pub engineering_enabled: bool, - pub engineering_signals: Vec<String>, - pub engineering_poll_interval_seconds: i64, - pub engineering_auto_run: bool, - pub roles: Vec<ComposeTeamRole>, - pub execution_plan: Option<crate::routes::tasks::ExecutionPlanDto>, - pub milestones: Vec<ComposeTeamMilestone>, -} - -#[derive(Debug, Serialize)] -pub struct ComposeTeamResponse { - pub available: bool, - pub reason: Option<String>, - pub proposal: Option<ComposeTeamProposal>, - pub rationale: Option<String>, - pub source: Option<String>, -} - -/// `POST /api/namespaces/:ns/compose-team` — orchestrate an org chart from a -/// charter. Same honesty contract as the mission composer: absent/failing -/// orchestrator returns `available:false` with a reason so the UI falls back to -/// manual composition. -pub async fn compose_team( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path(ns): Path<String>, - Json(req): Json<ComposeTeamRequest>, -) -> AppResult<Json<ComposeTeamResponse>> { - let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; - let charter = req.charter.trim(); - if charter.len() < 8 { - return Err(AppError::BadRequest("a real charter is required".into())); - } - - let mut options = build_options(cluster).await?; - options.mcp_servers.retain(|server| server.namespace == ns); - options.memories.retain(|memory| memory.namespace == ns); - options.skills.retain(|skill| skill.namespace == ns); - let efficiency = crate::routes::efficiency::compute_efficiency_for_owner( - cluster, - Some(principal.sub.as_str()), - ) - .await; - let orchestrator_route = select_orchestrator_route(&options, &efficiency); - let qualification_constraints = crate::routes::options::qualification_constraints_summary() - .unwrap_or_else(|error| format!(" (qualification records unavailable: {error})")); - let resource_qualification_constraints = - crate::routes::options::resource_qualification_summary(&options).unwrap_or_else(|error| { - format!(" (resource qualification records unavailable: {error})") - }); - let system = build_team_system_prompt( - &options, - &efficiency, - &qualification_constraints, - &resource_qualification_constraints, - ); - let user = format!( - "Team charter:\n{charter}\n\nCompose the org chart now. Respond with ONLY the JSON object." - ); - - let env_orchestrator = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") - .is_ok_and(|v| !v.trim().is_empty()) - && std::env::var("BRIDGE_ORCHESTRATOR_TOKEN").is_ok_and(|v| !v.trim().is_empty()) - && std::env::var("BRIDGE_ORCHESTRATOR_MODEL").is_ok_and(|v| !v.trim().is_empty()); - let pinned_orchestrator_model = cluster.bridge_orchestrator_model().await; - let resolved_model = orchestrator_route - .as_ref() - .map(|(_, deployment, _)| deployment.clone()) - .or(pinned_orchestrator_model) - .or_else(|| { - options - .models - .iter() - .find(|m| m.is_default) - .or_else(|| options.models.first()) - .map(|m| m.deployment.clone()) - }); - if resolved_model.is_none() && !env_orchestrator { - return Ok(Json(ComposeTeamResponse { - available: false, - reason: Some( - "No inference models are configured on this cluster, so the org-composer can't run. Add an inference provider in the Operator Console, or shape the org manually below.".into(), - ), - proposal: None, - rationale: None, - source: None, - })); - } - let default_model = resolved_model.unwrap_or_default(); - if !env_orchestrator - && let Some((provider, deployment, _)) = &orchestrator_route - && let Err(error) = cluster - .configure_bridge_orchestrator_model(provider, deployment) - .await - { - return Ok(Json(ComposeTeamResponse { - available: false, - reason: Some(format!( - "The best orchestrator route ({provider}/{deployment}) could not be configured: {error}" - )), - proposal: None, - rationale: None, - source: None, - })); - } - - let (raw, mut source) = match orchestrator_complete( - cluster, - &system, - &user, - &default_model, - TEAM_COMPOSE_MAX_TOKENS, - ) - .await - { - Ok(r) => r, - Err(e) => { - return Ok(Json(ComposeTeamResponse { - available: false, - reason: Some(format!( - "The org-composer could not compose ({e}). Shape the org manually below — the same building blocks the orchestrator would use." - )), - proposal: None, - rationale: None, - source: None, - })); - } - }; - - let mut execution_plan_error = execution_plan_error_from_raw(&raw); - let (mut proposal, mut rationale) = - parse_and_validate_team(&raw, &options, &efficiency, charter); - if !team_proposal_is_complete(&proposal) { - let repair_user = format!( - "{user}\n\nYour previous response was incomplete. The exact structural problem was: {}. \ - Return the complete JSON object now; preserve a small roster of independent evidence \ - roles, make every execution-plan role name exactly match one roster role name, and do \ - not include prose or code fences.", - incomplete_team_proposal_detail(&proposal, execution_plan_error.as_deref()) - ); - if let Ok((repair_raw, repair_source)) = orchestrator_complete( - cluster, - &system, - &repair_user, - &default_model, - TEAM_COMPOSE_MAX_TOKENS, - ) - .await - { - execution_plan_error = execution_plan_error_from_raw(&repair_raw); - (proposal, rationale) = - parse_and_validate_team(&repair_raw, &options, &efficiency, charter); - source = repair_source; - } - } - if !team_proposal_is_complete(&proposal) { - return Ok(Json(ComposeTeamResponse { - available: false, - reason: Some(format!( - "The org-composer returned an incomplete proposal twice (missing: {}). Shape the org manually below rather than treating generic fallback roles as an AI recommendation.", - incomplete_team_proposal_detail(&proposal, execution_plan_error.as_deref()), - )), - proposal: None, - rationale: None, - source: Some(source), - })); - } - if let Err(error) = normalize_team_proposal_route(&mut proposal, &options) { - let repair_user = format!( - "{user}\n\nYour previous response was not launchable: {error}\n\ - Recompose it so the principal route, every role route, and every selected MCP \ - server, memory binding, and approved skill fit retained qualification evidence at \ - the CURRENT digest. Generic route records do not prove a specific resource.\n\n\ - QUALIFIED EXECUTION RECORDS:\n{qualification_constraints}\n\n\ - RESOURCE QUALIFICATION RECORDS:\n{resource_qualification_constraints}\n\n\ - Return ONLY the complete JSON object." - ); - if let Ok((repair_raw, repair_source)) = orchestrator_complete( - cluster, - &system, - &repair_user, - &default_model, - TEAM_COMPOSE_MAX_TOKENS, - ) - .await - { - (proposal, rationale) = - parse_and_validate_team(&repair_raw, &options, &efficiency, charter); - source = repair_source; - } - } - if let Err(error) = normalize_team_proposal_route(&mut proposal, &options) { - return Ok(Json(ComposeTeamResponse { - available: false, - reason: Some(format!( - "The org-composer could not produce a launchable team after retry: {error}" - )), - proposal: None, - rationale: None, - source: Some(source), - })); - } - proposal.model_fallbacks = qualified_team_fallbacks(&proposal, &options); - Ok(Json(ComposeTeamResponse { - available: true, - reason: None, - proposal: Some(proposal), - rationale, - source: Some(source), - })) -} - -fn team_proposal_is_complete(proposal: &ComposeTeamProposal) -> bool { - !proposal.instructions.trim().is_empty() - && !proposal.roles.is_empty() - && proposal.execution_plan.is_some() -} - -fn incomplete_team_proposal_detail( - proposal: &ComposeTeamProposal, - execution_plan_error: Option<&str>, -) -> String { - let mut missing = Vec::new(); - if proposal.instructions.trim().is_empty() { - missing.push("instructions"); - } - if proposal.roles.is_empty() { - missing.push("independent roles"); - } - if proposal.execution_plan.is_none() { - missing.push( - execution_plan_error - .unwrap_or("valid execution_plan with role names exactly matching the roster"), - ); - } - if missing.is_empty() { - "unknown structural mismatch".into() - } else { - missing.join(", ") - } -} - -fn default_model_route(options: &crate::routes::options::Options) -> Option<String> { - options - .models - .iter() - .find(|model| model.is_default) - .or_else(|| options.models.first()) - .map(|model| format!("{}::{}", model.provider, model.deployment)) -} - -fn team_role_qualification_requirements( - plan: &crate::routes::tasks::ExecutionPlanDto, - role_name: &str, -) -> std::collections::BTreeSet<String> { - let mut required = - std::collections::BTreeSet::from(["team".to_string(), "telemetry".to_string()]); - if let Some(role) = plan.roles.iter().find(|role| role.name == role_name) { - for phase in &role.phases { - required.extend(phase.capabilities.iter().cloned()); - } - } - required -} - -fn qualify_role_resource( - runtime: &str, - provider: &str, - deployment: &str, - route: &str, - label: &str, - qualified: Result<bool, String>, - detail: impl FnOnce() -> String, -) -> Result<(), String> { - match qualified { - Ok(true) => Ok(()), - Ok(false) => Err(format!( - "{label} lacks retained resource qualification for {route}. {}", - detail() - )), - Err(error) => Err(format!( - "{label} could not be matched against qualification records for {runtime} · {provider}::{deployment}: {error}" - )), - } -} - -fn team_proposal_qualification( - proposal: &ComposeTeamProposal, - options: &crate::routes::options::Options, -) -> Result<(), String> { - let principal_runtime = "OpenClaw"; - let principal_route = proposal - .model - .split_once("::") - .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) - .or_else(|| { - default_model_route(options).and_then(|route| { - route - .split_once("::") - .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) - }) - }) - .ok_or_else(|| "the team proposal has no launchable principal model route".to_string())?; - let principal_blueprint = crate::routes::tasks::BlueprintDto { - runtime: Some(principal_runtime.to_string()), - model: Some(crate::routes::tasks::ModelDto { - provider: principal_route.0.clone(), - deployment: principal_route.1.clone(), - }), - model_fallbacks: Vec::new(), - instructions: Some(proposal.instructions.clone()), - tool_policy: None, - mcp_servers: proposal.mcp_servers.clone(), - egress: proposal - .egress - .iter() - .map(|entry| crate::routes::tasks::EgressDto { - host: entry.host.clone(), - port: entry.port.map(i32::from), - }) - .collect(), - egress_mode: Some(proposal.egress_mode.clone()), - isolation: None, - memory: proposal.memory.clone(), - skills: proposal - .roles - .iter() - .flat_map(|role| role.skills.iter().cloned()) - .collect(), - execution_plan: proposal.execution_plan.clone(), - }; - let (required, max_parallel) = - crate::routes::validate::qualification_requirements(&principal_blueprint, Some("team")); - if !crate::routes::options::route_qualification( - principal_runtime, - &principal_route.0, - &principal_route.1, - &required, - max_parallel, - None, - )? { - let missing = crate::routes::options::route_qualification_gap( - principal_runtime, - &principal_route.0, - &principal_route.1, - &required, - max_parallel, - None, - )?; - return Err(format!( - "{} lacks retained qualification for [{}] at max_parallel={max_parallel}", - crate::routes::options::route_label( - principal_runtime, - &principal_route.0, - &principal_route.1 - ), - missing.into_iter().collect::<Vec<_>>().join(", "), - )); - } - let principal_route_label = crate::routes::options::route_label( - principal_runtime, - &principal_route.0, - &principal_route.1, - ); - for server in &proposal.mcp_servers { - let option = option_named(&options.mcp_servers, server) - .ok_or_else(|| format!("MCP server `{server}` is not in the live options catalogue"))?; - qualify_role_resource( - principal_runtime, - &principal_route.0, - &principal_route.1, - &principal_route_label, - &format!("MCP server `{server}`"), - crate::routes::options::mcp_server_qualified_for_route( - principal_runtime, - &principal_route.0, - &principal_route.1, - option, - ), - || { - format!( - "Current schema digest: {}. Generic route records do not prove this server.", - option.tool_schema_digest.as_deref().unwrap_or("missing") - ) - }, - )?; - } - if let Some(memory) = proposal.memory.as_deref() { - let option = option_named(&options.memories, memory) - .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; - qualify_role_resource( - principal_runtime, - &principal_route.0, - &principal_route.1, - &principal_route_label, - &format!("memory `{memory}`"), - crate::routes::options::memory_binding_qualified_for_route( - principal_runtime, - &principal_route.0, - &principal_route.1, - option, - ), - || { - format!( - "Current backend/digest: {}/{}. Generic route records do not prove this memory binding.", - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing") - ) - }, - )?; - } - let Some(plan) = proposal.execution_plan.as_ref() else { - return Err("the team proposal has no typed execution plan".into()); - }; - let default_role_route = format!("{}::{}", principal_route.0, principal_route.1); - for role in &proposal.roles { - let route = if role.model.trim().is_empty() { - default_role_route.as_str() - } else { - role.model.trim() - }; - let (provider, deployment) = route - .split_once("::") - .ok_or_else(|| format!("role {} has no valid model route", role.name))?; - let runtime = if role.runtime.trim().is_empty() { - principal_runtime - } else { - role.runtime.trim() - }; - let role_required = team_role_qualification_requirements(plan, &role.name); - if !crate::routes::options::route_qualification( - runtime, - provider, - deployment, - &role_required, - 1, - None, - )? { - let missing = crate::routes::options::route_qualification_gap( - runtime, - provider, - deployment, - &role_required, - 1, - None, - )?; - return Err(format!( - "role `{}` route {} lacks retained qualification for [{}]", - role.name, - crate::routes::options::route_label(runtime, provider, deployment), - missing.into_iter().collect::<Vec<_>>().join(", "), - )); - } - let role_route_label = crate::routes::options::route_label(runtime, provider, deployment); - if role_required.contains("mcp") { - for server in &proposal.mcp_servers { - let option = option_named(&options.mcp_servers, server).ok_or_else(|| { - format!("MCP server `{server}` is not in the live options catalogue") - })?; - qualify_role_resource( - runtime, - provider, - deployment, - &role_route_label, - &format!("role `{}` MCP server `{server}`", role.name), - crate::routes::options::mcp_server_qualified_for_route( - runtime, provider, deployment, option, - ), - || { - format!( - "Current schema digest: {}. Generic route records do not prove this server.", - option.tool_schema_digest.as_deref().unwrap_or("missing") - ) - }, - )?; - } - } - if role_required.contains("memory") - && let Some(memory) = proposal.memory.as_deref() - { - let option = option_named(&options.memories, memory) - .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; - qualify_role_resource( - runtime, - provider, - deployment, - &role_route_label, - &format!("role `{}` memory `{memory}`", role.name), - crate::routes::options::memory_binding_qualified_for_route( - runtime, provider, deployment, option, - ), - || { - format!( - "Current backend/digest: {}/{}. Generic route records do not prove this memory binding.", - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing") - ) - }, - )?; - } - for skill in &role.skills { - let option = option_named(&options.skills, skill) - .ok_or_else(|| format!("skill `{skill}` is not in the approved live catalogue"))?; - qualify_role_resource( - runtime, - provider, - deployment, - &role_route_label, - &format!("role `{}` skill `{skill}`", role.name), - crate::routes::options::skill_version_qualified_for_route( - runtime, provider, deployment, option, - ), - || { - format!( - "Current version digest: {}. Generic route records do not prove this approved skill version.", - option.version_digest.as_deref().unwrap_or("missing") - ) - }, - )?; - } - } - Ok(()) -} - -fn normalize_team_proposal_route( - proposal: &mut ComposeTeamProposal, - options: &crate::routes::options::Options, -) -> Result<(), String> { - for role in &mut proposal.roles { - if is_non_autonomous_harness(&role.runtime) { - role.runtime = "OpenClaw".into(); - } - } - let initial_error = match team_proposal_qualification(proposal, options) { - Ok(()) => return Ok(()), - Err(error) => error, - }; - let original_model = proposal.model.clone(); - let original_role_routes = proposal - .roles - .iter() - .map(|role| (role.runtime.clone(), role.model.clone())) - .collect::<Vec<_>>(); - let mut candidates = Vec::new(); - if let Some(default) = default_model_route(options) { - candidates.push(default); - } - - candidates.extend( - options - .models - .iter() - .map(|model| format!("{}::{}", model.provider, model.deployment)), - ); - let mut seen = std::collections::HashSet::new(); - for route in candidates - .into_iter() - .filter(|route| seen.insert(route.clone())) - { - proposal.model = route.clone(); - for role in &mut proposal.roles { - role.runtime.clear(); - role.model.clear(); - } - if team_proposal_qualification(proposal, options).is_ok() { - proposal.model_basis = Some(format!( - "Bridge selected {route} because the complete team plan and its reviewed resources match one retained qualification record; the orchestrator's proposed route did not." - )); - proposal.expected_tokens_per_outcome = None; - proposal.efficiency_sample_runs = 0; - return Ok(()); - } - } - proposal.model = original_model; - for (role, (runtime, model)) in proposal.roles.iter_mut().zip(original_role_routes) { - role.runtime = runtime; - role.model = model; - } - Err(initial_error) -} - -fn qualified_team_fallbacks( - proposal: &ComposeTeamProposal, - options: &crate::routes::options::Options, -) -> Vec<String> { - let mut candidates = options - .models - .iter() - .map(|model| format!("{}::{}", model.provider, model.deployment)) - .filter(|route| route != &proposal.model) - .collect::<Vec<_>>(); - candidates.sort(); - candidates.dedup(); - candidates - .into_iter() - .filter(|route| { - let mut trial = proposal.clone(); - trial.model = route.clone(); - trial.model_fallbacks.clear(); - for role in &mut trial.roles { - role.model = route.clone(); - } - team_proposal_qualification(&trial, options).is_ok() - }) - .take(8) - .collect() -} - -fn should_strengthen_team_principal( - role_count: usize, - current_deployment: &str, - strongest_deployment: &str, -) -> bool { - if role_count < 3 || current_deployment == strongest_deployment { - return false; - } - let current = orchestrator_quality_score(current_deployment).unwrap_or(0); - let strongest = orchestrator_quality_score(strongest_deployment).unwrap_or(0); - current < 900 && strongest >= 950 -} - -fn efficient_member_route_is_qualified(runs: i64, acceptance_rate: f64) -> bool { - // A lower-cost member route needs repeated evidence before a newly composed - // team inherits it. This is intentionally stricter on sample count than a - // descriptive efficiency-basis label because it changes live execution. - runs >= 3 && acceptance_rate >= 0.67 -} - -/// Build the team-orchestrator system prompt. Enumerates the real harnesses + -/// models + the efficiency frontier, and asks for an org chart where roles are -/// purpose-fit and may use DIFFERENT harnesses/models per their function and -/// what the frontier shows performs. -fn build_team_system_prompt( - o: &crate::routes::options::Options, - eff: &crate::routes::efficiency::EfficiencyDto, - qualification_constraints: &str, - resource_qualification_constraints: &str, -) -> String { - let models = o - .models - .iter() - .map(|m| { - format!( - " - \"{}::{}\"{}", - m.provider, - m.deployment, - if m.is_default { " (default)" } else { "" } - ) - }) - .collect::<Vec<_>>() - .join("\n"); - let runtimes = o - .runtimes - .iter() - .filter(|r| r.wired && r.kind != "BYO") - .map(|r| format!(" - \"{}\" — {} ({})", r.kind, r.label, r.status)) - .collect::<Vec<_>>() - .join("\n"); - let mcp_servers = if o.mcp_servers.is_empty() { - " (none installed)".to_string() - } else { - o.mcp_servers - .iter() - .map(|server| { - format!( - " - \"{}\"{}{}{}{}", - server.name, - server - .summary - .as_deref() - .map(|s| format!(" — {s}")) - .unwrap_or_default(), - if server.discovered_tools.is_empty() { - String::new() - } else { - format!(" · tools=[{}]", server.discovered_tools.join(", ")) - }, - server - .tool_schema_digest - .as_deref() - .map(|digest| format!(" · schema_digest={digest}")) - .unwrap_or_else(|| " · schema_digest=missing".into()), - server - .mode - .as_deref() - .map(|mode| format!(" · mode={mode}")) - .unwrap_or_default(), - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - let memories = if o.memories.is_empty() { - " (none configured)".to_string() - } else { - o.memories - .iter() - .map(|memory| { - format!( - " - \"{}\"{}{}{}{}", - memory.name, - memory - .summary - .as_deref() - .map(|summary| format!(" — {summary}")) - .unwrap_or_default(), - memory - .backend - .as_deref() - .map(|backend| format!(" · backend={backend}")) - .unwrap_or_else(|| " · backend=missing".into()), - memory - .compiled_digest - .as_deref() - .map(|digest| format!(" · compiled_digest={digest}")) - .unwrap_or_else(|| " · compiled_digest=missing".into()), - memory - .readiness - .as_deref() - .map(|readiness| format!(" · readiness={readiness}")) - .unwrap_or_default(), - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - let skills = if o.skills.is_empty() { - " (none approved)".to_string() - } else { - o.skills - .iter() - .map(|skill| { - format!( - " - \"{}\"{}{}{}{}", - skill.name, - skill - .summary - .as_deref() - .map(|summary| format!(" — {summary}")) - .unwrap_or_default(), - skill - .version - .as_deref() - .map(|version| format!(" · version={version}")) - .unwrap_or_default(), - skill - .version_digest - .as_deref() - .map(|digest| format!(" · version_digest={digest}")) - .unwrap_or_else(|| " · version_digest=missing".into()), - skill - .recipe - .as_deref() - .map(|recipe| { - format!(" · recipe={}", recipe.chars().take(180).collect::<String>()) - }) - .unwrap_or_default(), - ) - }) - .collect::<Vec<_>>() - .join("\n") - }; - let efficiency = if eff.routes.is_empty() { - " (no completed runs yet — use the default model for roles unless a role clearly needs a stronger one)".to_string() - } else { - let mut lines = eff - .routes - .iter() - .take(6) - .map(|r| { - format!( - " - route \"{}\": {:.0}% accepted, {} tokens/outcome over {} run(s){}", - r.route, - r.acceptance_rate * 100.0, - r.tokens_per_outcome, - r.runs, - if !eff.recommended_low_confidence - && eff.recommended.as_deref() == Some(r.route.as_str()) - { - " ← recommended" - } else if eff.recommended_low_confidence - && eff.recommended.as_deref() == Some(r.route.as_str()) - { - " ← best observed, insufficient evidence" - } else { - "" - } - ) - }) - .collect::<Vec<_>>() - .join("\n"); - if eff.recommended_low_confidence { - lines.push_str("\n Evidence is too sparse for automatic route inheritance. Use the team default for routine roles and a stronger model only where the role's reasoning or orchestration burden clearly requires it."); - } else { - lines.push_str("\n Use the frontier to assign models: give cheap/high-acceptance routes to routine roles, and a stronger model only to roles whose work demands it."); - } - lines - }; - - format!( - r#"You are the kars team orchestrator. You turn a standing-team CHARTER into an org chart: a small roster of member roles that together fulfil the charter. Each role can run a DIFFERENT harness and model — choose what fits its job and what the efficiency frontier shows performs. You propose; a human reviews and edits before the team is created. - -You MUST only use the harnesses and models listed below — never invent one. - -HARNESSES (pick per role, or "" for the team default): -{runtimes} - -MODELS (pick per role as "provider::deployment", or "" for the team default): -{models} - -CONNECTED SERVICES / MCP (select only services the charter genuinely needs): -{mcp_servers} -Foundry-native web search, file search, memory, and code execution are Kars plugin tools and do not require MCP. If the customer explicitly requests an installed MCP server, select it and declare `mcp`; the complete capability combination must match one atomic qualification record. - -SHARED MEMORY STORES (optional; default to a qualified Foundry-backed store when one is already configured and useful for continuity): -{memories} - -APPROVED SKILLS (assign only when a role genuinely benefits from the recipe below): -{skills} - -EFFICIENCY FRONTIER (learned from completed runs; honest signal is human ACCEPTANCE): -{efficiency} - -QUALIFIED EXECUTION RECORDS (the full team plan MUST fit one route record; records do not compose): -{qualification_constraints} - -RESOURCE QUALIFICATION RECORDS (selected MCP servers, memory bindings, and skills MUST match one current-digest record on the chosen route; generic route records do not count): -{resource_qualification_constraints} - -GUIDANCE: -- Propose 2–4 focused roles (rarely more). Each role does ONE clear part of the charter. -- Produce one typed `execution_plan` whose role names exactly match the proposed roster. Define explicit dependencies, one or more bounded phases per role, and only the generic capabilities each phase requires: filesystem-read, filesystem-write, shell, network, web-search, mcp, memory. Set `min_tool_calls` to at least 1 when a phase must produce tool-backed evidence. Do not infer capabilities from role names. -- The principal owns orchestration and the final synthesis. Never propose a coordinator, editor, integrator, or synthesis-only member whose job is merely to reconcile other roles' handbacks or write the final report. Every member must collect, inspect, test, or verify independent evidence. -- Give each role a short, specific system prompt (1–2 sentences). -- Assign harness + model per role deliberately: a research/analysis role may warrant a stronger model; a routine triage/watch role should use an efficient one. Leave model/runtime "" to inherit the team default when no strong reason exists. -- For research charters, declare `web-search` on source-discovery phases and `network` on exact-URL fetch phases (or both on one combined phase) so the retained qualification stays atomic on one route. -- Select the smallest `mcp_servers` set needed by the whole team. Use the discovered tool names and schema digests above to choose the right server. A browser/UX investigator needs a browser MCP when one is installed. -- If you assign a skill, use the recipe and version digest above to justify it. If you select MCP, memory, or skills, the rationale must name the current-digest resource qualification record that makes the choice launchable. -- Select a team-default `model` for the principal; roles may override it only when their work needs a different route. -- Propose only the external `egress` hosts genuinely required by the charter. Do not invent internal/private hosts. Use `learning` for a reviewed discovery run or `strict` when the host list is complete. -- AUTONOMY TIER for the team: 1=Manual .. 5=Full. Default 3 unless the charter warrants otherwise. -- CADENCE minutes: how often the team wakes to act (0 = passive/on-demand). Pick a sensible value for the charter (e.g. 60 for hourly monitoring), else 0. -- If the charter is continuous repository maintenance, set `engineering_enabled=true`, choose the relevant signals from `dependabot_pr`, `dependabot_alert`, `code_scanning_alert`, `secret_scanning_alert`, choose a poll interval >=300 seconds, and normally set `engineering_auto_run=true`. Otherwise disable it. -- For a concrete build, launch, research campaign, migration, or other long-horizon deliverable, propose 2–8 topologically ordered `milestones`. Each milestone owns explicit acceptance criteria and may depend only on earlier milestone IDs. Set `review_required=true` at consequential handoff/release boundaries so dependent work pauses for customer approval. Use an empty milestone list only for genuinely continuous monitoring with no finite delivery. -- If you include Mermaid flowcharts in any deliverable description or rationale, quote every label containing parser-sensitive punctuation such as :, (), [], {{}}, or /. - -Respond with ONLY a JSON object (no prose, no code fences) of exactly this shape: -{{ - "tier": <int 1-5>, - "cadence_minutes": <int>, - "instructions": "<1-2 sentence team-level mandate>", - "model": "<provider::deployment or empty for cluster default>", - "mcp_servers": ["<installed MCP server name>"], - "memory": "<qualified memory name or null>", - "egress": [{{"host":"<public DNS host>","port":443}}], - "egress_mode": "<learning or strict>", - "engineering_enabled": <bool>, - "engineering_signals": ["<dependabot_pr|dependabot_alert|code_scanning_alert|secret_scanning_alert>"], - "engineering_poll_interval_seconds": <int >=300>, - "engineering_auto_run": <bool>, - "roles": [ - {{"name": "<short-kebab-name>", "system_prompt": "<what this role does>", "runtime": "<harness or empty>", "model": "<provider::deployment or empty>", "skills": []}} - ], - "execution_plan": {{ - "schema": "kars.execution-plan/v1", - "roles": [{{ - "name": "<exact roster role name>", - "objective": "<role outcome>", - "depends_on": ["<earlier role>", ...], - "budget_tokens": <int or null>, - "phases": [{{ - "name": "<short-kebab-phase>", - "objective": "<phase outcome>", - "capabilities": ["<filesystem-read|filesystem-write|shell|network|web-search|mcp|memory>", ...], - "min_tool_calls": <int 0-32, <= max_tool_calls>, - "max_tool_calls": <int 0-32>, - "fresh_context": <bool> - }}] - }}], - "max_parallel": <int 1-8>, - "synthesis": {{ - "objective": "<principal synthesis outcome>", - "capabilities": [], - "max_tool_calls": 0 - }}, - "deliverables": [] - }}, - "milestones": [ - {{"id":"<stable-kebab-id>","title":"<milestone>","description":"<work and expected artifact>","owner_role":"<roster role or empty>","depends_on":["<earlier-id>"],"acceptance_criteria":["<verifiable condition>"],"review_required":<bool>}} - ], - "rationale": "<1-3 sentences explaining the org shape + key model/harness choices>" -}}"# - ) -} - -fn is_synthesis_only_team_role(name: &str, system_prompt: &str) -> bool { - let name = name.to_ascii_lowercase(); - let prompt = system_prompt.to_ascii_lowercase(); - let explicitly_reconciles_handbacks = [ - "reconcile specialist handbacks", - "reconcile the specialist handbacks", - "synthesize specialist handbacks", - "synthesize the specialist handbacks", - "combine specialist handbacks", - "combine the specialist handbacks", - ] - .iter() - .any(|phrase| prompt.contains(phrase)); - let principal_like_name = [ - "readiness-editor", - "synthesis-editor", - "final-synthesizer", - "report-integrator", - ] - .contains(&name.as_str()); - - explicitly_reconciles_handbacks - || (principal_like_name - && ["final report", "final synthesis", "principal deliverable"] - .iter() - .any(|phrase| prompt.contains(phrase))) -} - -/// Validate the team orchestrator's JSON against real options — runtimes and -/// models must exist (or be empty for the default); tier/cadence clamped. -fn parse_and_validate_team( - raw: &str, - o: &crate::routes::options::Options, - eff: &crate::routes::efficiency::EfficiencyDto, - charter: &str, -) -> (ComposeTeamProposal, Option<String>) { - let json = extract_json(raw).unwrap_or_else(|| serde_json::json!({})); - - let tier = json - .get("tier") - .and_then(|v| v.as_i64()) - .map(|t| t.clamp(1, 5) as i32) - .unwrap_or(3); - let cadence_minutes = json - .get("cadence_minutes") - .and_then(|v| v.as_i64()) - .filter(|c| *c >= 0) - .unwrap_or(0); - let mut instructions = json - .get("instructions") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let mcp_servers = json - .get("mcp_servers") - .and_then(|v| v.as_array()) - .map(|servers| { - servers - .iter() - .filter_map(|server| server.as_str().map(str::trim)) - .filter(|server| o.mcp_servers.iter().any(|option| option.name == *server)) - .scan(std::collections::BTreeSet::new(), |seen, server| { - seen.insert(server.to_string()).then(|| server.to_string()) - }) - .take(8) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - let requested_memory = json - .get("memory") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|memory| !memory.is_empty()) - .filter(|memory| o.memories.iter().any(|option| option.name == *memory)) - .map(str::to_string); - - let valid_runtime = |rt: &str| o.runtimes.iter().any(|r| r.wired && r.kind == rt); - let valid_model = |model: &str| catalogue_has_model_key(&o.models, model); - let mut model = json - .get("model") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|model| valid_model(model)) - .unwrap_or("") - .to_string(); - let selected_deployment = model - .split_once("::") - .map(|(_, deployment)| deployment) - .unwrap_or(""); - let selected_efficiency = eff - .routes - .iter() - .find(|route| route.route == selected_deployment); - let mut model_basis = if selected_deployment.is_empty() { - Some("Team default — no explicit principal model was proposed.".to_string()) - } else if recommendation_is_actionable( - eff.recommended.as_deref(), - eff.recommended_low_confidence, - ) && eff - .recommended - .as_deref() - .is_some_and(|recommended| recommended == selected_deployment) - { - Some(efficiency_basis(eff, selected_deployment)) - } else if selected_efficiency.is_some() { - Some( - "Chosen by the org orchestrator for this charter; historical route evidence is shown for comparison." - .to_string(), - ) - } else { - Some("Chosen by the org orchestrator for this charter; no retained route history is available yet.".to_string()) - }; - let principal_runtime = "OpenClaw"; - let principal_route = model - .split_once("::") - .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) - .or_else(|| { - default_model_route(o).and_then(|route| { - route - .split_once("::") - .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) - }) - }); - let memory = if let Some(memory) = requested_memory { - Some(memory) - } else if let Some((provider, deployment)) = principal_route.as_ref() { - o.memories.iter().find_map(|option| { - let foundry_like = option - .backend - .as_deref() - .is_some_and(|backend| backend.to_ascii_lowercase().contains("foundry")); - let ready = option.readiness.as_deref().is_some_and(|readiness| { - readiness == "Ready" || readiness.starts_with("Ready=True") - }); - let qualified = crate::routes::options::memory_binding_qualified_for_route( - principal_runtime, - provider, - deployment, - option, - ) - .unwrap_or(false); - (foundry_like && ready && qualified).then(|| option.name.clone()) - }) - } else { - None - }; - let egress = json - .get("egress") - .and_then(|value| value.as_array()) - .map(|entries| { - entries - .iter() - .filter_map(|entry| { - let host = entry.get("host")?.as_str()?.trim(); - let valid = !host.is_empty() - && host.contains('.') - && host - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')); - valid.then(|| ComposeEgress { - host: host.to_ascii_lowercase(), - port: entry - .get("port") - .and_then(|port| port.as_u64()) - .and_then(|port| u16::try_from(port).ok()), - }) - }) - .take(16) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - let egress = complete_egress_recommendation(egress, charter, &mcp_servers); - let egress_mode = match json - .get("egress_mode") - .and_then(|value| value.as_str()) - .unwrap_or("learning") - .to_ascii_lowercase() - .as_str() - { - "strict" => "strict", - _ => "learning", - } - .to_string(); - - let mut roles = json - .get("roles") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|r| { - let name = r.get("name").and_then(|v| v.as_str())?.trim().to_string(); - if name.is_empty() || name.eq_ignore_ascii_case("principal") { - return None; - } - let system_prompt = r - .get("system_prompt") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if is_synthesis_only_team_role(&name, &system_prompt) { - return None; - } - // Harness capability: a bootstrap-only adapter can't run a - // standing member autonomously — correct it to OpenClaw so the - // role actually produces work (Hermes/BYO pass through). - let runtime = { - let rt = r - .get("runtime") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| valid_runtime(s)) - .unwrap_or("") - .to_string(); - if is_non_autonomous_harness(&rt) { - "OpenClaw".to_string() - } else { - rt - } - }; - let model = r - .get("model") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| valid_model(s)) - .unwrap_or("") - .to_string(); - let skills = r - .get("skills") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|s| s.as_str()) - .filter(|skill| o.skills.iter().any(|option| option.name == *skill)) - .map(str::to_string) - .collect() - }) - .unwrap_or_default(); - Some(ComposeTeamRole { - name, - system_prompt, - runtime, - model, - skills, - }) - }) - .take(6) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - let execution_plan = parse_execution_plan(&json).inspect(|plan| { - let proposed_roles = roles.clone(); - roles = plan - .roles - .iter() - .enumerate() - .map(|(index, planned_role)| { - let mut role = proposed_roles - .iter() - .find(|role| role.name == planned_role.name) - .cloned() - .or_else(|| proposed_roles.get(index).cloned()) - .unwrap_or_else(|| ComposeTeamRole { - name: planned_role.name.clone(), - system_prompt: planned_role.objective.clone(), - runtime: String::new(), - model: String::new(), - skills: Vec::new(), - }); - role.name = planned_role.name.clone(); - if role.system_prompt.trim().is_empty() { - role.system_prompt = planned_role.objective.clone(); - } - role - }) - .collect(); - }); - let role_names = roles - .iter() - .map(|role| role.name.as_str()) - .collect::<std::collections::HashSet<_>>(); - let mut seen_milestones = std::collections::BTreeSet::new(); - let normalize_milestone_id = |value: &str| { - value - .trim() - .to_ascii_lowercase() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '-' { - character - } else { - '-' - } - }) - .collect::<String>() - .trim_matches('-') - .chars() - .take(63) - .collect::<String>() - }; - let mut milestones_invalid = false; - let milestones = json - .get("milestones") - .and_then(serde_json::Value::as_array) - .map(|entries| { - entries - .iter() - .filter_map(|entry| { - let id = normalize_milestone_id( - entry.get("id").and_then(serde_json::Value::as_str)?, - ); - let title = entry - .get("title") - .and_then(serde_json::Value::as_str)? - .trim() - .to_string(); - if id.is_empty() || title.is_empty() || seen_milestones.contains(&id) { - return None; - } - let requested_dependencies = entry - .get("depends_on") - .and_then(serde_json::Value::as_array) - .map(|dependencies| { - dependencies - .iter() - .filter_map(serde_json::Value::as_str) - .map(normalize_milestone_id) - .filter(|dependency| !dependency.is_empty()) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - if requested_dependencies - .iter() - .any(|dependency| !seen_milestones.contains(dependency)) - { - milestones_invalid = true; - return None; - } - let depends_on = requested_dependencies; - let acceptance_criteria = entry - .get("acceptance_criteria") - .and_then(serde_json::Value::as_array) - .map(|criteria| { - criteria - .iter() - .filter_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|criterion| !criterion.is_empty()) - .take(20) - .map(str::to_string) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - let owner_role = entry - .get("owner_role") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|owner| role_names.contains(*owner)) - .map(str::to_string); - let description = entry - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .trim() - .to_string(); - let review_required = entry - .get("review_required") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - seen_milestones.insert(id.clone()); - Some(ComposeTeamMilestone { - id, - title, - description, - owner_role, - depends_on, - acceptance_criteria, - review_required, - }) - }) - .take(8) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - if milestones_invalid { - instructions.clear(); - } - - let mut rationale = json - .get("rationale") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - if let Some((provider, deployment, basis)) = select_orchestrator_route(o, eff) { - let current_route = if model.is_empty() { - o.models - .iter() - .find(|option| option.is_default) - .map(|option| format!("{}::{}", option.provider, option.deployment)) - .unwrap_or_default() - } else { - model.clone() - }; - let current_deployment = current_route - .split_once("::") - .map(|(_, deployment)| deployment) - .unwrap_or(""); - if should_strengthen_team_principal(roles.len(), current_deployment, &deployment) { - let current_evidence = eff - .routes - .iter() - .find(|route| route.route == current_deployment); - let keep_efficient_members = current_evidence.is_some_and(|route| { - efficient_member_route_is_qualified(route.runs, route.acceptance_rate) - }); - let frontier_route = format!("{provider}::{deployment}"); - let member_route = if keep_efficient_members { - current_route.clone() - } else { - frontier_route.clone() - }; - for role in &mut roles { - if role.model.is_empty() { - role.model = member_route.clone(); - } - } - model = frontier_route; - let member_basis = if keep_efficient_members { - format!( - "member roles retain the qualified {} route ({} historical run(s))", - current_deployment, - current_evidence.map(|route| route.runs).unwrap_or(0) - ) - } else { - format!( - "member roles also use {} until the proposed {} route has enough accepted outcomes to qualify", - deployment, current_deployment - ) - }; - model_basis = Some(format!( - "{basis} The principal coordinates {} independent roles; {member_basis}.", - roles.len(), - )); - let note = format!( - "The principal route was strengthened to {deployment} for multi-role orchestration reliability; {member_basis}." - ); - rationale = Some(match rationale { - Some(existing) => format!("{existing} {note}"), - None => note, - }); - } - } - let engineering_enabled = json - .get("engineering_enabled") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let allowed_engineering_signals = [ - "dependabot_pr", - "dependabot_alert", - "code_scanning_alert", - "secret_scanning_alert", - ]; - let engineering_signals = json - .get("engineering_signals") - .and_then(serde_json::Value::as_array) - .map(|signals| { - signals - .iter() - .filter_map(serde_json::Value::as_str) - .filter(|signal| allowed_engineering_signals.contains(signal)) - .map(str::to_string) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - let engineering_poll_interval_seconds = json - .get("engineering_poll_interval_seconds") - .and_then(serde_json::Value::as_i64) - .unwrap_or(900) - .clamp(300, 86_400); - let engineering_auto_run = json - .get("engineering_auto_run") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true); - ( - ComposeTeamProposal { - tier, - cadence_minutes, - instructions, - model, - model_fallbacks: Vec::new(), - model_basis, - expected_tokens_per_outcome: selected_efficiency - .map(|route| route.tokens_per_outcome) - .filter(|tokens| *tokens > 0), - efficiency_sample_runs: selected_efficiency.map(|route| route.runs).unwrap_or(0), - mcp_servers, - memory, - egress, - egress_mode, - engineering_enabled: engineering_enabled && !engineering_signals.is_empty(), - engineering_signals, - engineering_poll_interval_seconds, - engineering_auto_run, - roles, - execution_plan, - milestones, - }, - rationale, - ) -} - -#[cfg(test)] -mod capability_tests { - use super::{ - ComposeEgress, ComposeTeamProposal, TEAM_COMPOSE_MAX_TOKENS, - apply_weighted_role_budget_floors, build_system_prompt, build_team_system_prompt, - catalogue_has_model_key, complete_egress_recommendation, delegation_budget_allocation, - efficient_member_route_is_qualified, execution_plan_error_from_raw, is_autonomous_harness, - is_non_autonomous_harness, is_synthesis_only_team_role, orchestrator_quality_score, - parse_execution_plan, recommendation_is_actionable, should_strengthen_team_principal, - team_proposal_is_complete, validate_execution_plan, weighted_role_budget_floors, - }; - use crate::routes::efficiency::EfficiencyDto; - use crate::routes::options::{IsolationOption, ModelOption, Options, RuntimeOption}; - - fn test_options() -> Options { - Options { - models: vec![ModelOption { - provider: "github-copilot".into(), - deployment: "gpt-5.6-sol".into(), - is_default: true, - detail: None, - }], - default_model: Some("gpt-5.6-sol".into()), - provider: None, - runtimes: vec![RuntimeOption { - kind: "OpenClaw".into(), - label: "OpenClaw".into(), - wired: true, - status: "validated".into(), - note: "ready".into(), - }], - isolation: vec![IsolationOption { - value: "standard".into(), - label: "Standard".into(), - note: "sandboxed".into(), - }], - tool_policies: Vec::new(), - mcp_servers: Vec::new(), - mcp_profiles: Vec::new(), - memories: Vec::new(), - skills: Vec::new(), - } - } - - fn test_efficiency() -> EfficiencyDto { - EfficiencyDto { - routes: Vec::new(), - recommended: None, - recommended_harness: None, - recommended_basis: None, - recommended_low_confidence: false, - total_runs: 0, - priced: false, - } - } - - #[test] - fn autonomous_set_is_openclaw_hermes_byo() { - for k in ["OpenClaw", "openclaw", "Hermes", "hermes", "BYO", "byo"] { - assert!(is_autonomous_harness(k), "{k} should be autonomous"); - assert!( - !is_non_autonomous_harness(k), - "{k} should not be non-autonomous" - ); - } - } - - #[test] - fn synthesis_only_role_is_reserved_for_the_principal() { - assert!(is_synthesis_only_team_role( - "readiness-editor", - "Reconcile specialist handbacks into one truthful readiness report." - )); - assert!(!is_synthesis_only_team_role( - "ci-health-auditor", - "Verify exact-head CI checks and return independent evidence." - )); - } - - #[test] - fn empty_team_proposal_is_not_reported_as_available() { - let proposal = ComposeTeamProposal { - tier: 3, - cadence_minutes: 0, - instructions: String::new(), - model: String::new(), - model_fallbacks: Vec::new(), - model_basis: None, - expected_tokens_per_outcome: None, - efficiency_sample_runs: 0, - mcp_servers: Vec::new(), - memory: None, - egress: Vec::new(), - egress_mode: "learning".into(), - engineering_enabled: false, - engineering_signals: Vec::new(), - engineering_poll_interval_seconds: 900, - engineering_auto_run: false, - roles: Vec::new(), - execution_plan: None, - milestones: Vec::new(), - }; - assert!(!team_proposal_is_complete(&proposal)); - } - - #[test] - fn team_composer_budget_covers_full_milestone_contract() { - const { assert!(TEAM_COMPOSE_MAX_TOKENS >= 8_192) }; - } - - #[test] - fn collaborative_principal_uses_frontier_when_available() { - assert!(should_strengthen_team_principal( - 3, - "gpt-oss-120b", - "gpt-5.6-sol" - )); - assert!(!should_strengthen_team_principal( - 2, - "gpt-oss-120b", - "gpt-5.6-sol" - )); - assert!(!should_strengthen_team_principal( - 3, - "gpt-oss-120b", - "gpt-oss-120b" - )); - assert!(!efficient_member_route_is_qualified(2, 1.0)); - assert!(!efficient_member_route_is_qualified(3, 0.66)); - assert!(efficient_member_route_is_qualified(3, 0.67)); - } - - #[test] - fn sparse_route_history_does_not_drive_execution_model_selection() { - assert!(!recommendation_is_actionable(Some("gpt-5.6-sol"), true)); - assert!(recommendation_is_actionable(Some("gpt-5.6-sol"), false)); - assert!(!recommendation_is_actionable(None, false)); - } - - #[test] - fn bootstrap_only_adapters_are_non_autonomous() { - for k in [ - "Anthropic", - "OpenAIAgents", - "MicrosoftAgentFramework", - "LangGraph", - "PydanticAi", - ] { - assert!(!is_autonomous_harness(k), "{k} should NOT be autonomous"); - assert!(is_non_autonomous_harness(k), "{k} should be non-autonomous"); - } - } - - #[test] - fn empty_harness_is_not_treated_as_non_autonomous() { - // Empty = "inherit default" — must not trigger a correction. - assert!(!is_non_autonomous_harness("")); - assert!(!is_non_autonomous_harness(" ")); - } - - #[test] - fn arbitrary_execution_plan_is_preserved_without_role_rewrites() { - let value = serde_json::json!({ - "execution_plan": { - "schema": "kars.execution-plan/v1", - "roles": [ - { - "name": "source-reader", - "objective": "Read the supplied source material and retain exact evidence.", - "depends_on": [], - "phases": [{ - "name": "collect", - "objective": "Collect the required source evidence without synthesis.", - "capabilities": ["filesystem-read"], - "max_tool_calls": 4, - "fresh_context": true - }] - }, - { - "name": "decision-writer", - "objective": "Produce the requested decision from the retained source evidence.", - "depends_on": ["source-reader"], - "phases": [{ - "name": "draft", - "objective": "Draft the decision using only retained dependency evidence.", - "capabilities": [], - "max_tool_calls": 0, - "fresh_context": true - }] - } - ], - "max_parallel": 1, - "synthesis": { - "objective": "Reconcile the role handbacks into the final answer.", - "capabilities": [], - "max_tool_calls": 0 - }, - "deliverables": [{"name":"decision.md","media_type":"text/markdown"}] - } - }); - let plan = parse_execution_plan(&value).expect("valid plan"); - assert_eq!( - plan.roles - .iter() - .map(|role| role.name.as_str()) - .collect::<Vec<_>>(), - vec!["source-reader", "decision-writer"] - ); - assert_eq!(plan.roles[1].depends_on, vec!["source-reader"]); - } - - #[test] - fn execution_plan_rejects_unknown_capability_and_cycles() { - let mut plan = parse_execution_plan(&serde_json::json!({ - "execution_plan": { - "schema": "kars.execution-plan/v1", - "roles": [{ - "name": "one", - "objective": "Perform one arbitrary evidence task for the mission.", - "depends_on": [], - "phases": [{ - "name": "work", - "objective": "Perform the arbitrary evidence task completely.", - "capabilities": ["shell"], - "max_tool_calls": 2, - "fresh_context": true - }] - }], - "max_parallel": 1, - "synthesis": { - "objective": "Return the final mission answer from the handback.", - "capabilities": [], - "max_tool_calls": 0 - }, - "deliverables": [] - } - })) - .expect("valid baseline"); - plan.roles[0].phases[0].capabilities = vec!["repository-security".into()]; - assert!(validate_execution_plan(&plan).is_err()); - plan.roles[0].phases[0].capabilities = vec!["shell".into()]; - plan.roles[0].depends_on = vec!["one".into()]; - assert!(validate_execution_plan(&plan).is_err()); - } - - #[test] - fn execution_plan_parse_error_identifies_the_exact_repair() { - let raw = serde_json::json!({ - "execution_plan": { - "schema": "kars.execution-plan/v1", - "roles": [{ - "name": "triage", - "objective": "Triage", - "phases": [{ - "name": "inspect", - "objective": "Inspect the repository backlog and retain exact evidence.", - "capabilities": ["filesystem-read"], - "max_tool_calls": 1 - }] - }], - "max_parallel": 1, - "synthesis": { - "objective": "Present the verified maintenance recommendation to the reviewer.", - "capabilities": [], - "max_tool_calls": 0 - } - } - }) - .to_string(); - - assert_eq!( - execution_plan_error_from_raw(&raw).as_deref(), - Some("role triage has an invalid objective") - ); - } - - #[test] - fn research_prompts_require_web_search_and_quoted_mermaid_labels() { - let options = test_options(); - let efficiency = test_efficiency(); - let mission_prompt = build_system_prompt(&options, &efficiency, " (none)", " (none)"); - assert!(mission_prompt.contains("web-search")); - assert!(mission_prompt.contains("quote every label")); - - let team_prompt = build_team_system_prompt(&options, &efficiency, " (none)", " (none)"); - assert!(team_prompt.contains("web-search")); - assert!(team_prompt.contains("qualification stays atomic")); - } - - #[test] - fn weighted_budget_distribution_funds_scout_and_preserves_larger_explicit_roles() { - let plan = parse_execution_plan(&serde_json::json!({ - "execution_plan": { - "schema": "kars.execution-plan/v1", - "roles": [ - { - "name": "source-scout", - "objective": "Discover the authoritative URLs and fetch evidence.", - "depends_on": [], - "phases": [{ - "name": "discover", - "objective": "Search and fetch the exact URLs with evidence.", - "capabilities": ["web-search", "network"], - "min_tool_calls": 1, - "max_tool_calls": 32, - "fresh_context": true - }] - }, - { - "name": "analyst", - "objective": "Inspect the retained sources and extract facts.", - "depends_on": ["source-scout"], - "phases": [ - { - "name": "inspect", - "objective": "Inspect the retained source bundle.", - "capabilities": [], - "max_tool_calls": 0, - "fresh_context": true - }, - { - "name": "summarize", - "objective": "Summarize the retained evidence only.", - "capabilities": [], - "max_tool_calls": 0, - "fresh_context": true - } - ] - }, - { - "name": "reporter", - "objective": "Draft the downstream report from retained evidence.", - "depends_on": ["analyst"], - "phases": [ - { - "name": "outline", - "objective": "Outline the downstream report.", - "capabilities": [], - "max_tool_calls": 0, - "fresh_context": true - }, - { - "name": "draft", - "objective": "Draft the downstream report.", - "capabilities": [], - "max_tool_calls": 0, - "fresh_context": true - } - ] - } - ], - "max_parallel": 1, - "synthesis": { - "objective": "Return the final answer from the retained evidence.", - "capabilities": [], - "max_tool_calls": 0 - }, - "deliverables": [] - } - })) - .expect("valid weighted plan"); - let floors = weighted_role_budget_floors(320_000, &plan); - assert_eq!(floors.iter().sum::<i64>(), 320_000); - assert!(floors[0] > floors[1] * 4); - assert_eq!(floors[1], floors[2]); - - let mut explicit = plan.clone(); - explicit.roles[1].budget_tokens = Some(90_000); - let (changed, updated_total) = apply_weighted_role_budget_floors(&mut explicit, 320_000); - assert!(changed); - assert_eq!(explicit.roles[1].budget_tokens, Some(90_000)); - assert!(updated_total > 320_000); - assert!(explicit.roles[0].budget_tokens.expect("scout budget") > 200_000); - } - - #[test] - fn decomposed_budget_preserves_the_parent_ceiling() { - assert_eq!( - delegation_budget_allocation(600_000, 3).expect("allocation"), - (600_000, 200_000) - ); - assert_eq!( - delegation_budget_allocation(400_000, 3).expect("allocation"), - (400_000, 133_333) - ); - assert_eq!( - delegation_budget_allocation(260_000, 3).expect("allocation"), - (260_000, 86_666) - ); - assert_eq!( - delegation_budget_allocation(3, 3).expect("minimum allocation"), - (3, 1) - ); - assert!(delegation_budget_allocation(2, 3).is_err()); - } - - #[test] - fn orchestration_quality_prefers_reasoning_frontier_models() { - assert!( - orchestrator_quality_score("gpt-5.6-sol") > orchestrator_quality_score("gpt-oss-120b") - ); - assert!(orchestrator_quality_score("claude-opus-4.8").is_some()); - assert!(orchestrator_quality_score("text-embedding-3-small").is_none()); - assert!(orchestrator_quality_score("gpt-image-1").is_none()); - } - - #[test] - fn model_assignment_requires_an_exact_catalogue_pair() { - let models = vec![ - ModelOption { - provider: "github-copilot".into(), - deployment: "shared-name".into(), - is_default: true, - detail: None, - }, - ModelOption { - provider: "local-inference".into(), - deployment: "local-only".into(), - is_default: false, - detail: None, - }, - ]; - assert!(catalogue_has_model_key( - &models, - "github-copilot::shared-name" - )); - assert!(!catalogue_has_model_key( - &models, - "local-inference::shared-name" - )); - assert!(!catalogue_has_model_key(&models, "shared-name")); - } - - #[test] - fn repository_egress_is_inferred_and_provider_hosts_are_removed() { - let result = complete_egress_recommendation( - vec![ComposeEgress { - host: "api.githubcopilot.com".into(), - port: Some(443), - }], - "Maintain a TypeScript GitHub repository and its package.json", - &["github".into()], - ); - let hosts = result - .iter() - .map(|endpoint| endpoint.host.as_str()) - .collect::<Vec<_>>(); - assert!(!hosts.contains(&"api.githubcopilot.com")); - assert!(hosts.contains(&"api.github.com")); - assert!(hosts.contains(&"raw.githubusercontent.com")); - assert!(hosts.contains(&"patch-diff.githubusercontent.com")); - assert!(hosts.contains(&"registry.npmjs.org")); - } - - #[test] - fn dependabot_security_review_does_not_guess_wrong_package_registry() { - let result = complete_egress_recommendation( - vec![ComposeEgress { - host: "registry.npmjs.org".into(), - port: Some(443), - }], - "Review the newest Dependabot pull request and relevant security advisories", - &["github".into()], - ); - let hosts = result - .iter() - .map(|endpoint| endpoint.host.as_str()) - .collect::<Vec<_>>(); - assert!(!hosts.contains(&"registry.npmjs.org")); - assert!(!hosts.contains(&"pypi.org")); - assert!(hosts.contains(&"api.osv.dev")); - assert!(hosts.contains(&"api.github.com")); - } -} diff --git a/bridge/bff/src/routes/compose/capability_tests.rs b/bridge/bff/src/routes/compose/capability_tests.rs new file mode 100644 index 000000000..c16a51bff --- /dev/null +++ b/bridge/bff/src/routes/compose/capability_tests.rs @@ -0,0 +1,476 @@ +use super::egress::complete_egress_recommendation; +use super::execution::{ + apply_weighted_role_budget_floors, execution_plan_error_from_raw, parse_execution_plan, + weighted_role_budget_floors, +}; +use super::prompts::{build_system_prompt, build_team_system_prompt}; +use super::routing::{ + catalogue_has_model_key, efficient_member_route_is_qualified, orchestrator_quality_score, + recommendation_is_actionable, should_strengthen_team_principal, +}; +use super::team::{TEAM_COMPOSE_MAX_TOKENS, team_proposal_is_complete}; +use super::team_proposal::is_synthesis_only_team_role; +use super::{ + ComposeEgress, ComposeTeamProposal, delegation_budget_allocation, is_autonomous_harness, + is_non_autonomous_harness, validate_execution_plan, +}; +use crate::routes::efficiency::EfficiencyDto; +use crate::routes::options::{IsolationOption, ModelOption, Options, RuntimeOption}; + +fn test_options() -> Options { + Options { + models: vec![ModelOption { + provider: "github-copilot".into(), + deployment: "gpt-5.6-sol".into(), + is_default: true, + detail: None, + }], + default_model: Some("gpt-5.6-sol".into()), + provider: None, + runtimes: vec![RuntimeOption { + kind: "OpenClaw".into(), + label: "OpenClaw".into(), + wired: true, + status: "validated".into(), + note: "ready".into(), + }], + isolation: vec![IsolationOption { + value: "standard".into(), + label: "Standard".into(), + note: "sandboxed".into(), + }], + tool_policies: Vec::new(), + mcp_servers: Vec::new(), + mcp_profiles: Vec::new(), + memories: Vec::new(), + skills: Vec::new(), + } +} + +fn test_efficiency() -> EfficiencyDto { + EfficiencyDto { + routes: Vec::new(), + recommended: None, + recommended_harness: None, + recommended_basis: None, + recommended_low_confidence: false, + total_runs: 0, + priced: false, + } +} + +#[test] +fn autonomous_set_is_openclaw_hermes_byo() { + for k in ["OpenClaw", "openclaw", "Hermes", "hermes", "BYO", "byo"] { + assert!(is_autonomous_harness(k), "{k} should be autonomous"); + assert!( + !is_non_autonomous_harness(k), + "{k} should not be non-autonomous" + ); + } +} + +#[test] +fn synthesis_only_role_is_reserved_for_the_principal() { + assert!(is_synthesis_only_team_role( + "readiness-editor", + "Reconcile specialist handbacks into one truthful readiness report." + )); + assert!(!is_synthesis_only_team_role( + "ci-health-auditor", + "Verify exact-head CI checks and return independent evidence." + )); +} + +#[test] +fn empty_team_proposal_is_not_reported_as_available() { + let proposal = ComposeTeamProposal { + tier: 3, + cadence_minutes: 0, + instructions: String::new(), + model: String::new(), + model_fallbacks: Vec::new(), + model_basis: None, + expected_tokens_per_outcome: None, + efficiency_sample_runs: 0, + mcp_servers: Vec::new(), + memory: None, + egress: Vec::new(), + egress_mode: "learning".into(), + engineering_enabled: false, + engineering_signals: Vec::new(), + engineering_poll_interval_seconds: 900, + engineering_auto_run: false, + roles: Vec::new(), + execution_plan: None, + milestones: Vec::new(), + }; + assert!(!team_proposal_is_complete(&proposal)); +} + +#[test] +fn team_composer_budget_covers_full_milestone_contract() { + const { assert!(TEAM_COMPOSE_MAX_TOKENS >= 8_192) }; +} + +#[test] +fn collaborative_principal_uses_frontier_when_available() { + assert!(should_strengthen_team_principal( + 3, + "gpt-oss-120b", + "gpt-5.6-sol" + )); + assert!(!should_strengthen_team_principal( + 2, + "gpt-oss-120b", + "gpt-5.6-sol" + )); + assert!(!should_strengthen_team_principal( + 3, + "gpt-oss-120b", + "gpt-oss-120b" + )); + assert!(!efficient_member_route_is_qualified(2, 1.0)); + assert!(!efficient_member_route_is_qualified(3, 0.66)); + assert!(efficient_member_route_is_qualified(3, 0.67)); +} + +#[test] +fn sparse_route_history_does_not_drive_execution_model_selection() { + assert!(!recommendation_is_actionable(Some("gpt-5.6-sol"), true)); + assert!(recommendation_is_actionable(Some("gpt-5.6-sol"), false)); + assert!(!recommendation_is_actionable(None, false)); +} + +#[test] +fn bootstrap_only_adapters_are_non_autonomous() { + for k in [ + "Anthropic", + "OpenAIAgents", + "MicrosoftAgentFramework", + "LangGraph", + "PydanticAi", + ] { + assert!(!is_autonomous_harness(k), "{k} should NOT be autonomous"); + assert!(is_non_autonomous_harness(k), "{k} should be non-autonomous"); + } +} + +#[test] +fn empty_harness_is_not_treated_as_non_autonomous() { + // Empty = "inherit default" — must not trigger a correction. + assert!(!is_non_autonomous_harness("")); + assert!(!is_non_autonomous_harness(" ")); +} + +#[test] +fn arbitrary_execution_plan_is_preserved_without_role_rewrites() { + let value = serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [ + { + "name": "source-reader", + "objective": "Read the supplied source material and retain exact evidence.", + "depends_on": [], + "phases": [{ + "name": "collect", + "objective": "Collect the required source evidence without synthesis.", + "capabilities": ["filesystem-read"], + "max_tool_calls": 4, + "fresh_context": true + }] + }, + { + "name": "decision-writer", + "objective": "Produce the requested decision from the retained source evidence.", + "depends_on": ["source-reader"], + "phases": [{ + "name": "draft", + "objective": "Draft the decision using only retained dependency evidence.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + }] + } + ], + "max_parallel": 1, + "synthesis": { + "objective": "Reconcile the role handbacks into the final answer.", + "capabilities": [], + "max_tool_calls": 0 + }, + "deliverables": [{"name":"decision.md","media_type":"text/markdown"}] + } + }); + let plan = parse_execution_plan(&value).expect("valid plan"); + assert_eq!( + plan.roles + .iter() + .map(|role| role.name.as_str()) + .collect::<Vec<_>>(), + vec!["source-reader", "decision-writer"] + ); + assert_eq!(plan.roles[1].depends_on, vec!["source-reader"]); +} + +#[test] +fn execution_plan_rejects_unknown_capability_and_cycles() { + let mut plan = parse_execution_plan(&serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [{ + "name": "one", + "objective": "Perform one arbitrary evidence task for the mission.", + "depends_on": [], + "phases": [{ + "name": "work", + "objective": "Perform the arbitrary evidence task completely.", + "capabilities": ["shell"], + "max_tool_calls": 2, + "fresh_context": true + }] + }], + "max_parallel": 1, + "synthesis": { + "objective": "Return the final mission answer from the handback.", + "capabilities": [], + "max_tool_calls": 0 + }, + "deliverables": [] + } + })) + .expect("valid baseline"); + plan.roles[0].phases[0].capabilities = vec!["repository-security".into()]; + assert!(validate_execution_plan(&plan).is_err()); + plan.roles[0].phases[0].capabilities = vec!["shell".into()]; + plan.roles[0].depends_on = vec!["one".into()]; + assert!(validate_execution_plan(&plan).is_err()); +} + +#[test] +fn execution_plan_parse_error_identifies_the_exact_repair() { + let raw = serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [{ + "name": "triage", + "objective": "Triage", + "phases": [{ + "name": "inspect", + "objective": "Inspect the repository backlog and retain exact evidence.", + "capabilities": ["filesystem-read"], + "max_tool_calls": 1 + }] + }], + "max_parallel": 1, + "synthesis": { + "objective": "Present the verified maintenance recommendation to the reviewer.", + "capabilities": [], + "max_tool_calls": 0 + } + } + }) + .to_string(); + + assert_eq!( + execution_plan_error_from_raw(&raw).as_deref(), + Some("role triage has an invalid objective") + ); +} + +#[test] +fn research_prompts_require_web_search_and_quoted_mermaid_labels() { + let options = test_options(); + let efficiency = test_efficiency(); + let mission_prompt = build_system_prompt(&options, &efficiency, " (none)", " (none)"); + assert!(mission_prompt.contains("web-search")); + assert!(mission_prompt.contains("quote every label")); + + let team_prompt = build_team_system_prompt(&options, &efficiency, " (none)", " (none)"); + assert!(team_prompt.contains("web-search")); + assert!(team_prompt.contains("qualification stays atomic")); +} + +#[test] +fn weighted_budget_distribution_funds_scout_and_preserves_larger_explicit_roles() { + let plan = parse_execution_plan(&serde_json::json!({ + "execution_plan": { + "schema": "kars.execution-plan/v1", + "roles": [ + { + "name": "source-scout", + "objective": "Discover the authoritative URLs and fetch evidence.", + "depends_on": [], + "phases": [{ + "name": "discover", + "objective": "Search and fetch the exact URLs with evidence.", + "capabilities": ["web-search", "network"], + "min_tool_calls": 1, + "max_tool_calls": 32, + "fresh_context": true + }] + }, + { + "name": "analyst", + "objective": "Inspect the retained sources and extract facts.", + "depends_on": ["source-scout"], + "phases": [ + { + "name": "inspect", + "objective": "Inspect the retained source bundle.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + }, + { + "name": "summarize", + "objective": "Summarize the retained evidence only.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + } + ] + }, + { + "name": "reporter", + "objective": "Draft the downstream report from retained evidence.", + "depends_on": ["analyst"], + "phases": [ + { + "name": "outline", + "objective": "Outline the downstream report.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + }, + { + "name": "draft", + "objective": "Draft the downstream report.", + "capabilities": [], + "max_tool_calls": 0, + "fresh_context": true + } + ] + } + ], + "max_parallel": 1, + "synthesis": { + "objective": "Return the final answer from the retained evidence.", + "capabilities": [], + "max_tool_calls": 0 + }, + "deliverables": [] + } + })) + .expect("valid weighted plan"); + let floors = weighted_role_budget_floors(320_000, &plan); + assert_eq!(floors.iter().sum::<i64>(), 320_000); + assert!(floors[0] > floors[1] * 4); + assert_eq!(floors[1], floors[2]); + + let mut explicit = plan.clone(); + explicit.roles[1].budget_tokens = Some(90_000); + let (changed, updated_total) = apply_weighted_role_budget_floors(&mut explicit, 320_000); + assert!(changed); + assert_eq!(explicit.roles[1].budget_tokens, Some(90_000)); + assert!(updated_total > 320_000); + assert!(explicit.roles[0].budget_tokens.expect("scout budget") > 200_000); +} + +#[test] +fn decomposed_budget_preserves_the_parent_ceiling() { + assert_eq!( + delegation_budget_allocation(600_000, 3).expect("allocation"), + (600_000, 200_000) + ); + assert_eq!( + delegation_budget_allocation(400_000, 3).expect("allocation"), + (400_000, 133_333) + ); + assert_eq!( + delegation_budget_allocation(260_000, 3).expect("allocation"), + (260_000, 86_666) + ); + assert_eq!( + delegation_budget_allocation(3, 3).expect("minimum allocation"), + (3, 1) + ); + assert!(delegation_budget_allocation(2, 3).is_err()); +} + +#[test] +fn orchestration_quality_prefers_reasoning_frontier_models() { + assert!(orchestrator_quality_score("gpt-5.6-sol") > orchestrator_quality_score("gpt-oss-120b")); + assert!(orchestrator_quality_score("claude-opus-4.8").is_some()); + assert!(orchestrator_quality_score("text-embedding-3-small").is_none()); + assert!(orchestrator_quality_score("gpt-image-1").is_none()); +} + +#[test] +fn model_assignment_requires_an_exact_catalogue_pair() { + let models = vec![ + ModelOption { + provider: "github-copilot".into(), + deployment: "shared-name".into(), + is_default: true, + detail: None, + }, + ModelOption { + provider: "local-inference".into(), + deployment: "local-only".into(), + is_default: false, + detail: None, + }, + ]; + assert!(catalogue_has_model_key( + &models, + "github-copilot::shared-name" + )); + assert!(!catalogue_has_model_key( + &models, + "local-inference::shared-name" + )); + assert!(!catalogue_has_model_key(&models, "shared-name")); +} + +#[test] +fn repository_egress_is_inferred_and_provider_hosts_are_removed() { + let result = complete_egress_recommendation( + vec![ComposeEgress { + host: "api.githubcopilot.com".into(), + port: Some(443), + }], + "Maintain a TypeScript GitHub repository and its package.json", + &["github".into()], + ); + let hosts = result + .iter() + .map(|endpoint| endpoint.host.as_str()) + .collect::<Vec<_>>(); + assert!(!hosts.contains(&"api.githubcopilot.com")); + assert!(hosts.contains(&"api.github.com")); + assert!(hosts.contains(&"raw.githubusercontent.com")); + assert!(hosts.contains(&"patch-diff.githubusercontent.com")); + assert!(hosts.contains(&"registry.npmjs.org")); +} + +#[test] +fn dependabot_security_review_does_not_guess_wrong_package_registry() { + let result = complete_egress_recommendation( + vec![ComposeEgress { + host: "registry.npmjs.org".into(), + port: Some(443), + }], + "Review the newest Dependabot pull request and relevant security advisories", + &["github".into()], + ); + let hosts = result + .iter() + .map(|endpoint| endpoint.host.as_str()) + .collect::<Vec<_>>(); + assert!(!hosts.contains(&"registry.npmjs.org")); + assert!(!hosts.contains(&"pypi.org")); + assert!(hosts.contains(&"api.osv.dev")); + assert!(hosts.contains(&"api.github.com")); +} diff --git a/bridge/bff/src/routes/compose/client.rs b/bridge/bff/src/routes/compose/client.rs new file mode 100644 index 000000000..1d9909cc2 --- /dev/null +++ b/bridge/bff/src/routes/compose/client.rs @@ -0,0 +1,224 @@ +/// Find and parse the first top-level JSON object in a model response (it may be +/// fenced or prefixed with prose). Returns `None` when there's no parseable object. +pub(super) fn extract_json_object(raw: &str) -> Option<serde_json::Value> { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + if end <= start { + return None; + } + serde_json::from_str(&raw[start..=end]).ok() +} + +/// Complete the orchestrator prompt, returning `(raw_model_output, source)`. +/// +/// Two reachable paths, in priority order: +/// 1. **Ops override** — an explicit `BRIDGE_ORCHESTRATOR_{ENDPOINT,TOKEN,MODEL}` +/// triple (a dedicated composer endpoint the operator configured). +/// 2. **Native** — route through a Running sandbox's inference router via the +/// `pods/proxy` subresource. The router injects the provider's auth + +/// integration headers (Copilot/Foundry) and enforces governance, so this +/// works on workload-identity clusters with NO static token in the Bridge — +/// reusing exactly the secure path agents use. +pub(super) async fn orchestrator_complete( + cluster: &crate::kars::cluster::Cluster, + system: &str, + user: &str, + default_model: &str, + max_tokens: u32, +) -> anyhow::Result<(String, String)> { + // 1. Ops override — direct endpoint/token/model. + if let (Ok(endpoint), Ok(token), Ok(model)) = ( + std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT"), + std::env::var("BRIDGE_ORCHESTRATOR_TOKEN"), + std::env::var("BRIDGE_ORCHESTRATOR_MODEL"), + ) && !endpoint.trim().is_empty() + && !token.trim().is_empty() + && !model.trim().is_empty() + { + let raw = call_llm(&endpoint, &token, &model, system, user, max_tokens).await?; + return Ok((raw, model)); + } + + // 2. Native — through a running sandbox's secure inference router. Try each + // stable candidate in turn so a sandbox with stale provider auth or a + // warming router is skipped rather than failing the whole compose. + // Claude models use the native Anthropic `/v1/messages` path (the + // OpenAI-compat path returns empty content for Claude). + let candidates = cluster.running_sandbox_candidates().await; + if candidates.is_empty() { + anyhow::bail!( + "orchestrator has no inference path yet — the standing `bridge-orchestrator` sandbox is still starting (retry shortly), or set BRIDGE_ORCHESTRATOR_{{ENDPOINT,TOKEN,MODEL}} to route directly at Azure AI Foundry / Azure OpenAI (scales better for many teams)" + ); + } + let is_claude = default_model.to_ascii_lowercase().contains("claude"); + let mut last_err = String::from("no candidate router returned content"); + for (ns, pod) in candidates.iter().take(4) { + match orchestrator_via_router( + cluster, + ns, + pod, + default_model, + OrchestratorPrompt { system, user }, + is_claude, + max_tokens, + ) + .await + { + Ok(content) if !content.trim().is_empty() => { + return Ok((content, format!("{default_model} (cluster router)"))); + } + Ok(_) => last_err = "router returned empty content".into(), + Err(e) => last_err = e.to_string(), + } + } + anyhow::bail!("{last_err}") +} + +struct OrchestratorPrompt<'a> { + system: &'a str, + user: &'a str, +} + +/// Single orchestrator completion against one sandbox router (Anthropic +/// `/v1/messages` for Claude, OpenAI `/chat/completions` otherwise). +async fn orchestrator_via_router( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + pod: &str, + model: &str, + prompt: OrchestratorPrompt<'_>, + is_claude: bool, + max_tokens: u32, +) -> anyhow::Result<String> { + let OrchestratorPrompt { system, user } = prompt; + if is_claude { + let body = serde_json::json!({ + "model": model, + "system": system, + "messages": [{ "role": "user", "content": user }], + "max_tokens": max_tokens, + }); + let text = cluster.router_messages(ns, pod, &body).await?; + let parsed: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("router returned non-JSON: {e}"))?; + let content: String = parsed + .get("content") + .and_then(|c| c.as_array()) + .map(|blocks| { + blocks + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::<Vec<_>>() + .join("") + }) + .unwrap_or_default(); + return Ok(content); + } + + let body = serde_json::json!({ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "max_tokens": max_tokens, + }); + let text = cluster.router_chat(ns, pod, &body).await?; + let parsed: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("router returned non-JSON: {e}"))?; + Ok(parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or_default() + .to_string()) +} + +/// Call the orchestrator LLM (OpenAI-compatible chat/completions) and return +/// the assistant's text content. +async fn call_llm( + endpoint: &str, + token: &str, + model: &str, + system: &str, + user: &str, + max_tokens: u32, +) -> anyhow::Result<String> { + let url = format!("{}/chat/completions", endpoint.trim_end_matches('/')); + let body = serde_json::json!({ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "max_tokens": max_tokens, + }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(45)) + .build()?; + let resp = client + .post(&url) + .bearer_auth(token) + .json(&body) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!( + "HTTP {status}: {}", + text.chars().take(200).collect::<String>() + ); + } + let parsed: serde_json::Value = serde_json::from_str(&text)?; + let content = parsed + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("no completion content"))?; + Ok(content) +} + +/// Extract the first balanced JSON object from a string, tolerating code fences +/// and leading/trailing prose that some models add despite instructions. +pub(super) fn extract_json(raw: &str) -> Option<serde_json::Value> { + if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw.trim()) { + return Some(v); + } + let bytes = raw.as_bytes(); + let start = raw.find('{')?; + let mut depth = 0i32; + let mut in_str = false; + let mut esc = false; + for i in start..bytes.len() { + let c = bytes[i] as char; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + continue; + } + match c { + '"' => in_str = true, + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return serde_json::from_str(&raw[start..=i]).ok(); + } + } + _ => {} + } + } + None +} diff --git a/bridge/bff/src/routes/compose/egress.rs b/bridge/bff/src/routes/compose/egress.rs new file mode 100644 index 000000000..e2f4fe8c8 --- /dev/null +++ b/bridge/bff/src/routes/compose/egress.rs @@ -0,0 +1,89 @@ +use super::ComposeEgress; + +pub(super) fn complete_egress_recommendation( + egress: Vec<ComposeEgress>, + intent: &str, + mcp_servers: &[String], +) -> Vec<ComposeEgress> { + let mut endpoints = std::collections::BTreeMap::<String, Option<u16>>::new(); + let lower = intent.to_ascii_lowercase(); + let npm_intent = ["npm", "node", "javascript", "typescript", "package.json"] + .iter() + .any(|term| lower.contains(term)); + let python_intent = ["python", "pip", "pypi", "requirements.txt"] + .iter() + .any(|term| lower.contains(term)); + for endpoint in egress { + let host = endpoint.host.to_ascii_lowercase(); + if host.contains("githubcopilot.com") + || host.ends_with(".openai.azure.com") + || host.ends_with(".services.ai.azure.com") + { + continue; + } + if host == "registry.npmjs.org" && !npm_intent { + continue; + } + if matches!(host.as_str(), "pypi.org" | "files.pythonhosted.org") && !python_intent { + continue; + } + endpoints.insert(host, endpoint.port.or(Some(443))); + } + let github = mcp_servers + .iter() + .any(|server| server.to_ascii_lowercase().contains("github")) + || [ + "github", + "repository", + "pull request", + "dependabot", + "code scanning", + ] + .iter() + .any(|term| lower.contains(term)); + let mut add = |host: &str| { + endpoints.entry(host.to_string()).or_insert(Some(443)); + }; + if github { + for host in [ + "api.github.com", + "github.com", + "raw.githubusercontent.com", + "codeload.github.com", + "objects.githubusercontent.com", + "patch-diff.githubusercontent.com", + ] { + add(host); + } + } + if npm_intent { + add("registry.npmjs.org"); + } + if python_intent { + add("pypi.org"); + add("files.pythonhosted.org"); + } + if [ + "security advisory", + "security advisories", + "vulnerability", + "vulnerabilities", + "cve", + ] + .iter() + .any(|term| lower.contains(term)) + { + add("api.osv.dev"); + } + if ["rust", "cargo", "crates.io"] + .iter() + .any(|term| lower.contains(term)) + { + add("index.crates.io"); + add("static.crates.io"); + } + endpoints + .into_iter() + .map(|(host, port)| ComposeEgress { host, port }) + .collect() +} diff --git a/bridge/bff/src/routes/compose/execution.rs b/bridge/bff/src/routes/compose/execution.rs new file mode 100644 index 000000000..939cd5710 --- /dev/null +++ b/bridge/bff/src/routes/compose/execution.rs @@ -0,0 +1,376 @@ +use super::client::extract_json; +use super::{ComposeDelegation, ComposeDelegationRole}; + +fn valid_delegation_role(value: &serde_json::Value) -> Option<ComposeDelegationRole> { + let name = value.get("name")?.as_str()?.trim().to_ascii_lowercase(); + let objective = value.get("objective")?.as_str()?.trim().to_string(); + if name.is_empty() + || name.len() > 48 + || objective.len() < 20 + || objective.len() > 600 + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || name.starts_with('-') + || name.ends_with('-') + { + return None; + } + Some(ComposeDelegationRole { name, objective }) +} + +pub(super) fn single_agent_delegation() -> ComposeDelegation { + ComposeDelegation { + mode: "single-agent".into(), + roles: Vec::new(), + max_parallel: 1, + } +} + +pub(super) fn delegation_from_execution_plan( + plan: &crate::routes::tasks::ExecutionPlanDto, +) -> ComposeDelegation { + ComposeDelegation { + mode: "principal-specialists".into(), + roles: plan + .roles + .iter() + .map(|role| ComposeDelegationRole { + name: role.name.clone(), + objective: role.objective.clone(), + }) + .collect(), + max_parallel: plan.max_parallel, + } +} + +const EXECUTION_CAPABILITIES: &[&str] = &[ + "filesystem-read", + "filesystem-write", + "shell", + "network", + "web-search", + "mcp", + "memory", +]; + +const EXECUTION_ROLE_PHASE_BASE_WEIGHT: i64 = 4; + +fn valid_plan_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 48 + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !name.starts_with('-') + && !name.ends_with('-') +} + +fn valid_capabilities(capabilities: &[String]) -> bool { + let mut seen = std::collections::HashSet::new(); + capabilities.iter().all(|capability| { + EXECUTION_CAPABILITIES.contains(&capability.as_str()) && seen.insert(capability) + }) +} + +fn role_budget_weight(role: &crate::routes::tasks::ExecutionRoleDto) -> i64 { + role.phases + .iter() + .map(|phase| EXECUTION_ROLE_PHASE_BASE_WEIGHT + i64::from(phase.max_tool_calls)) + .sum::<i64>() + .max(1) +} + +pub(super) fn weighted_role_budget_floors( + total_tokens: i64, + plan: &crate::routes::tasks::ExecutionPlanDto, +) -> Vec<i64> { + let weights = plan + .roles + .iter() + .map(role_budget_weight) + .collect::<Vec<_>>(); + let total_weight = weights + .iter() + .map(|weight| i128::from(*weight)) + .sum::<i128>(); + let total_tokens_i128 = i128::from(total_tokens); + let mut floors = weights + .iter() + .map(|weight| ((total_tokens_i128 * i128::from(*weight)) / total_weight) as i64) + .collect::<Vec<_>>(); + let assigned = floors.iter().sum::<i64>(); + let mut remainders = weights + .iter() + .enumerate() + .map(|(index, weight)| { + ( + (total_tokens_i128 * i128::from(*weight)) % total_weight, + index, + ) + }) + .collect::<Vec<_>>(); + remainders.sort_by( + |(left_remainder, left_index), (right_remainder, right_index)| { + right_remainder + .cmp(left_remainder) + .then(left_index.cmp(right_index)) + }, + ); + for (_, index) in remainders + .into_iter() + .take((total_tokens - assigned) as usize) + { + floors[index] += 1; + } + floors +} + +pub(super) fn apply_weighted_role_budget_floors( + plan: &mut crate::routes::tasks::ExecutionPlanDto, + total_tokens: i64, +) -> (bool, i64) { + let mut changed = false; + let floors = weighted_role_budget_floors(total_tokens, plan); + for (role, floor) in plan.roles.iter_mut().zip(floors) { + let floor = floor.max(1); + if role.budget_tokens.is_none_or(|current| current < floor) { + role.budget_tokens = Some(floor); + changed = true; + } + } + let total = plan + .roles + .iter() + .filter_map(|role| role.budget_tokens) + .sum(); + (changed, total) +} + +fn valid_deliverable_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.starts_with('.') + && !name.contains('/') + && !name.contains('\\') + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +fn execution_plan_is_acyclic(plan: &crate::routes::tasks::ExecutionPlanDto) -> bool { + let dependencies = plan + .roles + .iter() + .map(|role| (role.name.as_str(), role.depends_on.as_slice())) + .collect::<std::collections::HashMap<_, _>>(); + fn visit<'a>( + role: &'a str, + dependencies: &std::collections::HashMap<&'a str, &'a [String]>, + visiting: &mut std::collections::HashSet<&'a str>, + visited: &mut std::collections::HashSet<&'a str>, + ) -> bool { + if visited.contains(role) { + return true; + } + if !visiting.insert(role) { + return false; + } + for dependency in dependencies.get(role).copied().unwrap_or_default() { + if !visit(dependency, dependencies, visiting, visited) { + return false; + } + } + visiting.remove(role); + visited.insert(role); + true + } + let mut visiting = std::collections::HashSet::new(); + let mut visited = std::collections::HashSet::new(); + plan.roles + .iter() + .all(|role| visit(&role.name, &dependencies, &mut visiting, &mut visited)) +} + +pub(crate) fn validate_execution_plan( + plan: &crate::routes::tasks::ExecutionPlanDto, +) -> Result<(), String> { + if plan.schema != "kars.execution-plan/v1" { + return Err("execution plan schema must be kars.execution-plan/v1".into()); + } + if plan.roles.is_empty() || plan.roles.len() > 8 { + return Err("execution plan requires 1-8 roles".into()); + } + if plan.max_parallel < 1 || plan.max_parallel > plan.roles.len() as i32 { + return Err("execution plan max_parallel must be within the role count".into()); + } + let role_names = plan + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::<std::collections::HashSet<_>>(); + if role_names.len() != plan.roles.len() || role_names.iter().any(|name| !valid_plan_name(name)) + { + return Err("execution plan role names must be unique DNS-safe labels".into()); + } + for role in &plan.roles { + if !(20..=1200).contains(&role.objective.len()) { + return Err(format!("role {} has an invalid objective", role.name)); + } + if role.phases.is_empty() || role.phases.len() > 8 { + return Err(format!("role {} requires 1-8 phases", role.name)); + } + if role.budget_tokens.is_some_and(|budget| budget <= 0) { + return Err(format!("role {} has an invalid budget", role.name)); + } + let mut phase_names = std::collections::HashSet::new(); + for phase in &role.phases { + if !valid_plan_name(&phase.name) || !phase_names.insert(phase.name.as_str()) { + return Err(format!("role {} has invalid phase names", role.name)); + } + if !(20..=1200).contains(&phase.objective.len()) + || phase.min_tool_calls < 0 + || phase.min_tool_calls > phase.max_tool_calls + || !(0..=32).contains(&phase.max_tool_calls) + || !valid_capabilities(&phase.capabilities) + { + return Err(format!( + "role {} phase {} is invalid", + role.name, phase.name + )); + } + if phase.required_tool_calls.len() > 8 + || phase.required_tool_calls.len() as i32 > phase.max_tool_calls + { + return Err(format!( + "role {} phase {} has invalid required tool calls", + role.name, phase.name + )); + } + for call in &phase.required_tool_calls { + if call.name != "github_actions_job_logs" + || !phase + .capabilities + .iter() + .any(|capability| capability == "mcp") + || ["owner", "repo", "job_id"].iter().any(|key| { + call.arguments + .get(*key) + .is_none_or(|value| value.trim().is_empty()) + }) + || call.arguments.get("tail_lines").is_some_and(|lines| { + lines + .parse::<u32>() + .ok() + .is_none_or(|value| !(1..=2000).contains(&value)) + }) + { + return Err(format!( + "role {} phase {} has an unsupported required tool call", + role.name, phase.name + )); + } + } + } + let mut dependencies = std::collections::HashSet::new(); + if role.depends_on.iter().any(|dependency| { + dependency == &role.name + || !role_names.contains(dependency.as_str()) + || !dependencies.insert(dependency) + }) { + return Err(format!("role {} has invalid dependencies", role.name)); + } + } + if !(20..=1200).contains(&plan.synthesis.objective.len()) + || !(0..=32).contains(&plan.synthesis.max_tool_calls) + || !valid_capabilities(&plan.synthesis.capabilities) + { + return Err("execution plan synthesis is invalid".into()); + } + let mut deliverables = std::collections::HashSet::new(); + if plan.deliverables.len() > 16 + || plan.deliverables.iter().any(|deliverable| { + !valid_deliverable_name(&deliverable.name) + || !deliverables.insert(deliverable.name.as_str()) + }) + { + return Err("execution plan deliverables are invalid".into()); + } + execution_plan_is_acyclic(plan) + .then_some(()) + .ok_or_else(|| "execution plan dependencies must be acyclic".into()) +} + +fn parse_execution_plan_result( + json: &serde_json::Value, +) -> Result<crate::routes::tasks::ExecutionPlanDto, String> { + let value = json + .get("execution_plan") + .ok_or_else(|| "execution_plan is missing".to_string())?; + let plan = serde_json::from_value(value.clone()) + .map_err(|error| format!("execution_plan does not match the required schema: {error}"))?; + validate_execution_plan(&plan)?; + Ok(plan) +} + +pub(super) fn parse_execution_plan( + json: &serde_json::Value, +) -> Option<crate::routes::tasks::ExecutionPlanDto> { + parse_execution_plan_result(json).ok() +} + +pub(super) fn execution_plan_error_from_raw(raw: &str) -> Option<String> { + let json = + extract_json(raw).ok_or_else(|| "response did not contain a JSON object".to_string()); + match json { + Ok(json) => parse_execution_plan_result(&json).err(), + Err(error) => Some(error), + } +} + +pub(crate) fn validate_delegation(delegation: &ComposeDelegation) -> Result<(), String> { + match delegation.mode.as_str() { + "single-agent" if delegation.roles.is_empty() && delegation.max_parallel == 1 => Ok(()), + "principal-specialists" + if (2..=4).contains(&delegation.roles.len()) + && delegation.max_parallel >= 1 + && delegation.max_parallel <= delegation.roles.len() as i32 + && delegation.roles.iter().all(|role| { + let value = serde_json::json!({ + "name": role.name, + "objective": role.objective, + }); + valid_delegation_role(&value).is_some() + }) => + { + let unique = delegation + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::<std::collections::HashSet<_>>(); + (unique.len() == delegation.roles.len()) + .then_some(()) + .ok_or_else(|| "delegation role names must be unique".to_string()) + } + "single-agent" => { + Err("single-agent delegation must have no roles and max_parallel=1".into()) + } + "principal-specialists" => Err( + "principal-specialists delegation requires 2–4 unique valid leaf roles and a bounded max_parallel" + .into(), + ), + _ => Err("delegation mode must be single-agent or principal-specialists".into()), + } +} + +pub(crate) fn delegation_budget_allocation( + total_tokens: i64, + role_count: usize, +) -> Result<(i64, i64), String> { + if role_count == 0 || total_tokens < role_count as i64 { + return Err( + "execution plans require a positive total budget with capacity for every role".into(), + ); + } + Ok((total_tokens, total_tokens / role_count as i64)) +} diff --git a/bridge/bff/src/routes/compose/loops.rs b/bridge/bff/src/routes/compose/loops.rs new file mode 100644 index 000000000..2edea5bf7 --- /dev/null +++ b/bridge/bff/src/routes/compose/loops.rs @@ -0,0 +1,197 @@ +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; +use crate::routes::options::build_options; +use crate::state::AppState; + +use super::client::{extract_json_object, orchestrator_complete}; + +const LOOP_COMPOSE_MAX_TOKENS: u32 = 1_200; + +/// `POST /api/namespaces/:ns/propose-loop` — the orchestrator turns a raw intent +/// into a PROPOSED loop (2026 loop engineering): it picks the feedback-loop +/// pattern that fits and drafts the goal + success criteria. The web then shows +/// this in the Loop Designer for the user to REVIEW and tweak before executing — +/// so the loop is orchestrator-defined, human-reviewed, then run. Falls back to a +/// keyword heuristic when the orchestrator is unreachable (never a dead end). +#[derive(Debug, serde::Deserialize)] +pub struct ProposeLoopRequest { + pub intent: String, + /// "mission" (single run) or "team" (standing cadence loop). + #[serde(default)] + pub surface: String, +} + +#[derive(Debug, Serialize)] +pub struct ProposeLoopResponse { + /// Chosen loop pattern id (matches the web catalog: react, reflect, + /// plan-execute, eval-iterate, explore-branch, standing-watch). + pub pattern: String, + /// The goal the orchestrator distilled from the intent. + pub goal: String, + /// Draft success criteria (one per line). + pub criteria: String, + /// One-line why-this-pattern rationale. + pub rationale: String, + /// "orchestrator" when the model chose it, "heuristic" on fallback. + pub source: String, +} + +const LOOP_PATTERN_IDS: [&str; 6] = [ + "react", + "reflect", + "plan-execute", + "eval-iterate", + "explore-branch", + "standing-watch", +]; + +/// Keyword heuristic used both to seed the orchestrator and as the fallback. +fn heuristic_pattern(intent: &str, surface: &str) -> &'static str { + let t = intent.to_ascii_lowercase(); + if surface == "team" + || t.contains("watch") + || t.contains("monitor") + || t.contains("keep an eye") + || t.contains("on cadence") + || t.contains("every ") + { + return "standing-watch"; + } + if t.contains("test") + || t.contains("verify") + || t.contains("pass") + || t.contains("acceptance") + || t.contains("ci") + { + return "eval-iterate"; + } + if t.contains("research") + || t.contains("investigate") + || t.contains("browse") + || t.contains("search") + || t.contains("find ") + { + return "react"; + } + if t.contains("write") + || t.contains("draft") + || t.contains("report") + || t.contains("polish") + || t.contains("review") + { + return "reflect"; + } + if t.contains("design") + || t.contains("compare") + || t.contains("options") + || t.contains("approach") + || t.contains("brainstorm") + { + return "explore-branch"; + } + "plan-execute" +} + +pub async fn propose_loop( + State(state): State<AppState>, + Json(req): Json<ProposeLoopRequest>, +) -> AppResult<Json<ProposeLoopResponse>> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let intent = req.intent.trim(); + if intent.is_empty() { + return Err(AppError::BadRequest("intent is required".into())); + } + let surface = if req.surface == "team" { + "team" + } else { + "mission" + }; + let heuristic = heuristic_pattern(intent, surface); + + let options = build_options(cluster).await?; + // Don't fabricate a specific model when none is configured — an empty model + // makes the orchestrator call fail cleanly and fall back to the heuristic + // below, rather than pretending a named model exists on this cluster. + let default_model = options + .models + .iter() + .find(|m| m.is_default) + .or_else(|| options.models.first()) + .map(|m| m.deployment.clone()) + .unwrap_or_default(); + + let system = format!( + "You are a loop-engineering orchestrator. Given a user's intent, pick the ONE feedback-loop \ + pattern that best fits and draft the loop. Patterns: react (reason+act with tools), reflect \ + (draft, self-critique, revise), plan-execute (plan then do), eval-iterate (define acceptance \ + checks first, loop until they pass), explore-branch (generate candidates, prune), \ + standing-watch (periodic observe->detect change->act, for standing {surface} work). \ + Respond with ONLY a JSON object: {{\"pattern\": one of [{}], \"goal\": string, \ + \"criteria\": string with one success criterion per line, \"rationale\": one short sentence}}.", + LOOP_PATTERN_IDS.join(", ") + ); + let user = format!( + "Surface: {surface}\nIntent:\n{intent}\n\nA reasonable default pattern is '{heuristic}', but \ + choose the best fit. Respond with ONLY the JSON object." + ); + + // Ask the orchestrator; parse its JSON. Any failure → honest heuristic. + match orchestrator_complete( + cluster, + &system, + &user, + &default_model, + LOOP_COMPOSE_MAX_TOKENS, + ) + .await + { + Ok((raw, _src)) => { + if let Some(v) = extract_json_object(&raw) { + let pattern = v + .get("pattern") + .and_then(|p| p.as_str()) + .filter(|p| LOOP_PATTERN_IDS.contains(p)) + .unwrap_or(heuristic) + .to_string(); + let goal = v + .get("goal") + .and_then(|g| g.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| intent.to_string()); + let criteria = v + .get("criteria") + .and_then(|c| c.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let rationale = v + .get("rationale") + .and_then(|r| r.as_str()) + .unwrap_or("Best fit for this intent.") + .trim() + .to_string(); + return Ok(Json(ProposeLoopResponse { + pattern, + goal, + criteria, + rationale, + source: "orchestrator".into(), + })); + } + // Unparseable model output → heuristic. + } + Err(_) => { /* orchestrator unreachable → heuristic */ } + } + + Ok(Json(ProposeLoopResponse { + pattern: heuristic.to_string(), + goal: intent.to_string(), + criteria: String::new(), + rationale: "Chosen from your intent's keywords (orchestrator unavailable).".into(), + source: "heuristic".into(), + })) +} diff --git a/bridge/bff/src/routes/compose/mission.rs b/bridge/bff/src/routes/compose/mission.rs new file mode 100644 index 000000000..94f73d3ce --- /dev/null +++ b/bridge/bff/src/routes/compose/mission.rs @@ -0,0 +1,416 @@ +use axum::Json; +use axum::extract::{Extension, State}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::options::build_options; +use crate::state::AppState; + +use super::client::orchestrator_complete; +use super::execution::apply_weighted_role_budget_floors; +use super::mission_proposal::parse_and_validate; +use super::prompts::build_system_prompt; +use super::routing::select_orchestrator_route; +use super::{ComposeModel, ComposeProposal, ComposeRequest, ComposeResponse}; + +const MISSION_COMPOSE_MAX_TOKENS: u32 = 4_096; + +fn mission_proposal_blueprint( + proposal: &ComposeProposal, +) -> Result< + ( + crate::routes::tasks::BlueprintDto, + &ComposeModel, + std::collections::BTreeSet<String>, + i32, + ), + String, +> { + let model = proposal + .model + .as_ref() + .ok_or_else(|| "the proposal has no model route".to_string())?; + let blueprint = crate::routes::tasks::BlueprintDto { + runtime: Some(proposal.runtime.clone()), + model: Some(crate::routes::tasks::ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }), + model_fallbacks: proposal + .model_fallbacks + .iter() + .map(|model| crate::routes::tasks::ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }) + .collect(), + instructions: Some(proposal.instructions.clone()), + tool_policy: proposal.tool_policy.clone(), + mcp_servers: proposal.mcp_servers.clone(), + egress: proposal + .egress + .iter() + .map(|endpoint| crate::routes::tasks::EgressDto { + host: endpoint.host.clone(), + port: endpoint.port.map(i32::from), + }) + .collect(), + egress_mode: Some("strict".into()), + isolation: Some(proposal.isolation.clone()), + memory: proposal.memory.clone(), + skills: proposal.skills.clone(), + execution_plan: proposal.execution_plan.clone(), + }; + let (required, max_parallel) = + crate::routes::validate::qualification_requirements(&blueprint, None); + Ok((blueprint, model, required, max_parallel)) +} + +fn apply_mission_budget_floor(proposal: &mut ComposeProposal) -> Result<Option<i64>, String> { + let (_, model, required, max_parallel) = mission_proposal_blueprint(proposal)?; + let minimum = crate::routes::options::route_minimum_tokens( + &proposal.runtime, + &model.provider, + &model.deployment, + &required, + max_parallel, + )?; + let Some(minimum) = minimum else { + return Ok(None); + }; + + let mut changed = false; + let mut required_total = minimum; + if let Some(plan) = proposal.execution_plan.as_mut() + && !plan.roles.is_empty() + { + let (role_budgets_changed, role_budget_total) = + apply_weighted_role_budget_floors(plan, minimum.max(plan.roles.len() as i64)); + changed |= role_budgets_changed; + required_total = required_total.max(role_budget_total); + } + if proposal + .budget_tokens + .is_none_or(|current| current < required_total) + { + proposal.budget_tokens = Some(required_total); + changed = true; + } + Ok(changed.then_some(required_total)) +} + +fn mission_proposal_qualification(proposal: &ComposeProposal) -> Result<(), String> { + let (_, model, required, max_parallel) = mission_proposal_blueprint(proposal)?; + let qualified = crate::routes::options::route_qualification( + &proposal.runtime, + &model.provider, + &model.deployment, + &required, + max_parallel, + proposal.budget_tokens, + )?; + if qualified { + return Ok(()); + } + let missing = crate::routes::options::route_qualification_gap( + &proposal.runtime, + &model.provider, + &model.deployment, + &required, + max_parallel, + proposal.budget_tokens, + )?; + Err(format!( + "{} · {}::{} lacks retained qualification for [{}] at max_parallel={max_parallel}", + proposal.runtime, + model.provider, + model.deployment, + missing.into_iter().collect::<Vec<_>>().join(", "), + )) +} + +pub(super) fn option_named<'a>( + options: &'a [crate::routes::options::RefOption], + name: &str, +) -> Option<&'a crate::routes::options::RefOption> { + options.iter().find(|option| option.name == name) +} + +fn mission_resource_qualification( + proposal: &ComposeProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + let (_, model, _, _) = mission_proposal_blueprint(proposal)?; + let route = + crate::routes::options::route_label(&proposal.runtime, &model.provider, &model.deployment); + for server in &proposal.mcp_servers { + let option = option_named(&options.mcp_servers, server) + .ok_or_else(|| format!("MCP server `{server}` is not in the live options catalogue"))?; + if !crate::routes::options::mcp_server_qualified_for_route( + &proposal.runtime, + &model.provider, + &model.deployment, + option, + )? { + return Err(format!( + "MCP server `{server}` lacks retained resource qualification for {route} at current schema {}. Generic route records do not prove this server.", + option.tool_schema_digest.as_deref().unwrap_or("missing"), + )); + } + } + if let Some(memory) = proposal.memory.as_deref() { + let option = option_named(&options.memories, memory) + .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; + if !crate::routes::options::memory_binding_qualified_for_route( + &proposal.runtime, + &model.provider, + &model.deployment, + option, + )? { + return Err(format!( + "Memory `{memory}` lacks retained resource qualification for {route} at backend {} / compiled digest {}. Generic route records do not prove this binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + )); + } + } + for skill in &proposal.skills { + let option = option_named(&options.skills, skill) + .ok_or_else(|| format!("skill `{skill}` is not in the approved live catalogue"))?; + if !crate::routes::options::skill_version_qualified_for_route( + &proposal.runtime, + &model.provider, + &model.deployment, + option, + )? { + return Err(format!( + "Skill `{skill}` lacks retained resource qualification for {route} at current version digest {}. Generic route records do not prove this approved version.", + option.version_digest.as_deref().unwrap_or("missing"), + )); + } + } + Ok(()) +} + +fn mission_proposal_launchability( + proposal: &ComposeProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + mission_proposal_qualification(proposal)?; + mission_resource_qualification(proposal, options) +} + +/// `POST /api/namespaces/:ns/compose` — orchestrate a launch package from an +/// objective. The `ns` is accepted for symmetry with the other task routes but +/// composition reads cluster-wide building blocks. +pub async fn compose( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Json(req): Json<ComposeRequest>, +) -> AppResult<Json<ComposeResponse>> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + + let objective = req.objective.trim(); + if objective.is_empty() { + return Err(AppError::BadRequest("objective is required".into())); + } + + let options = build_options(cluster).await?; + let efficiency = crate::routes::efficiency::compute_efficiency_for_owner( + cluster, + Some(principal.sub.as_str()), + ) + .await; + let orchestrator_route = select_orchestrator_route(&options, &efficiency); + + let qualification_constraints = crate::routes::options::qualification_constraints_summary() + .unwrap_or_else(|error| format!(" (qualification records unavailable: {error})")); + let resource_qualification_constraints = + crate::routes::options::resource_qualification_summary(&options).unwrap_or_else(|error| { + format!(" (resource qualification records unavailable: {error})") + }); + let system = build_system_prompt( + &options, + &efficiency, + &qualification_constraints, + &resource_qualification_constraints, + ); + let user = format!( + "Objective:\n{objective}\n\nCompose the launch package now. Respond with ONLY the JSON object." + ); + + // Resolve the model used to CALL the orchestrator. An explicit + // BRIDGE_ORCHESTRATOR_* env triple overrides (and carries its own model), so + // it makes `default_model` irrelevant. Otherwise we need a real cluster + // model — never a fabricated one, which would fail opaquely on a non-Anthropic + // cluster. When there is neither, say so honestly instead of guessing. + let env_orchestrator = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") + .is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_TOKEN").is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_MODEL").is_ok_and(|v| !v.trim().is_empty()); + let pinned_orchestrator_model = cluster.bridge_orchestrator_model().await; + let resolved_model = orchestrator_route + .as_ref() + .map(|(_, deployment, _)| deployment.clone()) + .or(pinned_orchestrator_model) + .or_else(|| { + options + .models + .iter() + .find(|m| m.is_default) + .or_else(|| options.models.first()) + .map(|m| m.deployment.clone()) + }); + if resolved_model.is_none() && !env_orchestrator { + return Ok(Json(ComposeResponse { + available: false, + reason: Some( + "No inference models are configured on this cluster, so the AI composer can't run. Add an inference provider in the Operator Console, or compose the package manually below.".into(), + ), + proposal: None, + rationale: None, + source: None, + })); + } + let default_model = resolved_model.unwrap_or_default(); + if !env_orchestrator + && let Some((provider, deployment, _)) = &orchestrator_route + && let Err(error) = cluster + .configure_bridge_orchestrator_model(provider, deployment) + .await + { + return Ok(Json(ComposeResponse { + available: false, + reason: Some(format!( + "The best orchestrator route ({provider}/{deployment}) could not be configured: {error}" + )), + proposal: None, + rationale: None, + source: None, + })); + } + + let (raw, mut source) = match orchestrator_complete( + cluster, + &system, + &user, + &default_model, + MISSION_COMPOSE_MAX_TOKENS, + ) + .await + { + Ok(r) => r, + Err(e) => { + // A reachable-but-failing orchestrator is reported honestly, not + // papered over with a fabricated package. + return Ok(Json(ComposeResponse { + available: false, + reason: Some(format!( + "The orchestrator could not compose a package ({e}). Compose it manually below — every field is the same one the orchestrator would propose." + )), + proposal: None, + rationale: None, + source: None, + })); + } + }; + + let (mut proposal, mut rationale) = parse_and_validate(&raw, &options, &efficiency, objective); + if let Ok(Some(minimum)) = apply_mission_budget_floor(&mut proposal) { + let note = format!( + "The token budget was raised to the retained qualification floor of {minimum} tokens." + ); + rationale = Some(match rationale { + Some(existing) if !existing.trim().is_empty() => format!("{existing} {note}"), + _ => note, + }); + } + if let Err(error) = mission_proposal_launchability(&proposal, &options) { + let repair_user = format!( + "{user}\n\nYour previous proposal was not launchable: {error}\n\ + Recompose it so the complete runtime/model/capability/max_parallel requirement fits \ + ONE qualified execution record below. Qualification records do not compose. If a \ + selected MCP server, memory binding, or approved skill is used, it MUST have a \ + retained resource-scoped qualification record at the CURRENT digest on the chosen \ + route — generic route records do not count. If a requested binary or file-writing \ + deliverable needs an unqualified capability, choose a launchable text/JSON \ + alternative and represent diagrams inline with quoted Mermaid flowchart labels \ + whenever they contain parser-sensitive punctuation.\n\n\ + QUALIFIED EXECUTION RECORDS:\n{qualification_constraints}\n\n\ + RESOURCE QUALIFICATION RECORDS:\n{resource_qualification_constraints}\n\n\ + Return ONLY the complete JSON object." + ); + if let Ok((repair_raw, repair_source)) = orchestrator_complete( + cluster, + &system, + &repair_user, + &default_model, + MISSION_COMPOSE_MAX_TOKENS, + ) + .await + { + (proposal, rationale) = + parse_and_validate(&repair_raw, &options, &efficiency, objective); + let _ = apply_mission_budget_floor(&mut proposal); + source = repair_source; + } + } + if let Err(error) = mission_proposal_launchability(&proposal, &options) { + return Ok(Json(ComposeResponse { + available: false, + reason: Some(format!( + "The orchestrator could not produce a launchable package after retry: {error}" + )), + proposal: None, + rationale: None, + source: Some(source), + })); + } + + proposal.model_fallbacks = qualified_mission_fallbacks(&proposal, &options); + Ok(Json(ComposeResponse { + available: true, + reason: None, + proposal: Some(proposal), + rationale, + source: Some(source), + })) +} + +fn qualified_mission_fallbacks( + proposal: &ComposeProposal, + options: &crate::routes::options::Options, +) -> Vec<ComposeModel> { + let primary = proposal + .model + .as_ref() + .map(|model| format!("{}::{}", model.provider, model.deployment)); + let mut candidates = options + .models + .iter() + .map(|model| (model.provider.clone(), model.deployment.clone())) + .collect::<Vec<_>>(); + candidates.sort(); + candidates.dedup(); + candidates + .into_iter() + .filter(|(provider, deployment)| { + primary.as_deref() != Some(format!("{provider}::{deployment}").as_str()) + }) + .filter_map(|(provider, deployment)| { + let mut trial = proposal.clone(); + trial.model = Some(ComposeModel { + provider: provider.clone(), + deployment: deployment.clone(), + }); + trial.model_fallbacks.clear(); + mission_proposal_launchability(&trial, options) + .is_ok() + .then_some(ComposeModel { + provider, + deployment, + }) + }) + .take(8) + .collect() +} diff --git a/bridge/bff/src/routes/compose/mission_proposal.rs b/bridge/bff/src/routes/compose/mission_proposal.rs new file mode 100644 index 000000000..d796c0dff --- /dev/null +++ b/bridge/bff/src/routes/compose/mission_proposal.rs @@ -0,0 +1,339 @@ +use super::client::extract_json; +use super::egress::complete_egress_recommendation; +use super::execution::{ + delegation_from_execution_plan, parse_execution_plan, single_agent_delegation, +}; +use super::routing::{efficiency_basis, recommendation_is_actionable}; +use super::{ + ComposeEgress, ComposeModel, ComposeProposal, default_tool_policy, is_non_autonomous_harness, +}; + +/// validate every field against the real options — the server is the authority, +/// not the model. Anything invalid is dropped or normalized to a safe default. +pub(super) fn parse_and_validate( + raw: &str, + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + intent: &str, +) -> (ComposeProposal, Option<String>) { + let json = extract_json(raw).unwrap_or_else(|| serde_json::json!({})); + + // Tier: clamp to 1..=5, default 3. + let tier = json + .get("tier") + .and_then(|v| v.as_i64()) + .map(|t| t.clamp(1, 5) as i32) + .unwrap_or(3); + + // ── Model selection (efficiency-driven) ───────────────────────────────── + // Priority: (1) the orchestrator's explicit, valid choice — objective-aware; + // (2) the learned efficiency frontier's recommended route — grounded in real + // accepted outcomes; (3) the cluster default; (4) the first catalogue model. + // `model_basis` records which of these fired so the reviewer sees WHY. + let orchestrator_pick = json.get("model").and_then(|m| { + let provider = m.get("provider").and_then(|p| p.as_str())?; + let dep = m.get("deployment").and_then(|d| d.as_str())?; + o.models + .iter() + .find(|mo| mo.provider == provider && mo.deployment == dep) + .map(|mo| ComposeModel { + provider: mo.provider.clone(), + deployment: mo.deployment.clone(), + }) + }); + + let recommended_model = eff + .recommended + .as_ref() + .filter(|_| { + recommendation_is_actionable(eff.recommended.as_deref(), eff.recommended_low_confidence) + }) + .and_then(|route| { + o.models + .iter() + .find(|mo| mo.deployment == *route) + .map(|mo| ComposeModel { + provider: mo.provider.clone(), + deployment: mo.deployment.clone(), + }) + }); + + let (model, mut model_basis, model_from_reco) = if let Some(m) = orchestrator_pick { + let is_reco = recommendation_is_actionable( + eff.recommended.as_deref(), + eff.recommended_low_confidence, + ) && eff.recommended.as_ref().is_some_and(|r| *r == m.deployment); + let basis = if is_reco { + efficiency_basis(eff, &m.deployment) + } else { + "Chosen by the orchestrator for this objective.".to_string() + }; + (Some(m), Some(basis), is_reco) + } else if let Some(m) = recommended_model { + let basis = efficiency_basis(eff, &m.deployment); + (Some(m), Some(basis), true) + } else { + let m = o + .models + .iter() + .find(|m| m.is_default) + .or_else(|| o.models.first()) + .map(|m| ComposeModel { + provider: m.provider.clone(), + deployment: m.deployment.clone(), + }); + let basis = m.as_ref().map(|_| { + if eff.total_runs == 0 { + "Cluster default — no completed runs yet to learn a better route.".to_string() + } else if eff.recommended.is_some() { + // There IS a learned recommendation, but it doesn't map to a live + // model — be honest rather than implying the default was "chosen". + "Cluster default — the recommended route is no longer in the catalogue.".to_string() + } else { + "Cluster default — the objective didn't clearly warrant another route.".to_string() + } + }); + (m, basis, false) + }; + + // Runtime: the orchestrator's valid choice wins; else, when the model came + // from the efficiency frontier, adopt the harness that ACTUALLY won on that + // route (so we propose the whole winning route, not the model on a default + // harness); else OpenClaw. Any adopted harness must be a wired runtime. + let mut runtime = json + .get("runtime") + .and_then(|v| v.as_str()) + .filter(|r| o.runtimes.iter().any(|ro| ro.wired && ro.kind == *r)) + .map(str::to_string) + .or_else(|| { + if model_from_reco { + eff.recommended_harness + .as_ref() + .filter(|h| o.runtimes.iter().any(|ro| ro.wired && ro.kind == **h)) + .cloned() + } else { + None + } + }) + .unwrap_or_else(|| "OpenClaw".to_string()); + + // Honesty guard (B1): the model can come from the recommended route while + // the orchestrator proposes a DIFFERENT harness. In that case the basis must + // NOT imply we adopted the whole recommended route — the reviewer was seeing + // "Best learned route … on OpenClaw" next to a package that actually ran on + // Hermes. Rewrite the basis to name the real divergence. + if model_from_reco + && let Some(rec_h) = eff.recommended_harness.as_deref() + && !rec_h.is_empty() + && rec_h != runtime + { + let dep = model + .as_ref() + .map(|m| m.deployment.clone()) + .unwrap_or_else(|| "the recommended model".to_string()); + model_basis = Some(format!( + "Recommended model ({dep}), proposed on the {runtime} harness — \ + note the learned best route ran on {rec_h}, so this is not the \ + full recommended route." + )); + } + + // ── Hard capability match (0.4) ───────────────────────────────────────── + // A one-shot MISSION requires an autonomous harness — one that consumes a + // delivered objective and returns a deliverable. A bootstrap-only adapter + // (Anthropic/OpenAIAgents/MAF/LangGraph/PydanticAi) has no task-execution + // loop and delivers nothing autonomously, so routing a mission to one is a + // silent no-op. This is a capability mismatch, not a preference, so we BLOCK + // it rather than soft-warn: it's corrected to OpenClaw (the autonomous + // default) and the decision is recorded in the rationale + stamped on the + // task at launch (kars.azure.com/harness-corrected). (Hermes and BYO are + // autonomous and pass through unchanged.) + let harness_correction: Option<String> = if is_non_autonomous_harness(&runtime) { + let note = format!( + "Capability match: {runtime} is a bootstrap-only adapter with no autonomous \ + task-execution loop, so it cannot run a one-shot mission — routed to OpenClaw \ + (autonomous harness).", + ); + runtime = "OpenClaw".to_string(); + Some(note) + } else { + None + }; + + // Isolation: must be a real level; else standard. + let isolation = json + .get("isolation") + .and_then(|v| v.as_str()) + .filter(|i| o.isolation.iter().any(|io| io.value == *i)) + .unwrap_or("standard") + .to_string(); + + // Tool policy: must be a real policy name. Every envelope MUST carry a + // governance policy — the agent runtime always initializes its AGT engine + // and fails closed on an empty policy set, so an envelope with no tool + // policy yields a sandbox that hangs (no tool/inference/mesh is allowed). + // When the orchestrator doesn't name one, fall back to the cluster default + // governance policy (`kars-default`, which allows inference/tool/mesh/spawn + // and denies dangerous shell) so the sandbox is governed AND functional. + let requested_tool_policy = json + .get("tool_policy") + .and_then(|v| v.as_str()) + .filter(|p| !p.is_empty() && o.tool_policies.iter().any(|tp| tp.name == *p)) + .map(str::to_string) + .or_else(|| default_tool_policy(o)); + let (tool_policy, policy_correction) = if requested_tool_policy.as_deref() + == Some("kars-team-member") + { + ( + default_tool_policy(o), + Some( + "Capability match: kars-team-member is reserved for declared standing-team specialists; routed this standalone mission to kars-default." + .to_string(), + ), + ) + } else { + (requested_tool_policy, None) + }; + + // MCP servers: subset of real servers. + let mut mcp_servers: Vec<String> = json + .get("mcp_servers") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .filter(|name| o.mcp_servers.iter().any(|m| m.name == *name)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + // Least privilege: a model sometimes lists the same server more than once — + // dedupe (order-preserving) so the package never carries a redundant MCP + // grant (the audit saw the same Playwright server selected twice). + { + let mut seen = std::collections::HashSet::new(); + mcp_servers.retain(|s| seen.insert(s.clone())); + } + // Governance invariant: MCP access requires a bounding tool policy. If the + // model asked for MCP without one, drop the MCP servers rather than emit an + // un-admittable package (the reviewer can re-add with a policy). + if !mcp_servers.is_empty() && tool_policy.is_none() { + mcp_servers.clear(); + } + + let requested_skills: Vec<String> = json + .get("skills") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .filter(|name| o.skills.iter().any(|s| s.name == *name)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let (skills, skill_correction) = if runtime == "OpenClaw" { + (requested_skills, None) + } else if requested_skills.is_empty() { + (Vec::new(), None) + } else { + ( + Vec::new(), + Some(format!( + "Capability match: {runtime} does not support controller-mounted file skills; omitted them instead of claiming they would be installed." + )), + ) + }; + + // Memory: must be a real store; else None. + let memory = json + .get("memory") + .and_then(|v| v.as_str()) + .filter(|m| !m.is_empty() && o.memories.iter().any(|mo| mo.name == *m)) + .map(str::to_string); + + // Egress: sanitized host list. + let egress = json + .get("egress") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host").and_then(|h| h.as_str())?.trim().to_string(); + if host.is_empty() { + return None; + } + let port = e + .get("port") + .and_then(|p| p.as_u64()) + .and_then(|p| u16::try_from(p).ok()); + Some(ComposeEgress { host, port }) + }) + .take(20) + .collect() + }) + .unwrap_or_default(); + let egress = complete_egress_recommendation(egress, intent, &mcp_servers); + + let instructions = json + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let execution_plan = parse_execution_plan(&json); + let delegation = execution_plan + .as_ref() + .map(delegation_from_execution_plan) + .unwrap_or_else(single_agent_delegation); + let proposed_budget = json + .get("budget_tokens") + .and_then(|v| v.as_i64()) + .filter(|t| *t > 0) + .filter(|tokens| *tokens > 0); + let budget_tokens = proposed_budget; + + let rationale = json + .get("rationale") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + // Surface the capability correction to the reviewer alongside the rationale so + // the harness swap is never silent. + let corrections = [ + harness_correction.as_deref(), + policy_correction.as_deref(), + skill_correction.as_deref(), + ] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join(" "); + let rationale = match (rationale, corrections.is_empty()) { + (Some(r), false) => Some(format!("{r} {corrections}")), + (None, false) => Some(corrections), + (r, true) => r, + }; + + ( + ComposeProposal { + tier, + model, + model_fallbacks: Vec::new(), + model_basis, + runtime, + instructions, + tool_policy, + mcp_servers, + skills, + egress, + isolation, + memory, + budget_tokens, + execution_plan, + delegation, + }, + rationale, + ) +} diff --git a/bridge/bff/src/routes/compose/models.rs b/bridge/bff/src/routes/compose/models.rs new file mode 100644 index 000000000..652b10a21 --- /dev/null +++ b/bridge/bff/src/routes/compose/models.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct ComposeRequest { + pub objective: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeModel { + pub provider: String, + pub deployment: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeEgress { + pub host: String, + pub port: Option<u16>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeDelegationRole { + pub name: String, + pub objective: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ComposeDelegation { + pub mode: String, + pub roles: Vec<ComposeDelegationRole>, + pub max_parallel: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeProposal { + pub tier: i32, + pub model: Option<ComposeModel>, + /// Ordered routes that independently qualify the complete Mission package. + pub model_fallbacks: Vec<ComposeModel>, + /// Plain-language basis for the model choice, so the reviewer sees WHY this + /// model was proposed — the learned efficiency frontier, the orchestrator's + /// objective-driven pick, or the cluster default. Never fabricated. + pub model_basis: Option<String>, + pub runtime: String, + pub instructions: String, + pub tool_policy: Option<String>, + pub mcp_servers: Vec<String>, + pub skills: Vec<String>, + pub egress: Vec<ComposeEgress>, + pub isolation: String, + pub memory: Option<String>, + pub budget_tokens: Option<i64>, + pub execution_plan: Option<crate::routes::tasks::ExecutionPlanDto>, + pub delegation: ComposeDelegation, +} + +#[derive(Debug, Serialize)] +pub struct ComposeResponse { + /// Whether the orchestrator is configured + reachable on this deployment. + pub available: bool, + /// Why it isn't available (only set when `available` is false), so the UI + /// can explain the manual-composer fallback honestly. + pub reason: Option<String>, + /// The composed, validated launch package — ready to review and edit. + pub proposal: Option<ComposeProposal>, + /// A short, plain-language rationale for the choices (for the reviewer). + pub rationale: Option<String>, + /// The model that composed the package (provenance). + pub source: Option<String>, +} diff --git a/bridge/bff/src/routes/compose/prompts.rs b/bridge/bff/src/routes/compose/prompts.rs new file mode 100644 index 000000000..b8bd20dcf --- /dev/null +++ b/bridge/bff/src/routes/compose/prompts.rs @@ -0,0 +1,597 @@ +/// Build the system prompt enumerating the real building blocks + the strict +/// JSON contract. The model is told it may ONLY use these exact identifiers, +/// and is given the learned efficiency frontier so its model choice is grounded +/// in what actually performs on this cluster — not a blind pick. +pub(super) fn build_system_prompt( + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + qualification_constraints: &str, + resource_qualification_constraints: &str, +) -> String { + let models = o + .models + .iter() + .map(|m| { + format!( + " - deployment=\"{}\" provider=\"{}\"{}", + m.deployment, + m.provider, + if m.is_default { " (default)" } else { "" } + ) + }) + .collect::<Vec<_>>() + .join("\n"); + let runtimes = o + .runtimes + .iter() + .filter(|r| r.wired && r.kind != "BYO") + .map(|r| format!(" - \"{}\"", r.kind)) + .collect::<Vec<_>>() + .join("\n"); + let isolation = o + .isolation + .iter() + .map(|i| format!(" - \"{}\" — {}", i.value, i.note)) + .collect::<Vec<_>>() + .join("\n"); + let policies = if o.tool_policies.is_empty() { + " (none)".to_string() + } else { + o.tool_policies + .iter() + .map(|p| { + format!( + " - \"{}\"{}", + p.name, + p.summary + .as_deref() + .map(|s| format!(" — {s}")) + .unwrap_or_default() + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + let mcp = if o.mcp_servers.is_empty() { + " (none)".to_string() + } else { + o.mcp_servers + .iter() + .map(|m| { + format!( + " - \"{}\"{}{}{}{}{}", + m.name, + m.summary + .as_deref() + .map(|s| format!(" — {s}")) + .unwrap_or_default(), + m.mode + .as_deref() + .map(|mode| format!(" · mode={mode}")) + .unwrap_or_default(), + if m.discovered_tools.is_empty() { + String::new() + } else { + format!(" · tools=[{}]", m.discovered_tools.join(", ")) + }, + m.tool_schema_digest + .as_deref() + .map(|digest| format!(" · schema_digest={digest}")) + .unwrap_or_else(|| " · schema_digest=missing".into()), + m.readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default() + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + let memories = if o.memories.is_empty() { + " (none)".to_string() + } else { + o.memories + .iter() + .map(|m| { + format!( + " - \"{}\"{}{}{}{}", + m.name, + m.summary + .as_deref() + .map(|summary| format!(" — {summary}")) + .unwrap_or_default(), + m.backend + .as_deref() + .map(|backend| format!(" · backend={backend}")) + .unwrap_or_else(|| " · backend=missing".into()), + m.compiled_digest + .as_deref() + .map(|digest| format!(" · compiled_digest={digest}")) + .unwrap_or_else(|| " · compiled_digest=missing".into()), + m.readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default(), + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + let skills = if o.skills.is_empty() { + " (none)".to_string() + } else { + o.skills + .iter() + .map(|s| { + format!( + " - \"{}\"{}{}{}{}{}", + s.name, + s.summary + .as_deref() + .map(|v| format!(" — {v}")) + .unwrap_or_default(), + s.version + .as_deref() + .map(|version| format!(" · version={version}")) + .unwrap_or_default(), + s.version_digest + .as_deref() + .map(|digest| format!(" · version_digest={digest}")) + .unwrap_or_else(|| " · version_digest=missing".into()), + s.recipe + .as_deref() + .map(|recipe| { + format!(" · recipe={}", recipe.chars().take(180).collect::<String>()) + }) + .unwrap_or_default(), + s.readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default() + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + + // The learned efficiency frontier — grounds the model choice in real + // outcomes. Honest: when no runs have completed yet, say so rather than + // inventing a recommendation. + let efficiency = if eff.routes.is_empty() { + " (no completed runs yet — choose the default model unless the objective clearly warrants another)".to_string() + } else { + let mut lines = eff + .routes + .iter() + .take(6) + .map(|r| { + // Structured, honest per-route signal. Absent metrics (pass^k + // with no repeats, USD with no price table) are omitted rather + // than faked, so the model never reasons over invented numbers. + let reliability = match (r.reliability_rate, r.reliability_k) { + (Some(rate), Some(k)) => { + format!(", pass^{k} reliability {:.0}% (n={})", rate * 100.0, r.reliability_samples) + } + _ => String::new(), + }; + let latency = if r.avg_wall_ms > 0 { + format!(", ~{:.0}s wall (p95 {:.0}s)", r.avg_wall_ms as f64 / 1000.0, r.p95_wall_ms as f64 / 1000.0) + } else { + String::new() + }; + let toolfail = if r.avg_tool_calls > 0.0 { + format!(", {:.0}% tool-fail", r.tool_fail_rate * 100.0) + } else { + String::new() + }; + let usd = match r.usd_per_outcome { + Some(u) => format!(", ${:.3}/outcome", u), + None => String::new(), + }; + let fault = if r.top_fault.is_empty() { + String::new() + } else { + format!(", top miss: {}", r.top_fault) + }; + format!( + " - route \"{}\": {:.0}% accepted, {:.0}% delivered, {} tokens/outcome{usd}{reliability}{latency}{toolfail}{fault} over {} run(s){}", + r.route, + r.acceptance_rate * 100.0, + r.success_rate * 100.0, + r.tokens_per_outcome, + r.runs, + if !eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← recommended" + } else if eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← best observed, insufficient evidence" + } else { + "" + } + ) + }) + .collect::<Vec<_>>() + .join("\n"); + if !eff.recommended_low_confidence + && let Some(rec) = &eff.recommended + { + lines.push_str(&format!( + "\n Prefer the recommended route's model (route contains its deployment: \"{rec}\") unless the objective clearly needs a stronger or cheaper model." + )); + } else if eff.recommended_low_confidence { + lines.push_str( + "\n The retained route history is too sparse for automatic model selection. Use the cluster default unless the objective itself clearly requires a stronger or more specialized model.", + ); + } + lines + }; + + format!( + r#"You are the kars launch-package orchestrator. You turn a user's plain-language objective into a single, well-governed launch package for a sandboxed AI agent on the kars runtime. You propose; a human reviews and approves before anything runs. + +You MUST only use the building blocks listed below — never invent a model, tool policy, MCP server, isolation level, or memory store that is not listed. + +AVAILABLE MODELS (pick exactly one by its deployment string): +{models} + +EFFICIENCY FRONTIER (learned from completed runs on THIS cluster — the honest signal is human ACCEPTANCE, not emitted tokens): +{efficiency} + +QUALIFIED EXECUTION RECORDS (the complete proposed package MUST fit one record; records do not compose): +{qualification_constraints} + +RESOURCE QUALIFICATION RECORDS (selected MCP servers, memory bindings, and skills MUST match one current-digest record on the chosen route; generic route records do not count): +{resource_qualification_constraints} + +MODEL ROUTING: the model running this composer is not automatically the model that should execute the mission. For routine, bounded, low-risk work, prefer the cluster default or a proven efficient route. Reserve the strongest model for objectives with substantial ambiguity, synthesis, security impact, long context, or difficult tool orchestration. Sparse history with few or zero accepted outcomes is not a recommendation. + +HARNESSES (pick exactly one): +{runtimes} + +ISOLATION LEVELS (pick exactly one): +{isolation} + +TOOL POLICIES (optional; pick one name or null): +{policies} + +MCP SERVERS (optional; pick zero or more names; if you pick any, you MUST also set a tool_policy): +{mcp} +Foundry-native web search, file search, memory, and code execution are Kars plugin tools and do not require MCP. If the customer explicitly requests an installed MCP server, select it and declare `mcp`; the complete capability combination must match one atomic qualification record. + +APPROVED SKILLS (optional; pick zero or more names): +{skills} + +SHARED MEMORY STORES (optional; pick one name or null): +{memories} + +AUTONOMY TIERS (pick the lowest tier that fits the objective): + 1 = Manual (proposes every step, acts on nothing) + 2 = Shared (acts only on low-risk steps) + 3 = Conditional (acts, but pauses before anything costly/external/irreversible) + 4 = Supervised (autonomous with periodic checkpoints) + 5 = Full (fully autonomous within budget) +Default to tier 3 unless the objective clearly warrants more or less. + +EGRESS: list the external network hosts the agent legitimately needs (e.g. an API host), as objects {{"host": "...", "port": 443}}. Prefer an empty list — the model path is always allowed; only add hosts the task truly requires. + +INSTRUCTIONS: write a concise, specific system prompt (2–5 sentences) framing the agent's role and standards for THIS objective. + +EXECUTION PLAN: when the objective benefits from decomposition, propose a workload-neutral typed execution plan. Choose arbitrary role names from the objective — never use a fixed role template. Each role has dependency-aware phases. Each phase declares only the generic capabilities it needs: filesystem-read, filesystem-write, shell, network, web-search, mcp, memory. `min_tool_calls` is the minimum successful evidence-producing calls required; set it to at least 1 whenever the phase outcome depends on tools or external evidence. `max_tool_calls` is the explicit upper bound. Set `fresh_context=true` when a phase should consume only prior handbacks instead of the full earlier transcript. Use null for a small single-agent objective. + +LAUNCHABILITY: every required capability, the runtime/model route, and max_parallel MUST fit one qualified execution record above. Never merge capabilities from separate records. If you select an MCP server, memory binding, or approved skill, state in the rationale which current-digest resource qualification record makes it launchable. Generic route records do not prove a specific server, backend, or skill version. If the requested output format requires an unqualified capability, propose a supported alternative (for example a Markdown report with inline Mermaid diagrams instead of generated binary images) and explain that choice in the rationale. When you emit Mermaid flowcharts, quote every label that contains parser-sensitive punctuation such as :, (), [], {{}}, or /. + +RESEARCH EVIDENCE: for current-events, incident, or authoritative-source research, declare `web-search` on the source-discovery phase and `network` on the exact-URL fetch phase (or declare both on one combined phase). The first fetch-capable phase must discover exact source URLs with an available search tool (`foundry_web_search` or `web_search`) before fetching pages. Never invent article paths. A timeout, non-success response, blocked page, or search snippet is not evidence for a factual claim. Later phases and synthesis may cite only URLs and facts retained from successful source-discovery/fetch tool results; if authoritative evidence is unavailable, report the gap instead of reconstructing unsupported details. + +BUDGET: optionally propose a token budget (integer) appropriate to the scope, or null for no cap. + +Respond with ONLY a JSON object (no prose, no code fences) of exactly this shape: +{{ + "tier": <int 1-5>, + "model": {{"provider": "<provider>", "deployment": "<deployment>"}}, + "runtime": "<harness>", + "instructions": "<system prompt>", + "tool_policy": "<name or null>", + "mcp_servers": ["<name>", ...], + "skills": ["<name>", ...], + "egress": [{{"host": "<host>", "port": <int or null>}}], + "isolation": "<level>", + "memory": "<name or null>", + "budget_tokens": <int or null>, + "execution_plan": {{ + "schema": "kars.execution-plan/v1", + "roles": [{{ + "name": "<short-kebab-role>", + "objective": "<narrow assignment>", + "depends_on": ["<earlier-role>", ...], + "budget_tokens": <int or null>, + "phases": [{{ + "name": "<short-kebab-phase>", + "objective": "<phase outcome>", + "capabilities": ["<filesystem-read|filesystem-write|shell|network|web-search|mcp|memory>", ...], + "min_tool_calls": <int 0-32, <= max_tool_calls>, + "max_tool_calls": <int 0-32>, + "fresh_context": <bool> + }}] + }}], + "max_parallel": <int 1-8>, + "synthesis": {{ + "objective": "<how the principal should reconcile handbacks>", + "capabilities": [], + "max_tool_calls": 0 + }}, + "deliverables": [{{"name":"<safe filename>","media_type":"<optional MIME>"}}] + }} | null, + "rationale": "<1-3 sentences explaining the key choices for the reviewer>" +}}"# + ) +} + +/// Build the team-orchestrator system prompt. Enumerates the real harnesses + +/// models + the efficiency frontier, and asks for an org chart where roles are +/// purpose-fit and may use DIFFERENT harnesses/models per their function and +/// what the frontier shows performs. +pub(super) fn build_team_system_prompt( + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + qualification_constraints: &str, + resource_qualification_constraints: &str, +) -> String { + let models = o + .models + .iter() + .map(|m| { + format!( + " - \"{}::{}\"{}", + m.provider, + m.deployment, + if m.is_default { " (default)" } else { "" } + ) + }) + .collect::<Vec<_>>() + .join("\n"); + let runtimes = o + .runtimes + .iter() + .filter(|r| r.wired && r.kind != "BYO") + .map(|r| format!(" - \"{}\" — {} ({})", r.kind, r.label, r.status)) + .collect::<Vec<_>>() + .join("\n"); + let mcp_servers = if o.mcp_servers.is_empty() { + " (none installed)".to_string() + } else { + o.mcp_servers + .iter() + .map(|server| { + format!( + " - \"{}\"{}{}{}{}", + server.name, + server + .summary + .as_deref() + .map(|s| format!(" — {s}")) + .unwrap_or_default(), + if server.discovered_tools.is_empty() { + String::new() + } else { + format!(" · tools=[{}]", server.discovered_tools.join(", ")) + }, + server + .tool_schema_digest + .as_deref() + .map(|digest| format!(" · schema_digest={digest}")) + .unwrap_or_else(|| " · schema_digest=missing".into()), + server + .mode + .as_deref() + .map(|mode| format!(" · mode={mode}")) + .unwrap_or_default(), + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + let memories = if o.memories.is_empty() { + " (none configured)".to_string() + } else { + o.memories + .iter() + .map(|memory| { + format!( + " - \"{}\"{}{}{}{}", + memory.name, + memory + .summary + .as_deref() + .map(|summary| format!(" — {summary}")) + .unwrap_or_default(), + memory + .backend + .as_deref() + .map(|backend| format!(" · backend={backend}")) + .unwrap_or_else(|| " · backend=missing".into()), + memory + .compiled_digest + .as_deref() + .map(|digest| format!(" · compiled_digest={digest}")) + .unwrap_or_else(|| " · compiled_digest=missing".into()), + memory + .readiness + .as_deref() + .map(|readiness| format!(" · readiness={readiness}")) + .unwrap_or_default(), + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + let skills = if o.skills.is_empty() { + " (none approved)".to_string() + } else { + o.skills + .iter() + .map(|skill| { + format!( + " - \"{}\"{}{}{}{}", + skill.name, + skill + .summary + .as_deref() + .map(|summary| format!(" — {summary}")) + .unwrap_or_default(), + skill + .version + .as_deref() + .map(|version| format!(" · version={version}")) + .unwrap_or_default(), + skill + .version_digest + .as_deref() + .map(|digest| format!(" · version_digest={digest}")) + .unwrap_or_else(|| " · version_digest=missing".into()), + skill + .recipe + .as_deref() + .map(|recipe| { + format!(" · recipe={}", recipe.chars().take(180).collect::<String>()) + }) + .unwrap_or_default(), + ) + }) + .collect::<Vec<_>>() + .join("\n") + }; + let efficiency = if eff.routes.is_empty() { + " (no completed runs yet — use the default model for roles unless a role clearly needs a stronger one)".to_string() + } else { + let mut lines = eff + .routes + .iter() + .take(6) + .map(|r| { + format!( + " - route \"{}\": {:.0}% accepted, {} tokens/outcome over {} run(s){}", + r.route, + r.acceptance_rate * 100.0, + r.tokens_per_outcome, + r.runs, + if !eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← recommended" + } else if eff.recommended_low_confidence + && eff.recommended.as_deref() == Some(r.route.as_str()) + { + " ← best observed, insufficient evidence" + } else { + "" + } + ) + }) + .collect::<Vec<_>>() + .join("\n"); + if eff.recommended_low_confidence { + lines.push_str("\n Evidence is too sparse for automatic route inheritance. Use the team default for routine roles and a stronger model only where the role's reasoning or orchestration burden clearly requires it."); + } else { + lines.push_str("\n Use the frontier to assign models: give cheap/high-acceptance routes to routine roles, and a stronger model only to roles whose work demands it."); + } + lines + }; + + format!( + r#"You are the kars team orchestrator. You turn a standing-team CHARTER into an org chart: a small roster of member roles that together fulfil the charter. Each role can run a DIFFERENT harness and model — choose what fits its job and what the efficiency frontier shows performs. You propose; a human reviews and edits before the team is created. + +You MUST only use the harnesses and models listed below — never invent one. + +HARNESSES (pick per role, or "" for the team default): +{runtimes} + +MODELS (pick per role as "provider::deployment", or "" for the team default): +{models} + +CONNECTED SERVICES / MCP (select only services the charter genuinely needs): +{mcp_servers} +Foundry-native web search, file search, memory, and code execution are Kars plugin tools and do not require MCP. If the customer explicitly requests an installed MCP server, select it and declare `mcp`; the complete capability combination must match one atomic qualification record. + +SHARED MEMORY STORES (optional; default to a qualified Foundry-backed store when one is already configured and useful for continuity): +{memories} + +APPROVED SKILLS (assign only when a role genuinely benefits from the recipe below): +{skills} + +EFFICIENCY FRONTIER (learned from completed runs; honest signal is human ACCEPTANCE): +{efficiency} + +QUALIFIED EXECUTION RECORDS (the full team plan MUST fit one route record; records do not compose): +{qualification_constraints} + +RESOURCE QUALIFICATION RECORDS (selected MCP servers, memory bindings, and skills MUST match one current-digest record on the chosen route; generic route records do not count): +{resource_qualification_constraints} + +GUIDANCE: +- Propose 2–4 focused roles (rarely more). Each role does ONE clear part of the charter. +- Produce one typed `execution_plan` whose role names exactly match the proposed roster. Define explicit dependencies, one or more bounded phases per role, and only the generic capabilities each phase requires: filesystem-read, filesystem-write, shell, network, web-search, mcp, memory. Set `min_tool_calls` to at least 1 when a phase must produce tool-backed evidence. Do not infer capabilities from role names. +- The principal owns orchestration and the final synthesis. Never propose a coordinator, editor, integrator, or synthesis-only member whose job is merely to reconcile other roles' handbacks or write the final report. Every member must collect, inspect, test, or verify independent evidence. +- Give each role a short, specific system prompt (1–2 sentences). +- Assign harness + model per role deliberately: a research/analysis role may warrant a stronger model; a routine triage/watch role should use an efficient one. Leave model/runtime "" to inherit the team default when no strong reason exists. +- For research charters, declare `web-search` on source-discovery phases and `network` on exact-URL fetch phases (or both on one combined phase) so the retained qualification stays atomic on one route. +- Select the smallest `mcp_servers` set needed by the whole team. Use the discovered tool names and schema digests above to choose the right server. A browser/UX investigator needs a browser MCP when one is installed. +- If you assign a skill, use the recipe and version digest above to justify it. If you select MCP, memory, or skills, the rationale must name the current-digest resource qualification record that makes the choice launchable. +- Select a team-default `model` for the principal; roles may override it only when their work needs a different route. +- Propose only the external `egress` hosts genuinely required by the charter. Do not invent internal/private hosts. Use `learning` for a reviewed discovery run or `strict` when the host list is complete. +- AUTONOMY TIER for the team: 1=Manual .. 5=Full. Default 3 unless the charter warrants otherwise. +- CADENCE minutes: how often the team wakes to act (0 = passive/on-demand). Pick a sensible value for the charter (e.g. 60 for hourly monitoring), else 0. +- If the charter is continuous repository maintenance, set `engineering_enabled=true`, choose the relevant signals from `dependabot_pr`, `dependabot_alert`, `code_scanning_alert`, `secret_scanning_alert`, choose a poll interval >=300 seconds, and normally set `engineering_auto_run=true`. Otherwise disable it. +- For a concrete build, launch, research campaign, migration, or other long-horizon deliverable, propose 2–8 topologically ordered `milestones`. Each milestone owns explicit acceptance criteria and may depend only on earlier milestone IDs. Set `review_required=true` at consequential handoff/release boundaries so dependent work pauses for customer approval. Use an empty milestone list only for genuinely continuous monitoring with no finite delivery. +- If you include Mermaid flowcharts in any deliverable description or rationale, quote every label containing parser-sensitive punctuation such as :, (), [], {{}}, or /. + +Respond with ONLY a JSON object (no prose, no code fences) of exactly this shape: +{{ + "tier": <int 1-5>, + "cadence_minutes": <int>, + "instructions": "<1-2 sentence team-level mandate>", + "model": "<provider::deployment or empty for cluster default>", + "mcp_servers": ["<installed MCP server name>"], + "memory": "<qualified memory name or null>", + "egress": [{{"host":"<public DNS host>","port":443}}], + "egress_mode": "<learning or strict>", + "engineering_enabled": <bool>, + "engineering_signals": ["<dependabot_pr|dependabot_alert|code_scanning_alert|secret_scanning_alert>"], + "engineering_poll_interval_seconds": <int >=300>, + "engineering_auto_run": <bool>, + "roles": [ + {{"name": "<short-kebab-name>", "system_prompt": "<what this role does>", "runtime": "<harness or empty>", "model": "<provider::deployment or empty>", "skills": []}} + ], + "execution_plan": {{ + "schema": "kars.execution-plan/v1", + "roles": [{{ + "name": "<exact roster role name>", + "objective": "<role outcome>", + "depends_on": ["<earlier role>", ...], + "budget_tokens": <int or null>, + "phases": [{{ + "name": "<short-kebab-phase>", + "objective": "<phase outcome>", + "capabilities": ["<filesystem-read|filesystem-write|shell|network|web-search|mcp|memory>", ...], + "min_tool_calls": <int 0-32, <= max_tool_calls>, + "max_tool_calls": <int 0-32>, + "fresh_context": <bool> + }}] + }}], + "max_parallel": <int 1-8>, + "synthesis": {{ + "objective": "<principal synthesis outcome>", + "capabilities": [], + "max_tool_calls": 0 + }}, + "deliverables": [] + }}, + "milestones": [ + {{"id":"<stable-kebab-id>","title":"<milestone>","description":"<work and expected artifact>","owner_role":"<roster role or empty>","depends_on":["<earlier-id>"],"acceptance_criteria":["<verifiable condition>"],"review_required":<bool>}} + ], + "rationale": "<1-3 sentences explaining the org shape + key model/harness choices>" +}}"# + ) +} diff --git a/bridge/bff/src/routes/compose/routing.rs b/bridge/bff/src/routes/compose/routing.rs new file mode 100644 index 000000000..91b40d044 --- /dev/null +++ b/bridge/bff/src/routes/compose/routing.rs @@ -0,0 +1,172 @@ +use crate::routes::options::ModelOption; + +pub(super) fn orchestrator_quality_score(deployment: &str) -> Option<i64> { + let model = deployment.to_ascii_lowercase().replace(['.', '_'], "-"); + if model.contains("embedding") + || model.contains("image") + || model.contains("flux") + || model.contains("dall-e") + { + return None; + } + let score = if model.contains("gpt-5-6") || model.contains("gpt-5.6") { + 1_000 + } else if model.contains("claude-opus-4-8") { + 990 + } else if model.contains("claude-opus-4-7") { + 980 + } else if model.contains("gpt-5-4-pro") { + 970 + } else if model.contains("gpt-5-4") { + 950 + } else if model.contains("claude-sonnet-5") { + 940 + } else if model.contains("gpt-4-1") { + 900 + } else if model.contains("gpt-oss-120b") { + 850 + } else if model.contains("gpt-5") || model.contains("claude") { + 800 + } else { + 500 + }; + Some(score) +} + +fn catalogue_has_model(models: &[ModelOption], provider: &str, deployment: &str) -> bool { + models + .iter() + .any(|model| model.provider == provider && model.deployment == deployment) +} + +pub(super) fn catalogue_has_model_key(models: &[ModelOption], key: &str) -> bool { + key.split_once("::") + .is_some_and(|(provider, deployment)| catalogue_has_model(models, provider, deployment)) +} + +pub(super) fn recommendation_is_actionable( + recommended: Option<&str>, + low_confidence: bool, +) -> bool { + recommended.is_some() && !low_confidence +} + +pub(super) fn select_orchestrator_route( + options: &crate::routes::options::Options, + efficiency: &crate::routes::efficiency::EfficiencyDto, +) -> Option<(String, String, String)> { + let actionable_recommendation = efficiency.recommended.as_deref().filter(|_| { + recommendation_is_actionable( + efficiency.recommended.as_deref(), + efficiency.recommended_low_confidence, + ) + }); + options + .models + .iter() + .filter_map(|model| { + let quality = orchestrator_quality_score(&model.deployment)?; + let route = efficiency + .routes + .iter() + .find(|route| route.route == model.deployment); + let frontier_bonus = if actionable_recommendation == Some(model.deployment.as_str()) { + 80 + } else { + 0 + }; + let evidence_bonus = route + .map(|route| (route.acceptance_rate * 50.0).round() as i64) + .unwrap_or(0); + Some((quality + frontier_bonus + evidence_bonus, model)) + }) + .max_by_key(|(score, _)| *score) + .map(|(_, model)| { + let basis = if actionable_recommendation == Some(model.deployment.as_str()) { + format!( + "Selected {} from {} as the strongest orchestration-capable model and current efficiency-frontier recommendation.", + model.deployment, model.provider + ) + } else { + format!( + "Selected {} from {} as the strongest orchestration-capable model in the configured catalogue.", + model.deployment, model.provider + ) + }; + (model.provider.clone(), model.deployment.clone(), basis) + }) +} + +/// A one-line, plain-language basis for recommending `deployment`, drawn from +/// the learned efficiency frontier — real accepted-outcome counts, never +/// fabricated. Falls back to a generic line if the route has no stats yet. +pub(super) fn efficiency_basis( + eff: &crate::routes::efficiency::EfficiencyDto, + deployment: &str, +) -> String { + if let Some(r) = eff.routes.iter().find(|r| r.route == deployment) { + let acc = (r.acceptance_rate * 100.0).round() as i64; + // Distinguish a CONFIDENT recommendation (enough runs, a real acceptance + // rate) from the best of a sparse/weak set. Without this, a route that is + // merely "least-bad" — e.g. 7% accepted over a handful of runs — read as a + // glowing endorsement next to the word "Recommended", which is dishonest. + let strong = r.runs >= 5 && r.acceptance_rate >= 0.5; + let mut s = if strong { + format!( + "Best learned route — {acc}% accepted over {} run{}", + r.runs, + if r.runs == 1 { "" } else { "s" } + ) + } else { + format!( + "Best available route so far (limited signal) — {acc}% accepted over {} run{}", + r.runs, + if r.runs == 1 { "" } else { "s" } + ) + }; + if !r.harness.is_empty() { + s.push_str(&format!(" on {}", r.harness)); + } + // Reliability of 0% over a tiny sample is "not yet established", not a + // meaningful "0%" — report it honestly so it doesn't read as "0% reliable". + match (r.reliability_rate, r.reliability_k, r.reliability_samples) { + (Some(rel), Some(k), samples) if samples >= 3 && rel > 0.0 => { + s.push_str(&format!( + ", pass^{k} reliability {}%", + (rel * 100.0).round() as i64 + )); + } + (Some(_), _, samples) => { + s.push_str(&format!(", reliability not yet established (n={samples})")); + } + _ => {} + } + if let Some(usd) = r.usd_per_outcome { + s.push_str(&format!(", ${usd:.2}/outcome")); + } + s.push('.'); + s + } else { + "Recommended by the learned efficiency frontier.".to_string() + } +} + +pub(super) fn should_strengthen_team_principal( + role_count: usize, + current_deployment: &str, + strongest_deployment: &str, +) -> bool { + if role_count < 3 || current_deployment == strongest_deployment { + return false; + } + let current = orchestrator_quality_score(current_deployment).unwrap_or(0); + let strongest = orchestrator_quality_score(strongest_deployment).unwrap_or(0); + current < 900 && strongest >= 950 +} + +pub(super) fn efficient_member_route_is_qualified(runs: i64, acceptance_rate: f64) -> bool { + // A lower-cost member route needs repeated evidence before a newly composed + // team inherits it. This is intentionally stricter on sample count than a + // descriptive efficiency-basis label because it changes live execution. + runs >= 3 && acceptance_rate >= 0.67 +} diff --git a/bridge/bff/src/routes/compose/team.rs b/bridge/bff/src/routes/compose/team.rs new file mode 100644 index 000000000..e4d53ccba --- /dev/null +++ b/bridge/bff/src/routes/compose/team.rs @@ -0,0 +1,319 @@ +// ─── Team orchestrator: charter → org chart ────────────────────────────────── +// +// The symmetric counterpart to the mission orchestrator. From a standing-team +// charter it proposes a full org chart — a roster of member roles, each with a +// purpose-fit harness and model — informed by the SAME efficiency frontier the +// mission composer uses. This is the bread-and-butter: different roles can run +// different harnesses/models, chosen by what actually performs. A human reviews +// and edits before the team is created. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::options::build_options; +use crate::state::AppState; + +use super::ComposeEgress; +use super::client::orchestrator_complete; +use super::execution::execution_plan_error_from_raw; +use super::prompts::build_team_system_prompt; +use super::routing::select_orchestrator_route; +use super::team_proposal::parse_and_validate_team; +use super::team_qualification::{normalize_team_proposal_route, qualified_team_fallbacks}; + +// A team proposal can contain eight milestone contracts plus four role contracts. +// Keep enough output room for the model's complete JSON rather than accepting a +// syntactically truncated proposal and wasting the single repair attempt. +pub(super) const TEAM_COMPOSE_MAX_TOKENS: u32 = 8_192; + +#[derive(Debug, Deserialize)] +pub struct ComposeTeamRequest { + pub charter: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeTeamRole { + pub name: String, + pub system_prompt: String, + /// Harness kind (validated against real wired runtimes) or empty for the + /// team default. + pub runtime: String, + /// Model as `provider::deployment` (validated) or empty for team default. + pub model: String, + pub skills: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeTeamMilestone { + pub id: String, + pub title: String, + pub description: String, + pub owner_role: Option<String>, + pub depends_on: Vec<String>, + pub acceptance_criteria: Vec<String>, + pub review_required: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeTeamProposal { + pub tier: i32, + pub cadence_minutes: i64, + pub instructions: String, + /// Principal/default model as `provider::deployment`. + pub model: String, + /// Ordered routes that independently qualify the complete Team contract. + pub model_fallbacks: Vec<String>, + /// Evidence-backed reason for the principal model choice. + pub model_basis: Option<String>, + /// Historical cost of the selected route, when the efficiency graph has + /// enough retained outcomes to measure it. + pub expected_tokens_per_outcome: Option<i64>, + pub efficiency_sample_runs: i64, + pub mcp_servers: Vec<String>, + pub memory: Option<String>, + pub egress: Vec<ComposeEgress>, + pub egress_mode: String, + pub engineering_enabled: bool, + pub engineering_signals: Vec<String>, + pub engineering_poll_interval_seconds: i64, + pub engineering_auto_run: bool, + pub roles: Vec<ComposeTeamRole>, + pub execution_plan: Option<crate::routes::tasks::ExecutionPlanDto>, + pub milestones: Vec<ComposeTeamMilestone>, +} + +#[derive(Debug, Serialize)] +pub struct ComposeTeamResponse { + pub available: bool, + pub reason: Option<String>, + pub proposal: Option<ComposeTeamProposal>, + pub rationale: Option<String>, + pub source: Option<String>, +} + +/// `POST /api/namespaces/:ns/compose-team` — orchestrate an org chart from a +/// charter. Same honesty contract as the mission composer: absent/failing +/// orchestrator returns `available:false` with a reason so the UI falls back to +/// manual composition. +pub async fn compose_team( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path(ns): Path<String>, + Json(req): Json<ComposeTeamRequest>, +) -> AppResult<Json<ComposeTeamResponse>> { + let cluster = state.cluster().ok_or(AppError::ClusterUnavailable)?; + let charter = req.charter.trim(); + if charter.len() < 8 { + return Err(AppError::BadRequest("a real charter is required".into())); + } + + let mut options = build_options(cluster).await?; + options.mcp_servers.retain(|server| server.namespace == ns); + options.memories.retain(|memory| memory.namespace == ns); + options.skills.retain(|skill| skill.namespace == ns); + let efficiency = crate::routes::efficiency::compute_efficiency_for_owner( + cluster, + Some(principal.sub.as_str()), + ) + .await; + let orchestrator_route = select_orchestrator_route(&options, &efficiency); + let qualification_constraints = crate::routes::options::qualification_constraints_summary() + .unwrap_or_else(|error| format!(" (qualification records unavailable: {error})")); + let resource_qualification_constraints = + crate::routes::options::resource_qualification_summary(&options).unwrap_or_else(|error| { + format!(" (resource qualification records unavailable: {error})") + }); + let system = build_team_system_prompt( + &options, + &efficiency, + &qualification_constraints, + &resource_qualification_constraints, + ); + let user = format!( + "Team charter:\n{charter}\n\nCompose the org chart now. Respond with ONLY the JSON object." + ); + + let env_orchestrator = std::env::var("BRIDGE_ORCHESTRATOR_ENDPOINT") + .is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_TOKEN").is_ok_and(|v| !v.trim().is_empty()) + && std::env::var("BRIDGE_ORCHESTRATOR_MODEL").is_ok_and(|v| !v.trim().is_empty()); + let pinned_orchestrator_model = cluster.bridge_orchestrator_model().await; + let resolved_model = orchestrator_route + .as_ref() + .map(|(_, deployment, _)| deployment.clone()) + .or(pinned_orchestrator_model) + .or_else(|| { + options + .models + .iter() + .find(|m| m.is_default) + .or_else(|| options.models.first()) + .map(|m| m.deployment.clone()) + }); + if resolved_model.is_none() && !env_orchestrator { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some( + "No inference models are configured on this cluster, so the org-composer can't run. Add an inference provider in the Operator Console, or shape the org manually below.".into(), + ), + proposal: None, + rationale: None, + source: None, + })); + } + let default_model = resolved_model.unwrap_or_default(); + if !env_orchestrator + && let Some((provider, deployment, _)) = &orchestrator_route + && let Err(error) = cluster + .configure_bridge_orchestrator_model(provider, deployment) + .await + { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The best orchestrator route ({provider}/{deployment}) could not be configured: {error}" + )), + proposal: None, + rationale: None, + source: None, + })); + } + + let (raw, mut source) = match orchestrator_complete( + cluster, + &system, + &user, + &default_model, + TEAM_COMPOSE_MAX_TOKENS, + ) + .await + { + Ok(r) => r, + Err(e) => { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The org-composer could not compose ({e}). Shape the org manually below — the same building blocks the orchestrator would use." + )), + proposal: None, + rationale: None, + source: None, + })); + } + }; + + let mut execution_plan_error = execution_plan_error_from_raw(&raw); + let (mut proposal, mut rationale) = + parse_and_validate_team(&raw, &options, &efficiency, charter); + if !team_proposal_is_complete(&proposal) { + let repair_user = format!( + "{user}\n\nYour previous response was incomplete. The exact structural problem was: {}. \ + Return the complete JSON object now; preserve a small roster of independent evidence \ + roles, make every execution-plan role name exactly match one roster role name, and do \ + not include prose or code fences.", + incomplete_team_proposal_detail(&proposal, execution_plan_error.as_deref()) + ); + if let Ok((repair_raw, repair_source)) = orchestrator_complete( + cluster, + &system, + &repair_user, + &default_model, + TEAM_COMPOSE_MAX_TOKENS, + ) + .await + { + execution_plan_error = execution_plan_error_from_raw(&repair_raw); + (proposal, rationale) = + parse_and_validate_team(&repair_raw, &options, &efficiency, charter); + source = repair_source; + } + } + if !team_proposal_is_complete(&proposal) { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The org-composer returned an incomplete proposal twice (missing: {}). Shape the org manually below rather than treating generic fallback roles as an AI recommendation.", + incomplete_team_proposal_detail(&proposal, execution_plan_error.as_deref()), + )), + proposal: None, + rationale: None, + source: Some(source), + })); + } + if let Err(error) = normalize_team_proposal_route(&mut proposal, &options) { + let repair_user = format!( + "{user}\n\nYour previous response was not launchable: {error}\n\ + Recompose it so the principal route, every role route, and every selected MCP \ + server, memory binding, and approved skill fit retained qualification evidence at \ + the CURRENT digest. Generic route records do not prove a specific resource.\n\n\ + QUALIFIED EXECUTION RECORDS:\n{qualification_constraints}\n\n\ + RESOURCE QUALIFICATION RECORDS:\n{resource_qualification_constraints}\n\n\ + Return ONLY the complete JSON object." + ); + if let Ok((repair_raw, repair_source)) = orchestrator_complete( + cluster, + &system, + &repair_user, + &default_model, + TEAM_COMPOSE_MAX_TOKENS, + ) + .await + { + (proposal, rationale) = + parse_and_validate_team(&repair_raw, &options, &efficiency, charter); + source = repair_source; + } + } + if let Err(error) = normalize_team_proposal_route(&mut proposal, &options) { + return Ok(Json(ComposeTeamResponse { + available: false, + reason: Some(format!( + "The org-composer could not produce a launchable team after retry: {error}" + )), + proposal: None, + rationale: None, + source: Some(source), + })); + } + proposal.model_fallbacks = qualified_team_fallbacks(&proposal, &options); + Ok(Json(ComposeTeamResponse { + available: true, + reason: None, + proposal: Some(proposal), + rationale, + source: Some(source), + })) +} + +pub(super) fn team_proposal_is_complete(proposal: &ComposeTeamProposal) -> bool { + !proposal.instructions.trim().is_empty() + && !proposal.roles.is_empty() + && proposal.execution_plan.is_some() +} + +fn incomplete_team_proposal_detail( + proposal: &ComposeTeamProposal, + execution_plan_error: Option<&str>, +) -> String { + let mut missing = Vec::new(); + if proposal.instructions.trim().is_empty() { + missing.push("instructions"); + } + if proposal.roles.is_empty() { + missing.push("independent roles"); + } + if proposal.execution_plan.is_none() { + missing.push( + execution_plan_error + .unwrap_or("valid execution_plan with role names exactly matching the roster"), + ); + } + if missing.is_empty() { + "unknown structural mismatch".into() + } else { + missing.join(", ") + } +} diff --git a/bridge/bff/src/routes/compose/team_proposal.rs b/bridge/bff/src/routes/compose/team_proposal.rs new file mode 100644 index 000000000..51e5a8fe1 --- /dev/null +++ b/bridge/bff/src/routes/compose/team_proposal.rs @@ -0,0 +1,524 @@ +use super::client::extract_json; +use super::egress::complete_egress_recommendation; +use super::execution::parse_execution_plan; +use super::routing::{ + catalogue_has_model_key, efficiency_basis, efficient_member_route_is_qualified, + recommendation_is_actionable, select_orchestrator_route, should_strengthen_team_principal, +}; +use super::team_qualification::default_model_route; +use super::{ + ComposeEgress, ComposeTeamMilestone, ComposeTeamProposal, ComposeTeamRole, + is_non_autonomous_harness, +}; + +pub(super) fn is_synthesis_only_team_role(name: &str, system_prompt: &str) -> bool { + let name = name.to_ascii_lowercase(); + let prompt = system_prompt.to_ascii_lowercase(); + let explicitly_reconciles_handbacks = [ + "reconcile specialist handbacks", + "reconcile the specialist handbacks", + "synthesize specialist handbacks", + "synthesize the specialist handbacks", + "combine specialist handbacks", + "combine the specialist handbacks", + ] + .iter() + .any(|phrase| prompt.contains(phrase)); + let principal_like_name = [ + "readiness-editor", + "synthesis-editor", + "final-synthesizer", + "report-integrator", + ] + .contains(&name.as_str()); + + explicitly_reconciles_handbacks + || (principal_like_name + && ["final report", "final synthesis", "principal deliverable"] + .iter() + .any(|phrase| prompt.contains(phrase))) +} + +/// Validate the team orchestrator's JSON against real options — runtimes and +/// models must exist (or be empty for the default); tier/cadence clamped. +pub(super) fn parse_and_validate_team( + raw: &str, + o: &crate::routes::options::Options, + eff: &crate::routes::efficiency::EfficiencyDto, + charter: &str, +) -> (ComposeTeamProposal, Option<String>) { + let json = extract_json(raw).unwrap_or_else(|| serde_json::json!({})); + + let tier = json + .get("tier") + .and_then(|v| v.as_i64()) + .map(|t| t.clamp(1, 5) as i32) + .unwrap_or(3); + let cadence_minutes = json + .get("cadence_minutes") + .and_then(|v| v.as_i64()) + .filter(|c| *c >= 0) + .unwrap_or(0); + let mut instructions = json + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let mcp_servers = json + .get("mcp_servers") + .and_then(|v| v.as_array()) + .map(|servers| { + servers + .iter() + .filter_map(|server| server.as_str().map(str::trim)) + .filter(|server| o.mcp_servers.iter().any(|option| option.name == *server)) + .scan(std::collections::BTreeSet::new(), |seen, server| { + seen.insert(server.to_string()).then(|| server.to_string()) + }) + .take(8) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let requested_memory = json + .get("memory") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|memory| !memory.is_empty()) + .filter(|memory| o.memories.iter().any(|option| option.name == *memory)) + .map(str::to_string); + + let valid_runtime = |rt: &str| o.runtimes.iter().any(|r| r.wired && r.kind == rt); + let valid_model = |model: &str| catalogue_has_model_key(&o.models, model); + let mut model = json + .get("model") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|model| valid_model(model)) + .unwrap_or("") + .to_string(); + let selected_deployment = model + .split_once("::") + .map(|(_, deployment)| deployment) + .unwrap_or(""); + let selected_efficiency = eff + .routes + .iter() + .find(|route| route.route == selected_deployment); + let mut model_basis = if selected_deployment.is_empty() { + Some("Team default — no explicit principal model was proposed.".to_string()) + } else if recommendation_is_actionable( + eff.recommended.as_deref(), + eff.recommended_low_confidence, + ) && eff + .recommended + .as_deref() + .is_some_and(|recommended| recommended == selected_deployment) + { + Some(efficiency_basis(eff, selected_deployment)) + } else if selected_efficiency.is_some() { + Some( + "Chosen by the org orchestrator for this charter; historical route evidence is shown for comparison." + .to_string(), + ) + } else { + Some("Chosen by the org orchestrator for this charter; no retained route history is available yet.".to_string()) + }; + let principal_runtime = "OpenClaw"; + let principal_route = model + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + .or_else(|| { + default_model_route(o).and_then(|route| { + route + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + }) + }); + let memory = if let Some(memory) = requested_memory { + Some(memory) + } else if let Some((provider, deployment)) = principal_route.as_ref() { + o.memories.iter().find_map(|option| { + let foundry_like = option + .backend + .as_deref() + .is_some_and(|backend| backend.to_ascii_lowercase().contains("foundry")); + let ready = option.readiness.as_deref().is_some_and(|readiness| { + readiness == "Ready" || readiness.starts_with("Ready=True") + }); + let qualified = crate::routes::options::memory_binding_qualified_for_route( + principal_runtime, + provider, + deployment, + option, + ) + .unwrap_or(false); + (foundry_like && ready && qualified).then(|| option.name.clone()) + }) + } else { + None + }; + let egress = json + .get("egress") + .and_then(|value| value.as_array()) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let host = entry.get("host")?.as_str()?.trim(); + let valid = !host.is_empty() + && host.contains('.') + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')); + valid.then(|| ComposeEgress { + host: host.to_ascii_lowercase(), + port: entry + .get("port") + .and_then(|port| port.as_u64()) + .and_then(|port| u16::try_from(port).ok()), + }) + }) + .take(16) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let egress = complete_egress_recommendation(egress, charter, &mcp_servers); + let egress_mode = match json + .get("egress_mode") + .and_then(|value| value.as_str()) + .unwrap_or("learning") + .to_ascii_lowercase() + .as_str() + { + "strict" => "strict", + _ => "learning", + } + .to_string(); + + let mut roles = json + .get("roles") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|r| { + let name = r.get("name").and_then(|v| v.as_str())?.trim().to_string(); + if name.is_empty() || name.eq_ignore_ascii_case("principal") { + return None; + } + let system_prompt = r + .get("system_prompt") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if is_synthesis_only_team_role(&name, &system_prompt) { + return None; + } + // Harness capability: a bootstrap-only adapter can't run a + // standing member autonomously — correct it to OpenClaw so the + // role actually produces work (Hermes/BYO pass through). + let runtime = { + let rt = r + .get("runtime") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| valid_runtime(s)) + .unwrap_or("") + .to_string(); + if is_non_autonomous_harness(&rt) { + "OpenClaw".to_string() + } else { + rt + } + }; + let model = r + .get("model") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| valid_model(s)) + .unwrap_or("") + .to_string(); + let skills = r + .get("skills") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str()) + .filter(|skill| o.skills.iter().any(|option| option.name == *skill)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some(ComposeTeamRole { + name, + system_prompt, + runtime, + model, + skills, + }) + }) + .take(6) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let execution_plan = parse_execution_plan(&json).inspect(|plan| { + let proposed_roles = roles.clone(); + roles = plan + .roles + .iter() + .enumerate() + .map(|(index, planned_role)| { + let mut role = proposed_roles + .iter() + .find(|role| role.name == planned_role.name) + .cloned() + .or_else(|| proposed_roles.get(index).cloned()) + .unwrap_or_else(|| ComposeTeamRole { + name: planned_role.name.clone(), + system_prompt: planned_role.objective.clone(), + runtime: String::new(), + model: String::new(), + skills: Vec::new(), + }); + role.name = planned_role.name.clone(); + if role.system_prompt.trim().is_empty() { + role.system_prompt = planned_role.objective.clone(); + } + role + }) + .collect(); + }); + let role_names = roles + .iter() + .map(|role| role.name.as_str()) + .collect::<std::collections::HashSet<_>>(); + let mut seen_milestones = std::collections::BTreeSet::new(); + let normalize_milestone_id = |value: &str| { + value + .trim() + .to_ascii_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' { + character + } else { + '-' + } + }) + .collect::<String>() + .trim_matches('-') + .chars() + .take(63) + .collect::<String>() + }; + let mut milestones_invalid = false; + let milestones = json + .get("milestones") + .and_then(serde_json::Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let id = normalize_milestone_id( + entry.get("id").and_then(serde_json::Value::as_str)?, + ); + let title = entry + .get("title") + .and_then(serde_json::Value::as_str)? + .trim() + .to_string(); + if id.is_empty() || title.is_empty() || seen_milestones.contains(&id) { + return None; + } + let requested_dependencies = entry + .get("depends_on") + .and_then(serde_json::Value::as_array) + .map(|dependencies| { + dependencies + .iter() + .filter_map(serde_json::Value::as_str) + .map(normalize_milestone_id) + .filter(|dependency| !dependency.is_empty()) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + if requested_dependencies + .iter() + .any(|dependency| !seen_milestones.contains(dependency)) + { + milestones_invalid = true; + return None; + } + let depends_on = requested_dependencies; + let acceptance_criteria = entry + .get("acceptance_criteria") + .and_then(serde_json::Value::as_array) + .map(|criteria| { + criteria + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|criterion| !criterion.is_empty()) + .take(20) + .map(str::to_string) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let owner_role = entry + .get("owner_role") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|owner| role_names.contains(*owner)) + .map(str::to_string); + let description = entry + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let review_required = entry + .get("review_required") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + seen_milestones.insert(id.clone()); + Some(ComposeTeamMilestone { + id, + title, + description, + owner_role, + depends_on, + acceptance_criteria, + review_required, + }) + }) + .take(8) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + if milestones_invalid { + instructions.clear(); + } + + let mut rationale = json + .get("rationale") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some((provider, deployment, basis)) = select_orchestrator_route(o, eff) { + let current_route = if model.is_empty() { + o.models + .iter() + .find(|option| option.is_default) + .map(|option| format!("{}::{}", option.provider, option.deployment)) + .unwrap_or_default() + } else { + model.clone() + }; + let current_deployment = current_route + .split_once("::") + .map(|(_, deployment)| deployment) + .unwrap_or(""); + if should_strengthen_team_principal(roles.len(), current_deployment, &deployment) { + let current_evidence = eff + .routes + .iter() + .find(|route| route.route == current_deployment); + let keep_efficient_members = current_evidence.is_some_and(|route| { + efficient_member_route_is_qualified(route.runs, route.acceptance_rate) + }); + let frontier_route = format!("{provider}::{deployment}"); + let member_route = if keep_efficient_members { + current_route.clone() + } else { + frontier_route.clone() + }; + for role in &mut roles { + if role.model.is_empty() { + role.model = member_route.clone(); + } + } + model = frontier_route; + let member_basis = if keep_efficient_members { + format!( + "member roles retain the qualified {} route ({} historical run(s))", + current_deployment, + current_evidence.map(|route| route.runs).unwrap_or(0) + ) + } else { + format!( + "member roles also use {} until the proposed {} route has enough accepted outcomes to qualify", + deployment, current_deployment + ) + }; + model_basis = Some(format!( + "{basis} The principal coordinates {} independent roles; {member_basis}.", + roles.len(), + )); + let note = format!( + "The principal route was strengthened to {deployment} for multi-role orchestration reliability; {member_basis}." + ); + rationale = Some(match rationale { + Some(existing) => format!("{existing} {note}"), + None => note, + }); + } + } + let engineering_enabled = json + .get("engineering_enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let allowed_engineering_signals = [ + "dependabot_pr", + "dependabot_alert", + "code_scanning_alert", + "secret_scanning_alert", + ]; + let engineering_signals = json + .get("engineering_signals") + .and_then(serde_json::Value::as_array) + .map(|signals| { + signals + .iter() + .filter_map(serde_json::Value::as_str) + .filter(|signal| allowed_engineering_signals.contains(signal)) + .map(str::to_string) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + let engineering_poll_interval_seconds = json + .get("engineering_poll_interval_seconds") + .and_then(serde_json::Value::as_i64) + .unwrap_or(900) + .clamp(300, 86_400); + let engineering_auto_run = json + .get("engineering_auto_run") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + ( + ComposeTeamProposal { + tier, + cadence_minutes, + instructions, + model, + model_fallbacks: Vec::new(), + model_basis, + expected_tokens_per_outcome: selected_efficiency + .map(|route| route.tokens_per_outcome) + .filter(|tokens| *tokens > 0), + efficiency_sample_runs: selected_efficiency.map(|route| route.runs).unwrap_or(0), + mcp_servers, + memory, + egress, + egress_mode, + engineering_enabled: engineering_enabled && !engineering_signals.is_empty(), + engineering_signals, + engineering_poll_interval_seconds, + engineering_auto_run, + roles, + execution_plan, + milestones, + }, + rationale, + ) +} diff --git a/bridge/bff/src/routes/compose/team_qualification.rs b/bridge/bff/src/routes/compose/team_qualification.rs new file mode 100644 index 000000000..ac1331ef7 --- /dev/null +++ b/bridge/bff/src/routes/compose/team_qualification.rs @@ -0,0 +1,368 @@ +use super::mission::option_named; +use super::{ComposeTeamProposal, is_non_autonomous_harness}; + +pub(super) fn default_model_route(options: &crate::routes::options::Options) -> Option<String> { + options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)) +} + +fn team_role_qualification_requirements( + plan: &crate::routes::tasks::ExecutionPlanDto, + role_name: &str, +) -> std::collections::BTreeSet<String> { + let mut required = + std::collections::BTreeSet::from(["team".to_string(), "telemetry".to_string()]); + if let Some(role) = plan.roles.iter().find(|role| role.name == role_name) { + for phase in &role.phases { + required.extend(phase.capabilities.iter().cloned()); + } + } + required +} + +fn qualify_role_resource( + runtime: &str, + provider: &str, + deployment: &str, + route: &str, + label: &str, + qualified: Result<bool, String>, + detail: impl FnOnce() -> String, +) -> Result<(), String> { + match qualified { + Ok(true) => Ok(()), + Ok(false) => Err(format!( + "{label} lacks retained resource qualification for {route}. {}", + detail() + )), + Err(error) => Err(format!( + "{label} could not be matched against qualification records for {runtime} · {provider}::{deployment}: {error}" + )), + } +} + +fn team_proposal_qualification( + proposal: &ComposeTeamProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + let principal_runtime = "OpenClaw"; + let principal_route = proposal + .model + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + .or_else(|| { + default_model_route(options).and_then(|route| { + route + .split_once("::") + .map(|(provider, deployment)| (provider.to_string(), deployment.to_string())) + }) + }) + .ok_or_else(|| "the team proposal has no launchable principal model route".to_string())?; + let principal_blueprint = crate::routes::tasks::BlueprintDto { + runtime: Some(principal_runtime.to_string()), + model: Some(crate::routes::tasks::ModelDto { + provider: principal_route.0.clone(), + deployment: principal_route.1.clone(), + }), + model_fallbacks: Vec::new(), + instructions: Some(proposal.instructions.clone()), + tool_policy: None, + mcp_servers: proposal.mcp_servers.clone(), + egress: proposal + .egress + .iter() + .map(|entry| crate::routes::tasks::EgressDto { + host: entry.host.clone(), + port: entry.port.map(i32::from), + }) + .collect(), + egress_mode: Some(proposal.egress_mode.clone()), + isolation: None, + memory: proposal.memory.clone(), + skills: proposal + .roles + .iter() + .flat_map(|role| role.skills.iter().cloned()) + .collect(), + execution_plan: proposal.execution_plan.clone(), + }; + let (required, max_parallel) = + crate::routes::validate::qualification_requirements(&principal_blueprint, Some("team")); + if !crate::routes::options::route_qualification( + principal_runtime, + &principal_route.0, + &principal_route.1, + &required, + max_parallel, + None, + )? { + let missing = crate::routes::options::route_qualification_gap( + principal_runtime, + &principal_route.0, + &principal_route.1, + &required, + max_parallel, + None, + )?; + return Err(format!( + "{} lacks retained qualification for [{}] at max_parallel={max_parallel}", + crate::routes::options::route_label( + principal_runtime, + &principal_route.0, + &principal_route.1 + ), + missing.into_iter().collect::<Vec<_>>().join(", "), + )); + } + let principal_route_label = crate::routes::options::route_label( + principal_runtime, + &principal_route.0, + &principal_route.1, + ); + for server in &proposal.mcp_servers { + let option = option_named(&options.mcp_servers, server) + .ok_or_else(|| format!("MCP server `{server}` is not in the live options catalogue"))?; + qualify_role_resource( + principal_runtime, + &principal_route.0, + &principal_route.1, + &principal_route_label, + &format!("MCP server `{server}`"), + crate::routes::options::mcp_server_qualified_for_route( + principal_runtime, + &principal_route.0, + &principal_route.1, + option, + ), + || { + format!( + "Current schema digest: {}. Generic route records do not prove this server.", + option.tool_schema_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + if let Some(memory) = proposal.memory.as_deref() { + let option = option_named(&options.memories, memory) + .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; + qualify_role_resource( + principal_runtime, + &principal_route.0, + &principal_route.1, + &principal_route_label, + &format!("memory `{memory}`"), + crate::routes::options::memory_binding_qualified_for_route( + principal_runtime, + &principal_route.0, + &principal_route.1, + option, + ), + || { + format!( + "Current backend/digest: {}/{}. Generic route records do not prove this memory binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + let Some(plan) = proposal.execution_plan.as_ref() else { + return Err("the team proposal has no typed execution plan".into()); + }; + let default_role_route = format!("{}::{}", principal_route.0, principal_route.1); + for role in &proposal.roles { + let route = if role.model.trim().is_empty() { + default_role_route.as_str() + } else { + role.model.trim() + }; + let (provider, deployment) = route + .split_once("::") + .ok_or_else(|| format!("role {} has no valid model route", role.name))?; + let runtime = if role.runtime.trim().is_empty() { + principal_runtime + } else { + role.runtime.trim() + }; + let role_required = team_role_qualification_requirements(plan, &role.name); + if !crate::routes::options::route_qualification( + runtime, + provider, + deployment, + &role_required, + 1, + None, + )? { + let missing = crate::routes::options::route_qualification_gap( + runtime, + provider, + deployment, + &role_required, + 1, + None, + )?; + return Err(format!( + "role `{}` route {} lacks retained qualification for [{}]", + role.name, + crate::routes::options::route_label(runtime, provider, deployment), + missing.into_iter().collect::<Vec<_>>().join(", "), + )); + } + let role_route_label = crate::routes::options::route_label(runtime, provider, deployment); + if role_required.contains("mcp") { + for server in &proposal.mcp_servers { + let option = option_named(&options.mcp_servers, server).ok_or_else(|| { + format!("MCP server `{server}` is not in the live options catalogue") + })?; + qualify_role_resource( + runtime, + provider, + deployment, + &role_route_label, + &format!("role `{}` MCP server `{server}`", role.name), + crate::routes::options::mcp_server_qualified_for_route( + runtime, provider, deployment, option, + ), + || { + format!( + "Current schema digest: {}. Generic route records do not prove this server.", + option.tool_schema_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + } + if role_required.contains("memory") + && let Some(memory) = proposal.memory.as_deref() + { + let option = option_named(&options.memories, memory) + .ok_or_else(|| format!("memory `{memory}` is not in the live options catalogue"))?; + qualify_role_resource( + runtime, + provider, + deployment, + &role_route_label, + &format!("role `{}` memory `{memory}`", role.name), + crate::routes::options::memory_binding_qualified_for_route( + runtime, provider, deployment, option, + ), + || { + format!( + "Current backend/digest: {}/{}. Generic route records do not prove this memory binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + for skill in &role.skills { + let option = option_named(&options.skills, skill) + .ok_or_else(|| format!("skill `{skill}` is not in the approved live catalogue"))?; + qualify_role_resource( + runtime, + provider, + deployment, + &role_route_label, + &format!("role `{}` skill `{skill}`", role.name), + crate::routes::options::skill_version_qualified_for_route( + runtime, provider, deployment, option, + ), + || { + format!( + "Current version digest: {}. Generic route records do not prove this approved skill version.", + option.version_digest.as_deref().unwrap_or("missing") + ) + }, + )?; + } + } + Ok(()) +} + +pub(super) fn normalize_team_proposal_route( + proposal: &mut ComposeTeamProposal, + options: &crate::routes::options::Options, +) -> Result<(), String> { + for role in &mut proposal.roles { + if is_non_autonomous_harness(&role.runtime) { + role.runtime = "OpenClaw".into(); + } + } + let initial_error = match team_proposal_qualification(proposal, options) { + Ok(()) => return Ok(()), + Err(error) => error, + }; + let original_model = proposal.model.clone(); + let original_role_routes = proposal + .roles + .iter() + .map(|role| (role.runtime.clone(), role.model.clone())) + .collect::<Vec<_>>(); + let mut candidates = Vec::new(); + if let Some(default) = default_model_route(options) { + candidates.push(default); + } + + candidates.extend( + options + .models + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)), + ); + let mut seen = std::collections::HashSet::new(); + for route in candidates + .into_iter() + .filter(|route| seen.insert(route.clone())) + { + proposal.model = route.clone(); + for role in &mut proposal.roles { + role.runtime.clear(); + role.model.clear(); + } + if team_proposal_qualification(proposal, options).is_ok() { + proposal.model_basis = Some(format!( + "Bridge selected {route} because the complete team plan and its reviewed resources match one retained qualification record; the orchestrator's proposed route did not." + )); + proposal.expected_tokens_per_outcome = None; + proposal.efficiency_sample_runs = 0; + return Ok(()); + } + } + proposal.model = original_model; + for (role, (runtime, model)) in proposal.roles.iter_mut().zip(original_role_routes) { + role.runtime = runtime; + role.model = model; + } + Err(initial_error) +} + +pub(super) fn qualified_team_fallbacks( + proposal: &ComposeTeamProposal, + options: &crate::routes::options::Options, +) -> Vec<String> { + let mut candidates = options + .models + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .filter(|route| route != &proposal.model) + .collect::<Vec<_>>(); + candidates.sort(); + candidates.dedup(); + candidates + .into_iter() + .filter(|route| { + let mut trial = proposal.clone(); + trial.model = route.clone(); + trial.model_fallbacks.clear(); + for role in &mut trial.roles { + role.model = route.clone(); + } + team_proposal_qualification(&trial, options).is_ok() + }) + .take(8) + .collect() +} From a0f4d1f16c5e8bdf60df50fe4dd0c3eec691755b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 18:52:29 +0200 Subject: [PATCH 012/111] Use the public operator review workflow for native writer enrollment Build the locked same-source core CLI and exercise real grant preview/apply instead of posting an unqualified writer grant. Preserve exclusive creation, workspace/writer UID and key checks, private review storage and controller readiness. No fabricated activation or Ready status; native and shared-workspace continuity remain unqualified until hosted proof. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-native.yml | 8 + bridge/docs/governed-credentials.md | 26 +++- .../tests/native-qualification.test.ts | 13 ++ bridge/tests/native-credentials/enrollment.py | 69 +++++++++ bridge/tests/native-credentials/native_api.py | 14 +- .../native-credentials/test_enrollment.py | 144 ++++++++++++++++++ 6 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 bridge/tests/native-credentials/enrollment.py create mode 100644 bridge/tests/native-credentials/test_enrollment.py diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml index 9ef45f18f..780cd1460 100644 --- a/.github/workflows/bridge-native.yml +++ b/.github/workflows/bridge-native.yml @@ -131,6 +131,14 @@ jobs: install .native/core/target/debug/kars-controller .native/core/bin/amd64/ install .native/core/target/debug/kars-inference-router .native/core/bin/amd64/ install bff/target/debug/kars-bridge-bff .native/bin/ + - name: Set up the exact operator CLI toolchain + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: bridge/.native/core/cli/package-lock.json + - name: Build the locked public operator CLI + run: npm ci --prefix .native/core/cli && npm run build --prefix .native/core/cli - name: Package only local qualification images, never publish run: | docker build --build-arg TARGETARCH=amd64 \ diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 4ba5cf928..fdcf14969 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -1,8 +1,9 @@ -# Private Bridge governed credential adapter +# Bridge governed credential adapter -Bridge stays private while the core contract is integrated and qualified. -This change neither publishes app source/images nor changes repository visibility. -Existing audit gates and pending review evidence remain required. +Bridge source is being integrated into **Azure/kars:kars-bridge** as an optional +add-on. Source publication is not release qualification; images are not published +by these acceptance workflows. Existing audit gates and pending review evidence +remain required. The BFF consumes `KarsCredentialGrant/workspace`; it cannot create or expand that operator grant. Operators enroll the actual BFF ServiceAccount UID and @@ -10,9 +11,24 @@ existing purpose-specific store UIDs using the core CLI. Bootstrap missing stores as explicitly selected empty Opaque objects before enrollment, never by adopting a racing existing object. +Enabled writers also require the core's reviewed private-consumption activation. +A direct grant CREATE with writers but without that activation is intentionally +denied. Use the same-source core CLI's `credentials grant preview` and +`credentials grant apply` workflow, including an explicit `--private-root` and +the actual `--private-controller-profile`; do not manufacture activation +metadata or patch a Ready condition to bypass enrollment. + +Activation can retire approved private consumers and replace the root controller +Pod. Shared-controller and multi-workspace continuity remain under qualification: +this draft is not authorization to migrate an existing installation. The native +lane uses the locked public CLI, verifies the reviewed workspace/writer UIDs and +key scope, stores its metadata-only review privately, and waits for actual +controller readiness. Local orchestration fixtures are not native authority +evidence. + Set `core.namespace` independently from the chart's `namespace`. BFF/web default workspace and provider operations use the configured core namespace; -the optional Teams Secret remains in the private Bridge integration namespace. +the optional Teams Secret remains in the dedicated Bridge integration namespace. The credential form now requires a target kind and workspace, and accepts an explicit reviewed target UID. It stores a governed source without precreating diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts index a8e5f6def..eda7a7494 100644 --- a/bridge/teams-gateway/tests/native-qualification.test.ts +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -74,6 +74,19 @@ describe("Monorepo native prerequisite", () => { expect(credentials).not.toContain("get_metadata"); }); + it("enrolls writers through the same-source public operator review workflow", () => { + expect(workflow).toContain("npm ci --prefix .native/core/cli"); + expect(workflow).toContain("npm run build --prefix .native/core/cli"); + const enrollment = read("tests/native-credentials/enrollment.py"); + expect(enrollment).toContain('".native/core/cli/dist/index.js"'); + expect(enrollment).toContain('"credentials", "grant", "preview"'); + expect(enrollment).toContain('"credentials", "grant", "apply"'); + expect(enrollment).toContain('"--private-controller-profile", "service-accounts"'); + expect(enrollment).toContain('setup.ready_grant(namespace)'); + expect(enrollment).not.toContain("admin.create("); + expect(enrollment).not.toMatch(/failurePolicy|patch.*conditions|break-glass/); + }); + it("qualifies actual CNI traffic and never treats API existence as enforcement", () => { expect(workflow).toContain("--version 1.18.5"); expect(workflow).toContain("needs: [contract-scope, api-admission, native-runtime]"); diff --git a/bridge/tests/native-credentials/enrollment.py b/bridge/tests/native-credentials/enrollment.py new file mode 100644 index 000000000..bdb443683 --- /dev/null +++ b/bridge/tests/native-credentials/enrollment.py @@ -0,0 +1,69 @@ +"""Exercise the shipped operator preview/apply path, never fabricated activation.""" + +import json + +from native_api import BRIDGE, CORE, ROOT, WRITER, Failure, command, core, private_file, require, resource, uid, until + +CLI = ROOT / ".native/core/cli/dist/index.js" + + +def enroll(setup, namespace, writer, keys): + require(CLI.is_file(), "The exact core CLI must be built before native enrollment") + path = resource(namespace, "karscredentialgrants", "workspace") + require(setup.admin.optional(path) is None, "Native enrollment refuses an existing grant") + workspace = setup.admin.get(f"/api/v1/namespaces/{namespace}") + current_writer = setup.admin.get(core(BRIDGE, "serviceaccounts", WRITER)) + require(uid(current_writer) == uid(writer), "Native writer UID changed before operator review") + definition = json.loads((ROOT / ".native/core/deploy/helm/kars/files/private-consumption.json").read_text()) + for name in definition["controllers"]: + until( + f"actual workload-controller account {name}", + lambda name=name: setup.admin.optional(core("kube-system", "serviceaccounts", name)), + 90, + ) + + args = [ + "node", str(CLI), "credentials", "grant", "preview", + "--namespace", namespace, "--writer", f"{BRIDGE}/{WRITER}", + "--private-root", CORE, "--private-controller-profile", "service-accounts", + "--private-consumer", f"{BRIDGE}/Deployment/kars-bridge-bff", + ] + for key in keys: + args.extend(["--agent-key", key]) + try: + reviewed = json.loads(command(*args, timeout=180)) + except json.JSONDecodeError: + raise Failure("Core operator preview did not return a JSON document") from None + require( + isinstance(reviewed, dict) + and reviewed.get("apiVersion") == "kars.azure.com/v1alpha1" + and reviewed.get("kind") == "KarsCredentialGrant" + and reviewed.get("metadata") == {"name": "workspace", "namespace": namespace}, + "Operator preview did not describe one exclusive native grant", + ) + spec = reviewed.get("spec", {}) + expected_writer = {"namespace": BRIDGE, "name": WRITER, "uid": uid(writer)} + require( + isinstance(spec, dict) + and spec.get("workspaceUid") == uid(workspace) + and spec.get("writers") == [expected_writer] + and spec.get("agentKeys") == keys + and isinstance(spec.get("privateActivation"), dict) + and spec.get("privateActivation", {}).get("phase") == "reviewed", + "Operator preview did not bind the requested native identities and keys", + ) + review_file = private_file(f"grant-review-{namespace}.json", json.dumps(reviewed)) + command("node", str(CLI), "credentials", "grant", "apply", str(review_file), timeout=360) + grant = setup.ready_grant(namespace) + recorded = grant.get("spec", {}) + activation = recorded.get("privateActivation") if isinstance(recorded, dict) else None + require( + isinstance(recorded, dict) + and recorded.get("workspaceUid") == uid(workspace) + and recorded.get("writers") == [expected_writer] + and recorded.get("agentKeys") == keys + and isinstance(activation, dict) + and activation.get("phase") == "qualified", + "The recorded native grant differs from its operator review", + ) + return grant diff --git a/bridge/tests/native-credentials/native_api.py b/bridge/tests/native-credentials/native_api.py index 3b6e8a721..673e76181 100644 --- a/bridge/tests/native-credentials/native_api.py +++ b/bridge/tests/native-credentials/native_api.py @@ -219,18 +219,8 @@ def account(self, namespace, name): }) def grant(self, namespace, writer, keys=None): - workspace = self.admin.get(f"/api/v1/namespaces/{namespace}") - result = self.admin.create(resource(namespace, "karscredentialgrants"), { - "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsCredentialGrant", - "metadata": {"name": "workspace", "namespace": namespace}, - "spec": {"workspaceUid": uid(workspace), "enabled": True, - "writers": [{"namespace": BRIDGE, "name": WRITER, "uid": uid(writer)}], - "agentKeys": keys or ["SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN"], - "integrationStores": [], "legacyImports": [], - "observationTargets": [], "githubConnections": []}, - }) - self.ready_grant(namespace) - return result + from enrollment import enroll + return enroll(self, namespace, writer, keys or ["SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN"]) def ready_grant(self, namespace): def ready(): diff --git a/bridge/tests/native-credentials/test_enrollment.py b/bridge/tests/native-credentials/test_enrollment.py new file mode 100644 index 000000000..6727b1be6 --- /dev/null +++ b/bridge/tests/native-credentials/test_enrollment.py @@ -0,0 +1,144 @@ +"""Operator enrollment transport checks, not live native authority evidence.""" + +import copy +import json +from pathlib import Path +import tempfile +import types +import unittest +from unittest.mock import Mock, patch + +import enrollment +from native_api import BRIDGE, CORE, WRITER, Failure, core, resource + + +class EnrollmentTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="bridge-enrollment-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.cli = self.root / ".native/core/cli/dist/index.js" + self.cli.parent.mkdir(parents=True) + self.cli.touch() + definition = self.root / ".native/core/deploy/helm/kars/files/private-consumption.json" + definition.parent.mkdir(parents=True) + definition.write_text(json.dumps({"controllers": ["deployment-controller", "replicaset-controller"]})) + self.namespace = "native-workspace" + self.keys = ["SLACK_BOT_TOKEN"] + self.writer = {"metadata": {"name": WRITER, "namespace": BRIDGE, "uid": "writer"}} + self.review = { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsCredentialGrant", + "metadata": {"name": "workspace", "namespace": self.namespace}, + "spec": {"workspaceUid": "workspace", "enabled": True, + "writers": [{"namespace": BRIDGE, "name": WRITER, "uid": "writer"}], + "agentKeys": self.keys, + "privateActivation": {"phase": "reviewed"}}, + } + self.grant = copy.deepcopy(self.review) + self.grant["metadata"]["uid"] = "grant" + self.grant["spec"]["privateActivation"]["phase"] = "qualified" + self.commands, self.reads, self.review_files = [], [], [] + self.admin = types.SimpleNamespace(get=self.get, optional=self.optional) + self.setup = types.SimpleNamespace(admin=self.admin, ready_grant=lambda _namespace: self.grant) + + def get(self, path): + self.reads.append(path) + if path == "/api/v1/namespaces/" + self.namespace: + return {"metadata": {"name": self.namespace, "uid": "workspace"}} + if path == core(BRIDGE, "serviceaccounts", WRITER): + return self.writer + self.fail("Unexpected administrative read") + + def optional(self, path): + self.reads.append(path) + if path == resource(self.namespace, "karscredentialgrants", "workspace"): + return None + if path.startswith("/api/v1/namespaces/kube-system/serviceaccounts/"): + return {"metadata": {"uid": "real-profile-account"}} + self.fail("Unexpected optional read") + + def command(self, *args, **kwargs): + self.commands.append((args, kwargs)) + if args[4] == "preview": + return json.dumps(self.review) + self.assertEqual(args[4], "apply") + self.assertEqual(json.loads(Path(args[5]).read_text()), self.review) + return "Reviewed grant recorded" + + def private_file(self, name, data): + path = self.root / name + path.write_text(data) + path.chmod(0o600) + self.review_files.append(path) + return path + + def enroll(self): + with patch.object(enrollment, "ROOT", self.root), \ + patch.object(enrollment, "CLI", self.cli), \ + patch.object(enrollment, "command", side_effect=self.command), \ + patch.object(enrollment, "private_file", side_effect=self.private_file): + return enrollment.enroll(self.setup, self.namespace, self.writer, self.keys) + + def test_uses_real_public_preview_apply_and_then_controller_readiness(self): + self.assertEqual(self.enroll(), self.grant) + self.assertEqual(len(self.commands), 2) + preview, options = self.commands[0] + self.assertEqual(preview[:5], ("node", str(self.cli), "credentials", "grant", "preview")) + self.assertIn("--private-root", preview) + self.assertIn(CORE, preview) + self.assertIn("service-accounts", preview) + self.assertIn(f"{BRIDGE}/Deployment/kars-bridge-bff", preview) + self.assertEqual(preview[-2:], ("--agent-key", "SLACK_BOT_TOKEN")) + self.assertEqual(options["timeout"], 180) + self.assertEqual(self.commands[1][1]["timeout"], 360) + self.assertEqual(self.review["spec"]["privateActivation"]["phase"], "reviewed") + self.assertEqual(self.review_files[0].stat().st_mode & 0o777, 0o600) + + def test_existing_grant_is_not_adopted_or_updated(self): + self.admin.optional = lambda _path: self.grant + with self.assertRaises(Failure): + self.enroll() + self.assertEqual(self.commands, []) + + def test_operator_apply_failure_cannot_become_ready_or_a_direct_create_fallback(self): + self.setup.ready_grant = Mock(return_value=self.grant) + original = self.command + def command(*args, **kwargs): + if args[4] == "apply": + raise Failure("Operator activation failed") + return original(*args, **kwargs) + self.command = command + with self.assertRaisesRegex(Failure, "Operator activation failed"): + self.enroll() + self.setup.ready_grant.assert_not_called() + + def test_changed_review_identity_or_keys_never_reaches_apply(self): + for field, value in (("workspaceUid", "other"), ("writers", []), + ("agentKeys", []), ("privateActivation", {"phase": "qualified"}), + ("privateActivation", None)): + original = copy.deepcopy(self.review) + self.review["spec"][field] = value + self.commands.clear() + with self.subTest(field=field, value=value), self.assertRaises(Failure): + self.enroll() + self.assertEqual(len(self.commands), 1) + self.review = original + + def test_unqualified_or_changed_recorded_grant_is_rejected(self): + for field, value in (("workspaceUid", "other"), ("writers", []), + ("agentKeys", []), ("privateActivation", {"phase": "reviewed"})): + original = copy.deepcopy(self.grant) + self.grant["spec"][field] = value + with self.subTest(field=field), self.assertRaises(Failure): + self.enroll() + self.grant = original + + def test_missing_exact_core_cli_does_not_fall_back_to_fabricated_authority(self): + self.cli.unlink() + with self.assertRaises(Failure): + self.enroll() + self.assertEqual(self.commands, []) + + +if __name__ == "__main__": + unittest.main() From 99c84c0d61dc70a9b13aad51b65448dbd8d207fd Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:05:24 +0200 Subject: [PATCH 013/111] Expose secret-safe native operator failure stages Keep CLI stderr out of public logs while reporting fixed preview/apply categories and allowlisted source locations. Exercise actual failing subprocesses and redaction. Preserve real operator commands and all authority checks;104native harness and19gateway regressions passed locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/tests/native-credentials/enrollment.py | 7 ++- bridge/tests/native-credentials/native_api.py | 9 ++- .../operator_diagnostics.py | 51 +++++++++++++++++ .../native-credentials/test_enrollment.py | 3 +- .../test_operator_diagnostics.py | 56 +++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 bridge/tests/native-credentials/operator_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_operator_diagnostics.py diff --git a/bridge/tests/native-credentials/enrollment.py b/bridge/tests/native-credentials/enrollment.py index bdb443683..0eeadabcc 100644 --- a/bridge/tests/native-credentials/enrollment.py +++ b/bridge/tests/native-credentials/enrollment.py @@ -2,7 +2,8 @@ import json -from native_api import BRIDGE, CORE, ROOT, WRITER, Failure, command, core, private_file, require, resource, uid, until +from native_api import BRIDGE, CORE, ROOT, WRITER, Failure, core, private_file, require, resource, uid, until +from operator_diagnostics import operator_command CLI = ROOT / ".native/core/cli/dist/index.js" @@ -31,7 +32,7 @@ def enroll(setup, namespace, writer, keys): for key in keys: args.extend(["--agent-key", key]) try: - reviewed = json.loads(command(*args, timeout=180)) + reviewed = json.loads(operator_command("preview", *args, timeout=180)) except json.JSONDecodeError: raise Failure("Core operator preview did not return a JSON document") from None require( @@ -53,7 +54,7 @@ def enroll(setup, namespace, writer, keys): "Operator preview did not bind the requested native identities and keys", ) review_file = private_file(f"grant-review-{namespace}.json", json.dumps(reviewed)) - command("node", str(CLI), "credentials", "grant", "apply", str(review_file), timeout=360) + operator_command("apply", "node", str(CLI), "credentials", "grant", "apply", str(review_file), timeout=360) grant = setup.ready_grant(namespace) recorded = grant.get("spec", {}) activation = recorded.get("privateActivation") if isinstance(recorded, dict) else None diff --git a/bridge/tests/native-credentials/native_api.py b/bridge/tests/native-credentials/native_api.py index 673e76181..a025148ce 100644 --- a/bridge/tests/native-credentials/native_api.py +++ b/bridge/tests/native-credentials/native_api.py @@ -23,6 +23,12 @@ class Failure(Exception): """Only static, secret-free diagnostics may enter this exception.""" +class CommandFailure(Failure): + def __init__(self, executable, code, stderr): + super().__init__(f"Setup command {executable} failed ({code})") + self.stderr = stderr + + def require(condition, message): if not condition: raise Failure(message) @@ -33,7 +39,8 @@ def command(*args, stdin=None, timeout=180): args, input=stdin, cwd=ROOT, capture_output=True, text=True, timeout=timeout, check=False, ) - require(result.returncode == 0, f"Setup command {args[0]} failed ({result.returncode})") + if result.returncode != 0: + raise CommandFailure(args[0], result.returncode, result.stderr) return result.stdout diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py new file mode 100644 index 000000000..6a9816ab6 --- /dev/null +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -0,0 +1,51 @@ +"""Project only fixed categories and allowlisted source locations from CLI errors.""" + +import re + +from native_api import CommandFailure, Failure, command + +ERRORS = { + "Private admission differs from the complete required bundle; upgrade core prerequisites before enrollment": "admission-bundle-mismatch", + "Private admission is not currently observed and type-checked": "admission-not-observed", + "Private admission changed since review": "admission-review-changed", + "Private activation metadata is malformed": "malformed-metadata", + "Private activation inventory is malformed": "malformed-inventory", + "Private activation requires live API UID/resourceVersion identities": "missing-live-identity", + "Reviewed private namespace changed": "namespace-review-changed", + "Private activation staging requires the existing cluster-scoped credential operator authority": "operator-authority", + "Explicit credential-grant operator permission is required": "operator-authority", +} +MODULES = ( + "commands/credential-grants", "lib/private-activation", + "lib/private-activation-retirement", "lib/kube-bootstrap", "lib/kube-context", + "lib/repo-assets", +) + + +def category(stderr): + categories = {value for message, value in ERRORS.items() + if f"Error: {message}" in stderr.splitlines()} + return sorted(categories)[0] if categories else "unclassified-cli-error" + + +def source_location(stderr): + for line in stderr.splitlines(): + if not re.match(r"\s+at ", line): + continue + for module in MODULES: + match = re.search(r"/cli/dist/" + re.escape(module) + r"\.js:([1-9][0-9]{0,5}):[0-9]+\)?$", line) + if match: + return f"{module}:{match[1]}" + return "unavailable" + + +def operator_command(stage, *args, timeout): + if stage not in ("preview", "apply"): + raise Failure("Unknown native operator enrollment stage") + try: + return command(*args, timeout=timeout) + except CommandFailure as error: + raise Failure( + f"Native operator {stage} failed: {category(error.stderr)} " + f"(source={source_location(error.stderr)})" + ) from None diff --git a/bridge/tests/native-credentials/test_enrollment.py b/bridge/tests/native-credentials/test_enrollment.py index 6727b1be6..4be355929 100644 --- a/bridge/tests/native-credentials/test_enrollment.py +++ b/bridge/tests/native-credentials/test_enrollment.py @@ -9,6 +9,7 @@ from unittest.mock import Mock, patch import enrollment +import operator_diagnostics from native_api import BRIDGE, CORE, WRITER, Failure, core, resource @@ -75,7 +76,7 @@ def private_file(self, name, data): def enroll(self): with patch.object(enrollment, "ROOT", self.root), \ patch.object(enrollment, "CLI", self.cli), \ - patch.object(enrollment, "command", side_effect=self.command), \ + patch.object(operator_diagnostics, "command", side_effect=self.command), \ patch.object(enrollment, "private_file", side_effect=self.private_file): return enrollment.enroll(self.setup, self.namespace, self.writer, self.keys) diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py new file mode 100644 index 000000000..340047fd8 --- /dev/null +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -0,0 +1,56 @@ +"""Run real subprocess failures and prove no CLI body is published.""" + +import io +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +import native_api +from native_api import Failure +from operator_diagnostics import ERRORS, category, operator_command, source_location + +PRIVATE = "DO-NOT-EMIT-TOKENS-OR-PRIVATE-API-BODIES" + + +class OperatorDiagnosticsTests(unittest.TestCase): + def test_actual_subprocess_failure_reports_only_stage_category_and_source_location(self): + error = next(iter(ERRORS)) + stderr = (f"{PRIVATE}\nError: {error}\n" + f" at verifyPrivateBundle (/private/{PRIVATE}/cli/dist/lib/private-activation.js:156:19)\n") + output = io.StringIO() + with tempfile.TemporaryDirectory(prefix="native-operator-error-") as directory, \ + patch.object(native_api, "ROOT", Path(directory)), redirect_stdout(output), redirect_stderr(output): + with self.assertRaises(Failure) as failure: + operator_command("preview", sys.executable, "-c", + "import sys; print(sys.argv[1],file=sys.stderr); sys.exit(1)", + stderr, timeout=5) + self.assertEqual(str(failure.exception), + "Native operator preview failed: admission-bundle-mismatch " + "(source=lib/private-activation:156)") + self.assertEqual(output.getvalue(), "") + self.assertNotIn(PRIVATE, str(failure.exception)) + + def test_error_bodies_cannot_create_arbitrary_categories_or_paths(self): + for message, expected in ERRORS.items(): + self.assertEqual(category("Error: " + message), expected) + self.assertEqual(category("Error: " + message + PRIVATE), "unclassified-cli-error") + self.assertEqual(category(PRIVATE), "unclassified-cli-error") + for line in ("/cli/dist/lib/private-activation.js:1:2", + f" at object (/private/{PRIVATE}.js:1:2)", + f" at object (/cli/dist/lib/private-activation.js:1:2){PRIVATE}"): + self.assertEqual(source_location(line), "unavailable") + + def test_success_and_unknown_stage_do_not_change_authority(self): + with tempfile.TemporaryDirectory(prefix="native-operator-success-") as directory, \ + patch.object(native_api, "ROOT", Path(directory)): + result = operator_command("preview", sys.executable, "-c", "print('review')", timeout=5) + self.assertEqual(result, "review\n") + with self.assertRaises(Failure): + operator_command("bypass", "never-run", timeout=1) + + +if __name__ == "__main__": + unittest.main() From 871277e9bb5f207987334b6a55192d1197ef5cce Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:08:37 +0200 Subject: [PATCH 014/111] Preserve manifest identity and honest remediation evidence Use versioned structured remediation IDs without folding Git path or package case. Reuse legacy IDs only with complete matching original source metadata, preserving history and links and surfacing ambiguity. PR-title matches now guide investigation rather than suppressing work or claiming delivery. Add framing, migration, state-continuity and title-only coverage regressions. Rust execution pending hosted CI; no local compile below disk floor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/engineering.rs | 136 +++--- .../bff/src/routes/engineering/remediation.rs | 155 +++++++ .../routes/engineering/remediation_tests.rs | 415 ++++++++++++++++++ bridge/docs/team-workflows.md | 20 + 4 files changed, 643 insertions(+), 83 deletions(-) create mode 100644 bridge/bff/src/routes/engineering/remediation.rs create mode 100644 bridge/bff/src/routes/engineering/remediation_tests.rs diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs index 303966bd0..951a4b063 100644 --- a/bridge/bff/src/routes/engineering.rs +++ b/bridge/bff/src/routes/engineering.rs @@ -25,6 +25,12 @@ use crate::routes::tasks::require_cluster; use crate::routes::teams::{TeamTaskDto, read_task_list, require_owned_team}; use crate::state::AppState; +mod remediation; +use remediation::{ + description_matches_remediation, match_remediation_task, note_candidate_pulls, + remediation_work_id, +}; + const CONFIG_KEY: &str = "config.json"; const CURSOR_KEY: &str = "cursor.json"; const STATUS_KEY: &str = "status.json"; @@ -658,7 +664,7 @@ fn is_dependabot_pr(pr: &GithubPull) -> bool { }) || pr.head.name.to_ascii_lowercase().starts_with("dependabot/") } -fn open_pull_covers_dependabot_alert(pr: &GithubPull, alert: &GithubDependabotAlert) -> bool { +fn open_pull_may_address_dependabot_alert(pr: &GithubPull, alert: &GithubDependabotAlert) -> bool { let haystack = format!("{} {}", pr.title, pr.head.name).to_ascii_lowercase(); if alert .security_advisory @@ -753,47 +759,6 @@ fn alert_work_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { format!("{}-{}", signal_slug(signal), hex::encode(&digest[..10])) } -fn remediation_work_id(repo: &str, manifest_path: Option<&str>, package: &str) -> String { - let identity = format!( - "{}:{}:{}", - repo.to_ascii_lowercase(), - manifest_path.unwrap_or("unknown").to_ascii_lowercase(), - package.to_ascii_lowercase() - ); - let digest = Sha256::digest(identity.as_bytes()); - format!("dependency-remediation-{}", hex::encode(&digest[..10])) -} - -fn description_matches_remediation( - description: &str, - repo: &str, - manifest_path: Option<&str>, - package: &str, -) -> bool { - let lower = description.to_ascii_lowercase(); - let repo = repo.to_ascii_lowercase(); - let package = package.to_ascii_lowercase(); - let repo_match = - lower.contains(&format!("repo={repo};")) || lower.contains(&format!("\"repo\":\"{repo}\"")); - let package_match = lower.contains(&format!("pkg={package};")) - || lower.contains(&format!("package={package};")) - || lower.contains(&format!("\"package\":\"{package}\"")); - let manifest_match = match manifest_path { - Some(manifest) => { - let manifest = manifest.to_ascii_lowercase(); - lower.contains(&format!("manifest={manifest};")) - || lower.contains(&format!("manifest_path={manifest};")) - || lower.contains(&format!("\"manifest_path\":\"{manifest}\"")) - } - None => { - lower.contains("manifest=unknown;") - || lower.contains("manifest_path=unknown;") - || lower.contains("\"manifest_path\":null") - } - }; - repo_match && package_match && manifest_match -} - fn legacy_alert_retirement(id: &str, remediation_id: &str, created_at: &str) -> TeamTaskDto { TeamTaskDto { id: id.to_string(), @@ -1021,8 +986,13 @@ fn merge_discovered_tasks( .map(|(index, task)| (task.id.clone(), index)) .collect::<BTreeMap<_, _>>(); let mut added = 0; - for task in discovered { - if let Some(index) = positions.get(&task.id).copied() { + for mut task in discovered { + let (matching_id, _) = match_remediation_task(&mut task, |id| { + positions + .get(id) + .map(|index| existing[*index].description.as_str()) + }); + if let Some(index) = positions.get(&matching_id).copied() { let current = &mut existing[index]; let renewable_alert = task.id.starts_with("dependabot-alert-") || task.id.starts_with("code-scanning-alert-") @@ -1085,11 +1055,16 @@ fn append_bounded_tasks( attempt_cap: usize, ) -> bool { let mut queue_candidates = Vec::new(); - for task in incoming { + for mut task in incoming { + let (matching_id, _) = match_remediation_task(&mut task, |id| { + known_tasks + .get(id) + .map(|(_, description)| description.as_str()) + }); let renewable_alert = task.id.starts_with("dependabot-alert-") || task.id.starts_with("code-scanning-alert-") || task.id.starts_with("secret-scanning-alert-"); - match known_tasks.get(&task.id) { + match known_tasks.get(&matching_id) { None => { known_tasks.insert( task.id.clone(), @@ -1667,12 +1642,12 @@ fn dedupe_followup_task( Some(TeamTaskDto { id: format!("github-pr-dedupe-{}", hex::encode(&digest[..10])), title: format!( - "[PR dedupe] Keep {repo} PR #{} and retire {} duplicate(s)", + "[PR dedupe] Review {repo} PR #{} and {} possible duplicate(s)", canonical.number, ordered.len() - 1 ), description: format!( - "Multiple open pull requests cover the same canonical remediation. Verify equivalent scope and preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence. Close superseded duplicates, never merge, and report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", + "Multiple open pull requests mention this remediation's package or advisory. Their titles are not coverage evidence. Compare actual changed files with the exact case-sensitive manifest, package, advisory and head-SHA checks before treating any work as equivalent. Preserve distinct manifest fixes. Only after equivalence is verified, preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence and close superseded duplicates. Never merge; report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", canonical.number, canonical.html_url, duplicates ), depends_on: Vec::new(), @@ -1955,6 +1930,15 @@ async fn perform_sync( let mut signal_tasks = Vec::new(); for alert in &alerts.items { let mut task = dependabot_alert_task(repo, alert, &now); + let (matching_id, identity_warning) = + match_remediation_task(&mut task, |id| { + known_tasks + .get(id) + .map(|(_, description)| description.as_str()) + }); + if let Some(warning) = identity_warning { + errors.push(warning); + } let legacy_ids = known_tasks .iter() .filter(|(id, (status, description))| { @@ -1971,7 +1955,7 @@ async fn perform_sync( .collect::<Vec<_>>(); for legacy_id in legacy_ids { let retirement = - legacy_alert_retirement(&legacy_id, &task.id, &now); + legacy_alert_retirement(&legacy_id, &matching_id, &now); known_tasks.insert( legacy_id, ("done".into(), retirement.description.clone()), @@ -1980,38 +1964,22 @@ async fn perform_sync( } let covering_pulls = open_pull_coverage .iter() - .filter(|pull| open_pull_covers_dependabot_alert(pull, alert)) + .filter(|pull| { + open_pull_may_address_dependabot_alert(pull, alert) + }) .collect::<Vec<_>>(); - if dedupe_seen.insert(task.id.clone()) - && let Some(dedupe) = - dedupe_followup_task(repo, &task.id, &covering_pulls, &now) + if dedupe_seen.insert(matching_id.clone()) + && let Some(dedupe) = dedupe_followup_task( + repo, + &matching_id, + &covering_pulls, + &now, + ) { tasks.push(dedupe); } - if let Some(pull) = covering_pulls - .iter() - .min_by_key(|pull| pull.number) - .copied() - { - if known_tasks - .get(&task.id) - .is_some_and(|(status, _)| status == "pending") - { - task.status = "done".into(); - task.done_at = Some(now.clone()); - task.description.push_str(&format!( - "\n\nCovered by existing open PR #{}: {}", - pull.number, pull.html_url - )); - known_tasks.insert( - task.id.clone(), - ("done".into(), task.description.clone()), - ); - tasks.push(task); - } - } else { - signal_tasks.push(task); - } + note_candidate_pulls(&mut task, &covering_pulls); + signal_tasks.push(task); } let bounded = append_bounded_tasks( &mut tasks, @@ -2766,7 +2734,7 @@ pub fn spawn_poller(state: AppState, sweep_interval: Duration) { mod tests { use super::*; - fn pull(login: &str, head: &str, number: u64) -> GithubPull { + pub(super) fn pull(login: &str, head: &str, number: u64) -> GithubPull { GithubPull { number, html_url: format!("https://github.com/acme/api/pull/{number}"), @@ -2863,7 +2831,9 @@ mod tests { fn legacy_remediation_matching_is_exact_and_repo_scoped() { let description = concat!( "AUTH SOURCE: manifest=package-lock.json; pkg=react-dom; ghsa=GHSA-a. ", - "Structured: {\"repo\":\"acme/web\",\"details\":{\"manifest_path\":\"package-lock.json\",", + "\n\nStructured source details (JSON):\n", + "{\"signal\":\"dependabot_alert\",\"work_id\":\"dependabot-alert-legacy\",", + "\"repo\":\"acme/web\",\"details\":{\"manifest_path\":\"package-lock.json\",", "\"package\":\"react-dom\"}}" ); assert!(description_matches_remediation( @@ -2908,7 +2878,7 @@ mod tests { } #[test] - fn open_pull_covers_same_package_or_advisory() { + fn open_pull_candidates_mention_the_same_package_or_advisory() { let alert = GithubDependabotAlert { number: 17, html_url: "https://github.com/acme/api/security/dependabot/17".into(), @@ -2936,12 +2906,12 @@ mod tests { }; let mut package_pr = pull("agent", "fix-babel-core", 42); package_pr.title = "chore: bump @babel/core to 8.0.0".into(); - assert!(open_pull_covers_dependabot_alert(&package_pr, &alert)); + assert!(open_pull_may_address_dependabot_alert(&package_pr, &alert)); let mut advisory_pr = pull("agent", "security-fix", 43); advisory_pr.title = "fix GHSA-aaaa-bbbb-cccc".into(); - assert!(open_pull_covers_dependabot_alert(&advisory_pr, &alert)); + assert!(open_pull_may_address_dependabot_alert(&advisory_pr, &alert)); let unrelated = pull("agent", "fix-vite", 44); - assert!(!open_pull_covers_dependabot_alert(&unrelated, &alert)); + assert!(!open_pull_may_address_dependabot_alert(&unrelated, &alert)); } #[test] diff --git a/bridge/bff/src/routes/engineering/remediation.rs b/bridge/bff/src/routes/engineering/remediation.rs new file mode 100644 index 000000000..a1ea3bfda --- /dev/null +++ b/bridge/bff/src/routes/engineering/remediation.rs @@ -0,0 +1,155 @@ +// kars Bridge BFF — remediation identity and compatibility with persisted intake. + +use sha2::{Digest, Sha256}; + +use super::{GithubPull, TeamTaskDto}; + +const SOURCE_MARKER: &str = "\n\nStructured source details (JSON):\n"; + +#[derive(Debug, PartialEq, Eq)] +struct RemediationIdentity { + repo: String, + manifest_path: Option<String>, + package: String, +} + +impl RemediationIdentity { + fn new(repo: &str, manifest_path: Option<&str>, package: &str) -> Self { + Self { + repo: repo.to_ascii_lowercase(), + manifest_path: manifest_path.map(str::to_string), + package: package.to_string(), + } + } + + fn work_id(&self) -> String { + let framed = serde_json::json!([2, self.repo, self.manifest_path, self.package]); + let digest = Sha256::digest(framed.to_string().as_bytes()); + format!("dependency-remediation-v2-{}", hex::encode(&digest[..10])) + } + + // Only locates potentially related history; this lossy hash is never proof of equivalence. + fn legacy_work_id(&self) -> String { + let identity = format!( + "{}:{}:{}", + self.repo, + self.manifest_path + .as_deref() + .unwrap_or("unknown") + .to_ascii_lowercase(), + self.package.to_ascii_lowercase() + ); + let digest = Sha256::digest(identity.as_bytes()); + format!("dependency-remediation-{}", hex::encode(&digest[..10])) + } +} + +pub(super) fn remediation_work_id( + repo: &str, + manifest_path: Option<&str>, + package: &str, +) -> String { + RemediationIdentity::new(repo, manifest_path, package).work_id() +} + +fn stored_identity(description: &str) -> Option<(String, RemediationIdentity)> { + let (_, source) = description.split_once(SOURCE_MARKER)?; + if source.contains(SOURCE_MARKER) { + return None; + } + // Coverage notes may follow the original JSON. Read that object, not prose or substrings. + let source = serde_json::Deserializer::from_str(source) + .into_iter::<serde_json::Value>() + .next()? + .ok()?; + if source.get("signal")?.as_str()? != "dependabot_alert" { + return None; + } + let work_id = source.get("work_id")?.as_str()?; + let repo = source.get("repo")?.as_str()?; + let details = source.get("details")?; + let package = details.get("package")?.as_str()?; + let manifest_path = match details.get("manifest_path")? { + serde_json::Value::Null => None, + serde_json::Value::String(path) => Some(path.as_str()), + _ => return None, + }; + if work_id.is_empty() || repo.is_empty() || package.is_empty() { + return None; + } + Some(( + work_id.to_string(), + RemediationIdentity::new(repo, manifest_path, package), + )) +} + +pub(super) fn description_matches_remediation( + description: &str, + repo: &str, + manifest_path: Option<&str>, + package: &str, +) -> bool { + stored_identity(description).is_some_and(|(_, identity)| { + identity == RemediationIdentity::new(repo, manifest_path, package) + }) +} + +pub(super) fn note_candidate_pulls(task: &mut TeamTaskDto, pulls: &[&GithubPull]) { + if pulls.is_empty() { + return; + } + task.description.push_str( + "\n\nOpen PR candidates mention this package or advisory; their titles are not \ + evidence of coverage. Before creating duplicate work, compare their actual diff \ + with this exact case-sensitive manifest and validate fixes and checks at the \ + current head SHA. Reuse or revise a genuinely matching PR rather than opening \ + another. Do not treat this remediation as delivered merely because a PR exists.", + ); + for pull in pulls { + task.description + .push_str(&format!("\n#{} {}", pull.number, pull.html_url)); + } +} + +// Resolve only incoming v2 work. Existing rows and their run/receipt links remain untouched. +pub(super) fn match_remediation_task<'a>( + task: &mut TeamTaskDto, + description_for_id: impl Fn(&str) -> Option<&'a str>, +) -> (String, Option<String>) { + if !task.id.starts_with("dependency-remediation-v2-") { + return (task.id.clone(), None); + } + let Some((source_work_id, identity)) = stored_identity(&task.description) else { + return (task.id.clone(), None); + }; + if source_work_id != task.id || identity.work_id() != task.id { + return (task.id.clone(), None); + } + let legacy_id = identity.legacy_work_id(); + let Some(description) = description_for_id(&legacy_id) else { + return (task.id.clone(), None); + }; + match stored_identity(description) { + Some((stored_work_id, stored)) if stored_work_id == legacy_id => { + if stored == identity && description_for_id(&task.id).is_none() { + return (legacy_id, None); + } + (task.id.clone(), None) + } + _ => { + let warning = format!( + "Remediation identity ambiguity: legacy task {legacy_id} lacks complete, attributable original repository/package/manifest metadata. Its history is preserved, but it is not evidence of delivery for {}. Track this versioned work separately; inspect the legacy run and receipts before making changes.", + task.id + ); + if !task.description.contains(&warning) { + task.description.push_str("\n\n"); + task.description.push_str(&warning); + } + (task.id.clone(), Some(warning)) + } + } +} + +#[cfg(test)] +#[path = "remediation_tests.rs"] +mod tests; diff --git a/bridge/bff/src/routes/engineering/remediation_tests.rs b/bridge/bff/src/routes/engineering/remediation_tests.rs new file mode 100644 index 000000000..2dc814950 --- /dev/null +++ b/bridge/bff/src/routes/engineering/remediation_tests.rs @@ -0,0 +1,415 @@ +use super::super::{ + GithubDependabotAlert, append_bounded_tasks, dependabot_alert_task, merge_discovered_tasks, +}; +use super::*; +use std::collections::BTreeMap; + +fn discovered(repo: &str, path: Option<&str>, package: &str, number: u64) -> TeamTaskDto { + let alert: GithubDependabotAlert = serde_json::from_value(serde_json::json!({ + "number": number, + "html_url": format!("https://github.com/{repo}/security/dependabot/{number}"), + "dependency": { + "package": {"ecosystem": "npm", "name": package}, + "manifest_path": path + }, + "security_vulnerability": {"vulnerable_version_range": "< 8"} + })) + .unwrap(); + dependabot_alert_task(repo, &alert, "2026-09-11T00:00:00Z") +} + +fn legacy(repo: &str, path: Option<&str>, package: &str, status: &str) -> TeamTaskDto { + let mut task = discovered(repo, path, package, 17); + let id = RemediationIdentity::new(repo, path, package).legacy_work_id(); + task.description = task.description.replace(&task.id, &id); + task.id = id; + task.status = status.into(); + task.run = Some("original-remediation-run".into()); + task.assignment_nonce = Some("original-assignment".into()); + task.depends_on = vec!["original-pr-assessment".into()]; + task.acceptance_criteria = vec!["Preserve exact-SHA verification receipt".into()]; + task.done_at = (status == "done").then(|| "2026-09-11T01:00:00Z".into()); + task.stuck_since = (status == "stuck").then(|| "2026-09-11T00:30:00Z".into()); + task +} + +fn snapshot(task: &TeamTaskDto) -> serde_json::Value { + serde_json::to_value(task).unwrap() +} + +#[test] +fn v2_ids_are_stable_repo_normalized_and_kubernetes_bounded() { + let expected = "dependency-remediation-v2-f06d77fbff81b7207777"; + assert_eq!( + remediation_work_id( + "Acme/API", + Some("Services/package-lock.json"), + "@babel/core" + ), + expected + ); + assert_eq!( + remediation_work_id( + "acme/api", + Some("Services/package-lock.json"), + "@babel/core" + ), + expected + ); + assert_eq!( + remediation_work_id("acme/api", None, "@babel/core"), + "dependency-remediation-v2-6a4b58166374d7af50e5" + ); + let long_path = "Services/".repeat(200); + let id = remediation_work_id("acme/api", Some(&long_path), "@babel/core"); + assert_eq!(id.len(), expected.len()); + assert!(id.len() <= 63); + assert!( + id.bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + ); +} + +#[test] +fn v2_identity_preserves_git_path_and_package_case() { + let upper = discovered("acme/api", Some("Services/package-lock.json"), "vite", 17); + let lower = discovered("acme/api", Some("services/package-lock.json"), "vite", 18); + assert_ne!(upper.id, lower.id); + assert_eq!( + upper.id, + remediation_work_id("acme/api", Some("Services/package-lock.json"), "vite") + ); + assert_ne!( + remediation_work_id("acme/api", Some("pom.xml"), "Artifact"), + remediation_work_id("acme/api", Some("pom.xml"), "artifact") + ); + let (merged, queued) = merge_discovered_tasks(Vec::new(), vec![upper, lower]); + assert_eq!(queued, 2); + assert_eq!(merged.len(), 2); +} + +#[test] +fn title_only_pr_candidates_cannot_complete_or_suppress_distinct_manifest_work() { + let candidate_pr = super::super::tests::pull("agent", "fix-vite", 42); + let mut tasks = vec![ + discovered("acme/api", Some("Services/package-lock.json"), "vite", 17), + discovered("acme/api", Some("services/package-lock.json"), "vite", 18), + ]; + for task in &mut tasks { + note_candidate_pulls(task, &[&candidate_pr]); + assert_eq!(task.status, "pending"); + assert!(task.done_at.is_none()); + assert!(task.run.is_none()); + assert!( + task.description + .contains("their titles are not evidence of coverage") + ); + assert!(task.description.contains("exact case-sensitive manifest")); + } + let mut admitted = Vec::new(); + let mut known = BTreeMap::new(); + let mut slots = 0; + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + tasks, + &mut slots, + 2 + )); + assert_eq!(slots, 2); + let (merged, queued) = merge_discovered_tasks(Vec::new(), admitted); + assert_eq!(queued, 2); + assert_eq!(merged.len(), 2); + assert!(merged.iter().all(|task| task.status == "pending")); +} + +#[test] +fn structured_framing_distinguishes_null_empty_unknown_and_delimiters() { + let pairs = [ + ((None, "pkg"), (Some("unknown"), "pkg")), + ((None, "pkg"), (Some(""), "pkg")), + ((Some("a:b"), "c"), (Some("a"), "b:c")), + ((Some("a\"/b"), "c"), (Some("a"), "\"/b:c")), + ]; + for ((left_path, left_package), (right_path, right_package)) in pairs { + assert_ne!( + remediation_work_id("acme/api", left_path, left_package), + remediation_work_id("acme/api", right_path, right_package) + ); + } + assert_eq!( + RemediationIdentity::new("acme/api", Some("a:b"), "c").legacy_work_id(), + RemediationIdentity::new("acme/api", Some("a"), "b:c").legacy_work_id() + ); +} + +#[test] +fn proven_legacy_work_resumes_without_changing_state_or_existing_links() { + for status in ["pending", "active", "done", "stuck"] { + let original = legacy( + "Acme/API", + Some("Services/package-lock.json"), + "vite", + status, + ); + let mut linked = discovered("acme/api", Some("consumer/package-lock.json"), "vite", 19); + linked.depends_on = vec![original.id.clone()]; + let before = vec![snapshot(&original), snapshot(&linked)]; + let candidate = discovered("acme/api", Some("Services/package-lock.json"), "vite", 99); + let (merged, queued) = merge_discovered_tasks(vec![original, linked], vec![candidate]); + assert_eq!(queued, 0, "{status}"); + assert_eq!(merged.iter().map(snapshot).collect::<Vec<_>>(), before); + } +} + +#[test] +fn legacy_explicit_null_manifest_can_resume_but_unknown_is_distinct() { + let original = legacy("acme/api", None, "vite", "done"); + let before = snapshot(&original); + let (merged, queued) = merge_discovered_tasks( + vec![original], + vec![discovered("ACME/API", None, "vite", 99)], + ); + assert_eq!(queued, 0); + assert_eq!(snapshot(&merged[0]), before); + let (merged, queued) = merge_discovered_tasks( + merged, + vec![discovered("acme/api", Some("unknown"), "vite", 100)], + ); + assert_eq!(queued, 1); + assert_eq!(merged.len(), 2); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(merged[1].status, "pending"); +} + +#[test] +fn distinct_completed_legacy_manifest_does_not_swallow_new_work() { + for (original_path, new_path) in [ + ("Services/package-lock.json", "services/package-lock.json"), + ("services/package-lock.json", "Services/package-lock.json"), + ] { + let original = legacy("acme/api", Some(original_path), "vite", "done"); + let before = snapshot(&original); + let candidate = discovered("acme/api", Some(new_path), "vite", 18); + let candidate_before = snapshot(&candidate); + let (merged, queued) = merge_discovered_tasks(vec![original], vec![candidate.clone()]); + assert_eq!(queued, 1); + assert_eq!(merged.len(), 2); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(snapshot(&merged[1]), candidate_before); + let (repeated, queued) = merge_discovered_tasks(merged, vec![candidate]); + assert_eq!(queued, 0); + assert_eq!(repeated.len(), 2); + assert_eq!(snapshot(&repeated[0]), before); + } +} + +#[test] +fn colon_colliding_legacy_work_does_not_absorb_a_different_framed_identity() { + let original = legacy("acme/api", Some("a:b"), "c", "done"); + let before = snapshot(&original); + let candidate = discovered("acme/api", Some("a"), "b:c", 18); + let (merged, queued) = merge_discovered_tasks(vec![original], vec![candidate]); + assert_eq!(queued, 1); + assert_eq!(merged.len(), 2); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(merged[1].status, "pending"); +} + +#[test] +fn incomplete_or_unattributed_legacy_metadata_preserves_history_and_warns() { + let original = legacy("acme/api", Some("package-lock.json"), "vite", "done"); + let complete = serde_json::json!({ + "signal": "dependabot_alert", + "work_id": original.id, + "repo": "acme/api", + "details": {"manifest_path": "package-lock.json", "package": "vite"} + }); + let mut missing_manifest = complete.clone(); + missing_manifest["details"] + .as_object_mut() + .unwrap() + .remove("manifest_path"); + let mut missing_repo = complete.clone(); + missing_repo.as_object_mut().unwrap().remove("repo"); + let mut wrong_work_id = complete.clone(); + wrong_work_id["work_id"] = serde_json::json!("unrelated-task"); + let mut wrong_signal = complete.clone(); + wrong_signal["signal"] = serde_json::json!("code_scanning_alert"); + let mut wrong_manifest_type = complete; + wrong_manifest_type["details"]["manifest_path"] = serde_json::json!(42); + let descriptions = [ + "Historical result without original source metadata".into(), + "repo=acme/api; manifest=package-lock.json; pkg=vite;".into(), + format!("{SOURCE_MARKER}{missing_manifest}"), + format!("{SOURCE_MARKER}{missing_repo}"), + format!("{SOURCE_MARKER}{wrong_work_id}"), + format!("{SOURCE_MARKER}{wrong_signal}"), + format!("{SOURCE_MARKER}{wrong_manifest_type}"), + format!("{SOURCE_MARKER}{{malformed"), + ]; + for description in descriptions { + let mut original = original.clone(); + original.description = description; + let before = snapshot(&original); + let candidate = discovered("acme/api", Some("package-lock.json"), "vite", 99); + let (merged, queued) = merge_discovered_tasks(vec![original], vec![candidate.clone()]); + assert_eq!(queued, 1); + assert_eq!(merged.len(), 2); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(merged[1].status, "pending"); + assert_eq!(merged[1].id, candidate.id); + assert!( + merged[1] + .description + .contains("Remediation identity ambiguity:") + ); + assert!(merged[1].description.contains("not evidence of delivery")); + let (repeated, queued) = merge_discovered_tasks(merged, vec![candidate]); + assert_eq!(queued, 0); + assert_eq!(repeated.len(), 2); + assert_eq!(snapshot(&repeated[0]), before); + assert_eq!( + repeated[1] + .description + .matches("Remediation identity ambiguity:") + .count(), + 1 + ); + } +} + +#[test] +fn matching_reads_original_json_not_case_folded_or_mixed_prose() { + let mut task = legacy( + "Acme/API", + Some("Services/package-lock.json"), + "vite", + "active", + ); + task.description.push_str( + "\n\nCovered by existing open PR #42. manifest=services/package-lock.json; pkg=other;", + ); + assert!(description_matches_remediation( + &task.description, + "acme/api", + Some("Services/package-lock.json"), + "vite" + )); + assert!(!description_matches_remediation( + &task.description, + "acme/api", + Some("services/package-lock.json"), + "vite" + )); + assert!(!description_matches_remediation( + &task.description, + "acme/api", + Some("Services/package-lock.json"), + "other" + )); + task.description.push_str(SOURCE_MARKER); + task.description.push_str("{}"); + assert!(stored_identity(&task.description).is_none()); +} + +#[test] +fn admission_reuses_proven_legacy_work_without_spending_queue_capacity() { + let original = legacy( + "acme/api", + Some("Services/package-lock.json"), + "vite", + "active", + ); + let before = snapshot(&original); + let mut known = BTreeMap::from([( + original.id.clone(), + (original.status.clone(), original.description.clone()), + )]); + let mut admitted = Vec::new(); + let mut slots = 0; + let distinct = discovered("acme/api", Some("services/package-lock.json"), "vite", 18); + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + vec![ + discovered("acme/api", Some("Services/package-lock.json"), "vite", 99), + distinct.clone(), + ], + &mut slots, + 1 + )); + assert_eq!(slots, 1); + assert_eq!(admitted.len(), 1); + assert_eq!(admitted[0].id, distinct.id); + let (merged, queued) = merge_discovered_tasks(vec![original], admitted); + assert_eq!(queued, 1); + assert_eq!(snapshot(&merged[0]), before); +} + +#[test] +fn ambiguity_is_reported_even_if_versioned_work_already_exists() { + let mut original = legacy("acme/api", Some("package-lock.json"), "vite", "done"); + original.description.clear(); + let mut candidate = discovered("acme/api", Some("package-lock.json"), "vite", 99); + let known = BTreeMap::from([ + (original.id.clone(), original.description.clone()), + (candidate.id.clone(), candidate.description.clone()), + ]); + for _ in 0..2 { + let (matching_id, warning) = + match_remediation_task(&mut candidate, |id| known.get(id).map(String::as_str)); + assert_eq!(matching_id, candidate.id); + assert!(warning.unwrap().contains(&original.id)); + } + assert_eq!( + candidate + .description + .matches("Remediation identity ambiguity:") + .count(), + 1 + ); +} + +#[test] +fn existing_v2_and_legacy_history_are_not_renamed_or_combined() { + let original = legacy("acme/api", Some("package-lock.json"), "vite", "done"); + let mut versioned = discovered("acme/api", Some("package-lock.json"), "vite", 99); + versioned.status = "active".into(); + versioned.run = Some("versioned-run".into()); + let before = vec![snapshot(&original), snapshot(&versioned)]; + let (merged, queued) = + merge_discovered_tasks(vec![original, versioned.clone()], vec![versioned]); + assert_eq!(queued, 0); + assert_eq!(merged.iter().map(snapshot).collect::<Vec<_>>(), before); +} + +#[test] +fn merge_rechecks_legacy_metadata_in_the_latest_persisted_state() { + let original = legacy( + "acme/api", + Some("Services/package-lock.json"), + "vite", + "done", + ); + let mut candidate = discovered("acme/api", Some("Services/package-lock.json"), "vite", 99); + let candidate_id = candidate.id.clone(); + let (matching_id, warning) = match_remediation_task(&mut candidate, |id| { + (id == original.id).then_some(original.description.as_str()) + }); + assert_eq!(matching_id, original.id); + assert!(warning.is_none()); + assert_eq!(candidate.id, candidate_id); + let different = legacy( + "acme/api", + Some("services/package-lock.json"), + "vite", + "done", + ); + assert_eq!(different.id, original.id); + let before = snapshot(&different); + let (merged, queued) = merge_discovered_tasks(vec![different], vec![candidate]); + assert_eq!(queued, 1); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(merged[1].id, candidate_id); +} diff --git a/bridge/docs/team-workflows.md b/bridge/docs/team-workflows.md index 853abe71c..06612f0bf 100644 --- a/bridge/docs/team-workflows.md +++ b/bridge/docs/team-workflows.md @@ -42,6 +42,26 @@ Intent | Memory | Approved retained knowledge injected into later runs. | A replay of every raw conversation. | | Engineering intake | Repository signal discovery and backlog creation. | The activity timeline or the agents doing the work. | +### Engineering intake identity and existing PRs + +Dependabot remediation work uses a versioned identity containing the repository, +the exact case-sensitive manifest path, and package name. For example, +`Services/package-lock.json` and `services/package-lock.json` are different +targets. Missing manifest metadata is also different from a file named +`unknown`. Repository casing does not create duplicate work. + +Existing work IDs, runs, receipts, dependencies and history are not renamed. +Intake reuses an older remediation ID only when its retained structured source +metadata proves the same repository, manifest and package. If that evidence is +missing or ambiguous, the old history remains intact, the new versioned work is +tracked separately, and intake reports the ambiguity for review. + +A PR title mentioning the package or advisory is only a search hint. It does +not prove that the PR fixes this manifest, does not mark remediation delivered, +and does not suppress new work. The assigned agent must inspect the actual diff +and current head-SHA evidence, reusing a matching PR rather than creating a +duplicate. Distinct manifest fixes must not be closed as duplicates. + ## 1. Compose a team From **Workspace -> Teams -> Set up a team**, enter a standing charter. The From d799abc1095d42862e6ca059f7810167fcc0156d Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:18:25 +0200 Subject: [PATCH 015/111] Name the native consumer execution-drift diagnostic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/tests/native-credentials/operator_diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index 6a9816ab6..ce9d75bfd 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -12,6 +12,7 @@ "Private activation inventory is malformed": "malformed-inventory", "Private activation requires live API UID/resourceVersion identities": "missing-live-identity", "Reviewed private namespace changed": "namespace-review-changed", + "Consumer execution differs from the reviewed controller template; preserve it for explicit Pod review": "consumer-execution-drift", "Private activation staging requires the existing cluster-scoped credential operator authority": "operator-authority", "Explicit credential-grant operator permission is required": "operator-authority", } From 003735e8aba26273acca8ed6d0b97040684fa5a0 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:20:41 +0200 Subject: [PATCH 016/111] Extract bounded receipt verification module without changing evidence Move verifier types and implementation with exact-byte parity, retaining receipt API exports and test-only pin injection. Preserve all wire formats, trust checks and existing test inventory. Syntax/formatting checked; hosted Rust execution remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/receipts.rs | 625 +----------------- .../bff/src/routes/receipts/verification.rs | 619 +++++++++++++++++ 2 files changed, 628 insertions(+), 616 deletions(-) create mode 100644 bridge/bff/src/routes/receipts/verification.rs diff --git a/bridge/bff/src/routes/receipts.rs b/bridge/bff/src/routes/receipts.rs index 5ab8e9f7b..b9ebbf3ad 100644 --- a/bridge/bff/src/routes/receipts.rs +++ b/bridge/bff/src/routes/receipts.rs @@ -21,8 +21,17 @@ use crate::state::AppState; mod anchor; mod statement; +mod verification; pub(crate) use anchor::AnchorPins; +use verification::sha256_hex; +pub(crate) use verification::verify_log_integrity; +pub use verification::{ + CheckpointEvidence, Evidence, InclusionEvidence, LogIntegrity, VerifyCheck, VerifyResult, + verify_receipt, +}; +#[cfg(test)] +pub(crate) use verification::{verify_log_integrity_with_pins, verify_receipt_with_pins}; /// A DSSE signature line, browser-facing. #[derive(Debug, Serialize)] @@ -365,619 +374,3 @@ pub async fn compliance_pack( advisory, })) } - -/// The outcome of an in-browser cryptographic verification — performed -/// server-side against the controller's public key and any configured -/// independent pins, so an auditor gets a verdict and underlying evidence -/// without installing the CLI. -#[derive(Debug, Serialize)] -pub struct VerifyResult { - /// True only when every independent check passed. - pub verified: bool, - /// Ordered, human-readable checks with their pass/fail outcome. - pub checks: Vec<VerifyCheck>, - /// The actual artifacts behind the verdict — what an auditor inspects. - pub evidence: Evidence, -} - -#[derive(Debug, Serialize)] -pub struct VerifyCheck { - pub name: String, - pub passed: bool, - pub detail: String, - /// BUG-4: a check that is DISPLAYED but not cryptographically re-verified - /// here (e.g. the independent witness whose public key isn't published in - /// V0). Rendered as an advisory "shown, not verified" state — never a green - /// ✓ — so an auditor is not misled into thinking all items were checked. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub advisory: bool, - /// The value the proof expected (e.g. a recorded hash), when applicable. - #[serde(skip_serializing_if = "Option::is_none")] - pub expected: Option<String>, - /// The value the BFF independently computed, shown so the auditor sees the - /// two match (or don't) rather than trusting a green tick. - #[serde(skip_serializing_if = "Option::is_none")] - pub computed: Option<String>, -} - -/// The evidence artifacts an auditor inspects — the signed payload, the -/// signature material, and the full inclusion proof. Everything here is the -/// real bytes the verdict was computed over. -#[derive(Debug, Serialize, Default)] -pub struct Evidence { - /// The exact in-toto Statement the signature covers (the signed payload). - pub signed_statement: Option<Value>, - /// Base64 Ed25519 signature over the DSSE PAE of the statement. - pub signature_b64: Option<String>, - pub scheme: Option<String>, - /// The published anchor checked against any configured independent pins. - pub anchor_key_id: Option<String>, - pub anchor_public_key_b64: Option<String>, - /// The receipt's position in the hash-chained inclusion log + the proof. - pub inclusion: Option<InclusionEvidence>, - /// The signed checkpoint (signed tree head) + independent witness. - pub checkpoint: Option<CheckpointEvidence>, -} - -#[derive(Debug, Serialize)] -pub struct InclusionEvidence { - pub seq: i64, - pub receipt: String, - pub payload_sha256: String, - pub prev_hash: String, - pub entry_hash: String, - /// The entry-hash the BFF recomputed from (seq | receipt | payloadSha | prev). - pub recomputed_entry_hash: String, - /// The head the chain links to — equals the checkpoint root when intact. - pub chain_head: String, - /// Whether the whole chain (genesis → head) recomputes consistently. - pub chain_consistent: bool, - pub tree_size: usize, -} - -#[derive(Debug, Serialize)] -pub struct CheckpointEvidence { - pub tree_size: i64, - pub root_hash: String, - /// The exact signed-note bytes the checkpoint signature covers. - pub signed_note: String, - pub signature_b64: String, - pub signature_valid: bool, - /// Advisory witness metadata; its public key is not published in V0, - /// so neither its identity nor its co-signature is verified here. - pub witness_key_id: Option<String>, - pub witness_signature_b64: Option<String>, -} - -fn sha256_hex(bytes: &[u8]) -> String { - use sha2::{Digest, Sha256}; - hex(&Sha256::digest(bytes)) -} - -fn hex(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(out, "{b:02x}"); - } - out -} - -/// A whole-log integrity verdict — the page-level answer to "is this audit log -/// actually tamper-evident?". Unlike a per-receipt proof, this recomputes the -/// ENTIRE hash chain and verifies the signed checkpoint, so the auditor banner -/// reflects real cryptographic verification, never mere field presence. -#[derive(Debug, Default, serde::Serialize)] -pub struct LogIntegrity { - /// The chain recomputes consistently genesis→head: contiguous `seq`, - /// prev-hash linkage, and every entry hash recomputes. False if empty/broken. - pub chain_consistent: bool, - /// Number of entries in the inclusion log. - pub tree_size: i64, - /// A signed checkpoint exists, its Ed25519 signature verifies against the - /// published out-of-band anchor, AND it commits to the current chain head. - pub checkpoint_verified: bool, - /// An independent transparency-witness co-signature is PRESENT. Shown, not - /// re-verified in V0 (the witness public key isn't published), so it is - /// advisory — never counted toward `checkpoint_verified`. - pub witness_present: bool, - /// Whether the anchor the checkpoint signature was verified against is pinned - /// OUT-OF-BAND (a BFF-configured key id / public key from a trust boundary - /// distinct from the log). When false, the anchor is the in-cluster - /// `kars-receipt-pubkey` — the SAME trust domain as the log — so a party that - /// can rewrite the log could also rewrite the anchor. The verdict is then - /// "consistent + signed by the cluster's published anchor", NOT absolute - /// tamper-evidence; the banner must not overclaim. - pub anchor_pinned: bool, -} - -/// Recompute the ENTIRE `kars-receipt-log` hash chain and verify the signed -/// checkpoint against the published anchor key. Used by the audit page so its -/// integrity verdict is a real verification, not a `inclusion_seq != null` proxy. -pub(crate) fn verify_log_integrity(log: &ReceiptLog) -> LogIntegrity { - verify_log_integrity_with_pins(log, AnchorPins::from_env()) -} - -pub(crate) fn verify_log_integrity_with_pins( - log: &ReceiptLog, - pins: Result<AnchorPins, &'static str>, -) -> LogIntegrity { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - let mut out = LogIntegrity::default(); - let chain = &log.entries; - if chain.is_empty() { - return out; - } - out.tree_size = chain.len() as i64; - - // Recompute the whole chain: contiguous seq, prev-hash linkage, entry hashes. - let mut chain_consistent = !chain.is_empty(); - let mut prev = "genesis".to_string(); - for (i, e) in chain.iter().enumerate() { - if e.seq != i as i64 - || e.prev_hash != prev - || chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash) != e.entry_hash - { - chain_consistent = false; - break; - } - prev = e.entry_hash.clone(); - } - out.chain_consistent = chain_consistent; - let chain_head = chain - .last() - .map(|e| e.entry_hash.clone()) - .unwrap_or_else(|| "genesis".into()); - - // Verify the signed checkpoint (Ed25519 over the canonical note) against the - // anchor key, and that it commits to the current chain head. - if let Some(cp) = log.checkpoint.as_ref() { - let cp_tree = cp - .get("treeSize") - .and_then(|s| s.parse::<i64>().ok()) - .unwrap_or(0); - let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); - let cp_sig = cp.get("signature").cloned().unwrap_or_default(); - let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); - let anchor = match pins.and_then(|pins| pins.resolve(log)) { - Ok(anchor) => { - out.anchor_pinned = anchor.pinned; - Some(anchor) - } - Err(reason) => { - tracing::warn!(reason, "Receipt checkpoint trust anchor rejected"); - None - } - }; - let cp_sig_ok = anchor - .as_ref() - .and_then(|anchor| VerifyingKey::from_bytes(&anchor.public_key).ok()) - .map(|vk| { - BASE64 - .decode(cp_sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| { - vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) - .is_ok() - }) - .unwrap_or(false) - }) - .unwrap_or(false); - out.checkpoint_verified = - chain_consistent && cp_sig_ok && cp_root == chain_head && cp_tree == out.tree_size; - } - - // Independent witness co-signature — present-or-not only (advisory in V0). - out.witness_present = log - .witness - .as_ref() - .and_then(|w| w.get("witnessSignature").cloned()) - .map(|s| !s.trim().is_empty()) - .unwrap_or(false); - - out -} - -/// DSSE Pre-Authentication Encoding — byte-for-byte the same framing the -/// controller signs (`controller/src/providers/signing.rs::pae`): -/// `"DSSEv1" SP len(type) SP type SP len(body) SP body`. -fn pae(payload_type: &str, body: &[u8]) -> Vec<u8> { - let mut out = Vec::with_capacity(payload_type.len() + body.len() + 32); - out.extend_from_slice(b"DSSEv1 "); - out.extend_from_slice(payload_type.len().to_string().as_bytes()); - out.push(b' '); - out.extend_from_slice(payload_type.as_bytes()); - out.push(b' '); - out.extend_from_slice(body.len().to_string().as_bytes()); - out.push(b' '); - out.extend_from_slice(body); - out -} - -/// `POST /api/namespaces/:ns/tasks/:name/receipt/verify` — independently verify -/// a Governance Receipt's DSSE/Ed25519 signature against the controller's -/// published public-key anchor (`kars-receipt-pubkey` ConfigMap). This is the -/// same trust root `kars receipt verify` uses; performing it here lets an -/// auditor get a real cryptographic verdict in the browser. The BFF never -/// trusts a key embedded in the receipt. Independently configured pins -/// constrain the cluster-published anchor when present. -pub async fn verify_receipt( - state: State<AppState>, - principal: Extension<Principal>, - path: Path<(String, String)>, -) -> AppResult<Json<VerifyResult>> { - verify_receipt_with_pins(state, principal, path, AnchorPins::from_env()).await -} - -pub(crate) async fn verify_receipt_with_pins( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - pins: Result<AnchorPins, &'static str>, -) -> AppResult<Json<VerifyResult>> { - use base64::engine::general_purpose::STANDARD as B64; - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - - let cluster = require_cluster(&state)?; - require_task_evidence_access(cluster, &ns, &name, &principal).await?; - let receipt = cluster - .receipts(&ns) - .get_opt(&name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - .ok_or(AppError::NotFound)?; - let spec = &receipt.spec; - - let mut checks: Vec<VerifyCheck> = Vec::new(); - let mut evidence = Evidence::default(); - let push = |checks: &mut Vec<VerifyCheck>, - name: &str, - passed: bool, - detail: String, - expected: Option<String>, - computed: Option<String>| { - checks.push(VerifyCheck { - name: name.to_string(), - passed, - detail, - advisory: false, - expected, - computed, - }); - passed - }; - - // Decode the signed statement once — it IS the evidence the auditor reads. - let decoded = match statement::decode(spec, &ns, &name) { - Ok(decoded) => decoded, - Err(error) => { - push( - &mut checks, - "Signed statement binding", - false, - error, - None, - None, - ); - return Ok(Json(VerifyResult { - verified: false, - checks, - evidence, - })); - } - }; - let payload_raw = decoded.payload; - evidence.signed_statement = Some(decoded.statement); - evidence.scheme = Some(spec.scheme.clone()); - evidence.signature_b64 = spec.dsse.signatures.first().map(|s| s.sig.clone()); - - // 1) Both verification paths resolve the same configured trust boundary. - let log = match cluster.receipt_log().await { - Ok(log) => log, - Err(error) => { - push( - &mut checks, - "Inclusion log", - false, - error.to_string(), - None, - None, - ); - return Ok(Json(VerifyResult { - verified: false, - checks, - evidence, - })); - } - }; - let anchor = match pins.and_then(|pins| pins.resolve(&log)) { - Ok(anchor) => anchor, - Err(reason) => { - push( - &mut checks, - "Trust anchor", - false, - reason.into(), - None, - None, - ); - return Ok(Json(VerifyResult { - verified: false, - checks, - evidence, - })); - } - }; - let anchor_key_id = anchor.key_id; - let anchor_pub_b64 = anchor.public_key_b64; - let anchor_scheme = anchor.scheme; - evidence.anchor_key_id = Some(anchor_key_id.clone()); - evidence.anchor_public_key_b64 = Some(anchor_pub_b64.clone()); - push( - &mut checks, - "Trust anchor", - true, - if anchor.pinned { - "The controller's public key matches the configured out-of-band pins.".into() - } else { - "The signature is checked against the cluster-published key, not a key in the receipt. No independent out-of-band pin is configured.".into() - }, - None, - None, - ); - - // 2) Receipt key id matches the anchor. - let key_match = spec.key_id == anchor_key_id; - push( - &mut checks, - "Signing key identity", - key_match, - if key_match { - "The receipt's key identifier matches the published anchor.".into() - } else { - "The receipt's signing key does NOT match the trusted anchor.".into() - }, - Some(anchor_key_id.clone()), - Some(spec.key_id.clone()), - ); - - // 3) Scheme is the expected DSSE/Ed25519. - let scheme_ok = spec.scheme == statement::SCHEME && spec.scheme == anchor_scheme; - push( - &mut checks, - "Signature scheme", - scheme_ok, - format!("Scheme: {}.", spec.scheme), - Some(anchor_scheme.clone()), - Some(spec.scheme.clone()), - ); - - // 4) Ed25519 signature verifies over the DSSE PAE of the exact payload. - let mut sig_ok = false; - let pub_bytes = Some(anchor.public_key); - if let (Some(pk), false) = (pub_bytes, payload_raw.is_empty()) - && let Ok(vk) = VerifyingKey::from_bytes(&pk) - { - let message = pae(&spec.dsse.payload_type, &payload_raw); - let valid_signature = spec.dsse.signatures.iter().find(|s| { - s.keyid == anchor_key_id - && B64 - .decode(s.sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| vk.verify(&message, &Signature::from_bytes(&sb)).is_ok()) - .unwrap_or(false) - }); - sig_ok = valid_signature.is_some(); - if let Some(signature) = valid_signature { - evidence.signature_b64 = Some(signature.sig.clone()); - } - } - push( - &mut checks, - "Cryptographic signature", - sig_ok, - if sig_ok { - "The Ed25519 signature verifies over the DSSE pre-authentication encoding of the statement below — so the payload is authentic and has not been altered by a single byte.".into() - } else { - "Ed25519 signature did NOT verify — the payload may have been altered.".into() - }, - None, - None, - ); - - // 5) The signed payload binds the trust-envelope digest the receipt claims. - let envelope_bound = evidence - .signed_statement - .as_ref() - .is_some_and(|value| statement::binds_subject(value, spec, &ns, &name)); - push( - &mut checks, - "Trust-envelope binding", - envelope_bound, - if envelope_bound { - "The signed statement contains the exact trust-envelope digest the receipt declares — the signature can't be lifted onto a different envelope.".into() - } else { - "The signed payload does not reference the declared envelope digest.".into() - }, - Some(spec.envelope_digest.clone()), - None, - ); - - // 6) FULL inclusion proof — fetch the hash-chained log + signed checkpoint, - // recompute this receipt's entry, confirm the chain links to the head, - // and verify the checkpoint signature. No CLI, no hand-waving. - let mut inclusion_ok = true; - let receipt_log_ref = format!("{ns}/{name}"); - let loaded_chain = &log.entries; - if !loaded_chain.is_empty() { - let chain = loaded_chain; - let tree_size = chain.len(); - // The receipt's own recorded position. - let seq = receipt.status.as_ref().and_then(|s| s.inclusion_seq); - let entry = seq.and_then(|q| { - chain - .iter() - .find(|e| e.seq == q && e.receipt == receipt_log_ref) - }); - - // (a) payloadSha256 of the entry equals sha256 of the signed payload. - let computed_payload_sha = sha256_hex(&payload_raw); - let payload_sha_ok = entry - .map(|e| e.payload_sha256 == computed_payload_sha) - .unwrap_or(false); - - // (b) entryHash recomputes from (seq | receipt | payloadSha | prev). - let recomputed_entry = - entry.map(|e| chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash)); - let entry_hash_ok = entry - .zip(recomputed_entry.as_ref()) - .map(|(e, r)| &e.entry_hash == r) - .unwrap_or(false); - - // (c) the WHOLE chain recomputes consistently (contiguous seq, - // prev-hash linkage, recomputed entry hashes) up to the head. - let mut chain_consistent = true; - let mut prev = "genesis".to_string(); - for (i, e) in chain.iter().enumerate() { - if e.seq != i as i64 - || e.prev_hash != prev - || chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash) - != e.entry_hash - { - chain_consistent = false; - break; - } - prev = e.entry_hash.clone(); - } - let chain_head = chain - .last() - .map(|e| e.entry_hash.clone()) - .unwrap_or_else(|| "genesis".into()); - - if let Some(e) = entry { - evidence.inclusion = Some(InclusionEvidence { - seq: e.seq, - receipt: e.receipt.clone(), - payload_sha256: e.payload_sha256.clone(), - prev_hash: e.prev_hash.clone(), - entry_hash: e.entry_hash.clone(), - recomputed_entry_hash: recomputed_entry.clone().unwrap_or_default(), - chain_head: chain_head.clone(), - chain_consistent, - tree_size, - }); - } - - inclusion_ok = payload_sha_ok && entry_hash_ok && chain_consistent; - push(&mut checks, "Payload digest in log", payload_sha_ok, - "The inclusion-log entry records a SHA-256 of the signed payload — recomputing it matches, so this exact receipt is the one logged.".into(), - entry.map(|e| e.payload_sha256.clone()), Some(computed_payload_sha)); - push(&mut checks, "Inclusion-log entry hash", entry_hash_ok, - "The entry hash recomputes from (seq | receipt | payload-digest | previous-hash) — binding this receipt to its exact position in the chain.".into(), - entry.map(|e| e.entry_hash.clone()), recomputed_entry); - push( - &mut checks, - "Chain integrity", - chain_consistent, - format!( - "All {tree_size} entries recompute and link genesis → head with no gap — removing or altering any one would break the chain." - ), - None, - Some(chain_head.clone()), - ); - - // (d) the signed checkpoint commits to this head, and its Ed25519 - // signature verifies with the anchor key. - if let Some(cp) = log.checkpoint.as_ref() { - let cp_tree = cp - .get("treeSize") - .and_then(|s| s.parse::<i64>().ok()) - .unwrap_or(0); - let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); - let cp_sig = cp.get("signature").cloned().unwrap_or_default(); - let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); - let cp_sig_ok = pub_bytes - .and_then(|pk| VerifyingKey::from_bytes(&pk).ok()) - .map(|vk| { - B64.decode(cp_sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| { - vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) - .is_ok() - }) - .unwrap_or(false) - }) - .unwrap_or(false); - let root_matches = cp_root == chain_head && cp_tree == tree_size as i64; - - let witness = log.witness.as_ref(); - evidence.checkpoint = Some(CheckpointEvidence { - tree_size: cp_tree, - root_hash: cp_root.clone(), - signed_note: note.clone(), - signature_b64: cp_sig.clone(), - signature_valid: cp_sig_ok, - witness_key_id: witness - .as_ref() - .and_then(|w| w.get("witnessKeyId").cloned()), - witness_signature_b64: witness - .as_ref() - .and_then(|w| w.get("witnessSignature").cloned()), - }); - - inclusion_ok = inclusion_ok && cp_sig_ok && root_matches; - push(&mut checks, "Signed checkpoint", cp_sig_ok && root_matches, - "A signed tree head commits to the chain head, and its Ed25519 signature verifies with the anchor key — pinning the whole log to a value an auditor can re-check.".into(), - Some(cp_root.clone()), Some(chain_head.clone())); - if let Some(w) = witness.as_ref().and_then(|w| w.get("witnessKeyId")) { - // BUG-4: the witness co-signature is DISPLAYED, not re-verified - // (its public key isn't published in V0). Mark it advisory so the - // UI shows "shown, not verified" instead of a deceptive ✓, and show - // the witness key itself (not the anchor key, which guaranteed a - // spurious recorded≠recomputed mismatch). - checks.push(VerifyCheck { - name: "Independent witness".to_string(), - passed: false, - advisory: true, - detail: "A witness co-signature is published, but its public key is unavailable here, so it is shown without verification.".into(), - expected: Some(w.clone()), - computed: None, - }); - } else { - checks.push(VerifyCheck { - name: "Independent witness".into(), - passed: false, - advisory: true, - detail: "No independent witness key is published; checkpoint signature verification is unaffected.".into(), - expected: None, - computed: None, - }); - } - } else { - inclusion_ok = false; - push( - &mut checks, - "Signed checkpoint", - false, - "No signed checkpoint is available for the inclusion log.".into(), - None, - None, - ); - } - } - if evidence.inclusion.is_none() { - let detail = "This receipt isn't yet recorded in the inclusion log (it may not have run / been chained).".into(); - push(&mut checks, "Inclusion proof", false, detail, None, None); - inclusion_ok = false; - } - - let verified = key_match && scheme_ok && sig_ok && envelope_bound && inclusion_ok; - Ok(Json(VerifyResult { - verified, - checks, - evidence, - })) -} diff --git a/bridge/bff/src/routes/receipts/verification.rs b/bridge/bff/src/routes/receipts/verification.rs new file mode 100644 index 000000000..8cdef7032 --- /dev/null +++ b/bridge/bff/src/routes/receipts/verification.rs @@ -0,0 +1,619 @@ +// kars Bridge BFF — receipt verification, extracted without changing wire formats. + +use super::*; + +/// The outcome of an in-browser cryptographic verification — performed +/// server-side against the controller's public key and any configured +/// independent pins, so an auditor gets a verdict and underlying evidence +/// without installing the CLI. +#[derive(Debug, Serialize)] +pub struct VerifyResult { + /// True only when every independent check passed. + pub verified: bool, + /// Ordered, human-readable checks with their pass/fail outcome. + pub checks: Vec<VerifyCheck>, + /// The actual artifacts behind the verdict — what an auditor inspects. + pub evidence: Evidence, +} + +#[derive(Debug, Serialize)] +pub struct VerifyCheck { + pub name: String, + pub passed: bool, + pub detail: String, + /// BUG-4: a check that is DISPLAYED but not cryptographically re-verified + /// here (e.g. the independent witness whose public key isn't published in + /// V0). Rendered as an advisory "shown, not verified" state — never a green + /// ✓ — so an auditor is not misled into thinking all items were checked. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub advisory: bool, + /// The value the proof expected (e.g. a recorded hash), when applicable. + #[serde(skip_serializing_if = "Option::is_none")] + pub expected: Option<String>, + /// The value the BFF independently computed, shown so the auditor sees the + /// two match (or don't) rather than trusting a green tick. + #[serde(skip_serializing_if = "Option::is_none")] + pub computed: Option<String>, +} + +/// The evidence artifacts an auditor inspects — the signed payload, the +/// signature material, and the full inclusion proof. Everything here is the +/// real bytes the verdict was computed over. +#[derive(Debug, Serialize, Default)] +pub struct Evidence { + /// The exact in-toto Statement the signature covers (the signed payload). + pub signed_statement: Option<Value>, + /// Base64 Ed25519 signature over the DSSE PAE of the statement. + pub signature_b64: Option<String>, + pub scheme: Option<String>, + /// The published anchor checked against any configured independent pins. + pub anchor_key_id: Option<String>, + pub anchor_public_key_b64: Option<String>, + /// The receipt's position in the hash-chained inclusion log + the proof. + pub inclusion: Option<InclusionEvidence>, + /// The signed checkpoint (signed tree head) + independent witness. + pub checkpoint: Option<CheckpointEvidence>, +} + +#[derive(Debug, Serialize)] +pub struct InclusionEvidence { + pub seq: i64, + pub receipt: String, + pub payload_sha256: String, + pub prev_hash: String, + pub entry_hash: String, + /// The entry-hash the BFF recomputed from (seq | receipt | payloadSha | prev). + pub recomputed_entry_hash: String, + /// The head the chain links to — equals the checkpoint root when intact. + pub chain_head: String, + /// Whether the whole chain (genesis → head) recomputes consistently. + pub chain_consistent: bool, + pub tree_size: usize, +} + +#[derive(Debug, Serialize)] +pub struct CheckpointEvidence { + pub tree_size: i64, + pub root_hash: String, + /// The exact signed-note bytes the checkpoint signature covers. + pub signed_note: String, + pub signature_b64: String, + pub signature_valid: bool, + /// Advisory witness metadata; its public key is not published in V0, + /// so neither its identity nor its co-signature is verified here. + pub witness_key_id: Option<String>, + pub witness_signature_b64: Option<String>, +} + +pub(super) fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex(&Sha256::digest(bytes)) +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(out, "{b:02x}"); + } + out +} + +/// A whole-log integrity verdict — the page-level answer to "is this audit log +/// actually tamper-evident?". Unlike a per-receipt proof, this recomputes the +/// ENTIRE hash chain and verifies the signed checkpoint, so the auditor banner +/// reflects real cryptographic verification, never mere field presence. +#[derive(Debug, Default, serde::Serialize)] +pub struct LogIntegrity { + /// The chain recomputes consistently genesis→head: contiguous `seq`, + /// prev-hash linkage, and every entry hash recomputes. False if empty/broken. + pub chain_consistent: bool, + /// Number of entries in the inclusion log. + pub tree_size: i64, + /// A signed checkpoint exists, its Ed25519 signature verifies against the + /// published out-of-band anchor, AND it commits to the current chain head. + pub checkpoint_verified: bool, + /// An independent transparency-witness co-signature is PRESENT. Shown, not + /// re-verified in V0 (the witness public key isn't published), so it is + /// advisory — never counted toward `checkpoint_verified`. + pub witness_present: bool, + /// Whether the anchor the checkpoint signature was verified against is pinned + /// OUT-OF-BAND (a BFF-configured key id / public key from a trust boundary + /// distinct from the log). When false, the anchor is the in-cluster + /// `kars-receipt-pubkey` — the SAME trust domain as the log — so a party that + /// can rewrite the log could also rewrite the anchor. The verdict is then + /// "consistent + signed by the cluster's published anchor", NOT absolute + /// tamper-evidence; the banner must not overclaim. + pub anchor_pinned: bool, +} + +/// Recompute the ENTIRE `kars-receipt-log` hash chain and verify the signed +/// checkpoint against the published anchor key. Used by the audit page so its +/// integrity verdict is a real verification, not a `inclusion_seq != null` proxy. +pub(crate) fn verify_log_integrity(log: &ReceiptLog) -> LogIntegrity { + verify_log_integrity_with_pins(log, AnchorPins::from_env()) +} + +pub(crate) fn verify_log_integrity_with_pins( + log: &ReceiptLog, + pins: Result<AnchorPins, &'static str>, +) -> LogIntegrity { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + let mut out = LogIntegrity::default(); + let chain = &log.entries; + if chain.is_empty() { + return out; + } + out.tree_size = chain.len() as i64; + + // Recompute the whole chain: contiguous seq, prev-hash linkage, entry hashes. + let mut chain_consistent = !chain.is_empty(); + let mut prev = "genesis".to_string(); + for (i, e) in chain.iter().enumerate() { + if e.seq != i as i64 + || e.prev_hash != prev + || chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash) != e.entry_hash + { + chain_consistent = false; + break; + } + prev = e.entry_hash.clone(); + } + out.chain_consistent = chain_consistent; + let chain_head = chain + .last() + .map(|e| e.entry_hash.clone()) + .unwrap_or_else(|| "genesis".into()); + + // Verify the signed checkpoint (Ed25519 over the canonical note) against the + // anchor key, and that it commits to the current chain head. + if let Some(cp) = log.checkpoint.as_ref() { + let cp_tree = cp + .get("treeSize") + .and_then(|s| s.parse::<i64>().ok()) + .unwrap_or(0); + let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); + let cp_sig = cp.get("signature").cloned().unwrap_or_default(); + let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); + let anchor = match pins.and_then(|pins| pins.resolve(log)) { + Ok(anchor) => { + out.anchor_pinned = anchor.pinned; + Some(anchor) + } + Err(reason) => { + tracing::warn!(reason, "Receipt checkpoint trust anchor rejected"); + None + } + }; + let cp_sig_ok = anchor + .as_ref() + .and_then(|anchor| VerifyingKey::from_bytes(&anchor.public_key).ok()) + .map(|vk| { + BASE64 + .decode(cp_sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| { + vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) + .is_ok() + }) + .unwrap_or(false) + }) + .unwrap_or(false); + out.checkpoint_verified = + chain_consistent && cp_sig_ok && cp_root == chain_head && cp_tree == out.tree_size; + } + + // Independent witness co-signature — present-or-not only (advisory in V0). + out.witness_present = log + .witness + .as_ref() + .and_then(|w| w.get("witnessSignature").cloned()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + out +} + +/// DSSE Pre-Authentication Encoding — byte-for-byte the same framing the +/// controller signs (`controller/src/providers/signing.rs::pae`): +/// `"DSSEv1" SP len(type) SP type SP len(body) SP body`. +fn pae(payload_type: &str, body: &[u8]) -> Vec<u8> { + let mut out = Vec::with_capacity(payload_type.len() + body.len() + 32); + out.extend_from_slice(b"DSSEv1 "); + out.extend_from_slice(payload_type.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload_type.as_bytes()); + out.push(b' '); + out.extend_from_slice(body.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(body); + out +} + +/// `POST /api/namespaces/:ns/tasks/:name/receipt/verify` — independently verify +/// a Governance Receipt's DSSE/Ed25519 signature against the controller's +/// published public-key anchor (`kars-receipt-pubkey` ConfigMap). This is the +/// same trust root `kars receipt verify` uses; performing it here lets an +/// auditor get a real cryptographic verdict in the browser. The BFF never +/// trusts a key embedded in the receipt. Independently configured pins +/// constrain the cluster-published anchor when present. +pub async fn verify_receipt( + state: State<AppState>, + principal: Extension<Principal>, + path: Path<(String, String)>, +) -> AppResult<Json<VerifyResult>> { + verify_receipt_with_pins(state, principal, path, AnchorPins::from_env()).await +} + +pub(crate) async fn verify_receipt_with_pins( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + pins: Result<AnchorPins, &'static str>, +) -> AppResult<Json<VerifyResult>> { + use base64::engine::general_purpose::STANDARD as B64; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let cluster = require_cluster(&state)?; + require_task_evidence_access(cluster, &ns, &name, &principal).await?; + let receipt = cluster + .receipts(&ns) + .get_opt(&name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or(AppError::NotFound)?; + let spec = &receipt.spec; + + let mut checks: Vec<VerifyCheck> = Vec::new(); + let mut evidence = Evidence::default(); + let push = |checks: &mut Vec<VerifyCheck>, + name: &str, + passed: bool, + detail: String, + expected: Option<String>, + computed: Option<String>| { + checks.push(VerifyCheck { + name: name.to_string(), + passed, + detail, + advisory: false, + expected, + computed, + }); + passed + }; + + // Decode the signed statement once — it IS the evidence the auditor reads. + let decoded = match statement::decode(spec, &ns, &name) { + Ok(decoded) => decoded, + Err(error) => { + push( + &mut checks, + "Signed statement binding", + false, + error, + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + } + }; + let payload_raw = decoded.payload; + evidence.signed_statement = Some(decoded.statement); + evidence.scheme = Some(spec.scheme.clone()); + evidence.signature_b64 = spec.dsse.signatures.first().map(|s| s.sig.clone()); + + // 1) Both verification paths resolve the same configured trust boundary. + let log = match cluster.receipt_log().await { + Ok(log) => log, + Err(error) => { + push( + &mut checks, + "Inclusion log", + false, + error.to_string(), + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + } + }; + let anchor = match pins.and_then(|pins| pins.resolve(&log)) { + Ok(anchor) => anchor, + Err(reason) => { + push( + &mut checks, + "Trust anchor", + false, + reason.into(), + None, + None, + ); + return Ok(Json(VerifyResult { + verified: false, + checks, + evidence, + })); + } + }; + let anchor_key_id = anchor.key_id; + let anchor_pub_b64 = anchor.public_key_b64; + let anchor_scheme = anchor.scheme; + evidence.anchor_key_id = Some(anchor_key_id.clone()); + evidence.anchor_public_key_b64 = Some(anchor_pub_b64.clone()); + push( + &mut checks, + "Trust anchor", + true, + if anchor.pinned { + "The controller's public key matches the configured out-of-band pins.".into() + } else { + "The signature is checked against the cluster-published key, not a key in the receipt. No independent out-of-band pin is configured.".into() + }, + None, + None, + ); + + // 2) Receipt key id matches the anchor. + let key_match = spec.key_id == anchor_key_id; + push( + &mut checks, + "Signing key identity", + key_match, + if key_match { + "The receipt's key identifier matches the published anchor.".into() + } else { + "The receipt's signing key does NOT match the trusted anchor.".into() + }, + Some(anchor_key_id.clone()), + Some(spec.key_id.clone()), + ); + + // 3) Scheme is the expected DSSE/Ed25519. + let scheme_ok = spec.scheme == statement::SCHEME && spec.scheme == anchor_scheme; + push( + &mut checks, + "Signature scheme", + scheme_ok, + format!("Scheme: {}.", spec.scheme), + Some(anchor_scheme.clone()), + Some(spec.scheme.clone()), + ); + + // 4) Ed25519 signature verifies over the DSSE PAE of the exact payload. + let mut sig_ok = false; + let pub_bytes = Some(anchor.public_key); + if let (Some(pk), false) = (pub_bytes, payload_raw.is_empty()) + && let Ok(vk) = VerifyingKey::from_bytes(&pk) + { + let message = pae(&spec.dsse.payload_type, &payload_raw); + let valid_signature = spec.dsse.signatures.iter().find(|s| { + s.keyid == anchor_key_id + && B64 + .decode(s.sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| vk.verify(&message, &Signature::from_bytes(&sb)).is_ok()) + .unwrap_or(false) + }); + sig_ok = valid_signature.is_some(); + if let Some(signature) = valid_signature { + evidence.signature_b64 = Some(signature.sig.clone()); + } + } + push( + &mut checks, + "Cryptographic signature", + sig_ok, + if sig_ok { + "The Ed25519 signature verifies over the DSSE pre-authentication encoding of the statement below — so the payload is authentic and has not been altered by a single byte.".into() + } else { + "Ed25519 signature did NOT verify — the payload may have been altered.".into() + }, + None, + None, + ); + + // 5) The signed payload binds the trust-envelope digest the receipt claims. + let envelope_bound = evidence + .signed_statement + .as_ref() + .is_some_and(|value| statement::binds_subject(value, spec, &ns, &name)); + push( + &mut checks, + "Trust-envelope binding", + envelope_bound, + if envelope_bound { + "The signed statement contains the exact trust-envelope digest the receipt declares — the signature can't be lifted onto a different envelope.".into() + } else { + "The signed payload does not reference the declared envelope digest.".into() + }, + Some(spec.envelope_digest.clone()), + None, + ); + + // 6) FULL inclusion proof — fetch the hash-chained log + signed checkpoint, + // recompute this receipt's entry, confirm the chain links to the head, + // and verify the checkpoint signature. No CLI, no hand-waving. + let mut inclusion_ok = true; + let receipt_log_ref = format!("{ns}/{name}"); + let loaded_chain = &log.entries; + if !loaded_chain.is_empty() { + let chain = loaded_chain; + let tree_size = chain.len(); + // The receipt's own recorded position. + let seq = receipt.status.as_ref().and_then(|s| s.inclusion_seq); + let entry = seq.and_then(|q| { + chain + .iter() + .find(|e| e.seq == q && e.receipt == receipt_log_ref) + }); + + // (a) payloadSha256 of the entry equals sha256 of the signed payload. + let computed_payload_sha = sha256_hex(&payload_raw); + let payload_sha_ok = entry + .map(|e| e.payload_sha256 == computed_payload_sha) + .unwrap_or(false); + + // (b) entryHash recomputes from (seq | receipt | payloadSha | prev). + let recomputed_entry = + entry.map(|e| chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash)); + let entry_hash_ok = entry + .zip(recomputed_entry.as_ref()) + .map(|(e, r)| &e.entry_hash == r) + .unwrap_or(false); + + // (c) the WHOLE chain recomputes consistently (contiguous seq, + // prev-hash linkage, recomputed entry hashes) up to the head. + let mut chain_consistent = true; + let mut prev = "genesis".to_string(); + for (i, e) in chain.iter().enumerate() { + if e.seq != i as i64 + || e.prev_hash != prev + || chain_entry_hash(e.seq, &e.receipt, &e.payload_sha256, &e.prev_hash) + != e.entry_hash + { + chain_consistent = false; + break; + } + prev = e.entry_hash.clone(); + } + let chain_head = chain + .last() + .map(|e| e.entry_hash.clone()) + .unwrap_or_else(|| "genesis".into()); + + if let Some(e) = entry { + evidence.inclusion = Some(InclusionEvidence { + seq: e.seq, + receipt: e.receipt.clone(), + payload_sha256: e.payload_sha256.clone(), + prev_hash: e.prev_hash.clone(), + entry_hash: e.entry_hash.clone(), + recomputed_entry_hash: recomputed_entry.clone().unwrap_or_default(), + chain_head: chain_head.clone(), + chain_consistent, + tree_size, + }); + } + + inclusion_ok = payload_sha_ok && entry_hash_ok && chain_consistent; + push(&mut checks, "Payload digest in log", payload_sha_ok, + "The inclusion-log entry records a SHA-256 of the signed payload — recomputing it matches, so this exact receipt is the one logged.".into(), + entry.map(|e| e.payload_sha256.clone()), Some(computed_payload_sha)); + push(&mut checks, "Inclusion-log entry hash", entry_hash_ok, + "The entry hash recomputes from (seq | receipt | payload-digest | previous-hash) — binding this receipt to its exact position in the chain.".into(), + entry.map(|e| e.entry_hash.clone()), recomputed_entry); + push( + &mut checks, + "Chain integrity", + chain_consistent, + format!( + "All {tree_size} entries recompute and link genesis → head with no gap — removing or altering any one would break the chain." + ), + None, + Some(chain_head.clone()), + ); + + // (d) the signed checkpoint commits to this head, and its Ed25519 + // signature verifies with the anchor key. + if let Some(cp) = log.checkpoint.as_ref() { + let cp_tree = cp + .get("treeSize") + .and_then(|s| s.parse::<i64>().ok()) + .unwrap_or(0); + let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); + let cp_sig = cp.get("signature").cloned().unwrap_or_default(); + let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); + let cp_sig_ok = pub_bytes + .and_then(|pk| VerifyingKey::from_bytes(&pk).ok()) + .map(|vk| { + B64.decode(cp_sig.as_bytes()) + .ok() + .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) + .map(|sb| { + vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) + .is_ok() + }) + .unwrap_or(false) + }) + .unwrap_or(false); + let root_matches = cp_root == chain_head && cp_tree == tree_size as i64; + + let witness = log.witness.as_ref(); + evidence.checkpoint = Some(CheckpointEvidence { + tree_size: cp_tree, + root_hash: cp_root.clone(), + signed_note: note.clone(), + signature_b64: cp_sig.clone(), + signature_valid: cp_sig_ok, + witness_key_id: witness + .as_ref() + .and_then(|w| w.get("witnessKeyId").cloned()), + witness_signature_b64: witness + .as_ref() + .and_then(|w| w.get("witnessSignature").cloned()), + }); + + inclusion_ok = inclusion_ok && cp_sig_ok && root_matches; + push(&mut checks, "Signed checkpoint", cp_sig_ok && root_matches, + "A signed tree head commits to the chain head, and its Ed25519 signature verifies with the anchor key — pinning the whole log to a value an auditor can re-check.".into(), + Some(cp_root.clone()), Some(chain_head.clone())); + if let Some(w) = witness.as_ref().and_then(|w| w.get("witnessKeyId")) { + // BUG-4: the witness co-signature is DISPLAYED, not re-verified + // (its public key isn't published in V0). Mark it advisory so the + // UI shows "shown, not verified" instead of a deceptive ✓, and show + // the witness key itself (not the anchor key, which guaranteed a + // spurious recorded≠recomputed mismatch). + checks.push(VerifyCheck { + name: "Independent witness".to_string(), + passed: false, + advisory: true, + detail: "A witness co-signature is published, but its public key is unavailable here, so it is shown without verification.".into(), + expected: Some(w.clone()), + computed: None, + }); + } else { + checks.push(VerifyCheck { + name: "Independent witness".into(), + passed: false, + advisory: true, + detail: "No independent witness key is published; checkpoint signature verification is unaffected.".into(), + expected: None, + computed: None, + }); + } + } else { + inclusion_ok = false; + push( + &mut checks, + "Signed checkpoint", + false, + "No signed checkpoint is available for the inclusion log.".into(), + None, + None, + ); + } + } + if evidence.inclusion.is_none() { + let detail = "This receipt isn't yet recorded in the inclusion log (it may not have run / been chained).".into(); + push(&mut checks, "Inclusion proof", false, detail, None, None); + inclusion_ok = false; + } + + let verified = key_match && scheme_ok && sig_ok && envelope_bound && inclusion_ok; + Ok(Json(VerifyResult { + verified, + checks, + evidence, + })) +} From 1778ebcff4470962d5c1facbe7ada65d46daa228 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:22:23 +0200 Subject: [PATCH 017/111] Retain mandatory review for all remediation identity versions Existing legacy and v2 remediation work both retain the human-review gate when rehydrated, without changing IDs, runs or backlog state. Added regression awaits hosted Rust execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/engineering.rs | 1 + .../src/routes/engineering/remediation_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs index 951a4b063..b7b3f2aab 100644 --- a/bridge/bff/src/routes/engineering.rs +++ b/bridge/bff/src/routes/engineering.rs @@ -1039,6 +1039,7 @@ fn merge_discovered_tasks( fn engineering_task_requires_review(task_id: &str) -> bool { task_id.starts_with("dependabot-pr-") + || task_id.starts_with("dependency-remediation-") || task_id.starts_with("dependabot-alert-") || task_id.starts_with("code-scanning-alert-") || task_id.starts_with("secret-scanning-alert-") diff --git a/bridge/bff/src/routes/engineering/remediation_tests.rs b/bridge/bff/src/routes/engineering/remediation_tests.rs index 2dc814950..d4583de98 100644 --- a/bridge/bff/src/routes/engineering/remediation_tests.rs +++ b/bridge/bff/src/routes/engineering/remediation_tests.rs @@ -384,6 +384,20 @@ fn existing_v2_and_legacy_history_are_not_renamed_or_combined() { assert_eq!(merged.iter().map(snapshot).collect::<Vec<_>>(), before); } +#[test] +fn persisted_legacy_and_versioned_remediation_keep_mandatory_human_review() { + let mut original = legacy("acme/api", Some("package-lock.json"), "vite", "pending"); + let mut versioned = discovered("acme/api", Some("services/package-lock.json"), "vite", 99); + original.review_required = false; + versioned.review_required = false; + let identities = [original.id.clone(), versioned.id.clone()]; + let (merged, queued) = merge_discovered_tasks(vec![original, versioned], Vec::new()); + assert_eq!(queued, 0); + assert!(merged.iter().all(|task| task.review_required)); + assert_eq!(merged[0].id, identities[0]); + assert_eq!(merged[1].id, identities[1]); +} + #[test] fn merge_rechecks_legacy_metadata_in_the_latest_persisted_state() { let original = legacy( From f716b30856fa937d91d64a3f721a478b14279afe Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:23:08 +0200 Subject: [PATCH 018/111] Retain the explicit no-merge wording in PR dedupe instructions Restore the exact never-merge contract exercised by existing hosted regression without removing its assertion or changing candidate-only coverage semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/engineering.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs index b7b3f2aab..10daf2ef9 100644 --- a/bridge/bff/src/routes/engineering.rs +++ b/bridge/bff/src/routes/engineering.rs @@ -1648,7 +1648,7 @@ fn dedupe_followup_task( ordered.len() - 1 ), description: format!( - "Multiple open pull requests mention this remediation's package or advisory. Their titles are not coverage evidence. Compare actual changed files with the exact case-sensitive manifest, package, advisory and head-SHA checks before treating any work as equivalent. Preserve distinct manifest fixes. Only after equivalence is verified, preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence and close superseded duplicates. Never merge; report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", + "Multiple open pull requests mention this remediation's package or advisory. Their titles are not coverage evidence. Compare actual changed files with the exact case-sensitive manifest, package, advisory and head-SHA checks before treating any work as equivalent. Preserve distinct manifest fixes. Only after equivalence is verified, preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence and close superseded duplicates; never merge. Report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", canonical.number, canonical.html_url, duplicates ), depends_on: Vec::new(), From 2383ccde571781d1ca402954ff37c5651951bd75 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:26:37 +0200 Subject: [PATCH 019/111] Split Bridge Team routes into bounded modules Preserve the existing Team facade and lifecycle contracts through mechanical extraction. Focused read-only review found no significant issues, and scoped formatting passed. Hosted Rust compilation and full test execution remain required; no source-audit sign-off is implied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/teams.rs | 3124 +-------------------- bridge/bff/src/routes/teams/backlog.rs | 327 +++ bridge/bff/src/routes/teams/channels.rs | 269 ++ bridge/bff/src/routes/teams/commons.rs | 424 +++ bridge/bff/src/routes/teams/lifecycle.rs | 268 ++ bridge/bff/src/routes/teams/mutations.rs | 687 +++++ bridge/bff/src/routes/teams/queries.rs | 616 ++++ bridge/bff/src/routes/teams/validation.rs | 618 ++++ 8 files changed, 3225 insertions(+), 3108 deletions(-) create mode 100644 bridge/bff/src/routes/teams/backlog.rs create mode 100644 bridge/bff/src/routes/teams/channels.rs create mode 100644 bridge/bff/src/routes/teams/commons.rs create mode 100644 bridge/bff/src/routes/teams/lifecycle.rs create mode 100644 bridge/bff/src/routes/teams/mutations.rs create mode 100644 bridge/bff/src/routes/teams/queries.rs create mode 100644 bridge/bff/src/routes/teams/validation.rs diff --git a/bridge/bff/src/routes/teams.rs b/bridge/bff/src/routes/teams.rs index 5f42dd95a..31e4dd7d1 100644 --- a/bridge/bff/src/routes/teams.rs +++ b/bridge/bff/src/routes/teams.rs @@ -1,3 +1,4 @@ +// Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — Teams API DTOs + handlers. // // A Team (KarsTeam) is a standing org with a charter and a cadence loop that @@ -6,68 +7,27 @@ // never depends on raw Kubernetes envelopes. Read-only: the controller is the // sole writer of team membership + generated tasks. -use axum::Json; -use axum::extract::{Extension, Path, State}; +mod backlog; +mod channels; +mod commons; +mod lifecycle; +mod mutations; +mod queries; +mod validation; + +pub use backlog::*; +pub use channels::*; +pub use commons::*; +pub use lifecycle::*; +pub use mutations::*; +pub use queries::*; + use serde::{Deserialize, Serialize}; use crate::auth::Principal; use crate::error::{AppError, AppResult}; use crate::kars::team::KarsTeam; -use crate::routes::options::{ModelOption, Options, RefOption, build_options}; -use crate::routes::tasks::{ - clean_display_name, clean_objective, deliverable_excerpt, deliverable_text, - extract_pull_requests, is_failure_shaped_output, is_no_change_output, require_cluster, -}; use kube::ResourceExt; -use kube::api::{Api, ListParams, Patch, PatchParams}; - -/// The on-disk commons index entry (mirrors the controller's `CommonsEntry`). -#[derive(Debug, Deserialize)] -pub struct CommonsIndexEntry { - pub id: String, - pub title: String, - pub author: String, - pub source_task: String, - pub created_at: String, - pub digest: String, - pub size_bytes: i64, -} - -/// Browser-facing commons entry — the index record plus resolved content. -#[derive(Debug, Serialize)] -pub struct CommonsEntryDto { - pub id: String, - pub title: String, - pub author: String, - pub source_task: String, - pub created_at: String, - pub digest: String, - pub size_bytes: i64, - pub content: Option<String>, -} - -/// Browser-facing commons response. -#[derive(Debug, Serialize)] -pub struct CommonsResponse { - pub commons: String, - pub count: i64, - pub entries: Vec<CommonsEntryDto>, -} - -fn commons_entry_key(id: &str) -> String { - format!( - "entry-{}", - id.chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { - character - } else { - '_' - } - },) - .collect::<String>() - ) -} /// One seat in the team roster (the org chart). #[derive(Debug, Serialize)] @@ -196,159 +156,6 @@ pub struct TeamDetailDto { pub channels: Vec<String>, } -#[derive(Debug, Serialize)] -pub struct TeamOutcomeDto { - pub run: String, - pub disposition: String, - pub headline: String, - pub detail: String, - pub objective: String, - pub finished_at: Option<String>, - pub duration_seconds: Option<i64>, - pub tokens: Option<i64>, - pub model: Option<String>, - pub pull_requests: Vec<crate::routes::tasks::PullRequestRef>, - pub artifact_count: i64, -} - -#[derive(Debug, Default, Serialize)] -pub struct TeamOutcomeSummaryDto { - pub change_proposed: i64, - pub no_action_needed: i64, - pub completed: i64, - pub failed: i64, -} - -fn run_started_at(run: &str) -> Option<chrono::DateTime<chrono::Utc>> { - let epoch = run.rsplit("-run-").next()?.get(..10)?.parse::<i64>().ok()?; - chrono::DateTime::from_timestamp(epoch, 0) -} - -fn is_internal_artifact_name(name: &str) -> bool { - let normalized = name.to_ascii_lowercase(); - normalized.contains("collaboration.jsonl") - || normalized.ends_with("role-plan.json") - || normalized.ends_with("research-evidence.jsonl") - || normalized.ends_with("activity.jsonl") - || normalized.ends_with("subagent-telemetry.jsonl") - || normalized.ends_with("execution-contract.json") - || normalized.ends_with("task-checkpoint.json") -} - -fn outcome_work_item(raw: &str) -> String { - let objective = clean_objective(raw); - if let Some(task) = objective - .split_once("TASK:") - .map(|(_, rest)| rest) - .map(|rest| rest.split("DETAILS:").next().unwrap_or(rest)) - .and_then(|rest| rest.lines().next()) - .map(str::trim) - .filter(|task| !task.is_empty()) - { - return task.chars().take(180).collect(); - } - clean_display_name(&None, &objective).unwrap_or(objective) -} - -fn outcome_from_output( - run: String, - data: &std::collections::BTreeMap<String, String>, -) -> TeamOutcomeDto { - let status = data.get("status").map(String::as_str); - let raw_output = data.get("output").map(String::as_str).unwrap_or(""); - let output = deliverable_text(raw_output); - let objective = outcome_work_item(data.get("objective").map(String::as_str).unwrap_or("")); - let pull_requests = extract_pull_requests(&output); - let no_action = is_no_change_output(&output); - let disposition = if status == Some("error") || is_failure_shaped_output(&output) { - "failed" - } else if !pull_requests.is_empty() { - "change_proposed" - } else if no_action { - "no_action_needed" - } else { - "completed" - }; - let detail = deliverable_excerpt(&output); - let headline = if let Some(pr) = pull_requests.first() { - format!("Change proposed in {} PR #{}", pr.repo, pr.number) - } else if disposition == "no_action_needed" { - if detail.is_empty() { - "No action needed".to_string() - } else { - detail.clone() - } - } else if disposition == "failed" { - let lower = output.to_ascii_lowercase(); - if lower.contains("unexpected tokens remaining in message header") { - "Agent response parser failed".to_string() - } else if lower.contains("kars sandbox - secure ai runtime") - && lower.contains("how can i help") - { - "Agent returned its runtime banner instead of work".to_string() - } else if lower.contains("llm request failed") - || lower.contains("network connection") - || lower.contains("connection refused") - { - "Model or network request failed".to_string() - } else if lower.contains("now await") - || lower.contains("awaiting handback") - || lower.contains("waiting for") && lower.contains("handback") - { - "Team run ended before all selected roles returned".to_string() - } else if detail.is_empty() { - "Run failed before producing an outcome".to_string() - } else { - detail.clone() - } - } else { - clean_display_name(&None, &detail) - .or_else(|| clean_display_name(&None, &objective)) - .unwrap_or_else(|| "Completed work".to_string()) - }; - let finished_at = data.get("finishedAt").cloned(); - let duration_seconds = finished_at - .as_deref() - .and_then(|finished| chrono::DateTime::parse_from_rfc3339(finished).ok()) - .and_then(|finished| { - run_started_at(&run).map(|started| { - finished - .with_timezone(&chrono::Utc) - .signed_duration_since(started) - .num_seconds() - .max(0) - }) - }); - let artifact_count = data - .get("artifacts") - .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(raw).ok()) - .map(|artifacts| { - artifacts - .iter() - .filter(|artifact| { - artifact - .get("name") - .and_then(serde_json::Value::as_str) - .is_none_or(|name| !is_internal_artifact_name(name)) - }) - .count() as i64 - }) - .unwrap_or(0); - TeamOutcomeDto { - run, - disposition: disposition.to_string(), - headline, - detail, - objective, - finished_at, - duration_seconds, - tokens: data.get("totalTokens").and_then(|value| value.parse().ok()), - model: data.get("model").cloned(), - pull_requests, - artifact_count, - } -} - fn phase_of(team: &KarsTeam) -> String { team.status .as_ref() @@ -380,54 +187,6 @@ pub(crate) async fn require_owned_team( Ok(team) } -fn to_summary(team: &KarsTeam) -> TeamSummaryDto { - let st = team.status.as_ref(); - let created_at = team - .metadata - .creation_timestamp - .as_ref() - .map(|timestamp| timestamp.0.to_rfc3339()); - let last_run_at = st.and_then(|status| status.last_run_at.clone()); - let last_success_at = st.and_then(|status| status.last_success_at.clone()); - let last_activity_at = [ - st.and_then(|status| status.last_activity_at.clone()), - last_run_at.clone(), - last_success_at.clone(), - created_at.clone(), - ] - .into_iter() - .flatten() - .max(); - TeamSummaryDto { - name: team.name_any(), - display_name: team.spec.display_name.clone(), - charter: team.spec.charter.clone(), - phase: phase_of(team), - reporting_to: team.spec.reporting_to.clone(), - tier: team.spec.envelope.tier, - member_count: st.and_then(|s| s.member_count).unwrap_or(0), - generated_task_count: st.and_then(|s| s.generated_task_count).unwrap_or(0), - every_minutes: team.spec.cadence.as_ref().and_then(|c| c.every_minutes), - lifecycle_mode: effective_lifecycle_mode(team), - warm_idle_seconds: team.spec.warm_idle_seconds, - runtime_state: runtime_state(team), - current_assignment_task: st.and_then(|s| s.current_assignment_task.clone()), - idle_deadline_at: st.and_then(|s| s.idle_deadline_at.clone()), - paused: team.spec.paused, - created_at, - last_run_at, - last_success_at, - last_activity_at, - next_run_at: st.and_then(|s| s.next_run_at.clone()), - health: st.and_then(|s| s.health.clone()), - detail: st.and_then(|s| s.detail.clone()), - runs_succeeded: st.and_then(|s| s.runs_succeeded).unwrap_or(0), - retained_delivered: 0, - retained_no_action: 0, - retained_failed: 0, - } -} - fn effective_lifecycle_mode(team: &KarsTeam) -> String { team.status .as_ref() @@ -442,1532 +201,6 @@ fn runtime_state(team: &KarsTeam) -> Option<String> { .and_then(|status| status.runtime_state.clone()) } -/// `GET /api/namespaces/:ns/teams` — list standing teams in a namespace. -pub async fn list_teams( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path(ns): Path<String>, -) -> AppResult<Json<Vec<TeamSummaryDto>>> { - let cluster = require_cluster(&state)?; - let api: Api<KarsTeam> = cluster.teams(&ns); - let list = api - .list(&ListParams::default()) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let task_list = cluster - .tasks(&ns) - .list(&ListParams::default()) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let mut retained_runs: std::collections::HashMap<String, i64> = - std::collections::HashMap::new(); - let visible_team_names = list - .items - .iter() - .filter(|team| is_team_owner(team, &principal)) - .map(ResourceExt::name_any) - .collect::<Vec<_>>(); - let mut retained_outcomes: std::collections::HashMap<String, (i64, i64, i64)> = - std::collections::HashMap::new(); - for record in cluster.list_mission_output_evidence().await { - let data = record.data; - let run = data - .get("assignmentNonce") - .cloned() - .unwrap_or(record.evidence_key); - let team_name = data - .get("team") - .filter(|team| visible_team_names.contains(team)) - .or_else(|| { - visible_team_names - .iter() - .find(|team_name| run.starts_with(&format!("{team_name}-run-"))) - }); - let Some(team_name) = team_name else { - continue; - }; - let counts = retained_outcomes.entry(team_name.clone()).or_default(); - let status = data.get("status").map(String::as_str); - let output = data.get("output").map(String::as_str).unwrap_or(""); - if status == Some("error") || is_failure_shaped_output(output) { - counts.2 += 1; - } else if is_no_change_output(output) { - counts.1 += 1; - } else if crate::routes::tasks::is_real_deliverable(status, output) { - counts.0 += 1; - } - } - for task in task_list.items { - let is_run = task - .annotations() - .get("kars.azure.com/team-role") - .is_some_and(|role| role == "taskforce"); - if !is_run { - continue; - } - if let Some(team_name) = task.labels().get("kars.azure.com/team") { - *retained_runs.entry(team_name.clone()).or_default() += 1; - } - } - let mut summaries = list - .items - .iter() - .filter(|team| is_team_owner(team, &principal)) - .map(|team| { - let mut summary = to_summary(team); - summary.generated_task_count = summary - .generated_task_count - .max(*retained_runs.get(&summary.name).unwrap_or(&0)); - if let Some((delivered, no_action, failed)) = retained_outcomes.get(&summary.name) { - summary.retained_delivered = *delivered; - summary.retained_no_action = *no_action; - summary.retained_failed = *failed; - } - summary - }) - .collect::<Vec<_>>(); - summaries.sort_by(|left, right| right.last_activity_at.cmp(&left.last_activity_at)); - Ok(Json(summaries)) -} - -/// Derive a meaningful, distinct title for a commons entry. The controller -/// historically titled every entry by the team charter's first line, so the -/// Knowledge tab showed 50+ identical rows. We recover a real headline from the -/// (already envelope-unwrapped) content: the first markdown heading, else the -/// first substantive line, capped. Falls back to the stored title only when the -/// content yields nothing usable. `charter_line` is passed so we can recognize -/// (and replace) the legacy charter-as-title rows. -fn commons_title(stored: &str, content: &str, charter_line: &str) -> String { - let derive = || -> Option<String> { - let lines: Vec<&str> = content.lines().collect(); - let clean = |line: &str| -> Option<String> { - let heading = line - .trim() - .trim_start_matches('#') - .trim() - .trim_start_matches("**") - .trim_end_matches("**") - .trim(); - // Drop leading noise — stray "?" placeholders (where an emoji was - // stripped upstream), bullets, dashes — so the title starts on a word. - let heading = heading - .trim_start_matches(|c: char| !c.is_alphanumeric()) - .trim(); - if !heading.chars().any(char::is_alphanumeric) { - return None; - } - let lower = heading.to_ascii_lowercase(); - if [ - "kars sandbox - secure ai runtime", - "foundry project", - "model:", - "sandbox id", - "security summary", - "capabilities", - "role plan", - "role roster", - "roles spawned", - ] - .iter() - .any(|prefix| lower.starts_with(prefix)) - { - return None; - } - - let title: String = heading.chars().take(90).collect(); - Some(if heading.chars().count() > 90 { - format!("{}…", title.trim_end()) - } else { - title - }) - }; - // Prefer the first real markdown heading near the top — briefings lead - // with a status sentence then a "## …" headline, which reads far better - // as a title than the preamble line. - for line in lines.iter().take(14) { - if line.trim_start().starts_with('#') - && let Some(t) = clean(line) - { - return Some(t); - } - } - // Otherwise the first substantive line. - lines.iter().find_map(|l| clean(l)) - }; - // Replace the legacy "title == charter" rows and any empty title. The - // controller stored the title as the charter's first line *truncated to 160 - // chars*, so we match by prefix rather than equality. - let stored_t = stored.trim(); - let stored_lower = stored_t.to_ascii_lowercase(); - let cl = charter_line.trim(); - let legacy = stored_t.is_empty() - || stored_t == cl - || (stored_t.len() >= 24 && cl.starts_with(stored_t)) - || (cl.len() >= 24 && stored_t.starts_with(cl)) - || ["kars sandbox", "role plan", "role roster", "current state"] - .iter() - .any(|prefix| stored_lower.starts_with(prefix)); - if legacy { - derive().unwrap_or_else(|| stored_t.to_string()) - } else { - stored_t.to_string() - } -} - -/// `GET /api/namespaces/:ns/teams/:name/commons` — the team's shared, -/// provenance-tracked knowledge commons (design note §14). Each entry records -/// which run authored it, when, and a content digest. -pub async fn get_team_commons( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<CommonsResponse>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - // Commons name defaults to the team name when unset. - let commons = team - .spec - .knowledge_commons - .clone() - .unwrap_or_else(|| name.clone()); - - let data = cluster.read_commons(&commons).await.unwrap_or_default(); - let index: Vec<CommonsIndexEntry> = data - .get("index.json") - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default(); - - // The charter's first line is what the controller historically used as every - // entry's title; we use it to recognize and replace those duplicate rows. - let charter_line = team - .spec - .charter - .lines() - .next() - .unwrap_or(&team.spec.charter) - .to_string(); - - // Newest first, with content resolved from the companion keys. We *heal* two - // legacy defects here so the Knowledge tab is readable for entries written - // before the source-side fixes: (1) content stored as the raw agent JSON - // envelope is unwrapped to its prose deliverable; (2) the duplicate - // charter-as-title is replaced with a real headline derived from that prose. - let mut entries: Vec<CommonsEntryDto> = index - .into_iter() - .rev() - .map(|e| { - let key = format!( - "entry-{}", - e.id.chars() - .map( - |c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - ) - .collect::<String>() - ); - let content = data.get(&key).map(|c| deliverable_text(c)); - let title = match content.as_deref() { - Some(c) => commons_title(&e.title, c, &charter_line), - None => e.title.clone(), - }; - CommonsEntryDto { - id: e.id, - title, - author: e.author, - source_task: e.source_task, - created_at: e.created_at, - digest: e.digest, - size_bytes: e.size_bytes, - content, - } - }) - .collect(); - let total_entries = entries.len() as i64; - entries.truncate(50); - - Ok(Json(CommonsResponse { - commons, - count: total_entries, - entries, - })) -} - -/// `GET /api/namespaces/:ns/teams/:name/runs/:run/archive` — retrieve one -/// durable archived run directly from the full commons index. -pub async fn get_archived_run( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name, run)): Path<(String, String, String)>, -) -> AppResult<Json<CommonsEntryDto>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - let taskforce = run.starts_with(&format!("{name}-run-")); - let persistent = run.starts_with(&format!("{name}-principal-assign-")); - if !taskforce && !persistent { - return Err(AppError::NotFound); - } - let commons_name = team - .spec - .knowledge_commons - .as_deref() - .filter(|commons| !commons.trim().is_empty()) - .unwrap_or(&name); - let data = cluster - .read_commons(commons_name) - .await - .ok_or(AppError::NotFound)?; - let entry = data - .get("index.json") - .and_then(|raw| serde_json::from_str::<Vec<CommonsIndexEntry>>(raw).ok()) - .and_then(|entries| { - entries - .into_iter() - .find(|entry| entry.id == run || entry.source_task == run) - }) - .ok_or(AppError::NotFound)?; - let content = data - .get(&commons_entry_key(&entry.id)) - .map(|content| deliverable_text(content)); - let charter_line = team - .spec - .charter - .lines() - .next() - .unwrap_or(&team.spec.charter); - let title = content - .as_deref() - .map(|content| commons_title(&entry.title, content, charter_line)) - .unwrap_or(entry.title); - Ok(Json(CommonsEntryDto { - id: entry.id, - title, - author: entry.author, - source_task: entry.source_task, - created_at: entry.created_at, - digest: entry.digest, - size_bytes: entry.size_bytes, - content, - })) -} - -#[derive(Debug, serde::Deserialize)] -pub struct PromoteRequest { - pub tier: i32, -} - -/// `POST /api/namespaces/:ns/teams/:name/promote` — request a governed -/// promotion to a higher autonomy tier (§12). Sets `spec.requestedTier`; the -/// controller opens a human approval and only widens the envelope on approval. -/// The BFF never raises the envelope directly (the envelope-write VAP forbids -/// it) — it only records the request. -pub async fn promote_team( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(body): Json<PromoteRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - if !(1..=5).contains(&body.tier) { - return Err(AppError::BadRequest("tier must be in 1..5".into())); - } - let api: Api<KarsTeam> = cluster.teams(&ns); - let patch = serde_json::json!({ "spec": { "requestedTier": body.tier } }); - api.patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(patch), - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - Ok(Json(serde_json::json!({ - "requested": true, - "tier": body.tier, - "note": "A human approval has been opened. The team is promoted only once it is approved." - }))) -} - -/// `POST /api/namespaces/:ns/teams/:name/run` — trigger an immediate run -/// ("Run now"). Sets the `kars.azure.com/run-now` annotation; the controller -/// mints one taskforce run under the normal readiness gates and clears the -/// annotation. This is the only way to make a cadence-less ("on demand") team -/// act, and a manual kick for cadenced teams. -pub async fn run_team( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - Ok(Json( - request_team_run(cluster, &ns, &name, &principal).await?, - )) -} - -pub(crate) async fn request_team_run( - cluster: &crate::kars::cluster::Cluster, - ns: &str, - name: &str, - principal: &Principal, -) -> AppResult<serde_json::Value> { - let api: Api<KarsTeam> = cluster.teams(ns); - let team = require_owned_team(cluster, ns, name, principal).await?; - if team.spec.paused { - return Err(AppError::BadRequest( - "team is paused — resume it before running".into(), - )); - } - if team - .annotations() - .get("kars.azure.com/run-now") - .is_some_and(|value| !value.trim().is_empty()) - { - return Err(AppError::BadRequest( - "a run request is already pending for this team".into(), - )); - } - let active_run = cluster - .tasks(ns) - .list(&ListParams::default().labels(&format!("kars.azure.com/team={name}"))) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - .items - .into_iter() - .any(|task| { - task.annotations() - .get("kars.azure.com/team-role") - .is_some_and(|role| role == "taskforce") - && task - .spec - .execution - .as_ref() - .is_some_and(|execution| execution.launch) - }); - if active_run { - return Err(AppError::BadRequest( - "this team already has a run in progress".into(), - )); - } - let patch = serde_json::json!({ - "metadata": { "annotations": { "kars.azure.com/run-now": chrono::Utc::now().to_rfc3339() } } - }); - api.patch( - name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(patch), - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - Ok(serde_json::json!({ - "triggered": true, - "note": "A run has been requested. It appears under the team's runs once the principal launches." - })) -} - -#[derive(Debug, Deserialize)] -pub struct HaltTeamRunRequest { - pub reason: Option<String>, -} - -/// Governed emergency stop for a standing-team run. The team is paused first -/// so cadence/intake cannot immediately mint replacement work, then the active -/// task is un-launched while its trace, output, and halt decision remain. -pub async fn halt_team_run( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name, run)): Path<(String, String, String)>, - Json(body): Json<HaltTeamRunRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - Ok(Json( - request_team_run_halt( - cluster, - &ns, - &name, - &run, - body.reason.as_deref(), - &principal, - ) - .await?, - )) -} - -pub(crate) async fn request_team_run_halt( - cluster: &crate::kars::cluster::Cluster, - ns: &str, - name: &str, - run: &str, - reason: Option<&str>, - principal: &Principal, -) -> AppResult<serde_json::Value> { - require_owned_team(cluster, ns, name, principal).await?; - let tasks = cluster.tasks(ns); - let task = tasks - .get(run) - .await - .map_err(|error| AppError::Upstream(error.to_string()))?; - if task - .labels() - .get("kars.azure.com/team") - .is_none_or(|team| team != name) - || task - .annotations() - .get("kars.azure.com/team-role") - .is_none_or(|role| role != "taskforce") - { - return Err(AppError::BadRequest( - "the requested task is not a taskforce run owned by this team".into(), - )); - } - if !task - .spec - .execution - .as_ref() - .is_some_and(|execution| execution.launch) - { - return Err(AppError::Conflict( - "the requested team run is not active".into(), - )); - } - - let reason = reason - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .unwrap_or("operator emergency-stop"); - let at = chrono::Utc::now().to_rfc3339(); - cluster - .teams(ns) - .patch( - name, - &PatchParams::default(), - &Patch::Merge(serde_json::json!({"spec": {"paused": true}})), - ) - .await - .map_err(|error| AppError::Upstream(error.to_string()))?; - tasks - .patch( - run, - &PatchParams::default(), - &Patch::Merge(serde_json::json!({ - "metadata": { - "annotations": { - "kars.azure.com/halted": format!( - "halted by operator at {at}: {reason}" - ) - } - }, - "spec": {"execution": {"launch": false}} - })), - ) - .await - .map_err(|error| AppError::Upstream(error.to_string()))?; - - Ok(serde_json::json!({ - "halted": true, - "team_paused": true, - "run": run, - "at": at, - "reason": reason, - "note": "The run sandbox is being torn down and the standing team is paused. Retained evidence remains available." - })) -} - -/// `DELETE /api/namespaces/:ns/teams/:name` — permanently delete a standing -/// team. Deleting the `KarsTeam` cascade-removes its runs + member sandboxes; -/// the BFF then sweeps the team's shared-memory commons, task backlog, and -/// channel secret so nothing is orphaned. Idempotent-ish: a not-found team is a -/// 404, but missing aux objects are ignored. -pub async fn delete_team( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - cluster - .delete_team( - &ns, - &name, - team.metadata - .uid - .as_deref() - .ok_or_else(|| AppError::Conflict("Team UID missing".into()))?, - team.metadata - .resource_version - .as_deref() - .ok_or_else(|| AppError::Conflict("Team resourceVersion missing".into()))?, - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - Ok(Json(serde_json::json!({ - "deleted": true, - "note": "Team deletion requested. Core garbage-collects sources bound to this exact Team UID; legacy credential stores are retained for operator review." - }))) -} - -/// One backlog task (mirrors the controller's `team_tasks::TeamTask`). -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct TeamTaskDto { - pub id: String, - pub title: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub depends_on: Vec<String>, - #[serde(default)] - pub acceptance_criteria: Vec<String>, - #[serde(default)] - pub review_required: bool, - pub status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub run: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub created_at: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub done_at: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stuck_since: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assignment_nonce: Option<String>, -} - -#[derive(Debug, Deserialize)] -pub struct AddTaskRequest { - #[serde(default)] - pub id: Option<String>, - pub title: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub depends_on: Vec<String>, - #[serde(default)] - pub acceptance_criteria: Vec<String>, - #[serde(default)] - pub review_required: bool, -} - -pub(crate) fn read_task_list(raw: &str) -> Vec<TeamTaskDto> { - serde_json::from_str::<Vec<TeamTaskDto>>(raw).unwrap_or_default() -} - -/// `GET /api/namespaces/:ns/teams/:name/tasks` — the team's task backlog. -pub async fn list_team_tasks( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<Vec<TeamTaskDto>>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - Ok(Json(read_task_list(&cluster.read_team_tasks(&name).await))) -} - -/// `POST /api/namespaces/:ns/teams/:name/tasks` — append a task to the backlog. -/// The controller picks up the oldest `pending` task on its next run. -pub async fn add_team_task( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(b): Json<AddTaskRequest>, -) -> AppResult<Json<TeamTaskDto>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - if b.title.trim().is_empty() { - return Err(AppError::BadRequest("task title is required".into())); - } - let existing_tasks = read_task_list(&cluster.read_team_tasks(&name).await); - if let Some(missing) = b.depends_on.iter().find(|dependency| { - !existing_tasks - .iter() - .any(|task| task.id.as_str() == dependency.as_str()) - }) { - return Err(AppError::BadRequest(format!( - "task dependency '{missing}' does not exist" - ))); - } - let requested_id = - b.id.as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - .map(|id| { - id.to_ascii_lowercase() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '-' { - character - } else { - '-' - } - }) - .collect::<String>() - .trim_matches('-') - .chars() - .take(63) - .collect::<String>() - }) - .filter(|id| !id.is_empty()); - let task_id = - requested_id.unwrap_or_else(|| format!("t-{}", chrono::Utc::now().timestamp_micros())); - if existing_tasks.iter().any(|task| task.id == task_id) { - return Err(AppError::BadRequest(format!( - "task id '{task_id}' already exists" - ))); - } - let task = TeamTaskDto { - id: task_id, - title: b.title.trim().to_string(), - description: b.description.trim().to_string(), - depends_on: b.depends_on, - acceptance_criteria: b - .acceptance_criteria - .into_iter() - .map(|criterion| criterion.trim().to_string()) - .filter(|criterion| !criterion.is_empty()) - .take(20) - .collect(), - review_required: b.review_required, - status: "pending".into(), - run: None, - created_at: Some(chrono::Utc::now().to_rfc3339()), - done_at: None, - stuck_since: None, - assignment_nonce: None, - }; - let task_for_write = task.clone(); - let duplicate = std::sync::atomic::AtomicBool::new(false); - let missing_dependency = std::sync::Mutex::new(None::<String>); - cluster - .update_configmap_data( - &format!("kars-team-tasks-{name}"), - &[("kars.azure.com/team-tasks", name.as_str())], - |data| { - let mut tasks = data - .get("tasks.json") - .map(|raw| read_task_list(raw)) - .unwrap_or_default(); - if tasks - .iter() - .any(|existing| existing.id == task_for_write.id) - { - duplicate.store(true, std::sync::atomic::Ordering::Relaxed); - return; - } - if let Some(dependency) = task_for_write.depends_on.iter().find(|dependency| { - !tasks - .iter() - .any(|task| task.id.as_str() == dependency.as_str()) - }) { - *missing_dependency.lock().expect("dependency lock") = Some(dependency.clone()); - return; - } - tasks.push(task_for_write.clone()); - data.insert( - "tasks.json".into(), - serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), - ); - }, - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - if duplicate.load(std::sync::atomic::Ordering::Relaxed) { - return Err(AppError::Conflict(format!( - "task id '{}' already exists", - task.id - ))); - } - if let Some(dependency) = missing_dependency.lock().expect("dependency lock").clone() { - return Err(AppError::Conflict(format!( - "task dependency '{dependency}' disappeared during update" - ))); - } - Ok(Json(task)) -} - -/// `DELETE /api/namespaces/:ns/teams/:name/tasks/:task_id` — remove a task from -/// the backlog (any status; removing an active task doesn't stop its run). -pub async fn delete_team_task( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name, task_id)): Path<(String, String, String)>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let removed = std::sync::atomic::AtomicBool::new(false); - let dependency_blocked = std::sync::atomic::AtomicBool::new(false); - cluster - .update_configmap_data( - &format!("kars-team-tasks-{name}"), - &[("kars.azure.com/team-tasks", name.as_str())], - |data| { - let mut tasks = data - .get("tasks.json") - .map(|raw| read_task_list(raw)) - .unwrap_or_default(); - if tasks.iter().any(|task| { - task.id != task_id && task.depends_on.iter().any(|id| id == &task_id) - }) { - dependency_blocked.store(true, std::sync::atomic::Ordering::Relaxed); - return; - } - let before = tasks.len(); - tasks.retain(|task| task.id != task_id); - removed.store(tasks.len() != before, std::sync::atomic::Ordering::Relaxed); - data.insert( - "tasks.json".into(), - serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), - ); - }, - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - if dependency_blocked.load(std::sync::atomic::Ordering::Relaxed) { - return Err(AppError::Conflict( - "cannot delete a milestone that is referenced by dependent work".into(), - )); - } - if !removed.load(std::sync::atomic::Ordering::Relaxed) { - return Err(AppError::NotFound); - } - - Ok(Json(serde_json::json!({ "removed": true }))) -} - -#[derive(Debug, Deserialize)] -pub struct ReviewTeamTaskRequest { - pub decision: String, - pub feedback: Option<String>, -} - -pub async fn review_team_task( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name, task_id)): Path<(String, String, String)>, - Json(body): Json<ReviewTeamTaskRequest>, -) -> AppResult<Json<TeamTaskDto>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - if !matches!(body.decision.as_str(), "approve" | "request_changes") { - return Err(AppError::BadRequest( - "decision must be approve or request_changes".into(), - )); - } - let current = read_task_list(&cluster.read_team_tasks(&name).await); - let existing = current - .iter() - .find(|task| task.id == task_id) - .cloned() - .ok_or(AppError::NotFound)?; - if existing.status != "awaiting_review" { - return Err(AppError::BadRequest( - "only an awaiting_review milestone can be decided".into(), - )); - } - let feedback = body - .feedback - .as_deref() - .map(str::trim) - .filter(|feedback| !feedback.is_empty()) - .map(str::to_string); - if body.decision == "request_changes" && feedback.is_none() { - return Err(AppError::BadRequest( - "request_changes requires written feedback".into(), - )); - } - let approvals = cluster.approvals(&ns); - let selector = format!("kars.azure.com/team={name},kars.azure.com/milestone={task_id}"); - let approval = approvals - .list(&ListParams::default().labels(&selector)) - .await - .map_err(|error| AppError::Upstream(error.to_string()))? - .into_iter() - .find(|approval| { - approval.spec.action.kind == "checkpoint" - && approval.spec.decision.is_none() - && approval - .status - .as_ref() - .and_then(|status| status.phase.as_deref()) - .is_none_or(|phase| phase == "Pending") - }) - .ok_or_else(|| { - AppError::Conflict( - "checkpoint approval is not pending yet; refresh before deciding".into(), - ) - })?; - approvals - .patch( - &approval.name_any(), - &PatchParams::default(), - &Patch::Merge(serde_json::json!({ - "spec": { - "decision": { - "verdict": if body.decision == "approve" { "approve" } else { "deny" }, - "decider": principal.name, - "deciderSubject": principal.sub, - "deciderRoles": principal.roles, - "reason": feedback, - } - } - })), - ) - .await - .map_err(|error| AppError::Upstream(error.to_string()))?; - - let updated = read_task_list(&cluster.read_team_tasks(&name).await) - .into_iter() - .find(|task| task.id == task_id) - .ok_or(AppError::NotFound)?; - Ok(Json(updated)) -} - -// ─── Communication channels (part of a team's envelope) ────────────────────── -// A standing team can report to its operator over Telegram / Slack / Discord / -// WhatsApp. Tokens live ONLY in the K8s Secret `kars-team-channel-<team>`, -// propagated by the controller into each ephemeral run sandbox. SECURITY: the -// API is write-only for tokens — GET never returns a token, only which channels -// are enabled. - -/// Map a channel id → the env keys the sandbox entrypoint reads for it. -pub(crate) fn channel_env_keys(channel: &str) -> &'static [&'static str] { - match channel { - "telegram" => &["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOW_FROM"], - "slack" => &["SLACK_BOT_TOKEN"], - "discord" => &["DISCORD_BOT_TOKEN"], - "whatsapp" => &["WHATSAPP_ENABLED"], - // Teams uses a dedicated Secret (kars-bridge-teams), not workspace channels. - // Only a non-secret marker key goes in workspace-channels for enabled detection. - "teams" => &["TEAMS_ENABLED"], - _ => &[], - } -} - -/// Derive which channels are enabled from the present secret keys (no values). -pub(crate) const SUPPORTED_CHANNELS: &[&str] = - &["telegram", "slack", "discord", "whatsapp", "teams"]; - -pub(crate) fn channels_from_keys(keys: &[String]) -> Vec<String> { - SUPPORTED_CHANNELS - .iter() - .copied() - .filter(|ch| { - // A channel is "enabled" if its primary token/flag key is present. - let primary = channel_env_keys(ch).first().copied().unwrap_or(""); - keys.iter().any(|k| k == primary) - }) - .map(String::from) - .collect() -} - -#[derive(Debug, Clone, Serialize)] -pub struct ChannelQualificationDto { - pub channel: String, - pub enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub qualified: Option<bool>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option<String>, -} - -#[derive(Debug, Serialize)] -pub struct ChannelsDto { - /// Channel ids currently enabled (e.g. ["telegram","slack"]). - pub enabled: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub statuses: Vec<ChannelQualificationDto>, -} - -async fn effective_team_route( - cluster: &crate::kars::cluster::Cluster, - team: &KarsTeam, -) -> Option<(String, String, String)> { - let runtime = team - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.runtime.clone()) - .filter(|runtime| !runtime.is_empty()) - .unwrap_or_else(|| "OpenClaw".to_string()); - if let Some(model) = team - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.model.as_ref()) - { - return Some((runtime, model.provider.clone(), model.deployment.clone())); - } - let deployment = cluster.controller_default_model().await?; - let provider = cluster.controller_provider().await.map(|(id, _, _)| id); - Some(( - runtime, - crate::routes::options::provider_for(&deployment, None, provider.as_deref()), - deployment, - )) -} - -async fn channel_statuses_for_team( - cluster: &crate::kars::cluster::Cluster, - team: &KarsTeam, - enabled: &[String], -) -> Vec<ChannelQualificationDto> { - let route = effective_team_route(cluster, team).await; - SUPPORTED_CHANNELS - .iter() - .copied() - .map(|channel| { - let enabled = enabled.iter().any(|configured| configured == channel); - match route.as_ref() { - Some((runtime, provider, deployment)) => { - let qualification = crate::routes::options::channel_adapter_qualified_for_route( - runtime, - provider, - deployment, - channel, - ); - match qualification { - Ok(qualified) => ChannelQualificationDto { - channel: channel.to_string(), - enabled, - qualified: Some(qualified), - detail: Some(if qualified { - format!( - "Retained channel-adapter evidence exists for {}.", - crate::routes::options::route_label( - runtime, provider, deployment - ) - ) - } else { - format!( - "No retained channel-adapter qualification matches {}. Credentials can be configured later, but generic route records do not prove this channel adapter.", - crate::routes::options::route_label( - runtime, provider, deployment - ) - ) - }), - }, - Err(error) => ChannelQualificationDto { - channel: channel.to_string(), - enabled, - qualified: None, - detail: Some(format!( - "Channel qualification could not be evaluated: {error}" - )), - }, - } - } - None => ChannelQualificationDto { - channel: channel.to_string(), - enabled, - qualified: None, - detail: Some( - "The team has no effective runtime/model route yet, so channel qualification cannot be evaluated." - .into(), - ), - }, - } - }) - .collect() -} - -#[derive(Debug, Deserialize)] -pub struct SetChannelRequest { - /// Channel id: telegram | slack | discord | whatsapp. - pub channel: String, - /// The channel's bot token / OAuth token. For whatsapp send "true". - pub token: String, - /// Telegram only: comma-separated allowed numeric user IDs. - #[serde(default)] - pub allow_from: Option<String>, -} - -/// `GET /api/namespaces/:ns/teams/:name/channels` — which channels are enabled. -pub async fn get_team_channels( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<ChannelsDto>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - let keys = cluster - .team_channel_keys(&ns, &name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let enabled = channels_from_keys(&keys); - Ok(Json(ChannelsDto { - statuses: channel_statuses_for_team(cluster, &team, &enabled).await, - enabled, - })) -} - -/// `POST /api/namespaces/:ns/teams/:name/channels` — enable/update a channel. -/// The token is written straight into the team's channel Secret and never -/// echoed back. -pub async fn set_team_channel( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(b): Json<SetChannelRequest>, -) -> AppResult<Json<ChannelsDto>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - let keys = channel_env_keys(b.channel.as_str()); - if keys.is_empty() { - return Err(AppError::BadRequest(format!( - "unknown channel '{}': use telegram|slack|discord|whatsapp", - b.channel - ))); - } - if b.token.trim().is_empty() { - return Err(AppError::BadRequest("token is required".into())); - } - let mut data = std::collections::BTreeMap::new(); - // whatsapp uses a presence flag, not a token. - let primary = keys[0]; - data.insert(primary.to_string(), b.token.trim().to_string()); - if b.channel == "telegram" - && let Some(allow) = b - .allow_from - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - data.insert("TELEGRAM_ALLOW_FROM".to_string(), allow.to_string()); - } - cluster - .merge_team_channel(&ns, &name, data) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let after = cluster - .team_channel_keys(&ns, &name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let enabled = channels_from_keys(&after); - Ok(Json(ChannelsDto { - statuses: channel_statuses_for_team(cluster, &team, &enabled).await, - enabled, - })) -} - -/// `DELETE /api/namespaces/:ns/teams/:name/channels/:channel` — disable a channel. -pub async fn delete_team_channel( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name, channel)): Path<(String, String, String)>, -) -> AppResult<Json<ChannelsDto>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - let keys: Vec<String> = channel_env_keys(channel.as_str()) - .iter() - .map(|s| s.to_string()) - .collect(); - if keys.is_empty() { - return Err(AppError::BadRequest(format!("unknown channel '{channel}'"))); - } - cluster - .remove_team_channel_keys(&ns, &name, &keys) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let after = cluster - .team_channel_keys(&ns, &name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let enabled = channels_from_keys(&after); - Ok(Json(ChannelsDto { - statuses: channel_statuses_for_team(cluster, &team, &enabled).await, - enabled, - })) -} - -pub async fn get_team( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<TeamDetailDto>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - - // Resolve generated task-force tasks: KarsTasks in the namespace owned by - // this team whose name carries the `<team>-run-` standing-operation prefix. - let tasks: Api<crate::kars::task::KarsTask> = cluster.tasks(&ns); - let run_prefix = format!("{name}-run-"); - let mut generated_tasks: Vec<String> = tasks - .list(&ListParams::default()) - .await - .map(|l| { - l.items - .iter() - .map(kube::ResourceExt::name_any) - .filter(|n| n.starts_with(&run_prefix)) - .collect() - }) - .unwrap_or_default(); - generated_tasks.sort(); - generated_tasks.reverse(); - let retained_run_count = generated_tasks.len() as i64; - let newest_retained_run = generated_tasks.first().cloned(); - let retained_run_names = generated_tasks - .iter() - .cloned() - .collect::<std::collections::HashSet<_>>(); - let mut recent_outcomes = cluster - .list_mission_output_evidence() - .await - .into_iter() - .filter_map(|record| { - let run = record - .data - .get("assignmentNonce") - .cloned() - .unwrap_or(record.evidence_key); - (retained_run_names.contains(&run) || record.data.get("team") == Some(&name)) - .then(|| outcome_from_output(run, &record.data)) - }) - .collect::<Vec<_>>(); - recent_outcomes.sort_by(|left, right| { - right - .finished_at - .cmp(&left.finished_at) - .then_with(|| right.run.cmp(&left.run)) - }); - let mut recent_outcome_summary = TeamOutcomeSummaryDto::default(); - for outcome in &recent_outcomes { - match outcome.disposition.as_str() { - "change_proposed" => recent_outcome_summary.change_proposed += 1, - "no_action_needed" => recent_outcome_summary.no_action_needed += 1, - "failed" => recent_outcome_summary.failed += 1, - _ => recent_outcome_summary.completed += 1, - } - } - - let st = team.status.as_ref(); - let member_names: Vec<String> = st - .map(|s| s.member_refs.iter().map(|r| r.name.clone()).collect()) - .unwrap_or_default(); - - // Build the org chart: pair each roster role with its materialized member - // task (named `<team>-<role>` by the reconciler). - let roster: Vec<TeamRoleDto> = team - .spec - .roster - .iter() - .map(|role| { - let member_task = format!("{name}-{}", role.name); - let materialized = member_names.iter().any(|m| m == &member_task); - TeamRoleDto { - name: role.name.clone(), - system_prompt: role.system_prompt.clone(), - tier: role.envelope.as_ref().map(|e| e.tier), - member_task: materialized.then_some(member_task), - skills: role.skills.clone(), - runtime: role.blueprint.as_ref().and_then(|b| b.runtime.clone()), - model: role - .blueprint - .as_ref() - .and_then(|b| b.model.as_ref()) - .map(|m| format!("{}::{}", m.provider, m.deployment)), - } - }) - .collect(); - - let bp = team.spec.blueprint.as_ref(); - // Effective tool policy: explicit blueprint override, else the system - // default `kars-default` (applied to every run sandbox via the - // `system-default=true` sandbox selector). Never "none" — a run without a - // governing policy fails closed. - let bp_tool_policy = bp.and_then(|b| b.tool_policy.clone()); - let tool_policy_default = bp_tool_policy.is_none(); - let tool_policy = bp_tool_policy.or_else(|| Some("kars-default".to_string())); - // Effective model: explicit blueprint override, else the controller's - // KARS_TASK_DEFAULT_MODEL that every run actually inherits. - let bp_model = bp - .and_then(|b| b.model.as_ref()) - .map(|m| format!("{}::{}", m.provider, m.deployment)); - let model_default = bp_model.is_none(); - let model = match bp_model { - Some(m) => Some(m), - None => cluster.controller_default_model().await, - }; - // Effective harness: explicit blueprint override, else the sandbox default - // (OpenClaw). Team runs inherit this via launched_run_blueprint. - let bp_runtime = bp.and_then(|b| b.runtime.clone()).filter(|s| !s.is_empty()); - let runtime_default = bp_runtime.is_none(); - let runtime = bp_runtime.or_else(|| Some("OpenClaw".to_string())); - let egress: Vec<String> = bp - .map(|b| { - b.egress - .iter() - .map(|e| { - if let Some(p) = e.port { - format!("{}:{}", e.host, p) - } else { - e.host.clone() - } - }) - .collect() - }) - .unwrap_or_default(); - - // Concrete "domains reached so far": aggregate the learn-mode observation - // buffers of the team's currently-running run sandboxes (`<team>-run-<epoch>` - // in kars-system). Per-run + best-effort — empty when no run is live. - let mut learned_egress: Vec<String> = Vec::new(); - if let Ok(sandboxes) = cluster - .list_kind_labeled("KarsSandbox", &format!("kars.azure.com/team={name}")) - .await - { - let mut seen = std::collections::BTreeSet::new(); - for sb in sandboxes.iter().take(8) { - let sb_name = sb.metadata.name.clone().unwrap_or_default(); - let running = sb - .data - .get("status") - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - == Some("Running"); - if !running || sb_name.is_empty() { - continue; - } - if let Ok(domains) = cluster.sandbox_learned_domains(&sb_name).await { - for d in domains { - seen.insert(d); - } - } - } - learned_egress = seen.into_iter().collect(); - } - - Ok(Json(TeamDetailDto { - name: team.name_any(), - display_name: team.spec.display_name.clone(), - charter: team.spec.charter.clone(), - phase: phase_of(&team), - reporting_to: team.spec.reporting_to.clone(), - knowledge_commons: team.spec.knowledge_commons.clone(), - tier: team.spec.envelope.tier, - authority_ceiling: team.spec.envelope.authority_ceiling, - delegation_depth: team.spec.envelope.delegation_depth, - paused: team.spec.paused, - every_minutes: team.spec.cadence.as_ref().and_then(|c| c.every_minutes), - lifecycle_mode: effective_lifecycle_mode(&team), - warm_idle_seconds: team.spec.warm_idle_seconds, - runtime_state: runtime_state(&team), - current_assignment_nonce: st.and_then(|s| s.current_assignment_nonce.clone()), - current_assignment_task: st.and_then(|s| s.current_assignment_task.clone()), - idle_deadline_at: st.and_then(|s| s.idle_deadline_at.clone()), - envelope_digest: st.and_then(|s| s.envelope_digest.clone()), - principal_task: st.and_then(|s| s.principal_ref.as_ref().map(|r| r.name.clone())), - roster, - member_count: st.and_then(|s| s.member_count).unwrap_or(0), - generated_task_count: st - .and_then(|s| s.generated_task_count) - .unwrap_or(0) - .max(retained_run_count), - last_generated_task: newest_retained_run - .or_else(|| st.and_then(|s| s.last_generated_task.clone())), - last_run_at: st.and_then(|s| s.last_run_at.clone()), - next_run_at: st.and_then(|s| s.next_run_at.clone()), - detail: st.and_then(|s| s.detail.clone()), - health: st.and_then(|s| s.health.clone()), - runs_succeeded: st.and_then(|s| s.runs_succeeded).unwrap_or(0), - tokens_spent_total: st.and_then(|s| s.tokens_spent_total).unwrap_or(0), - commons_entry_count: st.and_then(|s| s.commons_entry_count).unwrap_or(0), - last_success_at: st.and_then(|s| s.last_success_at.clone()), - created_at: team - .metadata - .creation_timestamp - .as_ref() - .map(|timestamp| timestamp.0.to_rfc3339()), - last_activity_at: [ - st.and_then(|status| status.last_activity_at.clone()), - st.and_then(|status| status.last_run_at.clone()), - st.and_then(|status| status.last_success_at.clone()), - team.metadata - .creation_timestamp - .as_ref() - .map(|timestamp| timestamp.0.to_rfc3339()), - ] - .into_iter() - .flatten() - .max(), - generated_tasks, - recent_outcomes, - recent_outcome_summary, - tool_policy, - tool_policy_default, - mcp_servers: bp.map(|b| b.mcp_servers.clone()).unwrap_or_default(), - git_write_repos: bp - .and_then(|b| b.git_write.as_ref()) - .map(|git_write| git_write.repos.clone()) - .unwrap_or_default(), - egress, - egress_mode: bp.and_then(|b| b.egress_mode.clone()), - learned_egress, - network_posture: "Default-deny egress (kernel-level). Only the inference router and AGT mesh relay are reachable; novel domains need an approved egress request.".to_string(), - model, - model_fallbacks: bp - .map(|blueprint| { - blueprint - .model_fallbacks - .iter() - .map(|model| format!("{}::{}", model.provider, model.deployment)) - .collect() - }) - .unwrap_or_default(), - model_default, - memory: bp.and_then(|blueprint| blueprint.memory.clone()), - runtime, - runtime_default, - isolation: bp.and_then(|b| b.isolation.clone()), - execution_plan: bp - .and_then(|blueprint| blueprint.execution_plan.as_ref()) - .map(crate::routes::tasks::ExecutionPlanDto::from_crd), - tasks: read_task_list(&cluster.read_team_tasks(&name).await), - channels: channels_from_keys(&cluster.team_channel_keys(&ns,&name).await.map_err(|e|AppError::Upstream(e.to_string()))?), - })) -} - -/// One event in a team's continuous ledger. -#[derive(Debug, Serialize)] -pub struct LedgerEvent { - pub at: String, - pub kind: String, - pub summary: String, - pub task: Option<String>, - pub tokens: Option<i64>, -} - -/// `GET /api/namespaces/:ns/teams/:name/ledger` — the team's continuous ledger -/// (§14): a streaming, append-only timeline of everything the standing -/// operation has done, composed from the durable records the controller already -/// writes (generated runs + their deliverables/tokens + harvested knowledge + -/// published digests). Newest first. -pub async fn get_team_ledger( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<Vec<LedgerEvent>>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let mut events: Vec<LedgerEvent> = Vec::new(); - - // Run deliverables (delivery events, with token cost). - let run_prefix = format!("{name}-run-"); - for record in cluster.list_mission_output_evidence().await { - let data = record.data; - let task = data - .get("assignmentNonce") - .cloned() - .unwrap_or(record.evidence_key); - let belongs_to_team = data.get("team") == Some(&name) - || task.starts_with(&run_prefix) - || task.starts_with(&format!("{name}-principal-assign-")); - if !belongs_to_team { - continue; - } - let at = data.get("finishedAt").cloned().unwrap_or_default(); - let tokens = data.get("totalTokens").and_then(|t| t.parse::<i64>().ok()); - let ok = data.get("status").map(String::as_str) == Some("ok"); - events.push(LedgerEvent { - at, - kind: if ok { - "delivery".into() - } else { - "delivery_error".into() - }, - summary: data - .get("output") - .map(|o| { - deliverable_text(o) - .lines() - .find(|l| !l.trim().is_empty()) - .unwrap_or("") - .chars() - .take(140) - .collect::<String>() - }) - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| "run completed".into()), - task: Some(task), - tokens, - }); - } - - // Harvested knowledge (commons entries). Heal the legacy charter-as-title so - // the ledger reads "Learned: <real headline>" rather than the same charter - // line on every knowledge event. - if let Some(cm) = cluster.read_commons(&name).await - && let Some(idx) = cm.get("index.json") - && let Ok(entries) = serde_json::from_str::<Vec<CommonsIndexEntry>>(idx) - { - let charter_line = cluster - .teams(&ns) - .get_opt(&name) - .await - .ok() - .flatten() - .map(|t| { - t.spec - .charter - .lines() - .next() - .unwrap_or(&t.spec.charter) - .to_string() - }) - .unwrap_or_default(); - for e in entries { - let key = format!( - "entry-{}", - e.id.chars() - .map( - |c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { - c - } else { - '_' - } - ) - .collect::<String>() - ); - let title = match cm.get(&key) { - Some(c) => commons_title(&e.title, &deliverable_text(c), &charter_line), - None => e.title.clone(), - }; - events.push(LedgerEvent { - at: e.created_at, - kind: "knowledge".into(), - summary: format!("Learned: {title}"), - task: Some(e.source_task), - tokens: None, - }); - } - } - - // Published digests (report events). - for d in cluster.list_team_digests().await { - if d.get("team").and_then(|v| v.as_str()) != Some(name.as_str()) { - continue; - } - events.push(LedgerEvent { - at: d - .get("at") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - kind: "digest".into(), - summary: d - .get("summary") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - task: None, - tokens: None, - }); - } - - events.sort_by(|a, b| b.at.cmp(&a.at)); - events.truncate(100); - Ok(Json(events)) -} - #[derive(Debug, Deserialize)] pub struct CreateTeamRequest { pub name: String, @@ -2063,174 +296,6 @@ pub struct CreateRole { #[serde(default)] pub skills: Vec<String>, } - -/// Build a `spec.roster` array from create/update role inputs, shared by team -/// creation and roster editing so both paths produce identical role shapes -/// (per-member systemPrompt + blueprint{runtime, model} + skills). -/// Reject roster role names that collide with the reserved task names the -/// controller derives from the team (`<team>-principal`). A role named -/// "principal" would otherwise re-materialize the principal task as a member -/// parented to itself, deadlocking the whole team. The controller also skips -/// such a role defensively, but rejecting here gives the operator a clear error -/// instead of a silently dropped role. -fn reject_reserved_role_names(team: &str, roles: &[CreateRole]) -> AppResult<()> { - let _ = team; - for r in roles { - let n = r.name.trim().to_ascii_lowercase(); - if n == "principal" { - return Err(AppError::BadRequest( - "role name 'principal' is reserved for the team's authority root — rename this role".into(), - )); - } - } - Ok(()) -} - -fn build_roster(roles: &[CreateRole]) -> Vec<serde_json::Value> { - roles - .iter() - .filter(|r| !r.name.trim().is_empty()) - .map(|r| { - let mut role = serde_json::json!({ "name": r.name.trim() }); - if let Some(sp) = &r.system_prompt - && !sp.trim().is_empty() - { - role["systemPrompt"] = serde_json::json!(sp.trim()); - } - let mut bp = serde_json::Map::new(); - if let Some(rt) = &r.runtime - && !rt.is_empty() - { - // Harness capability, defense-in-depth: a bootstrap-only adapter - // (no autonomous task loop) can't run a standing member — a - // hand-composed team could still name one, so correct it to - // OpenClaw here too. Hermes/BYO are autonomous and pass through. - let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { - "OpenClaw" - } else { - rt.as_str() - }; - bp.insert("runtime".into(), serde_json::json!(rt)); - } - if let Some(m) = &r.model - && let Some((provider, deployment)) = m.split_once("::") - { - bp.insert( - "model".into(), - serde_json::json!({ "provider": provider, "deployment": deployment }), - ); - } - if !bp.is_empty() { - role["blueprint"] = serde_json::Value::Object(bp); - } - if !r.skills.is_empty() { - role["skills"] = serde_json::json!(r.skills); - } - role - }) - .collect() -} - -fn normalize_mcp_servers(servers: &[String]) -> AppResult<Vec<String>> { - let mut seen = std::collections::BTreeSet::new(); - let mut normalized = Vec::new(); - for server in servers { - let server = server.trim(); - if !server.is_empty() && seen.insert(server.to_string()) { - normalized.push(server.to_string()); - } - } - if normalized.len() > 8 { - return Err(AppError::BadRequest( - "a team may connect at most 8 MCP servers".into(), - )); - } - Ok(normalized) -} - -fn normalize_autonomous_runtime(runtime: &mut Option<String>) { - if let Some(value) = runtime.as_deref() - && (value.trim().is_empty() || crate::routes::compose::is_non_autonomous_harness(value)) - { - *runtime = Some("OpenClaw".into()); - } -} - -fn normalize_model_fallback_routes( - routes: &[String], - primary: Option<&str>, -) -> AppResult<Vec<String>> { - let mut seen = std::collections::BTreeSet::new(); - let mut normalized = Vec::new(); - for route in routes { - let route = route.trim(); - if route.is_empty() - || primary.is_some_and(|primary| route == primary) - || !seen.insert(route.to_string()) - { - continue; - } - let valid = route - .split_once("::") - .is_some_and(|(provider, deployment)| { - !provider.trim().is_empty() && !deployment.trim().is_empty() - }); - if !valid { - return Err(AppError::BadRequest( - "model_fallbacks entries must be encoded as provider::deployment".into(), - )); - } - normalized.push(route.to_string()); - } - if normalized.len() > 8 { - return Err(AppError::BadRequest( - "model_fallbacks may contain at most 8 unique routes".into(), - )); - } - Ok(normalized) -} - -fn validate_model_route(models: &[ModelOption], route: &str) -> AppResult<()> { - let route = route.trim(); - if route.is_empty() { - return Ok(()); - } - let valid = route - .split_once("::") - .is_some_and(|(provider, deployment)| { - models - .iter() - .any(|model| model.provider == provider && model.deployment == deployment) - }); - if valid { - Ok(()) - } else { - Err(AppError::BadRequest(format!( - "model route `{route}` is not present in the live model catalogue" - ))) - } -} - -fn option_named<'a>(items: &'a [RefOption], namespace: &str, name: &str) -> Option<&'a RefOption> { - items - .iter() - .find(|option| option.name == name && option.namespace == namespace) -} - -fn team_role_qualification_requirements( - plan: &crate::routes::tasks::ExecutionPlanDto, - role_name: &str, -) -> std::collections::BTreeSet<String> { - let mut required = - std::collections::BTreeSet::from(["team".to_string(), "telemetry".to_string()]); - if let Some(role) = plan.roles.iter().find(|role| role.name == role_name) { - for phase in &role.phases { - required.extend(phase.capabilities.iter().cloned()); - } - } - required -} - struct TeamModelRoutes<'a> { namespace: &'a str, runtime: Option<&'a str>, @@ -2242,696 +307,6 @@ struct TeamModelRoutes<'a> { memory: Option<&'a str>, } -fn validate_team_model_routes(options: &Options, routes: TeamModelRoutes<'_>) -> AppResult<()> { - let TeamModelRoutes { - namespace, - runtime, - model, - model_fallbacks, - roles, - execution_plan, - mcp_servers, - memory, - } = routes; - let memory = memory.map(str::trim).filter(|memory| !memory.is_empty()); - let default_route = options - .models - .iter() - .find(|model| model.is_default) - .or_else(|| options.models.first()) - .map(|model| format!("{}::{}", model.provider, model.deployment)) - .unwrap_or_default(); - let principal_route = model - .map(str::trim) - .filter(|model| !model.is_empty()) - .unwrap_or(default_route.as_str()); - validate_model_route(&options.models, principal_route)?; - let principal_runtime = runtime - .map(str::trim) - .filter(|runtime| !runtime.is_empty()) - .unwrap_or("OpenClaw"); - let principal_model = principal_route.split_once("::").ok_or_else(|| { - AppError::BadRequest(format!( - "model route `{principal_route}` must use provider::deployment" - )) - })?; - let principal_blueprint = crate::routes::tasks::BlueprintDto { - runtime: Some(principal_runtime.to_string()), - model: Some(crate::routes::tasks::ModelDto { - provider: principal_model.0.to_string(), - deployment: principal_model.1.to_string(), - }), - model_fallbacks: Vec::new(), - instructions: None, - tool_policy: None, - mcp_servers: mcp_servers.to_vec(), - egress: Vec::new(), - egress_mode: None, - isolation: None, - memory: memory.map(str::to_string), - skills: roles - .iter() - .flat_map(|role| role.skills.iter().cloned()) - .collect(), - execution_plan: Some(execution_plan.clone()), - }; - let (principal_required, principal_parallel) = - crate::routes::validate::qualification_requirements(&principal_blueprint, Some("team")); - validate_qualified_model_route( - principal_runtime, - principal_route, - &principal_required, - principal_parallel, - )?; - let principal_route_label = crate::routes::options::route_label( - principal_runtime, - principal_model.0, - principal_model.1, - ); - for server in mcp_servers { - let option = option_named(&options.mcp_servers, namespace, server).ok_or_else(|| { - AppError::BadRequest(format!( - "MCP server `{server}` is not present in the live options catalogue" - )) - })?; - match crate::routes::options::mcp_server_qualified_for_route( - principal_runtime, - principal_model.0, - principal_model.1, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "MCP server `{server}` lacks retained resource qualification for {principal_route_label} at current schema {}", - option.tool_schema_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - if let Some(memory) = memory.map(str::trim).filter(|memory| !memory.is_empty()) { - let option = option_named(&options.memories, namespace, memory).ok_or_else(|| { - AppError::BadRequest(format!( - "memory `{memory}` is not present in the live options catalogue" - )) - })?; - match crate::routes::options::memory_binding_qualified_for_route( - principal_runtime, - principal_model.0, - principal_model.1, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "memory `{memory}` lacks retained resource qualification for {principal_route_label} at backend {} / compiled digest {}", - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - for role in roles { - let role_route = role - .model - .as_deref() - .map(str::trim) - .filter(|model| !model.is_empty()) - .unwrap_or(principal_route); - validate_model_route(&options.models, role_route)?; - let role_runtime = role - .runtime - .as_deref() - .map(str::trim) - .filter(|runtime| !runtime.is_empty()) - .unwrap_or(principal_runtime); - let role_required = team_role_qualification_requirements(execution_plan, &role.name); - validate_qualified_model_route(role_runtime, role_route, &role_required, 1)?; - let (provider, deployment) = role_route.split_once("::").ok_or_else(|| { - AppError::BadRequest(format!( - "model route `{role_route}` must use provider::deployment" - )) - })?; - let role_route_label = - crate::routes::options::route_label(role_runtime, provider, deployment); - if role_required.contains("mcp") { - for server in mcp_servers { - let option = - option_named(&options.mcp_servers, namespace, server).ok_or_else(|| { - AppError::BadRequest(format!( - "MCP server `{server}` is not present in the live options catalogue" - )) - })?; - match crate::routes::options::mcp_server_qualified_for_route( - role_runtime, - provider, - deployment, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "role `{}` MCP server `{server}` lacks retained resource qualification for {role_route_label} at current schema {}", - role.name, - option.tool_schema_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - } - if role_required.contains("memory") - && let Some(memory) = memory.map(str::trim).filter(|memory| !memory.is_empty()) - { - let option = option_named(&options.memories, namespace, memory).ok_or_else(|| { - AppError::BadRequest(format!( - "memory `{memory}` is not present in the live options catalogue" - )) - })?; - match crate::routes::options::memory_binding_qualified_for_route( - role_runtime, - provider, - deployment, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "role `{}` memory `{memory}` lacks retained resource qualification for {role_route_label} at backend {} / compiled digest {}", - role.name, - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - for skill in &role.skills { - let option = option_named(&options.skills, namespace, skill).ok_or_else(|| { - AppError::BadRequest(format!( - "skill `{skill}` is not present in the approved live catalogue" - )) - })?; - match crate::routes::options::skill_version_qualified_for_route( - role_runtime, - provider, - deployment, - option, - ) { - Ok(true) => {} - Ok(false) => { - return Err(AppError::BadRequest(format!( - "role `{}` skill `{skill}` lacks retained resource qualification for {role_route_label} at current version digest {}", - role.name, - option.version_digest.as_deref().unwrap_or("missing") - ))); - } - Err(error) => { - return Err(AppError::Upstream(format!( - "resource qualification configuration error: {error}" - ))); - } - } - } - } - let mut seen_fallbacks = std::collections::BTreeSet::new(); - for fallback in model_fallbacks { - let fallback = fallback.trim(); - if fallback.is_empty() { - continue; - } - if !seen_fallbacks.insert(fallback.to_string()) { - continue; - } - if seen_fallbacks.len() > 8 { - return Err(AppError::BadRequest( - "model_fallbacks may contain at most 8 unique routes".into(), - )); - } - validate_model_route(&options.models, fallback)?; - let fallback_roles = roles - .iter() - .cloned() - .map(|mut role| { - role.model = Some(fallback.to_string()); - role - }) - .collect::<Vec<_>>(); - validate_team_model_routes( - options, - TeamModelRoutes { - namespace, - runtime, - model: Some(fallback), - model_fallbacks: &[], - roles: &fallback_roles, - execution_plan, - mcp_servers, - memory, - }, - )?; - } - Ok(()) -} - -fn validate_qualified_model_route( - runtime: &str, - route: &str, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, -) -> AppResult<()> { - let Some((provider, deployment)) = route.split_once("::") else { - return Err(AppError::BadRequest(format!( - "model route `{route}` must use provider::deployment" - ))); - }; - match crate::routes::options::route_qualification( - runtime, - provider, - deployment, - required_capabilities, - max_parallel, - None, - ) { - Ok(true) => Ok(()), - Ok(false) => Err(AppError::BadRequest(format!( - "runtime/model route `{runtime} · {provider}::{deployment}` has not passed the fresh E2E qualification matrix" - ))), - Err(error) => Err(AppError::Upstream(format!( - "route qualification configuration error: {error}" - ))), - } -} - -fn normalize_lifecycle_mode(mode: Option<&str>) -> AppResult<Option<&'static str>> { - let Some(mode) = mode.map(str::trim).filter(|mode| !mode.is_empty()) else { - return Ok(None); - }; - match mode.to_ascii_lowercase().replace(['-', '_'], "").as_str() { - "ephemeral" => Ok(Some("ephemeral")), - "resourceoptimized" => Ok(Some("resourceOptimized")), - "persistent" => Ok(Some("persistent")), - _ => Err(AppError::BadRequest( - "lifecycle_mode must be 'ephemeral', 'resourceOptimized', or 'persistent'".into(), - )), - } -} - -fn validate_warm_idle_seconds(seconds: Option<i64>) -> AppResult<Option<i64>> { - match seconds { - Some(seconds) if seconds < 0 => Err(AppError::BadRequest( - "warm_idle_seconds must be non-negative".into(), - )), - value => Ok(value), - } -} - -fn apply_team_git_write( - spec: &mut serde_json::Value, - git_write: Option<&crate::kars::task::GitWriteConfig>, -) -> AppResult<()> { - let Some(git_write) = git_write else { - return Ok(()); - }; - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["gitWrite"] = - serde_json::to_value(git_write).map_err(|e| AppError::Upstream(e.to_string()))?; - Ok(()) -} - -async fn validate_mcp_servers( - cluster: &crate::kars::cluster::Cluster, - namespace: &str, - servers: &[String], -) -> AppResult<()> { - for server in servers { - let Some(resource) = cluster - .get_kind(namespace, "McpServer", server) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - else { - return Err(AppError::BadRequest(format!( - "MCP server `{server}` is not installed in namespace `{namespace}`" - ))); - }; - let phase = resource - .data - .get("status") - .and_then(|status| status.get("phase")) - .and_then(|phase| phase.as_str()); - let observed_generation = resource - .data - .get("status") - .and_then(|status| status.get("observedGeneration")) - .and_then(|generation| generation.as_i64()); - if phase != Some("Ready") || observed_generation != resource.metadata.generation { - return Err(AppError::BadRequest(format!( - "MCP server `{server}` is not Ready for its current generation in namespace `{namespace}`" - ))); - } - } - Ok(()) -} - -/// `POST /api/namespaces/:ns/teams` — create a standing team. The controller -/// validates the envelope; cadence drives the autonomous tick. Defaults are -/// conservative (tier 3, ceiling=tier, depth 1) so a team can't self-amplify. -pub async fn create_team( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path(ns): Path<String>, - Json(mut b): Json<CreateTeamRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - cluster - .credential_grant(&ns) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - if b.name.trim().is_empty() || b.charter.trim().len() < 8 { - return Err(AppError::BadRequest( - "name and a real charter are required".into(), - )); - } - reject_reserved_role_names(&b.name, &b.roles)?; - let execution_plan = b - .execution_plan - .as_ref() - .ok_or_else(|| AppError::BadRequest("a typed execution_plan is required".into()))?; - crate::routes::compose::validate_execution_plan(execution_plan) - .map_err(AppError::BadRequest)?; - let roster_names = b - .roles - .iter() - .map(|role| role.name.trim()) - .collect::<std::collections::BTreeSet<_>>(); - let plan_names = execution_plan - .roles - .iter() - .map(|role| role.name.as_str()) - .collect::<std::collections::BTreeSet<_>>(); - if roster_names != plan_names { - return Err(AppError::BadRequest( - "execution_plan role names must exactly match the team roster".into(), - )); - } - b.mcp_servers = normalize_mcp_servers(&b.mcp_servers)?; - validate_mcp_servers(cluster, &ns, &b.mcp_servers).await?; - normalize_autonomous_runtime(&mut b.runtime); - for role in &mut b.roles { - normalize_autonomous_runtime(&mut role.runtime); - } - let options = build_options(cluster).await?; - if b.model - .as_deref() - .is_none_or(|model| model.trim().is_empty()) - { - b.model = options - .models - .iter() - .find(|model| model.is_default) - .or_else(|| options.models.first()) - .map(|model| format!("{}::{}", model.provider, model.deployment)); - } - b.model_fallbacks = normalize_model_fallback_routes(&b.model_fallbacks, b.model.as_deref())?; - validate_team_model_routes( - &options, - TeamModelRoutes { - namespace: &ns, - runtime: b.runtime.as_deref(), - model: b.model.as_deref(), - model_fallbacks: &b.model_fallbacks, - roles: &b.roles, - execution_plan, - mcp_servers: &b.mcp_servers, - memory: b.memory.as_deref(), - }, - )?; - b.created_by = Some(principal.name.clone()); - let created_by = principal.name.clone(); - let git_write = crate::routes::github::authorize_git_write( - cluster, - &ns, - &principal, - b.git_write_repos.as_deref(), - ) - .await?; - // Aggregate inference-budget gate (cluster + workspace + user): a launched - // team immediately kicks off a run (token spend), so block starting new work - // when a budget at any tier is strict/over-buffer. A paused team passes. - if b.launch.unwrap_or(false) { - crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; - } - let tier = b.tier.unwrap_or(3).clamp(1, 5); - let ceiling = b.authority_ceiling.unwrap_or(tier).clamp(1, tier); - // Governance: create PAUSED unless the operator explicitly opts into - // launching. A paused team does not auto-kickoff (the controller mints the - // initial run only when `!paused`), so "Launch" is a genuine human approval - // — clicking Run now / Resume — not an automatic side-effect of Create. - let paused = !b.launch.unwrap_or(false); - let mut spec = serde_json::json!({ - "charter": b.charter, "paused": paused, "envelope": { "tier": tier, "authorityCeiling": ceiling, "delegationDepth": b.delegation_depth.unwrap_or(1) }, - }); - if let Some(mode) = normalize_lifecycle_mode(b.lifecycle_mode.as_deref())? { - spec["lifecycleMode"] = serde_json::json!(mode); - } - if let Some(seconds) = validate_warm_idle_seconds(b.warm_idle_seconds)? { - spec["warmIdleSeconds"] = serde_json::json!(seconds); - } - if let Some(r) = &b.reporting_to { - spec["reportingTo"] = serde_json::json!(r); - } - if let Some(d) = b - .display_name - .as_deref() - .map(str::trim) - .filter(|d| !d.is_empty()) - { - spec["displayName"] = serde_json::json!(d); - } - if let Some(c) = &b.knowledge_commons { - spec["knowledgeCommons"] = serde_json::json!(c); - } - // cadence_minutes == 0 (or absent) means a cadence-LESS "run on demand" team: - // the CRD requires everyMinutes >= 1 when the cadence field is present, so we - // OMIT it entirely rather than write an invalid everyMinutes: 0 (which the - // apiserver rejects 422). A cadence-less team is minted once on creation - // (kickoff) and thereafter only runs via "Run now". - if let Some(m) = b.cadence_minutes - && m >= 1 - { - spec["cadence"] = serde_json::json!({ "everyMinutes": m }); - } - // Every team run must be governed by a real ToolPolicy. Without one the run - // sandbox is created with governance disabled, the agent's AGT engine starts - // with an empty policy set and fails closed, and the run hangs until the - // dispatch times out. Resolve the requested policy (or the cluster default - // `kars-default`) and pin it on the team's run blueprint. - let tool_policy = resolve_team_tool_policy(cluster, &ns, b.tool_policy.as_deref()).await; - if let Some(tp) = &tool_policy { - spec["blueprint"] = serde_json::json!({ "toolPolicy": tp }); - } - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["executionPlan"] = serde_json::to_value(execution_plan.clone().into_crd()) - .map_err(|error| { - AppError::BadRequest(format!("execution_plan could not be serialized: {error}")) - })?; - // Team-level harness: the runtime every minted run executes on. Correct a - // bootstrap-only adapter (no autonomous task loop) to OpenClaw — a standing - // run must be able to run autonomously. Hermes/BYO are autonomous and pass - // through. The controller inherits this via the team's run blueprint. - if let Some(rt) = b - .runtime - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { - "OpenClaw" - } else { - rt - }; - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["runtime"] = serde_json::json!(rt); - } - if let Some(model) = b.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) - && let Some((provider, deployment)) = model.split_once("::") - { - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["model"] = - serde_json::json!({"provider": provider, "deployment": deployment}); - } - let model_fallbacks = b - .model_fallbacks - .iter() - .map(|route| route.trim()) - .filter(|route| !route.is_empty()) - .filter_map(|route| route.split_once("::")) - .map(|(provider, deployment)| { - serde_json::json!({"provider": provider, "deployment": deployment}) - }) - .collect::<Vec<_>>(); - spec["blueprint"]["modelFallbacks"] = serde_json::json!(model_fallbacks); - if let Some(memory) = b.memory.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["memory"] = serde_json::json!(memory); - } - if !b.egress.is_empty() { - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["egress"] = serde_json::json!( - b.egress - .iter() - .filter_map(|entry| { - let host = entry.host.trim(); - (!host.is_empty()) - .then(|| serde_json::json!({"host": host, "port": entry.port})) - }) - .collect::<Vec<_>>() - ); - } - if let Some(mode) = b.egress_mode.as_deref().map(str::trim) { - let mode = match mode.to_ascii_lowercase().as_str() { - "strict" => "Strict", - "learning" | "learn" => "Learn", - _ => { - return Err(AppError::BadRequest( - "egress_mode must be 'learning' or 'strict'".into(), - )); - } - }; - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["egressMode"] = serde_json::json!(mode); - } - apply_team_git_write(&mut spec, git_write.as_ref().map(|(config, _)| config))?; - if let Some((_, binding)) = &git_write { - spec["blueprint"]["githubBinding"] = - serde_json::to_value(binding).map_err(|error| AppError::Upstream(error.to_string()))?; - } - if !b.mcp_servers.is_empty() { - if !spec["blueprint"].is_object() { - spec["blueprint"] = serde_json::json!({}); - } - spec["blueprint"]["mcpServers"] = serde_json::json!( - b.mcp_servers - .iter() - .map(|server| server.trim()) - .filter(|server| !server.is_empty()) - .collect::<Vec<_>>() - ); - } - if !b.roles.is_empty() { - spec["roster"] = serde_json::json!(build_roster(&b.roles)); - } - if let Some(ttl) = b.run_retention_ttl_seconds { - spec["runRetentionTtlSeconds"] = serde_json::json!(ttl); - } - let body = serde_json::json!({ "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTeam", "metadata": {"name": b.name.trim(), "namespace": ns}, "spec": spec }); - let mut body = body; - // Stamp the creator for per-user budget attribution (propagated onto runs). - if body["metadata"]["annotations"].is_null() { - body["metadata"]["annotations"] = serde_json::json!({}); - } - body["metadata"]["annotations"]["kars.azure.com/created-by"] = serde_json::json!(created_by); - body["metadata"]["annotations"]["kars.azure.com/owner-sub"] = serde_json::json!(principal.sub); - body["metadata"]["annotations"]["kars.azure.com/owner-name"] = - serde_json::json!(principal.name); - let active = !body["spec"]["paused"].as_bool().unwrap_or(false); - body["spec"]["paused"] = serde_json::json!(true); - let captured = cluster - .create_kind(&ns, "KarsTeam", body) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - cluster - .finish_created_credentials( - &crate::kars::credentials::Target { - kind: "KarsTeam".into(), - namespace: ns.clone(), - name: captured.name_any(), - uid: captured - .uid() - .ok_or_else(|| AppError::Upstream("Team CREATE omitted UID".into()))?, - }, - active, - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - Ok(Json( - serde_json::json!({"created": true, "name": b.name.trim()}), - )) -} - -/// Resolve the governance policy to pin on a team's run blueprint: the -/// requested policy when it exists, else the cluster default (`kars-default`), -/// else the first installed policy. Returns `None` only when the cluster has no -/// ToolPolicy at all (nothing we can assign). -async fn resolve_team_tool_policy( - cluster: &crate::kars::cluster::Cluster, - ns: &str, - requested: Option<&str>, -) -> Option<String> { - if let Some(r) = requested.map(str::trim).filter(|r| !r.is_empty()) - && cluster - .get_kind(ns, "ToolPolicy", r) - .await - .ok() - .flatten() - .is_some() - { - return Some(r.to_string()); - } - if cluster - .get_kind( - ns, - "ToolPolicy", - crate::routes::compose::DEFAULT_TOOL_POLICY, - ) - .await - .ok() - .flatten() - .is_some() - { - return Some(crate::routes::compose::DEFAULT_TOOL_POLICY.to_string()); - } - cluster - .list_kind_all("ToolPolicy") - .await - .ok()? - .into_iter() - .find(|policy| policy.namespace().as_deref() == Some(ns)) - .map(|policy| policy.name_any()) -} - #[derive(Debug, Deserialize)] pub struct UpdateTeamRequest { pub charter: Option<String>, @@ -2985,353 +360,6 @@ pub struct UpdateTeamRequest { pub execution_plan: Option<crate::routes::tasks::ExecutionPlanDto>, } -/// `PATCH /api/namespaces/:ns/teams/:name` — edit charter, cadence, reporting, -/// or pause. Envelope-raising fields are out of scope here (promote handles -/// governed tier changes); this is the non-amplifying day-to-day edit. -pub async fn update_team( - State(state): State<crate::state::AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(mut b): Json<UpdateTeamRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - let team = require_owned_team(cluster, &ns, &name, &principal).await?; - normalize_autonomous_runtime(&mut b.runtime); - if let Some(roles) = &mut b.roles { - for role in roles { - normalize_autonomous_runtime(&mut role.runtime); - } - } - let execution_plan_changed = b.execution_plan.is_some(); - if b.runtime.is_some() - || b.model.is_some() - || b.model_fallbacks.is_some() - || b.memory.is_some() - || b.roles.is_some() - || b.mcp_servers.is_some() - || b.execution_plan.is_some() - { - let options = build_options(cluster).await?; - if b.model - .as_deref() - .is_some_and(|model| model.trim().is_empty()) - { - b.model = options - .models - .iter() - .find(|model| model.is_default) - .or_else(|| options.models.first()) - .map(|model| format!("{}::{}", model.provider, model.deployment)); - } - let existing_runtime = team - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.runtime.as_deref()); - let existing_model = team - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.model.as_ref()) - .map(|model| format!("{}::{}", model.provider, model.deployment)); - let existing_model_fallbacks = team - .spec - .blueprint - .as_ref() - .map(|blueprint| { - blueprint - .model_fallbacks - .iter() - .map(|model| format!("{}::{}", model.provider, model.deployment)) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - if b.model.is_some() || b.model_fallbacks.is_some() { - b.model_fallbacks = Some(normalize_model_fallback_routes( - b.model_fallbacks - .as_deref() - .unwrap_or(existing_model_fallbacks.as_slice()), - b.model.as_deref().or(existing_model.as_deref()), - )?); - } - let existing_roles = team - .spec - .roster - .iter() - .map(|role| CreateRole { - name: role.name.clone(), - system_prompt: role.system_prompt.clone(), - runtime: role - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.runtime.clone()), - model: role - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.model.as_ref()) - .map(|model| format!("{}::{}", model.provider, model.deployment)), - skills: role.skills.clone(), - }) - .collect::<Vec<_>>(); - let existing_execution_plan = team - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.execution_plan.as_ref()) - .map(crate::routes::tasks::ExecutionPlanDto::from_crd); - let existing_mcp_servers = team - .spec - .blueprint - .as_ref() - .map(|blueprint| blueprint.mcp_servers.clone()) - .unwrap_or_default(); - let existing_memory = team - .spec - .blueprint - .as_ref() - .and_then(|blueprint| blueprint.memory.as_deref()); - let execution_plan = b - .execution_plan - .as_ref() - .or(existing_execution_plan.as_ref()) - .ok_or_else(|| { - AppError::BadRequest( - "this team cannot change runtime/model/roles/MCP until it has a typed execution_plan" - .into(), - ) - })?; - crate::routes::compose::validate_execution_plan(execution_plan) - .map_err(AppError::BadRequest)?; - let effective_roles = b.roles.as_deref().unwrap_or(existing_roles.as_slice()); - let roster_names = effective_roles - .iter() - .map(|role| role.name.trim()) - .collect::<std::collections::BTreeSet<_>>(); - let plan_names = execution_plan - .roles - .iter() - .map(|role| role.name.as_str()) - .collect::<std::collections::BTreeSet<_>>(); - if roster_names != plan_names { - return Err(AppError::BadRequest( - "execution_plan role names must exactly match the team roster".into(), - )); - } - validate_team_model_routes( - &options, - TeamModelRoutes { - namespace: &ns, - runtime: b.runtime.as_deref().or(existing_runtime), - model: b.model.as_deref().or(existing_model.as_deref()), - model_fallbacks: b - .model_fallbacks - .as_deref() - .unwrap_or(existing_model_fallbacks.as_slice()), - roles: effective_roles, - execution_plan, - mcp_servers: b - .mcp_servers - .as_deref() - .unwrap_or(existing_mcp_servers.as_slice()), - memory: b.memory.as_deref().or(existing_memory), - }, - )?; - } - if b.paused == Some(false) { - crate::routes::budgets::enforce_launch_budget(cluster, &ns, &principal.name).await?; - } - let mut spec = serde_json::Map::new(); - if let Some(c) = &b.charter { - spec.insert("charter".into(), serde_json::json!(c)); - } - if let Some(p) = b.paused { - spec.insert("paused".into(), serde_json::json!(p)); - } - if let Some(r) = &b.reporting_to { - spec.insert("reportingTo".into(), serde_json::json!(r)); - } - if let Some(mode) = normalize_lifecycle_mode(b.lifecycle_mode.as_deref())? { - spec.insert("lifecycleMode".into(), serde_json::json!(mode)); - } - if let Some(seconds) = validate_warm_idle_seconds(b.warm_idle_seconds)? { - spec.insert("warmIdleSeconds".into(), serde_json::json!(seconds)); - } - if let Some(m) = b.cadence_minutes { - if m >= 1 { - spec.insert("cadence".into(), serde_json::json!({"everyMinutes": m})); - } else { - // 0 = passive / run-on-demand: clear the cadence entirely (a merge - // patch null removes the field) so the team actually stops auto- - // running, honouring the "0 = passive" label instead of silently - // leaving the previous cadence in place. - spec.insert("cadence".into(), serde_json::Value::Null); - } - } - if let Some(roles) = &b.roles { - reject_reserved_role_names(&name, roles)?; - spec.insert("roster".into(), serde_json::json!(build_roster(roles))); - } - let mut blueprint = serde_json::Map::new(); - if let Some(rt) = b - .runtime - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { - "OpenClaw" - } else { - rt - }; - blueprint.insert("runtime".into(), serde_json::json!(rt)); - } - if let Some(model) = b.model.as_deref().map(str::trim) { - if model.is_empty() { - blueprint.insert("model".into(), serde_json::Value::Null); - } else if let Some((provider, deployment)) = model.split_once("::") { - blueprint.insert( - "model".into(), - serde_json::json!({"provider": provider, "deployment": deployment}), - ); - } else { - return Err(AppError::BadRequest( - "model must be encoded as provider::deployment".into(), - )); - } - } - if let Some(fallbacks) = &b.model_fallbacks { - let mut seen = std::collections::BTreeSet::new(); - let mut routes = Vec::new(); - for fallback in fallbacks { - let fallback = fallback.trim(); - if fallback.is_empty() || !seen.insert(fallback.to_string()) { - continue; - } - let Some((provider, deployment)) = fallback.split_once("::") else { - return Err(AppError::BadRequest( - "model_fallbacks entries must be encoded as provider::deployment".into(), - )); - }; - routes.push(serde_json::json!({ - "provider": provider, - "deployment": deployment, - })); - } - if routes.len() > 8 { - return Err(AppError::BadRequest( - "model_fallbacks may contain at most 8 unique routes".into(), - )); - } - blueprint.insert("modelFallbacks".into(), serde_json::json!(routes)); - } - if let Some(memory) = b.memory.as_deref() { - blueprint.insert( - "memory".into(), - if memory.trim().is_empty() { - serde_json::Value::Null - } else { - serde_json::json!(memory.trim()) - }, - ); - } - if let Some(servers) = &b.mcp_servers { - let servers = normalize_mcp_servers(servers)?; - validate_mcp_servers(cluster, &ns, &servers).await?; - blueprint.insert("mcpServers".into(), serde_json::json!(servers)); - } - if let Some(repos) = &b.git_write_repos { - let git_write = - crate::routes::github::authorize_git_write(cluster, &ns, &principal, Some(repos)) - .await?; - blueprint.insert( - "githubBinding".into(), - git_write - .as_ref() - .map(|(_, binding)| serde_json::to_value(binding)) - .transpose() - .map_err(|error| AppError::Upstream(error.to_string()))? - .unwrap_or(serde_json::Value::Null), - ); - blueprint.insert( - "gitWrite".into(), - git_write - .map(|(grant, _)| { - serde_json::to_value(grant).map_err(|e| AppError::Upstream(e.to_string())) - }) - .transpose()? - .unwrap_or(serde_json::Value::Null), - ); - } - if let Some(egress) = &b.egress { - let entries = egress - .iter() - .filter_map(|entry| { - let host = entry.host.trim(); - (!host.is_empty()).then(|| serde_json::json!({"host": host, "port": entry.port})) - }) - .collect::<Vec<_>>(); - blueprint.insert("egress".into(), serde_json::json!(entries)); - } - if let Some(mode) = b.egress_mode.as_deref().map(str::trim) { - let mode = match mode.to_ascii_lowercase().as_str() { - "strict" => "Strict", - "learning" | "learn" => "Learn", - _ => { - return Err(AppError::BadRequest( - "egress_mode must be 'learning' or 'strict'".into(), - )); - } - }; - blueprint.insert("egressMode".into(), serde_json::json!(mode)); - } - if let Some(execution_plan) = &b.execution_plan { - blueprint.insert( - "executionPlan".into(), - serde_json::to_value(execution_plan.clone().into_crd()).map_err(|error| { - AppError::BadRequest(format!("execution_plan could not be serialized: {error}")) - })?, - ); - } - if !blueprint.is_empty() { - // Merge-patch the nested blueprint so runtime/MCP edits preserve the - // team's existing toolPolicy/model and can be changed together. - spec.insert("blueprint".into(), serde_json::Value::Object(blueprint)); - } - if let Some(ttl) = b.run_retention_ttl_seconds { - spec.insert("runRetentionTtlSeconds".into(), serde_json::json!(ttl)); - } - let api: Api<KarsTeam> = cluster.teams(&ns); - api.patch( - &name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(serde_json::json!({"spec": spec})), - ) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - if execution_plan_changed { - cluster - .merge_patch_kind( - &ns, - "KarsTask", - &format!("{name}-principal"), - serde_json::json!({ - "metadata": { - "annotations": { - "kars.azure.com/retry-not-before": null - } - } - }), - ) - .await - .map_err(|error| { - AppError::Upstream(format!( - "team plan was updated but its retry park could not be cleared: {error}" - )) - })?; - } - Ok(Json(serde_json::json!({"updated": true}))) -} - #[cfg(test)] mod tests { use super::*; @@ -3425,124 +453,4 @@ mod tests { assert_eq!(update.lifecycle_mode.as_deref(), Some("persistent")); assert_eq!(update.warm_idle_seconds, Some(1800)); } - - #[test] - fn team_lifecycle_modes_are_normalized_and_idle_window_is_bounded() { - assert_eq!( - normalize_lifecycle_mode(Some("resource-optimized")).expect("mode"), - Some("resourceOptimized") - ); - assert_eq!( - normalize_lifecycle_mode(Some("persistent")).expect("mode"), - Some("persistent") - ); - assert!(normalize_lifecycle_mode(Some("always-on")).is_err()); - assert_eq!( - validate_warm_idle_seconds(Some(900)).expect("idle"), - Some(900) - ); - assert_eq!(validate_warm_idle_seconds(Some(0)).expect("idle"), Some(0)); - assert!(validate_warm_idle_seconds(Some(-1)).is_err()); - } - - #[test] - fn team_models_require_exact_live_catalogue_pairs() { - let models = vec![ModelOption { - provider: "github-copilot".into(), - deployment: "shared-name".into(), - is_default: true, - detail: None, - }]; - assert!(validate_model_route(&models, "github-copilot::shared-name").is_ok()); - assert!(validate_model_route(&models, "").is_ok()); - assert!(validate_model_route(&models, "local-inference::shared-name").is_err()); - assert!(validate_model_route(&models, "shared-name").is_err()); - } - - #[test] - fn team_mcp_servers_are_deduplicated_and_bounded() { - assert_eq!( - normalize_mcp_servers(&[ - " playwright ".into(), - "playwright".into(), - "everything".into() - ]) - .expect("normalize"), - vec!["playwright", "everything"] - ); - let too_many = (0..9).map(|i| format!("mcp-{i}")).collect::<Vec<_>>(); - assert!(normalize_mcp_servers(&too_many).is_err()); - } - - #[test] - fn team_git_write_is_applied_without_mcp_servers() { - let mut spec = serde_json::json!({"charter": "Deliver a feature"}); - let git_write = crate::kars::task::GitWriteConfig { - connection_config_map_ref: crate::kars::task::LocalObjectRef { - name: "kars-github-connection-0123456789abcdef".into(), - }, - repos: vec!["owner/repo".into()], - }; - apply_team_git_write(&mut spec, Some(&git_write)).expect("git write applies"); - assert_eq!( - spec["blueprint"]["gitWrite"]["connectionConfigMapRef"]["name"], - "kars-github-connection-0123456789abcdef" - ); - assert_eq!(spec["blueprint"]["gitWrite"]["repos"][0], "owner/repo"); - assert!(spec["blueprint"].get("mcpServers").is_none()); - } - - #[test] - fn team_outcomes_distinguish_change_no_action_and_failure() { - let output = |status: &str, text: &str| { - std::collections::BTreeMap::from([ - ("status".to_string(), status.to_string()), - ("output".to_string(), text.to_string()), - ("finishedAt".to_string(), "2026-07-22T20:12:27Z".to_string()), - ]) - }; - let change = outcome_from_output( - "team-run-1784750586".into(), - &output( - "ok", - "Opened https://github.com/example/repo/pull/42 with the dependency fix.", - ), - ); - assert_eq!(change.disposition, "change_proposed"); - assert!(change.headline.contains("PR #42")); - let change_with_old_sentinel = outcome_from_output( - "team-run-1784750586".into(), - &output( - "ok", - "Opened https://github.com/example/repo/pull/43.\nPrior run: [[NO_MATERIAL_CHANGE]]", - ), - ); - assert_eq!(change_with_old_sentinel.disposition, "change_proposed"); - - let no_action = outcome_from_output( - "team-run-1784750586".into(), - &output( - "ok", - "[[NO_MATERIAL_CHANGE]] The dependency is already fixed on main.", - ), - ); - assert_eq!(no_action.disposition, "no_action_needed"); - assert!(no_action.headline.contains("already fixed")); - - let failed = outcome_from_output( - "team-run-1784750586".into(), - &output("error", "Parser failed before a handback was produced."), - ); - assert_eq!(failed.disposition, "failed"); - } - - #[test] - fn team_outcome_uses_assigned_task_as_work_item() { - assert_eq!( - outcome_work_item( - "Assigned task for team 'maintenance'.\nTASK: [Dependabot alert] owner/repo #7: tar DETAILS: internal scaffolding" - ), - "[Dependabot alert] owner/repo #7: tar" - ); - } } diff --git a/bridge/bff/src/routes/teams/backlog.rs b/bridge/bff/src/routes/teams/backlog.rs new file mode 100644 index 000000000..82afb1e88 --- /dev/null +++ b/bridge/bff/src/routes/teams/backlog.rs @@ -0,0 +1,327 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::{ListParams, Patch, PatchParams}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::tasks::require_cluster; + +use super::require_owned_team; + +/// One backlog task (mirrors the controller's `team_tasks::TeamTask`). +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TeamTaskDto { + pub id: String, + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub depends_on: Vec<String>, + #[serde(default)] + pub acceptance_criteria: Vec<String>, + #[serde(default)] + pub review_required: bool, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub done_at: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stuck_since: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assignment_nonce: Option<String>, +} + +#[derive(Debug, Deserialize)] +pub struct AddTaskRequest { + #[serde(default)] + pub id: Option<String>, + pub title: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub depends_on: Vec<String>, + #[serde(default)] + pub acceptance_criteria: Vec<String>, + #[serde(default)] + pub review_required: bool, +} + +pub(crate) fn read_task_list(raw: &str) -> Vec<TeamTaskDto> { + serde_json::from_str::<Vec<TeamTaskDto>>(raw).unwrap_or_default() +} + +/// `GET /api/namespaces/:ns/teams/:name/tasks` — the team's task backlog. +pub async fn list_team_tasks( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<Vec<TeamTaskDto>>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + Ok(Json(read_task_list(&cluster.read_team_tasks(&name).await))) +} + +/// `POST /api/namespaces/:ns/teams/:name/tasks` — append a task to the backlog. +/// The controller picks up the oldest `pending` task on its next run. +pub async fn add_team_task( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(b): Json<AddTaskRequest>, +) -> AppResult<Json<TeamTaskDto>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + if b.title.trim().is_empty() { + return Err(AppError::BadRequest("task title is required".into())); + } + let existing_tasks = read_task_list(&cluster.read_team_tasks(&name).await); + if let Some(missing) = b.depends_on.iter().find(|dependency| { + !existing_tasks + .iter() + .any(|task| task.id.as_str() == dependency.as_str()) + }) { + return Err(AppError::BadRequest(format!( + "task dependency '{missing}' does not exist" + ))); + } + let requested_id = + b.id.as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|id| { + id.to_ascii_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' { + character + } else { + '-' + } + }) + .collect::<String>() + .trim_matches('-') + .chars() + .take(63) + .collect::<String>() + }) + .filter(|id| !id.is_empty()); + let task_id = + requested_id.unwrap_or_else(|| format!("t-{}", chrono::Utc::now().timestamp_micros())); + if existing_tasks.iter().any(|task| task.id == task_id) { + return Err(AppError::BadRequest(format!( + "task id '{task_id}' already exists" + ))); + } + let task = TeamTaskDto { + id: task_id, + title: b.title.trim().to_string(), + description: b.description.trim().to_string(), + depends_on: b.depends_on, + acceptance_criteria: b + .acceptance_criteria + .into_iter() + .map(|criterion| criterion.trim().to_string()) + .filter(|criterion| !criterion.is_empty()) + .take(20) + .collect(), + review_required: b.review_required, + status: "pending".into(), + run: None, + created_at: Some(chrono::Utc::now().to_rfc3339()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }; + let task_for_write = task.clone(); + let duplicate = std::sync::atomic::AtomicBool::new(false); + let missing_dependency = std::sync::Mutex::new(None::<String>); + cluster + .update_configmap_data( + &format!("kars-team-tasks-{name}"), + &[("kars.azure.com/team-tasks", name.as_str())], + |data| { + let mut tasks = data + .get("tasks.json") + .map(|raw| read_task_list(raw)) + .unwrap_or_default(); + if tasks + .iter() + .any(|existing| existing.id == task_for_write.id) + { + duplicate.store(true, std::sync::atomic::Ordering::Relaxed); + return; + } + if let Some(dependency) = task_for_write.depends_on.iter().find(|dependency| { + !tasks + .iter() + .any(|task| task.id.as_str() == dependency.as_str()) + }) { + *missing_dependency.lock().expect("dependency lock") = Some(dependency.clone()); + return; + } + tasks.push(task_for_write.clone()); + data.insert( + "tasks.json".into(), + serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), + ); + }, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if duplicate.load(std::sync::atomic::Ordering::Relaxed) { + return Err(AppError::Conflict(format!( + "task id '{}' already exists", + task.id + ))); + } + if let Some(dependency) = missing_dependency.lock().expect("dependency lock").clone() { + return Err(AppError::Conflict(format!( + "task dependency '{dependency}' disappeared during update" + ))); + } + Ok(Json(task)) +} + +/// `DELETE /api/namespaces/:ns/teams/:name/tasks/:task_id` — remove a task from +/// the backlog (any status; removing an active task doesn't stop its run). +pub async fn delete_team_task( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name, task_id)): Path<(String, String, String)>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let removed = std::sync::atomic::AtomicBool::new(false); + let dependency_blocked = std::sync::atomic::AtomicBool::new(false); + cluster + .update_configmap_data( + &format!("kars-team-tasks-{name}"), + &[("kars.azure.com/team-tasks", name.as_str())], + |data| { + let mut tasks = data + .get("tasks.json") + .map(|raw| read_task_list(raw)) + .unwrap_or_default(); + if tasks.iter().any(|task| { + task.id != task_id && task.depends_on.iter().any(|id| id == &task_id) + }) { + dependency_blocked.store(true, std::sync::atomic::Ordering::Relaxed); + return; + } + let before = tasks.len(); + tasks.retain(|task| task.id != task_id); + removed.store(tasks.len() != before, std::sync::atomic::Ordering::Relaxed); + data.insert( + "tasks.json".into(), + serde_json::to_string(&tasks).unwrap_or_else(|_| "[]".into()), + ); + }, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if dependency_blocked.load(std::sync::atomic::Ordering::Relaxed) { + return Err(AppError::Conflict( + "cannot delete a milestone that is referenced by dependent work".into(), + )); + } + if !removed.load(std::sync::atomic::Ordering::Relaxed) { + return Err(AppError::NotFound); + } + + Ok(Json(serde_json::json!({ "removed": true }))) +} + +#[derive(Debug, Deserialize)] +pub struct ReviewTeamTaskRequest { + pub decision: String, + pub feedback: Option<String>, +} + +pub async fn review_team_task( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name, task_id)): Path<(String, String, String)>, + Json(body): Json<ReviewTeamTaskRequest>, +) -> AppResult<Json<TeamTaskDto>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + if !matches!(body.decision.as_str(), "approve" | "request_changes") { + return Err(AppError::BadRequest( + "decision must be approve or request_changes".into(), + )); + } + let current = read_task_list(&cluster.read_team_tasks(&name).await); + let existing = current + .iter() + .find(|task| task.id == task_id) + .cloned() + .ok_or(AppError::NotFound)?; + if existing.status != "awaiting_review" { + return Err(AppError::BadRequest( + "only an awaiting_review milestone can be decided".into(), + )); + } + let feedback = body + .feedback + .as_deref() + .map(str::trim) + .filter(|feedback| !feedback.is_empty()) + .map(str::to_string); + if body.decision == "request_changes" && feedback.is_none() { + return Err(AppError::BadRequest( + "request_changes requires written feedback".into(), + )); + } + let approvals = cluster.approvals(&ns); + let selector = format!("kars.azure.com/team={name},kars.azure.com/milestone={task_id}"); + let approval = approvals + .list(&ListParams::default().labels(&selector)) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .into_iter() + .find(|approval| { + approval.spec.action.kind == "checkpoint" + && approval.spec.decision.is_none() + && approval + .status + .as_ref() + .and_then(|status| status.phase.as_deref()) + .is_none_or(|phase| phase == "Pending") + }) + .ok_or_else(|| { + AppError::Conflict( + "checkpoint approval is not pending yet; refresh before deciding".into(), + ) + })?; + approvals + .patch( + &approval.name_any(), + &PatchParams::default(), + &Patch::Merge(serde_json::json!({ + "spec": { + "decision": { + "verdict": if body.decision == "approve" { "approve" } else { "deny" }, + "decider": principal.name, + "deciderSubject": principal.sub, + "deciderRoles": principal.roles, + "reason": feedback, + } + } + })), + ) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + + let updated = read_task_list(&cluster.read_team_tasks(&name).await) + .into_iter() + .find(|task| task.id == task_id) + .ok_or(AppError::NotFound)?; + Ok(Json(updated)) +} diff --git a/bridge/bff/src/routes/teams/channels.rs b/bridge/bff/src/routes/teams/channels.rs new file mode 100644 index 000000000..155575605 --- /dev/null +++ b/bridge/bff/src/routes/teams/channels.rs @@ -0,0 +1,269 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::team::KarsTeam; +use crate::routes::tasks::require_cluster; + +use super::require_owned_team; + +// ─── Communication channels (part of a team's envelope) ────────────────────── +// A standing team can report to its operator over Telegram / Slack / Discord / +// WhatsApp. Tokens live ONLY in the K8s Secret `kars-team-channel-<team>`, +// propagated by the controller into each ephemeral run sandbox. SECURITY: the +// API is write-only for tokens — GET never returns a token, only which channels +// are enabled. + +/// Map a channel id → the env keys the sandbox entrypoint reads for it. +pub(crate) fn channel_env_keys(channel: &str) -> &'static [&'static str] { + match channel { + "telegram" => &["TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOW_FROM"], + "slack" => &["SLACK_BOT_TOKEN"], + "discord" => &["DISCORD_BOT_TOKEN"], + "whatsapp" => &["WHATSAPP_ENABLED"], + // Teams uses a dedicated Secret (kars-bridge-teams), not workspace channels. + // Only a non-secret marker key goes in workspace-channels for enabled detection. + "teams" => &["TEAMS_ENABLED"], + _ => &[], + } +} + +/// Derive which channels are enabled from the present secret keys (no values). +pub(crate) const SUPPORTED_CHANNELS: &[&str] = + &["telegram", "slack", "discord", "whatsapp", "teams"]; + +pub(crate) fn channels_from_keys(keys: &[String]) -> Vec<String> { + SUPPORTED_CHANNELS + .iter() + .copied() + .filter(|ch| { + // A channel is "enabled" if its primary token/flag key is present. + let primary = channel_env_keys(ch).first().copied().unwrap_or(""); + keys.iter().any(|k| k == primary) + }) + .map(String::from) + .collect() +} + +#[derive(Debug, Clone, Serialize)] +pub struct ChannelQualificationDto { + pub channel: String, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub qualified: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct ChannelsDto { + /// Channel ids currently enabled (e.g. ["telegram","slack"]). + pub enabled: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub statuses: Vec<ChannelQualificationDto>, +} + +async fn effective_team_route( + cluster: &crate::kars::cluster::Cluster, + team: &KarsTeam, +) -> Option<(String, String, String)> { + let runtime = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.runtime.clone()) + .filter(|runtime| !runtime.is_empty()) + .unwrap_or_else(|| "OpenClaw".to_string()); + if let Some(model) = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.model.as_ref()) + { + return Some((runtime, model.provider.clone(), model.deployment.clone())); + } + let deployment = cluster.controller_default_model().await?; + let provider = cluster.controller_provider().await.map(|(id, _, _)| id); + Some(( + runtime, + crate::routes::options::provider_for(&deployment, None, provider.as_deref()), + deployment, + )) +} + +async fn channel_statuses_for_team( + cluster: &crate::kars::cluster::Cluster, + team: &KarsTeam, + enabled: &[String], +) -> Vec<ChannelQualificationDto> { + let route = effective_team_route(cluster, team).await; + SUPPORTED_CHANNELS + .iter() + .copied() + .map(|channel| { + let enabled = enabled.iter().any(|configured| configured == channel); + match route.as_ref() { + Some((runtime, provider, deployment)) => { + let qualification = crate::routes::options::channel_adapter_qualified_for_route( + runtime, + provider, + deployment, + channel, + ); + match qualification { + Ok(qualified) => ChannelQualificationDto { + channel: channel.to_string(), + enabled, + qualified: Some(qualified), + detail: Some(if qualified { + format!( + "Retained channel-adapter evidence exists for {}.", + crate::routes::options::route_label( + runtime, provider, deployment + ) + ) + } else { + format!( + "No retained channel-adapter qualification matches {}. Credentials can be configured later, but generic route records do not prove this channel adapter.", + crate::routes::options::route_label( + runtime, provider, deployment + ) + ) + }), + }, + Err(error) => ChannelQualificationDto { + channel: channel.to_string(), + enabled, + qualified: None, + detail: Some(format!( + "Channel qualification could not be evaluated: {error}" + )), + }, + } + } + None => ChannelQualificationDto { + channel: channel.to_string(), + enabled, + qualified: None, + detail: Some( + "The team has no effective runtime/model route yet, so channel qualification cannot be evaluated." + .into(), + ), + }, + } + }) + .collect() +} + +#[derive(Debug, Deserialize)] +pub struct SetChannelRequest { + /// Channel id: telegram | slack | discord | whatsapp. + pub channel: String, + /// The channel's bot token / OAuth token. For whatsapp send "true". + pub token: String, + /// Telegram only: comma-separated allowed numeric user IDs. + #[serde(default)] + pub allow_from: Option<String>, +} + +/// `GET /api/namespaces/:ns/teams/:name/channels` — which channels are enabled. +pub async fn get_team_channels( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<ChannelsDto>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let keys = cluster + .team_channel_keys(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&keys); + Ok(Json(ChannelsDto { + statuses: channel_statuses_for_team(cluster, &team, &enabled).await, + enabled, + })) +} + +/// `POST /api/namespaces/:ns/teams/:name/channels` — enable/update a channel. +/// The token is written straight into the team's channel Secret and never +/// echoed back. +pub async fn set_team_channel( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(b): Json<SetChannelRequest>, +) -> AppResult<Json<ChannelsDto>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let keys = channel_env_keys(b.channel.as_str()); + if keys.is_empty() { + return Err(AppError::BadRequest(format!( + "unknown channel '{}': use telegram|slack|discord|whatsapp", + b.channel + ))); + } + if b.token.trim().is_empty() { + return Err(AppError::BadRequest("token is required".into())); + } + let mut data = std::collections::BTreeMap::new(); + // whatsapp uses a presence flag, not a token. + let primary = keys[0]; + data.insert(primary.to_string(), b.token.trim().to_string()); + if b.channel == "telegram" + && let Some(allow) = b + .allow_from + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + data.insert("TELEGRAM_ALLOW_FROM".to_string(), allow.to_string()); + } + cluster + .merge_team_channel(&ns, &name, data) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let after = cluster + .team_channel_keys(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&after); + Ok(Json(ChannelsDto { + statuses: channel_statuses_for_team(cluster, &team, &enabled).await, + enabled, + })) +} + +/// `DELETE /api/namespaces/:ns/teams/:name/channels/:channel` — disable a channel. +pub async fn delete_team_channel( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name, channel)): Path<(String, String, String)>, +) -> AppResult<Json<ChannelsDto>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let keys: Vec<String> = channel_env_keys(channel.as_str()) + .iter() + .map(|s| s.to_string()) + .collect(); + if keys.is_empty() { + return Err(AppError::BadRequest(format!("unknown channel '{channel}'"))); + } + cluster + .remove_team_channel_keys(&ns, &name, &keys) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let after = cluster + .team_channel_keys(&ns, &name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let enabled = channels_from_keys(&after); + Ok(Json(ChannelsDto { + statuses: channel_statuses_for_team(cluster, &team, &enabled).await, + enabled, + })) +} diff --git a/bridge/bff/src/routes/teams/commons.rs b/bridge/bff/src/routes/teams/commons.rs new file mode 100644 index 000000000..bad63abd9 --- /dev/null +++ b/bridge/bff/src/routes/teams/commons.rs @@ -0,0 +1,424 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use serde::{Deserialize, Serialize}; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::routes::tasks::{deliverable_text, require_cluster}; + +use super::require_owned_team; + +/// The on-disk commons index entry (mirrors the controller's `CommonsEntry`). +#[derive(Debug, Deserialize)] +pub struct CommonsIndexEntry { + pub id: String, + pub title: String, + pub author: String, + pub source_task: String, + pub created_at: String, + pub digest: String, + pub size_bytes: i64, +} + +/// Browser-facing commons entry — the index record plus resolved content. +#[derive(Debug, Serialize)] +pub struct CommonsEntryDto { + pub id: String, + pub title: String, + pub author: String, + pub source_task: String, + pub created_at: String, + pub digest: String, + pub size_bytes: i64, + pub content: Option<String>, +} + +/// Browser-facing commons response. +#[derive(Debug, Serialize)] +pub struct CommonsResponse { + pub commons: String, + pub count: i64, + pub entries: Vec<CommonsEntryDto>, +} + +fn commons_entry_key(id: &str) -> String { + format!( + "entry-{}", + id.chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character + } else { + '_' + } + },) + .collect::<String>() + ) +} + +/// Derive a meaningful, distinct title for a commons entry. The controller +/// historically titled every entry by the team charter's first line, so the +/// Knowledge tab showed 50+ identical rows. We recover a real headline from the +/// (already envelope-unwrapped) content: the first markdown heading, else the +/// first substantive line, capped. Falls back to the stored title only when the +/// content yields nothing usable. `charter_line` is passed so we can recognize +/// (and replace) the legacy charter-as-title rows. +fn commons_title(stored: &str, content: &str, charter_line: &str) -> String { + let derive = || -> Option<String> { + let lines: Vec<&str> = content.lines().collect(); + let clean = |line: &str| -> Option<String> { + let heading = line + .trim() + .trim_start_matches('#') + .trim() + .trim_start_matches("**") + .trim_end_matches("**") + .trim(); + // Drop leading noise — stray "?" placeholders (where an emoji was + // stripped upstream), bullets, dashes — so the title starts on a word. + let heading = heading + .trim_start_matches(|c: char| !c.is_alphanumeric()) + .trim(); + if !heading.chars().any(char::is_alphanumeric) { + return None; + } + let lower = heading.to_ascii_lowercase(); + if [ + "kars sandbox - secure ai runtime", + "foundry project", + "model:", + "sandbox id", + "security summary", + "capabilities", + "role plan", + "role roster", + "roles spawned", + ] + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return None; + } + + let title: String = heading.chars().take(90).collect(); + Some(if heading.chars().count() > 90 { + format!("{}…", title.trim_end()) + } else { + title + }) + }; + // Prefer the first real markdown heading near the top — briefings lead + // with a status sentence then a "## …" headline, which reads far better + // as a title than the preamble line. + for line in lines.iter().take(14) { + if line.trim_start().starts_with('#') + && let Some(t) = clean(line) + { + return Some(t); + } + } + // Otherwise the first substantive line. + lines.iter().find_map(|l| clean(l)) + }; + // Replace the legacy "title == charter" rows and any empty title. The + // controller stored the title as the charter's first line *truncated to 160 + // chars*, so we match by prefix rather than equality. + let stored_t = stored.trim(); + let stored_lower = stored_t.to_ascii_lowercase(); + let cl = charter_line.trim(); + let legacy = stored_t.is_empty() + || stored_t == cl + || (stored_t.len() >= 24 && cl.starts_with(stored_t)) + || (cl.len() >= 24 && stored_t.starts_with(cl)) + || ["kars sandbox", "role plan", "role roster", "current state"] + .iter() + .any(|prefix| stored_lower.starts_with(prefix)); + if legacy { + derive().unwrap_or_else(|| stored_t.to_string()) + } else { + stored_t.to_string() + } +} + +/// `GET /api/namespaces/:ns/teams/:name/commons` — the team's shared, +/// provenance-tracked knowledge commons (design note §14). Each entry records +/// which run authored it, when, and a content digest. +pub async fn get_team_commons( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<CommonsResponse>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + // Commons name defaults to the team name when unset. + let commons = team + .spec + .knowledge_commons + .clone() + .unwrap_or_else(|| name.clone()); + + let data = cluster.read_commons(&commons).await.unwrap_or_default(); + let index: Vec<CommonsIndexEntry> = data + .get("index.json") + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + // The charter's first line is what the controller historically used as every + // entry's title; we use it to recognize and replace those duplicate rows. + let charter_line = team + .spec + .charter + .lines() + .next() + .unwrap_or(&team.spec.charter) + .to_string(); + + // Newest first, with content resolved from the companion keys. We *heal* two + // legacy defects here so the Knowledge tab is readable for entries written + // before the source-side fixes: (1) content stored as the raw agent JSON + // envelope is unwrapped to its prose deliverable; (2) the duplicate + // charter-as-title is replaced with a real headline derived from that prose. + let mut entries: Vec<CommonsEntryDto> = index + .into_iter() + .rev() + .map(|e| { + let key = format!( + "entry-{}", + e.id.chars() + .map( + |c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + ) + .collect::<String>() + ); + let content = data.get(&key).map(|c| deliverable_text(c)); + let title = match content.as_deref() { + Some(c) => commons_title(&e.title, c, &charter_line), + None => e.title.clone(), + }; + CommonsEntryDto { + id: e.id, + title, + author: e.author, + source_task: e.source_task, + created_at: e.created_at, + digest: e.digest, + size_bytes: e.size_bytes, + content, + } + }) + .collect(); + let total_entries = entries.len() as i64; + entries.truncate(50); + + Ok(Json(CommonsResponse { + commons, + count: total_entries, + entries, + })) +} + +/// `GET /api/namespaces/:ns/teams/:name/runs/:run/archive` — retrieve one +/// durable archived run directly from the full commons index. +pub async fn get_archived_run( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name, run)): Path<(String, String, String)>, +) -> AppResult<Json<CommonsEntryDto>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + let taskforce = run.starts_with(&format!("{name}-run-")); + let persistent = run.starts_with(&format!("{name}-principal-assign-")); + if !taskforce && !persistent { + return Err(AppError::NotFound); + } + let commons_name = team + .spec + .knowledge_commons + .as_deref() + .filter(|commons| !commons.trim().is_empty()) + .unwrap_or(&name); + let data = cluster + .read_commons(commons_name) + .await + .ok_or(AppError::NotFound)?; + let entry = data + .get("index.json") + .and_then(|raw| serde_json::from_str::<Vec<CommonsIndexEntry>>(raw).ok()) + .and_then(|entries| { + entries + .into_iter() + .find(|entry| entry.id == run || entry.source_task == run) + }) + .ok_or(AppError::NotFound)?; + let content = data + .get(&commons_entry_key(&entry.id)) + .map(|content| deliverable_text(content)); + let charter_line = team + .spec + .charter + .lines() + .next() + .unwrap_or(&team.spec.charter); + let title = content + .as_deref() + .map(|content| commons_title(&entry.title, content, charter_line)) + .unwrap_or(entry.title); + Ok(Json(CommonsEntryDto { + id: entry.id, + title, + author: entry.author, + source_task: entry.source_task, + created_at: entry.created_at, + digest: entry.digest, + size_bytes: entry.size_bytes, + content, + })) +} + +/// One event in a team's continuous ledger. +#[derive(Debug, Serialize)] +pub struct LedgerEvent { + pub at: String, + pub kind: String, + pub summary: String, + pub task: Option<String>, + pub tokens: Option<i64>, +} + +/// `GET /api/namespaces/:ns/teams/:name/ledger` — the team's continuous ledger +/// (§14): a streaming, append-only timeline of everything the standing +/// operation has done, composed from the durable records the controller already +/// writes (generated runs + their deliverables/tokens + harvested knowledge + +/// published digests). Newest first. +pub async fn get_team_ledger( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<Vec<LedgerEvent>>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let mut events: Vec<LedgerEvent> = Vec::new(); + + // Run deliverables (delivery events, with token cost). + let run_prefix = format!("{name}-run-"); + for record in cluster.list_mission_output_evidence().await { + let data = record.data; + let task = data + .get("assignmentNonce") + .cloned() + .unwrap_or(record.evidence_key); + let belongs_to_team = data.get("team") == Some(&name) + || task.starts_with(&run_prefix) + || task.starts_with(&format!("{name}-principal-assign-")); + if !belongs_to_team { + continue; + } + let at = data.get("finishedAt").cloned().unwrap_or_default(); + let tokens = data.get("totalTokens").and_then(|t| t.parse::<i64>().ok()); + let ok = data.get("status").map(String::as_str) == Some("ok"); + events.push(LedgerEvent { + at, + kind: if ok { + "delivery".into() + } else { + "delivery_error".into() + }, + summary: data + .get("output") + .map(|o| { + deliverable_text(o) + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .chars() + .take(140) + .collect::<String>() + }) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "run completed".into()), + task: Some(task), + tokens, + }); + } + + // Harvested knowledge (commons entries). Heal the legacy charter-as-title so + // the ledger reads "Learned: <real headline>" rather than the same charter + // line on every knowledge event. + if let Some(cm) = cluster.read_commons(&name).await + && let Some(idx) = cm.get("index.json") + && let Ok(entries) = serde_json::from_str::<Vec<CommonsIndexEntry>>(idx) + { + let charter_line = cluster + .teams(&ns) + .get_opt(&name) + .await + .ok() + .flatten() + .map(|t| { + t.spec + .charter + .lines() + .next() + .unwrap_or(&t.spec.charter) + .to_string() + }) + .unwrap_or_default(); + for e in entries { + let key = format!( + "entry-{}", + e.id.chars() + .map( + |c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + ) + .collect::<String>() + ); + let title = match cm.get(&key) { + Some(c) => commons_title(&e.title, &deliverable_text(c), &charter_line), + None => e.title.clone(), + }; + events.push(LedgerEvent { + at: e.created_at, + kind: "knowledge".into(), + summary: format!("Learned: {title}"), + task: Some(e.source_task), + tokens: None, + }); + } + } + + // Published digests (report events). + for d in cluster.list_team_digests().await { + if d.get("team").and_then(|v| v.as_str()) != Some(name.as_str()) { + continue; + } + events.push(LedgerEvent { + at: d + .get("at") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + kind: "digest".into(), + summary: d + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + task: None, + tokens: None, + }); + } + + events.sort_by(|a, b| b.at.cmp(&a.at)); + events.truncate(100); + Ok(Json(events)) +} diff --git a/bridge/bff/src/routes/teams/lifecycle.rs b/bridge/bff/src/routes/teams/lifecycle.rs new file mode 100644 index 000000000..29456c069 --- /dev/null +++ b/bridge/bff/src/routes/teams/lifecycle.rs @@ -0,0 +1,268 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::{Api, ListParams, Patch, PatchParams}; +use serde::Deserialize; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::team::KarsTeam; +use crate::routes::tasks::require_cluster; + +use super::require_owned_team; + +#[derive(Debug, serde::Deserialize)] +pub struct PromoteRequest { + pub tier: i32, +} + +/// `POST /api/namespaces/:ns/teams/:name/promote` — request a governed +/// promotion to a higher autonomy tier (§12). Sets `spec.requestedTier`; the +/// controller opens a human approval and only widens the envelope on approval. +/// The BFF never raises the envelope directly (the envelope-write VAP forbids +/// it) — it only records the request. +pub async fn promote_team( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(body): Json<PromoteRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + if !(1..=5).contains(&body.tier) { + return Err(AppError::BadRequest("tier must be in 1..5".into())); + } + let api: Api<KarsTeam> = cluster.teams(&ns); + let patch = serde_json::json!({ "spec": { "requestedTier": body.tier } }); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(serde_json::json!({ + "requested": true, + "tier": body.tier, + "note": "A human approval has been opened. The team is promoted only once it is approved." + }))) +} + +/// `POST /api/namespaces/:ns/teams/:name/run` — trigger an immediate run +/// ("Run now"). Sets the `kars.azure.com/run-now` annotation; the controller +/// mints one taskforce run under the normal readiness gates and clears the +/// annotation. This is the only way to make a cadence-less ("on demand") team +/// act, and a manual kick for cadenced teams. +pub async fn run_team( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + Ok(Json( + request_team_run(cluster, &ns, &name, &principal).await?, + )) +} + +pub(crate) async fn request_team_run( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + principal: &Principal, +) -> AppResult<serde_json::Value> { + let api: Api<KarsTeam> = cluster.teams(ns); + let team = require_owned_team(cluster, ns, name, principal).await?; + if team.spec.paused { + return Err(AppError::BadRequest( + "team is paused — resume it before running".into(), + )); + } + if team + .annotations() + .get("kars.azure.com/run-now") + .is_some_and(|value| !value.trim().is_empty()) + { + return Err(AppError::BadRequest( + "a run request is already pending for this team".into(), + )); + } + let active_run = cluster + .tasks(ns) + .list(&ListParams::default().labels(&format!("kars.azure.com/team={name}"))) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .items + .into_iter() + .any(|task| { + task.annotations() + .get("kars.azure.com/team-role") + .is_some_and(|role| role == "taskforce") + && task + .spec + .execution + .as_ref() + .is_some_and(|execution| execution.launch) + }); + if active_run { + return Err(AppError::BadRequest( + "this team already has a run in progress".into(), + )); + } + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/run-now": chrono::Utc::now().to_rfc3339() } } + }); + api.patch( + name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(patch), + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(serde_json::json!({ + "triggered": true, + "note": "A run has been requested. It appears under the team's runs once the principal launches." + })) +} + +#[derive(Debug, Deserialize)] +pub struct HaltTeamRunRequest { + pub reason: Option<String>, +} + +/// Governed emergency stop for a standing-team run. The team is paused first +/// so cadence/intake cannot immediately mint replacement work, then the active +/// task is un-launched while its trace, output, and halt decision remain. +pub async fn halt_team_run( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name, run)): Path<(String, String, String)>, + Json(body): Json<HaltTeamRunRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + Ok(Json( + request_team_run_halt( + cluster, + &ns, + &name, + &run, + body.reason.as_deref(), + &principal, + ) + .await?, + )) +} + +pub(crate) async fn request_team_run_halt( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + name: &str, + run: &str, + reason: Option<&str>, + principal: &Principal, +) -> AppResult<serde_json::Value> { + require_owned_team(cluster, ns, name, principal).await?; + let tasks = cluster.tasks(ns); + let task = tasks + .get(run) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + if task + .labels() + .get("kars.azure.com/team") + .is_none_or(|team| team != name) + || task + .annotations() + .get("kars.azure.com/team-role") + .is_none_or(|role| role != "taskforce") + { + return Err(AppError::BadRequest( + "the requested task is not a taskforce run owned by this team".into(), + )); + } + if !task + .spec + .execution + .as_ref() + .is_some_and(|execution| execution.launch) + { + return Err(AppError::Conflict( + "the requested team run is not active".into(), + )); + } + + let reason = reason + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .unwrap_or("operator emergency-stop"); + let at = chrono::Utc::now().to_rfc3339(); + cluster + .teams(ns) + .patch( + name, + &PatchParams::default(), + &Patch::Merge(serde_json::json!({"spec": {"paused": true}})), + ) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + tasks + .patch( + run, + &PatchParams::default(), + &Patch::Merge(serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/halted": format!( + "halted by operator at {at}: {reason}" + ) + } + }, + "spec": {"execution": {"launch": false}} + })), + ) + .await + .map_err(|error| AppError::Upstream(error.to_string()))?; + + Ok(serde_json::json!({ + "halted": true, + "team_paused": true, + "run": run, + "at": at, + "reason": reason, + "note": "The run sandbox is being torn down and the standing team is paused. Retained evidence remains available." + })) +} + +/// `DELETE /api/namespaces/:ns/teams/:name` — permanently delete a standing +/// team. Deleting the `KarsTeam` cascade-removes its runs + member sandboxes; +/// the BFF then sweeps the team's shared-memory commons, task backlog, and +/// channel secret so nothing is orphaned. Idempotent-ish: a not-found team is a +/// 404, but missing aux objects are ignored. +pub async fn delete_team( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + cluster + .delete_team( + &ns, + &name, + team.metadata + .uid + .as_deref() + .ok_or_else(|| AppError::Conflict("Team UID missing".into()))?, + team.metadata + .resource_version + .as_deref() + .ok_or_else(|| AppError::Conflict("Team resourceVersion missing".into()))?, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json(serde_json::json!({ + "deleted": true, + "note": "Team deletion requested. Core garbage-collects sources bound to this exact Team UID; legacy credential stores are retained for operator review." + }))) +} diff --git a/bridge/bff/src/routes/teams/mutations.rs b/bridge/bff/src/routes/teams/mutations.rs new file mode 100644 index 000000000..80c27ee30 --- /dev/null +++ b/bridge/bff/src/routes/teams/mutations.rs @@ -0,0 +1,687 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::Api; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::team::KarsTeam; +use crate::routes::options::build_options; +use crate::routes::tasks::require_cluster; + +use super::validation::{ + apply_team_git_write, build_roster, normalize_autonomous_runtime, normalize_lifecycle_mode, + normalize_mcp_servers, normalize_model_fallback_routes, reject_reserved_role_names, + validate_mcp_servers, validate_team_model_routes, validate_warm_idle_seconds, +}; +use super::{ + CreateRole, CreateTeamRequest, TeamModelRoutes, UpdateTeamRequest, require_owned_team, +}; + +/// `POST /api/namespaces/:ns/teams` — create a standing team. The controller +/// validates the envelope; cadence drives the autonomous tick. Defaults are +/// conservative (tier 3, ceiling=tier, depth 1) so a team can't self-amplify. +pub async fn create_team( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path(ns): Path<String>, + Json(mut b): Json<CreateTeamRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + cluster + .credential_grant(&ns) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if b.name.trim().is_empty() || b.charter.trim().len() < 8 { + return Err(AppError::BadRequest( + "name and a real charter are required".into(), + )); + } + reject_reserved_role_names(&b.name, &b.roles)?; + let execution_plan = b + .execution_plan + .as_ref() + .ok_or_else(|| AppError::BadRequest("a typed execution_plan is required".into()))?; + crate::routes::compose::validate_execution_plan(execution_plan) + .map_err(AppError::BadRequest)?; + let roster_names = b + .roles + .iter() + .map(|role| role.name.trim()) + .collect::<std::collections::BTreeSet<_>>(); + let plan_names = execution_plan + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::<std::collections::BTreeSet<_>>(); + if roster_names != plan_names { + return Err(AppError::BadRequest( + "execution_plan role names must exactly match the team roster".into(), + )); + } + b.mcp_servers = normalize_mcp_servers(&b.mcp_servers)?; + validate_mcp_servers(cluster, &ns, &b.mcp_servers).await?; + normalize_autonomous_runtime(&mut b.runtime); + for role in &mut b.roles { + normalize_autonomous_runtime(&mut role.runtime); + } + let options = build_options(cluster).await?; + if b.model + .as_deref() + .is_none_or(|model| model.trim().is_empty()) + { + b.model = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)); + } + b.model_fallbacks = normalize_model_fallback_routes(&b.model_fallbacks, b.model.as_deref())?; + validate_team_model_routes( + &options, + TeamModelRoutes { + namespace: &ns, + runtime: b.runtime.as_deref(), + model: b.model.as_deref(), + model_fallbacks: &b.model_fallbacks, + roles: &b.roles, + execution_plan, + mcp_servers: &b.mcp_servers, + memory: b.memory.as_deref(), + }, + )?; + b.created_by = Some(principal.name.clone()); + let created_by = principal.name.clone(); + let git_write = crate::routes::github::authorize_git_write( + cluster, + &ns, + &principal, + b.git_write_repos.as_deref(), + ) + .await?; + // Aggregate inference-budget gate (cluster + workspace + user): a launched + // team immediately kicks off a run (token spend), so block starting new work + // when a budget at any tier is strict/over-buffer. A paused team passes. + if b.launch.unwrap_or(false) { + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &created_by).await?; + } + let tier = b.tier.unwrap_or(3).clamp(1, 5); + let ceiling = b.authority_ceiling.unwrap_or(tier).clamp(1, tier); + // Governance: create PAUSED unless the operator explicitly opts into + // launching. A paused team does not auto-kickoff (the controller mints the + // initial run only when `!paused`), so "Launch" is a genuine human approval + // — clicking Run now / Resume — not an automatic side-effect of Create. + let paused = !b.launch.unwrap_or(false); + let mut spec = serde_json::json!({ + "charter": b.charter, "paused": paused, "envelope": { "tier": tier, "authorityCeiling": ceiling, "delegationDepth": b.delegation_depth.unwrap_or(1) }, + }); + if let Some(mode) = normalize_lifecycle_mode(b.lifecycle_mode.as_deref())? { + spec["lifecycleMode"] = serde_json::json!(mode); + } + if let Some(seconds) = validate_warm_idle_seconds(b.warm_idle_seconds)? { + spec["warmIdleSeconds"] = serde_json::json!(seconds); + } + if let Some(r) = &b.reporting_to { + spec["reportingTo"] = serde_json::json!(r); + } + if let Some(d) = b + .display_name + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + { + spec["displayName"] = serde_json::json!(d); + } + if let Some(c) = &b.knowledge_commons { + spec["knowledgeCommons"] = serde_json::json!(c); + } + // cadence_minutes == 0 (or absent) means a cadence-LESS "run on demand" team: + // the CRD requires everyMinutes >= 1 when the cadence field is present, so we + // OMIT it entirely rather than write an invalid everyMinutes: 0 (which the + // apiserver rejects 422). A cadence-less team is minted once on creation + // (kickoff) and thereafter only runs via "Run now". + if let Some(m) = b.cadence_minutes + && m >= 1 + { + spec["cadence"] = serde_json::json!({ "everyMinutes": m }); + } + // Every team run must be governed by a real ToolPolicy. Without one the run + // sandbox is created with governance disabled, the agent's AGT engine starts + // with an empty policy set and fails closed, and the run hangs until the + // dispatch times out. Resolve the requested policy (or the cluster default + // `kars-default`) and pin it on the team's run blueprint. + let tool_policy = resolve_team_tool_policy(cluster, &ns, b.tool_policy.as_deref()).await; + if let Some(tp) = &tool_policy { + spec["blueprint"] = serde_json::json!({ "toolPolicy": tp }); + } + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["executionPlan"] = serde_json::to_value(execution_plan.clone().into_crd()) + .map_err(|error| { + AppError::BadRequest(format!("execution_plan could not be serialized: {error}")) + })?; + // Team-level harness: the runtime every minted run executes on. Correct a + // bootstrap-only adapter (no autonomous task loop) to OpenClaw — a standing + // run must be able to run autonomously. Hermes/BYO are autonomous and pass + // through. The controller inherits this via the team's run blueprint. + if let Some(rt) = b + .runtime + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { + "OpenClaw" + } else { + rt + }; + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["runtime"] = serde_json::json!(rt); + } + if let Some(model) = b.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) + && let Some((provider, deployment)) = model.split_once("::") + { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["model"] = + serde_json::json!({"provider": provider, "deployment": deployment}); + } + let model_fallbacks = b + .model_fallbacks + .iter() + .map(|route| route.trim()) + .filter(|route| !route.is_empty()) + .filter_map(|route| route.split_once("::")) + .map(|(provider, deployment)| { + serde_json::json!({"provider": provider, "deployment": deployment}) + }) + .collect::<Vec<_>>(); + spec["blueprint"]["modelFallbacks"] = serde_json::json!(model_fallbacks); + if let Some(memory) = b.memory.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["memory"] = serde_json::json!(memory); + } + if !b.egress.is_empty() { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["egress"] = serde_json::json!( + b.egress + .iter() + .filter_map(|entry| { + let host = entry.host.trim(); + (!host.is_empty()) + .then(|| serde_json::json!({"host": host, "port": entry.port})) + }) + .collect::<Vec<_>>() + ); + } + if let Some(mode) = b.egress_mode.as_deref().map(str::trim) { + let mode = match mode.to_ascii_lowercase().as_str() { + "strict" => "Strict", + "learning" | "learn" => "Learn", + _ => { + return Err(AppError::BadRequest( + "egress_mode must be 'learning' or 'strict'".into(), + )); + } + }; + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["egressMode"] = serde_json::json!(mode); + } + apply_team_git_write(&mut spec, git_write.as_ref().map(|(config, _)| config))?; + if let Some((_, binding)) = &git_write { + spec["blueprint"]["githubBinding"] = + serde_json::to_value(binding).map_err(|error| AppError::Upstream(error.to_string()))?; + } + if !b.mcp_servers.is_empty() { + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["mcpServers"] = serde_json::json!( + b.mcp_servers + .iter() + .map(|server| server.trim()) + .filter(|server| !server.is_empty()) + .collect::<Vec<_>>() + ); + } + if !b.roles.is_empty() { + spec["roster"] = serde_json::json!(build_roster(&b.roles)); + } + if let Some(ttl) = b.run_retention_ttl_seconds { + spec["runRetentionTtlSeconds"] = serde_json::json!(ttl); + } + let body = serde_json::json!({ "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTeam", "metadata": {"name": b.name.trim(), "namespace": ns}, "spec": spec }); + let mut body = body; + // Stamp the creator for per-user budget attribution (propagated onto runs). + if body["metadata"]["annotations"].is_null() { + body["metadata"]["annotations"] = serde_json::json!({}); + } + body["metadata"]["annotations"]["kars.azure.com/created-by"] = serde_json::json!(created_by); + body["metadata"]["annotations"]["kars.azure.com/owner-sub"] = serde_json::json!(principal.sub); + body["metadata"]["annotations"]["kars.azure.com/owner-name"] = + serde_json::json!(principal.name); + let active = !body["spec"]["paused"].as_bool().unwrap_or(false); + body["spec"]["paused"] = serde_json::json!(true); + let captured = cluster + .create_kind(&ns, "KarsTeam", body) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + cluster + .finish_created_credentials( + &crate::kars::credentials::Target { + kind: "KarsTeam".into(), + namespace: ns.clone(), + name: captured.name_any(), + uid: captured + .uid() + .ok_or_else(|| AppError::Upstream("Team CREATE omitted UID".into()))?, + }, + active, + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(Json( + serde_json::json!({"created": true, "name": b.name.trim()}), + )) +} + +/// Resolve the governance policy to pin on a team's run blueprint: the +/// requested policy when it exists, else the cluster default (`kars-default`), +/// else the first installed policy. Returns `None` only when the cluster has no +/// ToolPolicy at all (nothing we can assign). +async fn resolve_team_tool_policy( + cluster: &crate::kars::cluster::Cluster, + ns: &str, + requested: Option<&str>, +) -> Option<String> { + if let Some(r) = requested.map(str::trim).filter(|r| !r.is_empty()) + && cluster + .get_kind(ns, "ToolPolicy", r) + .await + .ok() + .flatten() + .is_some() + { + return Some(r.to_string()); + } + if cluster + .get_kind( + ns, + "ToolPolicy", + crate::routes::compose::DEFAULT_TOOL_POLICY, + ) + .await + .ok() + .flatten() + .is_some() + { + return Some(crate::routes::compose::DEFAULT_TOOL_POLICY.to_string()); + } + cluster + .list_kind_all("ToolPolicy") + .await + .ok()? + .into_iter() + .find(|policy| policy.namespace().as_deref() == Some(ns)) + .map(|policy| policy.name_any()) +} + +/// `PATCH /api/namespaces/:ns/teams/:name` — edit charter, cadence, reporting, +/// or pause. Envelope-raising fields are out of scope here (promote handles +/// governed tier changes); this is the non-amplifying day-to-day edit. +pub async fn update_team( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(mut b): Json<UpdateTeamRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + normalize_autonomous_runtime(&mut b.runtime); + if let Some(roles) = &mut b.roles { + for role in roles { + normalize_autonomous_runtime(&mut role.runtime); + } + } + let execution_plan_changed = b.execution_plan.is_some(); + if b.runtime.is_some() + || b.model.is_some() + || b.model_fallbacks.is_some() + || b.memory.is_some() + || b.roles.is_some() + || b.mcp_servers.is_some() + || b.execution_plan.is_some() + { + let options = build_options(cluster).await?; + if b.model + .as_deref() + .is_some_and(|model| model.trim().is_empty()) + { + b.model = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)); + } + let existing_runtime = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.runtime.as_deref()); + let existing_model = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.model.as_ref()) + .map(|model| format!("{}::{}", model.provider, model.deployment)); + let existing_model_fallbacks = team + .spec + .blueprint + .as_ref() + .map(|blueprint| { + blueprint + .model_fallbacks + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + if b.model.is_some() || b.model_fallbacks.is_some() { + b.model_fallbacks = Some(normalize_model_fallback_routes( + b.model_fallbacks + .as_deref() + .unwrap_or(existing_model_fallbacks.as_slice()), + b.model.as_deref().or(existing_model.as_deref()), + )?); + } + let existing_roles = team + .spec + .roster + .iter() + .map(|role| CreateRole { + name: role.name.clone(), + system_prompt: role.system_prompt.clone(), + runtime: role + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.runtime.clone()), + model: role + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.model.as_ref()) + .map(|model| format!("{}::{}", model.provider, model.deployment)), + skills: role.skills.clone(), + }) + .collect::<Vec<_>>(); + let existing_execution_plan = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + .map(crate::routes::tasks::ExecutionPlanDto::from_crd); + let existing_mcp_servers = team + .spec + .blueprint + .as_ref() + .map(|blueprint| blueprint.mcp_servers.clone()) + .unwrap_or_default(); + let existing_memory = team + .spec + .blueprint + .as_ref() + .and_then(|blueprint| blueprint.memory.as_deref()); + let execution_plan = b + .execution_plan + .as_ref() + .or(existing_execution_plan.as_ref()) + .ok_or_else(|| { + AppError::BadRequest( + "this team cannot change runtime/model/roles/MCP until it has a typed execution_plan" + .into(), + ) + })?; + crate::routes::compose::validate_execution_plan(execution_plan) + .map_err(AppError::BadRequest)?; + let effective_roles = b.roles.as_deref().unwrap_or(existing_roles.as_slice()); + let roster_names = effective_roles + .iter() + .map(|role| role.name.trim()) + .collect::<std::collections::BTreeSet<_>>(); + let plan_names = execution_plan + .roles + .iter() + .map(|role| role.name.as_str()) + .collect::<std::collections::BTreeSet<_>>(); + if roster_names != plan_names { + return Err(AppError::BadRequest( + "execution_plan role names must exactly match the team roster".into(), + )); + } + validate_team_model_routes( + &options, + TeamModelRoutes { + namespace: &ns, + runtime: b.runtime.as_deref().or(existing_runtime), + model: b.model.as_deref().or(existing_model.as_deref()), + model_fallbacks: b + .model_fallbacks + .as_deref() + .unwrap_or(existing_model_fallbacks.as_slice()), + roles: effective_roles, + execution_plan, + mcp_servers: b + .mcp_servers + .as_deref() + .unwrap_or(existing_mcp_servers.as_slice()), + memory: b.memory.as_deref().or(existing_memory), + }, + )?; + } + if b.paused == Some(false) { + crate::routes::budgets::enforce_launch_budget(cluster, &ns, &principal.name).await?; + } + let mut spec = serde_json::Map::new(); + if let Some(c) = &b.charter { + spec.insert("charter".into(), serde_json::json!(c)); + } + if let Some(p) = b.paused { + spec.insert("paused".into(), serde_json::json!(p)); + } + if let Some(r) = &b.reporting_to { + spec.insert("reportingTo".into(), serde_json::json!(r)); + } + if let Some(mode) = normalize_lifecycle_mode(b.lifecycle_mode.as_deref())? { + spec.insert("lifecycleMode".into(), serde_json::json!(mode)); + } + if let Some(seconds) = validate_warm_idle_seconds(b.warm_idle_seconds)? { + spec.insert("warmIdleSeconds".into(), serde_json::json!(seconds)); + } + if let Some(m) = b.cadence_minutes { + if m >= 1 { + spec.insert("cadence".into(), serde_json::json!({"everyMinutes": m})); + } else { + // 0 = passive / run-on-demand: clear the cadence entirely (a merge + // patch null removes the field) so the team actually stops auto- + // running, honouring the "0 = passive" label instead of silently + // leaving the previous cadence in place. + spec.insert("cadence".into(), serde_json::Value::Null); + } + } + if let Some(roles) = &b.roles { + reject_reserved_role_names(&name, roles)?; + spec.insert("roster".into(), serde_json::json!(build_roster(roles))); + } + let mut blueprint = serde_json::Map::new(); + if let Some(rt) = b + .runtime + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { + "OpenClaw" + } else { + rt + }; + blueprint.insert("runtime".into(), serde_json::json!(rt)); + } + if let Some(model) = b.model.as_deref().map(str::trim) { + if model.is_empty() { + blueprint.insert("model".into(), serde_json::Value::Null); + } else if let Some((provider, deployment)) = model.split_once("::") { + blueprint.insert( + "model".into(), + serde_json::json!({"provider": provider, "deployment": deployment}), + ); + } else { + return Err(AppError::BadRequest( + "model must be encoded as provider::deployment".into(), + )); + } + } + if let Some(fallbacks) = &b.model_fallbacks { + let mut seen = std::collections::BTreeSet::new(); + let mut routes = Vec::new(); + for fallback in fallbacks { + let fallback = fallback.trim(); + if fallback.is_empty() || !seen.insert(fallback.to_string()) { + continue; + } + let Some((provider, deployment)) = fallback.split_once("::") else { + return Err(AppError::BadRequest( + "model_fallbacks entries must be encoded as provider::deployment".into(), + )); + }; + routes.push(serde_json::json!({ + "provider": provider, + "deployment": deployment, + })); + } + if routes.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 unique routes".into(), + )); + } + blueprint.insert("modelFallbacks".into(), serde_json::json!(routes)); + } + if let Some(memory) = b.memory.as_deref() { + blueprint.insert( + "memory".into(), + if memory.trim().is_empty() { + serde_json::Value::Null + } else { + serde_json::json!(memory.trim()) + }, + ); + } + if let Some(servers) = &b.mcp_servers { + let servers = normalize_mcp_servers(servers)?; + validate_mcp_servers(cluster, &ns, &servers).await?; + blueprint.insert("mcpServers".into(), serde_json::json!(servers)); + } + if let Some(repos) = &b.git_write_repos { + let git_write = + crate::routes::github::authorize_git_write(cluster, &ns, &principal, Some(repos)) + .await?; + blueprint.insert( + "githubBinding".into(), + git_write + .as_ref() + .map(|(_, binding)| serde_json::to_value(binding)) + .transpose() + .map_err(|error| AppError::Upstream(error.to_string()))? + .unwrap_or(serde_json::Value::Null), + ); + blueprint.insert( + "gitWrite".into(), + git_write + .map(|(grant, _)| { + serde_json::to_value(grant).map_err(|e| AppError::Upstream(e.to_string())) + }) + .transpose()? + .unwrap_or(serde_json::Value::Null), + ); + } + if let Some(egress) = &b.egress { + let entries = egress + .iter() + .filter_map(|entry| { + let host = entry.host.trim(); + (!host.is_empty()).then(|| serde_json::json!({"host": host, "port": entry.port})) + }) + .collect::<Vec<_>>(); + blueprint.insert("egress".into(), serde_json::json!(entries)); + } + if let Some(mode) = b.egress_mode.as_deref().map(str::trim) { + let mode = match mode.to_ascii_lowercase().as_str() { + "strict" => "Strict", + "learning" | "learn" => "Learn", + _ => { + return Err(AppError::BadRequest( + "egress_mode must be 'learning' or 'strict'".into(), + )); + } + }; + blueprint.insert("egressMode".into(), serde_json::json!(mode)); + } + if let Some(execution_plan) = &b.execution_plan { + blueprint.insert( + "executionPlan".into(), + serde_json::to_value(execution_plan.clone().into_crd()).map_err(|error| { + AppError::BadRequest(format!("execution_plan could not be serialized: {error}")) + })?, + ); + } + if !blueprint.is_empty() { + // Merge-patch the nested blueprint so runtime/MCP edits preserve the + // team's existing toolPolicy/model and can be changed together. + spec.insert("blueprint".into(), serde_json::Value::Object(blueprint)); + } + if let Some(ttl) = b.run_retention_ttl_seconds { + spec.insert("runRetentionTtlSeconds".into(), serde_json::json!(ttl)); + } + let api: Api<KarsTeam> = cluster.teams(&ns); + api.patch( + &name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(serde_json::json!({"spec": spec})), + ) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if execution_plan_changed { + cluster + .merge_patch_kind( + &ns, + "KarsTask", + &format!("{name}-principal"), + serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/retry-not-before": null + } + } + }), + ) + .await + .map_err(|error| { + AppError::Upstream(format!( + "team plan was updated but its retry park could not be cleared: {error}" + )) + })?; + } + Ok(Json(serde_json::json!({"updated": true}))) +} diff --git a/bridge/bff/src/routes/teams/queries.rs b/bridge/bff/src/routes/teams/queries.rs new file mode 100644 index 000000000..8d85cf3db --- /dev/null +++ b/bridge/bff/src/routes/teams/queries.rs @@ -0,0 +1,616 @@ +// Copyright (c) Pal Lakatos-Toth. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use kube::ResourceExt; +use kube::api::{Api, ListParams}; +use serde::Serialize; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::kars::team::KarsTeam; +use crate::routes::tasks::{ + clean_display_name, clean_objective, deliverable_excerpt, deliverable_text, + extract_pull_requests, is_failure_shaped_output, is_no_change_output, require_cluster, +}; + +use super::{ + TeamDetailDto, TeamRoleDto, TeamSummaryDto, channels_from_keys, effective_lifecycle_mode, + is_team_owner, phase_of, read_task_list, require_owned_team, runtime_state, +}; + +#[derive(Debug, Serialize)] +pub struct TeamOutcomeDto { + pub run: String, + pub disposition: String, + pub headline: String, + pub detail: String, + pub objective: String, + pub finished_at: Option<String>, + pub duration_seconds: Option<i64>, + pub tokens: Option<i64>, + pub model: Option<String>, + pub pull_requests: Vec<crate::routes::tasks::PullRequestRef>, + pub artifact_count: i64, +} + +#[derive(Debug, Default, Serialize)] +pub struct TeamOutcomeSummaryDto { + pub change_proposed: i64, + pub no_action_needed: i64, + pub completed: i64, + pub failed: i64, +} + +fn run_started_at(run: &str) -> Option<chrono::DateTime<chrono::Utc>> { + let epoch = run.rsplit("-run-").next()?.get(..10)?.parse::<i64>().ok()?; + chrono::DateTime::from_timestamp(epoch, 0) +} + +fn is_internal_artifact_name(name: &str) -> bool { + let normalized = name.to_ascii_lowercase(); + normalized.contains("collaboration.jsonl") + || normalized.ends_with("role-plan.json") + || normalized.ends_with("research-evidence.jsonl") + || normalized.ends_with("activity.jsonl") + || normalized.ends_with("subagent-telemetry.jsonl") + || normalized.ends_with("execution-contract.json") + || normalized.ends_with("task-checkpoint.json") +} + +fn outcome_work_item(raw: &str) -> String { + let objective = clean_objective(raw); + if let Some(task) = objective + .split_once("TASK:") + .map(|(_, rest)| rest) + .map(|rest| rest.split("DETAILS:").next().unwrap_or(rest)) + .and_then(|rest| rest.lines().next()) + .map(str::trim) + .filter(|task| !task.is_empty()) + { + return task.chars().take(180).collect(); + } + clean_display_name(&None, &objective).unwrap_or(objective) +} + +fn outcome_from_output( + run: String, + data: &std::collections::BTreeMap<String, String>, +) -> TeamOutcomeDto { + let status = data.get("status").map(String::as_str); + let raw_output = data.get("output").map(String::as_str).unwrap_or(""); + let output = deliverable_text(raw_output); + let objective = outcome_work_item(data.get("objective").map(String::as_str).unwrap_or("")); + let pull_requests = extract_pull_requests(&output); + let no_action = is_no_change_output(&output); + let disposition = if status == Some("error") || is_failure_shaped_output(&output) { + "failed" + } else if !pull_requests.is_empty() { + "change_proposed" + } else if no_action { + "no_action_needed" + } else { + "completed" + }; + let detail = deliverable_excerpt(&output); + let headline = if let Some(pr) = pull_requests.first() { + format!("Change proposed in {} PR #{}", pr.repo, pr.number) + } else if disposition == "no_action_needed" { + if detail.is_empty() { + "No action needed".to_string() + } else { + detail.clone() + } + } else if disposition == "failed" { + let lower = output.to_ascii_lowercase(); + if lower.contains("unexpected tokens remaining in message header") { + "Agent response parser failed".to_string() + } else if lower.contains("kars sandbox - secure ai runtime") + && lower.contains("how can i help") + { + "Agent returned its runtime banner instead of work".to_string() + } else if lower.contains("llm request failed") + || lower.contains("network connection") + || lower.contains("connection refused") + { + "Model or network request failed".to_string() + } else if lower.contains("now await") + || lower.contains("awaiting handback") + || lower.contains("waiting for") && lower.contains("handback") + { + "Team run ended before all selected roles returned".to_string() + } else if detail.is_empty() { + "Run failed before producing an outcome".to_string() + } else { + detail.clone() + } + } else { + clean_display_name(&None, &detail) + .or_else(|| clean_display_name(&None, &objective)) + .unwrap_or_else(|| "Completed work".to_string()) + }; + let finished_at = data.get("finishedAt").cloned(); + let duration_seconds = finished_at + .as_deref() + .and_then(|finished| chrono::DateTime::parse_from_rfc3339(finished).ok()) + .and_then(|finished| { + run_started_at(&run).map(|started| { + finished + .with_timezone(&chrono::Utc) + .signed_duration_since(started) + .num_seconds() + .max(0) + }) + }); + let artifact_count = data + .get("artifacts") + .and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(raw).ok()) + .map(|artifacts| { + artifacts + .iter() + .filter(|artifact| { + artifact + .get("name") + .and_then(serde_json::Value::as_str) + .is_none_or(|name| !is_internal_artifact_name(name)) + }) + .count() as i64 + }) + .unwrap_or(0); + TeamOutcomeDto { + run, + disposition: disposition.to_string(), + headline, + detail, + objective, + finished_at, + duration_seconds, + tokens: data.get("totalTokens").and_then(|value| value.parse().ok()), + model: data.get("model").cloned(), + pull_requests, + artifact_count, + } +} + +fn to_summary(team: &KarsTeam) -> TeamSummaryDto { + let st = team.status.as_ref(); + let created_at = team + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()); + let last_run_at = st.and_then(|status| status.last_run_at.clone()); + let last_success_at = st.and_then(|status| status.last_success_at.clone()); + let last_activity_at = [ + st.and_then(|status| status.last_activity_at.clone()), + last_run_at.clone(), + last_success_at.clone(), + created_at.clone(), + ] + .into_iter() + .flatten() + .max(); + TeamSummaryDto { + name: team.name_any(), + display_name: team.spec.display_name.clone(), + charter: team.spec.charter.clone(), + phase: phase_of(team), + reporting_to: team.spec.reporting_to.clone(), + tier: team.spec.envelope.tier, + member_count: st.and_then(|s| s.member_count).unwrap_or(0), + generated_task_count: st.and_then(|s| s.generated_task_count).unwrap_or(0), + every_minutes: team.spec.cadence.as_ref().and_then(|c| c.every_minutes), + lifecycle_mode: effective_lifecycle_mode(team), + warm_idle_seconds: team.spec.warm_idle_seconds, + runtime_state: runtime_state(team), + current_assignment_task: st.and_then(|s| s.current_assignment_task.clone()), + idle_deadline_at: st.and_then(|s| s.idle_deadline_at.clone()), + paused: team.spec.paused, + created_at, + last_run_at, + last_success_at, + last_activity_at, + next_run_at: st.and_then(|s| s.next_run_at.clone()), + health: st.and_then(|s| s.health.clone()), + detail: st.and_then(|s| s.detail.clone()), + runs_succeeded: st.and_then(|s| s.runs_succeeded).unwrap_or(0), + retained_delivered: 0, + retained_no_action: 0, + retained_failed: 0, + } +} + +/// `GET /api/namespaces/:ns/teams` — list standing teams in a namespace. +pub async fn list_teams( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path(ns): Path<String>, +) -> AppResult<Json<Vec<TeamSummaryDto>>> { + let cluster = require_cluster(&state)?; + let api: Api<KarsTeam> = cluster.teams(&ns); + let list = api + .list(&ListParams::default()) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let task_list = cluster + .tasks(&ns) + .list(&ListParams::default()) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let mut retained_runs: std::collections::HashMap<String, i64> = + std::collections::HashMap::new(); + let visible_team_names = list + .items + .iter() + .filter(|team| is_team_owner(team, &principal)) + .map(ResourceExt::name_any) + .collect::<Vec<_>>(); + let mut retained_outcomes: std::collections::HashMap<String, (i64, i64, i64)> = + std::collections::HashMap::new(); + for record in cluster.list_mission_output_evidence().await { + let data = record.data; + let run = data + .get("assignmentNonce") + .cloned() + .unwrap_or(record.evidence_key); + let team_name = data + .get("team") + .filter(|team| visible_team_names.contains(team)) + .or_else(|| { + visible_team_names + .iter() + .find(|team_name| run.starts_with(&format!("{team_name}-run-"))) + }); + let Some(team_name) = team_name else { + continue; + }; + let counts = retained_outcomes.entry(team_name.clone()).or_default(); + let status = data.get("status").map(String::as_str); + let output = data.get("output").map(String::as_str).unwrap_or(""); + if status == Some("error") || is_failure_shaped_output(output) { + counts.2 += 1; + } else if is_no_change_output(output) { + counts.1 += 1; + } else if crate::routes::tasks::is_real_deliverable(status, output) { + counts.0 += 1; + } + } + for task in task_list.items { + let is_run = task + .annotations() + .get("kars.azure.com/team-role") + .is_some_and(|role| role == "taskforce"); + if !is_run { + continue; + } + if let Some(team_name) = task.labels().get("kars.azure.com/team") { + *retained_runs.entry(team_name.clone()).or_default() += 1; + } + } + let mut summaries = list + .items + .iter() + .filter(|team| is_team_owner(team, &principal)) + .map(|team| { + let mut summary = to_summary(team); + summary.generated_task_count = summary + .generated_task_count + .max(*retained_runs.get(&summary.name).unwrap_or(&0)); + if let Some((delivered, no_action, failed)) = retained_outcomes.get(&summary.name) { + summary.retained_delivered = *delivered; + summary.retained_no_action = *no_action; + summary.retained_failed = *failed; + } + summary + }) + .collect::<Vec<_>>(); + summaries.sort_by(|left, right| right.last_activity_at.cmp(&left.last_activity_at)); + Ok(Json(summaries)) +} + +pub async fn get_team( + State(state): State<crate::state::AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<TeamDetailDto>> { + let cluster = require_cluster(&state)?; + let team = require_owned_team(cluster, &ns, &name, &principal).await?; + + // Resolve generated task-force tasks: KarsTasks in the namespace owned by + // this team whose name carries the `<team>-run-` standing-operation prefix. + let tasks: Api<crate::kars::task::KarsTask> = cluster.tasks(&ns); + let run_prefix = format!("{name}-run-"); + let mut generated_tasks: Vec<String> = tasks + .list(&ListParams::default()) + .await + .map(|l| { + l.items + .iter() + .map(kube::ResourceExt::name_any) + .filter(|n| n.starts_with(&run_prefix)) + .collect() + }) + .unwrap_or_default(); + generated_tasks.sort(); + generated_tasks.reverse(); + let retained_run_count = generated_tasks.len() as i64; + let newest_retained_run = generated_tasks.first().cloned(); + let retained_run_names = generated_tasks + .iter() + .cloned() + .collect::<std::collections::HashSet<_>>(); + let mut recent_outcomes = cluster + .list_mission_output_evidence() + .await + .into_iter() + .filter_map(|record| { + let run = record + .data + .get("assignmentNonce") + .cloned() + .unwrap_or(record.evidence_key); + (retained_run_names.contains(&run) || record.data.get("team") == Some(&name)) + .then(|| outcome_from_output(run, &record.data)) + }) + .collect::<Vec<_>>(); + recent_outcomes.sort_by(|left, right| { + right + .finished_at + .cmp(&left.finished_at) + .then_with(|| right.run.cmp(&left.run)) + }); + let mut recent_outcome_summary = TeamOutcomeSummaryDto::default(); + for outcome in &recent_outcomes { + match outcome.disposition.as_str() { + "change_proposed" => recent_outcome_summary.change_proposed += 1, + "no_action_needed" => recent_outcome_summary.no_action_needed += 1, + "failed" => recent_outcome_summary.failed += 1, + _ => recent_outcome_summary.completed += 1, + } + } + + let st = team.status.as_ref(); + let member_names: Vec<String> = st + .map(|s| s.member_refs.iter().map(|r| r.name.clone()).collect()) + .unwrap_or_default(); + + // Build the org chart: pair each roster role with its materialized member + // task (named `<team>-<role>` by the reconciler). + let roster: Vec<TeamRoleDto> = team + .spec + .roster + .iter() + .map(|role| { + let member_task = format!("{name}-{}", role.name); + let materialized = member_names.iter().any(|m| m == &member_task); + TeamRoleDto { + name: role.name.clone(), + system_prompt: role.system_prompt.clone(), + tier: role.envelope.as_ref().map(|e| e.tier), + member_task: materialized.then_some(member_task), + skills: role.skills.clone(), + runtime: role.blueprint.as_ref().and_then(|b| b.runtime.clone()), + model: role + .blueprint + .as_ref() + .and_then(|b| b.model.as_ref()) + .map(|m| format!("{}::{}", m.provider, m.deployment)), + } + }) + .collect(); + + let bp = team.spec.blueprint.as_ref(); + // Effective tool policy: explicit blueprint override, else the system + // default `kars-default` (applied to every run sandbox via the + // `system-default=true` sandbox selector). Never "none" — a run without a + // governing policy fails closed. + let bp_tool_policy = bp.and_then(|b| b.tool_policy.clone()); + let tool_policy_default = bp_tool_policy.is_none(); + let tool_policy = bp_tool_policy.or_else(|| Some("kars-default".to_string())); + // Effective model: explicit blueprint override, else the controller's + // KARS_TASK_DEFAULT_MODEL that every run actually inherits. + let bp_model = bp + .and_then(|b| b.model.as_ref()) + .map(|m| format!("{}::{}", m.provider, m.deployment)); + let model_default = bp_model.is_none(); + let model = match bp_model { + Some(m) => Some(m), + None => cluster.controller_default_model().await, + }; + // Effective harness: explicit blueprint override, else the sandbox default + // (OpenClaw). Team runs inherit this via launched_run_blueprint. + let bp_runtime = bp.and_then(|b| b.runtime.clone()).filter(|s| !s.is_empty()); + let runtime_default = bp_runtime.is_none(); + let runtime = bp_runtime.or_else(|| Some("OpenClaw".to_string())); + let egress: Vec<String> = bp + .map(|b| { + b.egress + .iter() + .map(|e| { + if let Some(p) = e.port { + format!("{}:{}", e.host, p) + } else { + e.host.clone() + } + }) + .collect() + }) + .unwrap_or_default(); + + // Concrete "domains reached so far": aggregate the learn-mode observation + // buffers of the team's currently-running run sandboxes (`<team>-run-<epoch>` + // in kars-system). Per-run + best-effort — empty when no run is live. + let mut learned_egress: Vec<String> = Vec::new(); + if let Ok(sandboxes) = cluster + .list_kind_labeled("KarsSandbox", &format!("kars.azure.com/team={name}")) + .await + { + let mut seen = std::collections::BTreeSet::new(); + for sb in sandboxes.iter().take(8) { + let sb_name = sb.metadata.name.clone().unwrap_or_default(); + let running = sb + .data + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + == Some("Running"); + if !running || sb_name.is_empty() { + continue; + } + if let Ok(domains) = cluster.sandbox_learned_domains(&sb_name).await { + for d in domains { + seen.insert(d); + } + } + } + learned_egress = seen.into_iter().collect(); + } + + Ok(Json(TeamDetailDto { + name: team.name_any(), + display_name: team.spec.display_name.clone(), + charter: team.spec.charter.clone(), + phase: phase_of(&team), + reporting_to: team.spec.reporting_to.clone(), + knowledge_commons: team.spec.knowledge_commons.clone(), + tier: team.spec.envelope.tier, + authority_ceiling: team.spec.envelope.authority_ceiling, + delegation_depth: team.spec.envelope.delegation_depth, + paused: team.spec.paused, + every_minutes: team.spec.cadence.as_ref().and_then(|c| c.every_minutes), + lifecycle_mode: effective_lifecycle_mode(&team), + warm_idle_seconds: team.spec.warm_idle_seconds, + runtime_state: runtime_state(&team), + current_assignment_nonce: st.and_then(|s| s.current_assignment_nonce.clone()), + current_assignment_task: st.and_then(|s| s.current_assignment_task.clone()), + idle_deadline_at: st.and_then(|s| s.idle_deadline_at.clone()), + envelope_digest: st.and_then(|s| s.envelope_digest.clone()), + principal_task: st.and_then(|s| s.principal_ref.as_ref().map(|r| r.name.clone())), + roster, + member_count: st.and_then(|s| s.member_count).unwrap_or(0), + generated_task_count: st + .and_then(|s| s.generated_task_count) + .unwrap_or(0) + .max(retained_run_count), + last_generated_task: newest_retained_run + .or_else(|| st.and_then(|s| s.last_generated_task.clone())), + last_run_at: st.and_then(|s| s.last_run_at.clone()), + next_run_at: st.and_then(|s| s.next_run_at.clone()), + detail: st.and_then(|s| s.detail.clone()), + health: st.and_then(|s| s.health.clone()), + runs_succeeded: st.and_then(|s| s.runs_succeeded).unwrap_or(0), + tokens_spent_total: st.and_then(|s| s.tokens_spent_total).unwrap_or(0), + commons_entry_count: st.and_then(|s| s.commons_entry_count).unwrap_or(0), + last_success_at: st.and_then(|s| s.last_success_at.clone()), + created_at: team + .metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + last_activity_at: [ + st.and_then(|status| status.last_activity_at.clone()), + st.and_then(|status| status.last_run_at.clone()), + st.and_then(|status| status.last_success_at.clone()), + team.metadata + .creation_timestamp + .as_ref() + .map(|timestamp| timestamp.0.to_rfc3339()), + ] + .into_iter() + .flatten() + .max(), + generated_tasks, + recent_outcomes, + recent_outcome_summary, + tool_policy, + tool_policy_default, + mcp_servers: bp.map(|b| b.mcp_servers.clone()).unwrap_or_default(), + git_write_repos: bp + .and_then(|b| b.git_write.as_ref()) + .map(|git_write| git_write.repos.clone()) + .unwrap_or_default(), + egress, + egress_mode: bp.and_then(|b| b.egress_mode.clone()), + learned_egress, + network_posture: "Default-deny egress (kernel-level). Only the inference router and AGT mesh relay are reachable; novel domains need an approved egress request.".to_string(), + model, + model_fallbacks: bp + .map(|blueprint| { + blueprint + .model_fallbacks + .iter() + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .collect() + }) + .unwrap_or_default(), + model_default, + memory: bp.and_then(|blueprint| blueprint.memory.clone()), + runtime, + runtime_default, + isolation: bp.and_then(|b| b.isolation.clone()), + execution_plan: bp + .and_then(|blueprint| blueprint.execution_plan.as_ref()) + .map(crate::routes::tasks::ExecutionPlanDto::from_crd), + tasks: read_task_list(&cluster.read_team_tasks(&name).await), + channels: channels_from_keys(&cluster.team_channel_keys(&ns,&name).await.map_err(|e|AppError::Upstream(e.to_string()))?), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn team_outcomes_distinguish_change_no_action_and_failure() { + let output = |status: &str, text: &str| { + std::collections::BTreeMap::from([ + ("status".to_string(), status.to_string()), + ("output".to_string(), text.to_string()), + ("finishedAt".to_string(), "2026-07-22T20:12:27Z".to_string()), + ]) + }; + let change = outcome_from_output( + "team-run-1784750586".into(), + &output( + "ok", + "Opened https://github.com/example/repo/pull/42 with the dependency fix.", + ), + ); + assert_eq!(change.disposition, "change_proposed"); + assert!(change.headline.contains("PR #42")); + let change_with_old_sentinel = outcome_from_output( + "team-run-1784750586".into(), + &output( + "ok", + "Opened https://github.com/example/repo/pull/43.\nPrior run: [[NO_MATERIAL_CHANGE]]", + ), + ); + assert_eq!(change_with_old_sentinel.disposition, "change_proposed"); + + let no_action = outcome_from_output( + "team-run-1784750586".into(), + &output( + "ok", + "[[NO_MATERIAL_CHANGE]] The dependency is already fixed on main.", + ), + ); + assert_eq!(no_action.disposition, "no_action_needed"); + assert!(no_action.headline.contains("already fixed")); + + let failed = outcome_from_output( + "team-run-1784750586".into(), + &output("error", "Parser failed before a handback was produced."), + ); + assert_eq!(failed.disposition, "failed"); + } + + #[test] + fn team_outcome_uses_assigned_task_as_work_item() { + assert_eq!( + outcome_work_item( + "Assigned task for team 'maintenance'.\nTASK: [Dependabot alert] owner/repo #7: tar DETAILS: internal scaffolding" + ), + "[Dependabot alert] owner/repo #7: tar" + ); + } +} diff --git a/bridge/bff/src/routes/teams/validation.rs b/bridge/bff/src/routes/teams/validation.rs new file mode 100644 index 000000000..11ca1d915 --- /dev/null +++ b/bridge/bff/src/routes/teams/validation.rs @@ -0,0 +1,618 @@ +// Copyright (c) Pal Lakatos-Toth. + +use crate::error::{AppError, AppResult}; +use crate::routes::options::{ModelOption, Options, RefOption}; + +use super::{CreateRole, TeamModelRoutes}; + +/// Build a `spec.roster` array from create/update role inputs, shared by team +/// creation and roster editing so both paths produce identical role shapes +/// (per-member systemPrompt + blueprint{runtime, model} + skills). +/// Reject roster role names that collide with the reserved task names the +/// controller derives from the team (`<team>-principal`). A role named +/// "principal" would otherwise re-materialize the principal task as a member +/// parented to itself, deadlocking the whole team. The controller also skips +/// such a role defensively, but rejecting here gives the operator a clear error +/// instead of a silently dropped role. +pub(super) fn reject_reserved_role_names(team: &str, roles: &[CreateRole]) -> AppResult<()> { + let _ = team; + for r in roles { + let n = r.name.trim().to_ascii_lowercase(); + if n == "principal" { + return Err(AppError::BadRequest( + "role name 'principal' is reserved for the team's authority root — rename this role".into(), + )); + } + } + Ok(()) +} + +pub(super) fn build_roster(roles: &[CreateRole]) -> Vec<serde_json::Value> { + roles + .iter() + .filter(|r| !r.name.trim().is_empty()) + .map(|r| { + let mut role = serde_json::json!({ "name": r.name.trim() }); + if let Some(sp) = &r.system_prompt + && !sp.trim().is_empty() + { + role["systemPrompt"] = serde_json::json!(sp.trim()); + } + let mut bp = serde_json::Map::new(); + if let Some(rt) = &r.runtime + && !rt.is_empty() + { + // Harness capability, defense-in-depth: a bootstrap-only adapter + // (no autonomous task loop) can't run a standing member — a + // hand-composed team could still name one, so correct it to + // OpenClaw here too. Hermes/BYO are autonomous and pass through. + let rt = if crate::routes::compose::is_non_autonomous_harness(rt) { + "OpenClaw" + } else { + rt.as_str() + }; + bp.insert("runtime".into(), serde_json::json!(rt)); + } + if let Some(m) = &r.model + && let Some((provider, deployment)) = m.split_once("::") + { + bp.insert( + "model".into(), + serde_json::json!({ "provider": provider, "deployment": deployment }), + ); + } + if !bp.is_empty() { + role["blueprint"] = serde_json::Value::Object(bp); + } + if !r.skills.is_empty() { + role["skills"] = serde_json::json!(r.skills); + } + role + }) + .collect() +} + +pub(super) fn normalize_mcp_servers(servers: &[String]) -> AppResult<Vec<String>> { + let mut seen = std::collections::BTreeSet::new(); + let mut normalized = Vec::new(); + for server in servers { + let server = server.trim(); + if !server.is_empty() && seen.insert(server.to_string()) { + normalized.push(server.to_string()); + } + } + if normalized.len() > 8 { + return Err(AppError::BadRequest( + "a team may connect at most 8 MCP servers".into(), + )); + } + Ok(normalized) +} + +pub(super) fn normalize_autonomous_runtime(runtime: &mut Option<String>) { + if let Some(value) = runtime.as_deref() + && (value.trim().is_empty() || crate::routes::compose::is_non_autonomous_harness(value)) + { + *runtime = Some("OpenClaw".into()); + } +} + +pub(super) fn normalize_model_fallback_routes( + routes: &[String], + primary: Option<&str>, +) -> AppResult<Vec<String>> { + let mut seen = std::collections::BTreeSet::new(); + let mut normalized = Vec::new(); + for route in routes { + let route = route.trim(); + if route.is_empty() + || primary.is_some_and(|primary| route == primary) + || !seen.insert(route.to_string()) + { + continue; + } + let valid = route + .split_once("::") + .is_some_and(|(provider, deployment)| { + !provider.trim().is_empty() && !deployment.trim().is_empty() + }); + if !valid { + return Err(AppError::BadRequest( + "model_fallbacks entries must be encoded as provider::deployment".into(), + )); + } + normalized.push(route.to_string()); + } + if normalized.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 unique routes".into(), + )); + } + Ok(normalized) +} + +fn validate_model_route(models: &[ModelOption], route: &str) -> AppResult<()> { + let route = route.trim(); + if route.is_empty() { + return Ok(()); + } + let valid = route + .split_once("::") + .is_some_and(|(provider, deployment)| { + models + .iter() + .any(|model| model.provider == provider && model.deployment == deployment) + }); + if valid { + Ok(()) + } else { + Err(AppError::BadRequest(format!( + "model route `{route}` is not present in the live model catalogue" + ))) + } +} + +fn option_named<'a>(items: &'a [RefOption], namespace: &str, name: &str) -> Option<&'a RefOption> { + items + .iter() + .find(|option| option.name == name && option.namespace == namespace) +} + +fn team_role_qualification_requirements( + plan: &crate::routes::tasks::ExecutionPlanDto, + role_name: &str, +) -> std::collections::BTreeSet<String> { + let mut required = + std::collections::BTreeSet::from(["team".to_string(), "telemetry".to_string()]); + if let Some(role) = plan.roles.iter().find(|role| role.name == role_name) { + for phase in &role.phases { + required.extend(phase.capabilities.iter().cloned()); + } + } + required +} + +pub(super) fn validate_team_model_routes( + options: &Options, + routes: TeamModelRoutes<'_>, +) -> AppResult<()> { + let TeamModelRoutes { + namespace, + runtime, + model, + model_fallbacks, + roles, + execution_plan, + mcp_servers, + memory, + } = routes; + let memory = memory.map(str::trim).filter(|memory| !memory.is_empty()); + let default_route = options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| format!("{}::{}", model.provider, model.deployment)) + .unwrap_or_default(); + let principal_route = model + .map(str::trim) + .filter(|model| !model.is_empty()) + .unwrap_or(default_route.as_str()); + validate_model_route(&options.models, principal_route)?; + let principal_runtime = runtime + .map(str::trim) + .filter(|runtime| !runtime.is_empty()) + .unwrap_or("OpenClaw"); + let principal_model = principal_route.split_once("::").ok_or_else(|| { + AppError::BadRequest(format!( + "model route `{principal_route}` must use provider::deployment" + )) + })?; + let principal_blueprint = crate::routes::tasks::BlueprintDto { + runtime: Some(principal_runtime.to_string()), + model: Some(crate::routes::tasks::ModelDto { + provider: principal_model.0.to_string(), + deployment: principal_model.1.to_string(), + }), + model_fallbacks: Vec::new(), + instructions: None, + tool_policy: None, + mcp_servers: mcp_servers.to_vec(), + egress: Vec::new(), + egress_mode: None, + isolation: None, + memory: memory.map(str::to_string), + skills: roles + .iter() + .flat_map(|role| role.skills.iter().cloned()) + .collect(), + execution_plan: Some(execution_plan.clone()), + }; + let (principal_required, principal_parallel) = + crate::routes::validate::qualification_requirements(&principal_blueprint, Some("team")); + validate_qualified_model_route( + principal_runtime, + principal_route, + &principal_required, + principal_parallel, + )?; + let principal_route_label = crate::routes::options::route_label( + principal_runtime, + principal_model.0, + principal_model.1, + ); + for server in mcp_servers { + let option = option_named(&options.mcp_servers, namespace, server).ok_or_else(|| { + AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::mcp_server_qualified_for_route( + principal_runtime, + principal_model.0, + principal_model.1, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` lacks retained resource qualification for {principal_route_label} at current schema {}", + option.tool_schema_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + if let Some(memory) = memory.map(str::trim).filter(|memory| !memory.is_empty()) { + let option = option_named(&options.memories, namespace, memory).ok_or_else(|| { + AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::memory_binding_qualified_for_route( + principal_runtime, + principal_model.0, + principal_model.1, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "memory `{memory}` lacks retained resource qualification for {principal_route_label} at backend {} / compiled digest {}", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + for role in roles { + let role_route = role + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + .unwrap_or(principal_route); + validate_model_route(&options.models, role_route)?; + let role_runtime = role + .runtime + .as_deref() + .map(str::trim) + .filter(|runtime| !runtime.is_empty()) + .unwrap_or(principal_runtime); + let role_required = team_role_qualification_requirements(execution_plan, &role.name); + validate_qualified_model_route(role_runtime, role_route, &role_required, 1)?; + let (provider, deployment) = role_route.split_once("::").ok_or_else(|| { + AppError::BadRequest(format!( + "model route `{role_route}` must use provider::deployment" + )) + })?; + let role_route_label = + crate::routes::options::route_label(role_runtime, provider, deployment); + if role_required.contains("mcp") { + for server in mcp_servers { + let option = + option_named(&options.mcp_servers, namespace, server).ok_or_else(|| { + AppError::BadRequest(format!( + "MCP server `{server}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::mcp_server_qualified_for_route( + role_runtime, + provider, + deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "role `{}` MCP server `{server}` lacks retained resource qualification for {role_route_label} at current schema {}", + role.name, + option.tool_schema_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + } + if role_required.contains("memory") + && let Some(memory) = memory.map(str::trim).filter(|memory| !memory.is_empty()) + { + let option = option_named(&options.memories, namespace, memory).ok_or_else(|| { + AppError::BadRequest(format!( + "memory `{memory}` is not present in the live options catalogue" + )) + })?; + match crate::routes::options::memory_binding_qualified_for_route( + role_runtime, + provider, + deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "role `{}` memory `{memory}` lacks retained resource qualification for {role_route_label} at backend {} / compiled digest {}", + role.name, + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + for skill in &role.skills { + let option = option_named(&options.skills, namespace, skill).ok_or_else(|| { + AppError::BadRequest(format!( + "skill `{skill}` is not present in the approved live catalogue" + )) + })?; + match crate::routes::options::skill_version_qualified_for_route( + role_runtime, + provider, + deployment, + option, + ) { + Ok(true) => {} + Ok(false) => { + return Err(AppError::BadRequest(format!( + "role `{}` skill `{skill}` lacks retained resource qualification for {role_route_label} at current version digest {}", + role.name, + option.version_digest.as_deref().unwrap_or("missing") + ))); + } + Err(error) => { + return Err(AppError::Upstream(format!( + "resource qualification configuration error: {error}" + ))); + } + } + } + } + let mut seen_fallbacks = std::collections::BTreeSet::new(); + for fallback in model_fallbacks { + let fallback = fallback.trim(); + if fallback.is_empty() { + continue; + } + if !seen_fallbacks.insert(fallback.to_string()) { + continue; + } + if seen_fallbacks.len() > 8 { + return Err(AppError::BadRequest( + "model_fallbacks may contain at most 8 unique routes".into(), + )); + } + validate_model_route(&options.models, fallback)?; + let fallback_roles = roles + .iter() + .cloned() + .map(|mut role| { + role.model = Some(fallback.to_string()); + role + }) + .collect::<Vec<_>>(); + validate_team_model_routes( + options, + TeamModelRoutes { + namespace, + runtime, + model: Some(fallback), + model_fallbacks: &[], + roles: &fallback_roles, + execution_plan, + mcp_servers, + memory, + }, + )?; + } + Ok(()) +} + +fn validate_qualified_model_route( + runtime: &str, + route: &str, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, +) -> AppResult<()> { + let Some((provider, deployment)) = route.split_once("::") else { + return Err(AppError::BadRequest(format!( + "model route `{route}` must use provider::deployment" + ))); + }; + match crate::routes::options::route_qualification( + runtime, + provider, + deployment, + required_capabilities, + max_parallel, + None, + ) { + Ok(true) => Ok(()), + Ok(false) => Err(AppError::BadRequest(format!( + "runtime/model route `{runtime} · {provider}::{deployment}` has not passed the fresh E2E qualification matrix" + ))), + Err(error) => Err(AppError::Upstream(format!( + "route qualification configuration error: {error}" + ))), + } +} + +pub(super) fn normalize_lifecycle_mode(mode: Option<&str>) -> AppResult<Option<&'static str>> { + let Some(mode) = mode.map(str::trim).filter(|mode| !mode.is_empty()) else { + return Ok(None); + }; + match mode.to_ascii_lowercase().replace(['-', '_'], "").as_str() { + "ephemeral" => Ok(Some("ephemeral")), + "resourceoptimized" => Ok(Some("resourceOptimized")), + "persistent" => Ok(Some("persistent")), + _ => Err(AppError::BadRequest( + "lifecycle_mode must be 'ephemeral', 'resourceOptimized', or 'persistent'".into(), + )), + } +} + +pub(super) fn validate_warm_idle_seconds(seconds: Option<i64>) -> AppResult<Option<i64>> { + match seconds { + Some(seconds) if seconds < 0 => Err(AppError::BadRequest( + "warm_idle_seconds must be non-negative".into(), + )), + value => Ok(value), + } +} + +pub(super) fn apply_team_git_write( + spec: &mut serde_json::Value, + git_write: Option<&crate::kars::task::GitWriteConfig>, +) -> AppResult<()> { + let Some(git_write) = git_write else { + return Ok(()); + }; + if !spec["blueprint"].is_object() { + spec["blueprint"] = serde_json::json!({}); + } + spec["blueprint"]["gitWrite"] = + serde_json::to_value(git_write).map_err(|e| AppError::Upstream(e.to_string()))?; + Ok(()) +} + +pub(super) async fn validate_mcp_servers( + cluster: &crate::kars::cluster::Cluster, + namespace: &str, + servers: &[String], +) -> AppResult<()> { + for server in servers { + let Some(resource) = cluster + .get_kind(namespace, "McpServer", server) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + else { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` is not installed in namespace `{namespace}`" + ))); + }; + let phase = resource + .data + .get("status") + .and_then(|status| status.get("phase")) + .and_then(|phase| phase.as_str()); + let observed_generation = resource + .data + .get("status") + .and_then(|status| status.get("observedGeneration")) + .and_then(|generation| generation.as_i64()); + if phase != Some("Ready") || observed_generation != resource.metadata.generation { + return Err(AppError::BadRequest(format!( + "MCP server `{server}` is not Ready for its current generation in namespace `{namespace}`" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn team_lifecycle_modes_are_normalized_and_idle_window_is_bounded() { + assert_eq!( + normalize_lifecycle_mode(Some("resource-optimized")).expect("mode"), + Some("resourceOptimized") + ); + assert_eq!( + normalize_lifecycle_mode(Some("persistent")).expect("mode"), + Some("persistent") + ); + assert!(normalize_lifecycle_mode(Some("always-on")).is_err()); + assert_eq!( + validate_warm_idle_seconds(Some(900)).expect("idle"), + Some(900) + ); + assert_eq!(validate_warm_idle_seconds(Some(0)).expect("idle"), Some(0)); + assert!(validate_warm_idle_seconds(Some(-1)).is_err()); + } + + #[test] + fn team_models_require_exact_live_catalogue_pairs() { + let models = vec![ModelOption { + provider: "github-copilot".into(), + deployment: "shared-name".into(), + is_default: true, + detail: None, + }]; + assert!(validate_model_route(&models, "github-copilot::shared-name").is_ok()); + assert!(validate_model_route(&models, "").is_ok()); + assert!(validate_model_route(&models, "local-inference::shared-name").is_err()); + assert!(validate_model_route(&models, "shared-name").is_err()); + } + + #[test] + fn team_mcp_servers_are_deduplicated_and_bounded() { + assert_eq!( + normalize_mcp_servers(&[ + " playwright ".into(), + "playwright".into(), + "everything".into() + ]) + .expect("normalize"), + vec!["playwright", "everything"] + ); + let too_many = (0..9).map(|i| format!("mcp-{i}")).collect::<Vec<_>>(); + assert!(normalize_mcp_servers(&too_many).is_err()); + } + + #[test] + fn team_git_write_is_applied_without_mcp_servers() { + let mut spec = serde_json::json!({"charter": "Deliver a feature"}); + let git_write = crate::kars::task::GitWriteConfig { + connection_config_map_ref: crate::kars::task::LocalObjectRef { + name: "kars-github-connection-0123456789abcdef".into(), + }, + repos: vec!["owner/repo".into()], + }; + apply_team_git_write(&mut spec, Some(&git_write)).expect("git write applies"); + assert_eq!( + spec["blueprint"]["gitWrite"]["connectionConfigMapRef"]["name"], + "kars-github-connection-0123456789abcdef" + ); + assert_eq!(spec["blueprint"]["gitWrite"]["repos"][0], "owner/repo"); + assert!(spec["blueprint"].get("mcpServers").is_none()); + } +} From 66a07f0db6d492e02cee7b7b03ba7f63f26d2258 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:29:23 +0200 Subject: [PATCH 020/111] Split the Bridge cluster adapter into bounded modules Mechanical extraction preserves121public methods,11public types,158normalized function signatures/bodies and19registered tests.123inherent bodies and940literals remain identical, including trace and objective digest framing. Rustfmt/offline metadata passed; hosted compilation remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/kars/cluster.rs | 4110 +---------------- bridge/bff/src/kars/cluster/configuration.rs | 392 ++ bridge/bff/src/kars/cluster/connections.rs | 429 ++ .../src/kars/cluster/engineering_sources.rs | 198 + .../bff/src/kars/cluster/local_inference.rs | 361 ++ .../bff/src/kars/cluster/mission_records.rs | 414 ++ bridge/bff/src/kars/cluster/mission_runs.rs | 287 ++ bridge/bff/src/kars/cluster/orchestrator.rs | 308 ++ bridge/bff/src/kars/cluster/provider_tests.rs | 419 ++ bridge/bff/src/kars/cluster/providers.rs | 601 +++ bridge/bff/src/kars/cluster/resources.rs | 373 ++ bridge/bff/src/kars/cluster/sandboxes.rs | 347 ++ 12 files changed, 4154 insertions(+), 4085 deletions(-) create mode 100644 bridge/bff/src/kars/cluster/configuration.rs create mode 100644 bridge/bff/src/kars/cluster/connections.rs create mode 100644 bridge/bff/src/kars/cluster/engineering_sources.rs create mode 100644 bridge/bff/src/kars/cluster/local_inference.rs create mode 100644 bridge/bff/src/kars/cluster/mission_records.rs create mode 100644 bridge/bff/src/kars/cluster/mission_runs.rs create mode 100644 bridge/bff/src/kars/cluster/orchestrator.rs create mode 100644 bridge/bff/src/kars/cluster/provider_tests.rs create mode 100644 bridge/bff/src/kars/cluster/providers.rs create mode 100644 bridge/bff/src/kars/cluster/resources.rs create mode 100644 bridge/bff/src/kars/cluster/sandboxes.rs diff --git a/bridge/bff/src/kars/cluster.rs b/bridge/bff/src/kars/cluster.rs index fbf8acf4b..e8ce9c089 100644 --- a/bridge/bff/src/kars/cluster.rs +++ b/bridge/bff/src/kars/cluster.rs @@ -4,270 +4,20 @@ // receives kube credentials. For local dev the client is built from the // ambient kubeconfig; in-cluster it uses the mounted ServiceAccount. -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use k8s_openapi::api::core::v1::{ConfigMap, Node, Pod}; -use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams}; -use kube::core::ApiResource; -use kube::{Client, ResourceExt}; -use sha2::{Digest, Sha256}; +use kube::Client; -const ORCHESTRATOR_POLICY_READY_ATTEMPTS: usize = 180; -const ORCHESTRATOR_POLICY_POLL_INTERVAL: std::time::Duration = - std::time::Duration::from_millis(500); +use super::credentials; -/// True when a sandbox name denotes an ephemeral standing-run sandbox -/// (`<team>-run-<timestamp>`), which is short-lived and often mid-execution — -/// not a stable target for routing the Bridge's orchestrator inference through. -fn is_ephemeral_run(sandbox: &str) -> bool { - if let Some(idx) = sandbox.rfind("-run-") { - let suffix = &sandbox[idx + 5..]; - return !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()); - } - false -} - -fn normalize_registry_host(value: &str) -> String { - value - .trim() - .trim_start_matches("https://") - .trim_start_matches("http://") - .split('/') - .next() - .unwrap_or_default() - .to_ascii_lowercase() -} - -fn image_registry_host(image: &str) -> String { - let first = image.trim().split('/').next().unwrap_or_default(); - if first.contains('.') || first.contains(':') || first == "localhost" { - first.to_ascii_lowercase() - } else { - "docker.io".to_string() - } -} - -fn public_registry(registry: &str) -> bool { - matches!( - registry, - "docker.io" | "registry-1.docker.io" | "mcr.microsoft.com" | "public.ecr.aws" - ) -} - -fn descendant_sandbox_objects(sandboxes: &[DynamicObject], root: &str) -> Vec<DynamicObject> { - let mut descendants = Vec::new(); - let mut frontier = vec![root.to_string()]; - let mut seen = std::collections::HashSet::from([root.to_string()]); - while let Some(parent) = frontier.pop() { - for sandbox in sandboxes { - let Some(name) = sandbox.metadata.name.as_ref() else { - continue; - }; - let is_child = sandbox - .metadata - .labels - .as_ref() - .and_then(|labels| labels.get("kars.azure.com/parent")) - == Some(&parent); - if is_child && seen.insert(name.clone()) { - descendants.push(sandbox.clone()); - frontier.push(name.clone()); - } - } - } - descendants -} - -fn mission_evidence_key(cm: &ConfigMap, legacy_label: &str) -> Option<String> { - cm.metadata - .annotations - .as_ref() - .and_then(|annotations| { - annotations - .get("kars.azure.com/mission-evidence-key") - .filter(|value| !value.trim().is_empty()) - .cloned() - }) - .or_else(|| { - cm.metadata - .labels - .as_ref() - .and_then(|labels| labels.get(legacy_label).cloned()) - }) -} - -fn mission_evidence_role(cm: &ConfigMap) -> Option<String> { - cm.metadata.annotations.as_ref().and_then(|annotations| { - annotations - .get("kars.azure.com/mission-evidence-role") - .filter(|value| !value.trim().is_empty()) - .cloned() - }) -} - -fn mission_principal_name(cm: &ConfigMap) -> Option<String> { - cm.metadata - .annotations - .as_ref() - .and_then(|annotations| { - annotations - .get("kars.azure.com/mission-principal-name") - .filter(|value| !value.trim().is_empty()) - .cloned() - }) - .or_else(|| { - cm.metadata - .labels - .as_ref() - .and_then(|labels| labels.get("kars.azure.com/mission-principal").cloned()) - }) -} - -fn mission_output_candidate( - cm: ConfigMap, -) -> Option<( - String, - Option<String>, - std::collections::BTreeMap<String, String>, -)> { - let evidence_key = mission_evidence_key(&cm, "kars.azure.com/mission-output")?; - let role = mission_evidence_role(&cm); - let principal_name = mission_principal_name(&cm); - let mut data = cm.data.unwrap_or_default(); - if !data.contains_key("taskName") { - if let Some(principal_name) = principal_name { - data.insert("taskName".to_string(), principal_name); - } else if data - .get("assignmentNonce") - .is_some_and(|nonce| nonce != &evidence_key) - { - data.insert("taskName".to_string(), evidence_key.clone()); - } - } - Some((evidence_key, role, data)) -} - -fn select_mission_output_records( - records: Vec<( - String, - Option<String>, - std::collections::BTreeMap<String, String>, - )>, -) -> Vec<(String, std::collections::BTreeMap<String, String>)> { - let mut grouped = std::collections::BTreeMap::< - String, - Vec<( - String, - Option<String>, - std::collections::BTreeMap<String, String>, - )>, - >::new(); - for (key, role, data) in records { - let task_name = data.get("taskName").cloned().unwrap_or_else(|| key.clone()); - grouped - .entry(task_name) - .or_default() - .push((key, role, data)); - } - grouped - .into_values() - .filter_map(|group| { - group - .into_iter() - .max_by_key(|(key, role, data)| match role.as_deref() { - Some("current") => 4, - Some("canonical") => 3, - Some("archive") => 1, - Some(_) => 0, - None if data.get("assignmentNonce").is_none() => 3, - None if data.get("assignmentNonce") != Some(key) => 2, - None => 1, - }) - .map(|(key, _, data)| (key, data)) - }) - .collect() -} - -fn select_mission_evidence_records( - records: Vec<( - String, - Option<String>, - std::collections::BTreeMap<String, String>, - )>, -) -> Vec<(String, std::collections::BTreeMap<String, String>)> { - let mut grouped = std::collections::BTreeMap::< - String, - Vec<( - String, - Option<String>, - std::collections::BTreeMap<String, String>, - )>, - >::new(); - for (key, role, data) in records { - let identity = data - .get("assignmentNonce") - .cloned() - .unwrap_or_else(|| key.clone()); - grouped.entry(identity).or_default().push((key, role, data)); - } - grouped - .into_values() - .filter_map(|group| { - group - .into_iter() - .max_by_key(|(key, role, data)| match role.as_deref() { - Some("archive" | "canonical") => 3, - Some("current") => 1, - Some(_) => 0, - None if data.get("assignmentNonce") == Some(key) => 2, - None => 1, - }) - .map(|(key, _, data)| (key, data)) - }) - .collect() -} - -fn project_mission_output_record( - evidence_key: String, - data: std::collections::BTreeMap<String, String>, -) -> MissionOutputRecord { - let task_name = data - .get("taskName") - .cloned() - .unwrap_or_else(|| evidence_key.clone()); - MissionOutputRecord { - task_name, - evidence_key, - data, - } -} - -fn trace_record_identity(cm: &ConfigMap) -> Option<String> { - if !cm - .metadata - .name - .as_deref() - .is_some_and(|name| name.starts_with("kars-mission-trace-")) - { - return None; - } - if mission_evidence_role(cm).as_deref() == Some("current") { - return None; - } - let data = cm.data.as_ref()?; - let trace = data.get("trace.json").filter(|trace| trace.len() > 2)?; - if let Some(nonce) = data.get("assignmentNonce") { - return Some(format!("nonce:{nonce}")); - } - let captured_at = data.get("capturedAt").map(String::as_str).unwrap_or(""); - Some(format!( - "legacy:{captured_at}:{:x}", - Sha256::digest(trace.as_bytes()) - )) -} -use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; - -use crate::kars::task::KarsTask; +mod configuration; +mod connections; +mod engineering_sources; +mod local_inference; +mod mission_records; +mod mission_runs; +mod orchestrator; +mod providers; +mod resources; +mod sandboxes; #[derive(Debug, Clone)] pub struct MissionOutputRecord { @@ -276,64 +26,6 @@ pub struct MissionOutputRecord { pub data: std::collections::BTreeMap<String, String>, } -/// Classify the inherited inference provider from an optional `KARS_PROVIDER` -/// override plus the configured endpoint hosts. Mirrors the router's detection -/// (`inference-router/src/config.rs`): the three providers kars supports are -/// GitHub Copilot, GitHub Models, and Azure AI Foundry. Returns `(id, label, -/// note)`, or `None` when nothing identifiable is configured. -fn classify_provider( - override_val: Option<&str>, - endpoints: &[String], - token_hint: Option<&str>, -) -> Option<(String, String, String)> { - let host_has = |needle: &str| endpoints.iter().any(|e| e.contains(needle)); - let copilot = ( - "github-copilot", - "GitHub Copilot", - "Models served through your GitHub Copilot subscription (GitHub-hosted inference).", - ); - let gh_models = ( - "github-models", - "GitHub Models", - "Models served through GitHub Models (OpenAI-compatible, GitHub-hosted).", - ); - let foundry = ( - "azure-foundry", - "Azure AI Foundry", - "Models served through your Azure AI Foundry project.", - ); - // A local in-cluster model deployed via the "Local model" wizard — - // its endpoint is always a Service DNS name inside the Bridge-owned - // kars-local-inference namespace (see docs/local-inference.md). Checked - // before the generic Foundry fallback so promoting one to the cluster - // default doesn't display as a misleading "Azure AI Foundry" label. - let local = ( - "local-inference", - "Local model (in-cluster)", - "Models served by an in-cluster deployment — no external API, no per-token billing.", - ); - let is_local_host = host_has(".kars-local-inference.svc.cluster.local"); - // A GitHub OAuth/user token (`gho_`/`ghu_`) indicates a Copilot login; a - // classic PAT (`ghp_`) indicates free GitHub Models. - let is_oauth_token = - matches!(token_hint, Some(t) if t.starts_with("gho_") || t.starts_with("ghu_")); - let on_github = host_has("models.github.ai") || host_has("models.inference.ai.azure.com"); - let pick = match override_val { - // Explicit operator declaration is authoritative. - Some("github-copilot") | Some("copilot") => copilot, - Some("github-models") => gh_models, - Some("foundry") | Some("azure-openai") | Some("azure-foundry") => foundry, - // Otherwise infer from endpoint + token kind. - _ if host_has("api.githubcopilot.com") => copilot, - _ if on_github && is_oauth_token => copilot, - _ if on_github => gh_models, - _ if is_local_host => local, - _ if !endpoints.is_empty() => foundry, - _ => return None, - }; - Some((pick.0.to_string(), pick.1.to_string(), pick.2.to_string())) -} - /// A handle to the kars cluster, scoped per request to a namespace. #[derive(Clone)] pub struct Cluster { @@ -397,3318 +89,6 @@ pub struct ContainerState { pub reason: Option<String>, } -impl Cluster { - #[cfg(test)] - pub(crate) fn for_test_client(client: Client) -> Self { - Self { client } - } - - /// Build a cluster handle from the ambient configuration (kubeconfig - /// locally, in-cluster ServiceAccount in production). Returns `None`-style - /// errors as `anyhow` so the readiness probe can report honest status. - pub async fn connect() -> anyhow::Result<Self> { - let client = Client::try_default().await?; - Ok(Self { client }) - } - - /// `KarsTask` API scoped to a namespace. - pub fn tasks(&self, namespace: &str) -> Api<KarsTask> { - Api::namespaced(self.client.clone(), namespace) - } - - /// Task metadata for usage attribution: `name -> (namespace, created_by)`, - /// listed across ALL namespaces so per-workspace (namespace) and per-user - /// (the `kars.azure.com/created-by` annotation the Bridge stamps) budgets can - /// attribute a run's tokens to the tenant that owns it. Team runs - /// (`<team>-run-<epoch>`) are attributed to the parent team's creator. - pub async fn list_task_meta(&self) -> std::collections::HashMap<String, (String, String)> { - use kube::api::ListParams; - let api: Api<KarsTask> = Api::all(self.client.clone()); - let mut out = std::collections::HashMap::new(); - let Ok(list) = api.list(&ListParams::default()).await else { - return out; - }; - for t in list.items { - let name = t.metadata.name.clone().unwrap_or_default(); - let ns = t - .metadata - .namespace - .clone() - .unwrap_or_else(|| "kars-system".into()); - let created_by = t - .metadata - .annotations - .as_ref() - .and_then(|a| a.get("kars.azure.com/created-by").cloned()) - .unwrap_or_else(|| "unattributed".into()); - out.insert(name, (ns, created_by)); - } - out - } - - /// `KarsTeam` API scoped to a namespace. - pub fn teams(&self, namespace: &str) -> Api<crate::kars::team::KarsTeam> { - Api::namespaced(self.client.clone(), namespace) - } - - /// Read the operator-curated MCP profiles (named vetted server bundles), - /// stored as `profiles.json` in the `kars-mcp-profiles` ConfigMap. Returns - /// `[]` when unset. A profile is `{name, summary, servers:[mcpserver names]}`. - pub async fn read_mcp_profiles(&self) -> String { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - cms.get_opt("kars-mcp-profiles") - .await - .ok() - .flatten() - .and_then(|cm| cm.data) - .and_then(|d| d.get("profiles.json").cloned()) - .unwrap_or_else(|| "[]".to_string()) - } - - /// Persist the operator-curated MCP profiles (server-side apply). - pub async fn write_mcp_profiles(&self, profiles_json: &str) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { "name": "kars-mcp-profiles", "labels": { "app.kubernetes.io/managed-by": "kars-bridge" } }, - "data": { "profiles.json": profiles_json }, - }); - cms.patch( - "kars-mcp-profiles", - &PatchParams::apply("kars-bridge/mcp-profiles").force(), - &Patch::Apply(patch), - ) - .await?; - Ok(()) - } - - /// Persist a skill PACKAGE's files as the `karsskill-<name>` ConfigMap in - /// kars-system. Each entry is `<flat filename> -> <content>` (SKILL.md + - /// scripts). The controller mirrors this ConfigMap into a granting sandbox's - /// namespace and mounts it into the agent's skills dir. - pub async fn write_skill_package( - &self, - skill_name: &str, - files: &std::collections::BTreeMap<String, String>, - package_digest: &str, - ) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let cm_name = format!("karsskill-{skill_name}"); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": cm_name, - "labels": { - "app.kubernetes.io/managed-by": "kars-bridge", - "kars.azure.com/skill": skill_name, - }, - "annotations": { - "kars.azure.com/package-digest": package_digest, - }, - }, - "data": files, - }); - cms.patch( - &cm_name, - &PatchParams::apply("kars-bridge/skill-package").force(), - &Patch::Apply(patch), - ) - .await?; - Ok(()) - } - - // ── Keyless git write: shared App + per-principal connections (§14) ────── - - /// The cluster-shared kars GitHub App credentials (App id + PEM private key) - /// from `Secret kars-github-app` in kars-system. `None` when the operator - /// hasn't configured the App — git write is simply off (fail-closed). - pub async fn github_app_creds(&self) -> Result<Option<(String, String)>, kube::Error> { - let (_, s) = self - .integration_store(&self.core_namespace(), "kars-github-app") - .await?; - let Some(data) = s.data else { return Ok(None) }; - let read = |k: &str| -> Option<String> { - data.get(k) - .and_then(|v| String::from_utf8(v.0.clone()).ok()) - }; - let Some(id) = read("GITHUB_APP_ID") else { - return Ok(None); - }; - let Some(key) = read("GITHUB_APP_PRIVATE_KEY") else { - return Ok(None); - }; - if id.trim().is_empty() || key.trim().is_empty() { - return Ok(None); - } - Ok(Some((id.trim().to_string(), key))) - } - - /// Read a principal's GitHub connection ConfigMap in the namespace. - pub async fn read_github_connection( - &self, - ns: &str, - connection_name: &str, - ) -> Option<(String, String, Vec<String>)> { - self.read_github_connection_result(ns, connection_name) - .await - .ok() - .flatten() - } - - /// Read a principal GitHub connection while preserving Kubernetes API - /// failures for background jobs that must report an honest source status. - pub async fn read_github_connection_result( - &self, - ns: &str, - connection_name: &str, - ) -> Result<Option<(String, String, Vec<String>)>, kube::Error> { - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns); - let Some(data) = api.get_opt(connection_name).await?.and_then(|cm| cm.data) else { - return Ok(None); - }; - let read = |key: &str| data.get(key).cloned(); - let Some(installation_id) = read("installation_id") else { - return Ok(None); - }; - let account = read("account").unwrap_or_default(); - let repos = read("repos") - .and_then(|r| serde_json::from_str::<Vec<String>>(&r).ok()) - .unwrap_or_default(); - Ok(Some((installation_id, account, repos))) - } - - /// Store a principal's GitHub connection. No token or credential is stored. - pub async fn write_github_connection( - &self, - ns: &str, - connection_name: &str, - installation_id: &str, - account: &str, - repos: &[String], - ) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams, PostParams}; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns); - let data = std::collections::BTreeMap::from([ - ("installation_id".to_string(), installation_id.to_string()), - ("account".to_string(), account.to_string()), - ("repos".to_string(), serde_json::to_string(repos)?), - ]); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": connection_name, - "namespace": ns, - "labels": { "app.kubernetes.io/managed-by": "kars-bridge", "kars.azure.com/github-connection": "true" }, - }, - "data": data, - }); - if let Some(current) = api.get_opt(connection_name).await? { - let grant = self.credential_grant(ns).await?; - if current.metadata.deletion_timestamp.is_some() - || current.uid().is_none() - || !grant.document.data["spec"]["githubConnections"] - .as_array() - .is_some_and(|entries| { - entries.iter().any(|entry| { - entry["connection"]["name"] == connection_name - && entry["connection"]["uid"] - == serde_json::json!(current.metadata.uid) - }) - }) - { - anyhow::bail!( - "Existing GitHub connection requires exact operator UID enrollment before mutation; no adoption" - ); - } - api.patch(connection_name,&PatchParams::default(),&Patch::Merge(serde_json::json!({ - "metadata":{"uid":current.metadata.uid,"resourceVersion":current.metadata.resource_version},"data":data - }))).await?; - } else { - let created: ConfigMap = serde_json::from_value(patch)?; - api.create(&PostParams::default(), &created).await?; - } - Ok(()) - } - - /// Remove only the named principal GitHub connection. - pub async fn delete_github_connection( - &self, - ns: &str, - connection_name: &str, - ) -> anyhow::Result<()> { - use kube::api::{DeleteParams, Preconditions}; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns); - if let Some(current) = api.get_opt(connection_name).await? { - if current.uid().is_none() || current.resource_version().is_none() { - anyhow::bail!("GitHub connection identity is unavailable; no deletion"); - } - api.delete( - connection_name, - &DeleteParams { - preconditions: Some(Preconditions { - uid: current.metadata.uid, - resource_version: current.metadata.resource_version, - }), - ..Default::default() - }, - ) - .await?; - } - Ok(()) - } - - // ── Bridge engineering intake sources ─────────────────────────────────── - - pub async fn read_engineering_source( - &self, - name: &str, - ) -> Result<Option<ConfigMap>, kube::Error> { - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - api.get_opt(name).await - } - - pub async fn list_engineering_sources( - &self, - limit: u32, - ) -> Result<Vec<ConfigMap>, kube::Error> { - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let params = ListParams::default() - .labels("bridge.kars.azure.com/engineering-source=true") - .limit(limit); - Ok(api.list(¶ms).await?.items) - } - - pub async fn create_engineering_source( - &self, - name: &str, - annotations: &std::collections::BTreeMap<String, String>, - data: &std::collections::BTreeMap<String, String>, - ) -> Result<(), kube::Error> { - use kube::api::PostParams; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let config_map: ConfigMap = serde_json::from_value(serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": name, - "namespace": "kars-system", - "labels": { - "app.kubernetes.io/managed-by": "kars-bridge", - "bridge.kars.azure.com/engineering-source": "true", - }, - "annotations": annotations, - }, - "data": data, - })) - .expect("engineering source ConfigMap is valid"); - api.create(&PostParams::default(), &config_map) - .await - .map(|_| ()) - } - - pub async fn patch_engineering_source_data( - &self, - name: &str, - data: &std::collections::BTreeMap<String, String>, - ) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - api.patch( - name, - &PatchParams::default(), - &Patch::Merge(serde_json::json!({ "data": data })), - ) - .await?; - Ok(()) - } - - pub async fn claim_engineering_source( - &self, - name: &str, - expected_config: &str, - expected_status: &str, - claimed_status: &str, - ) -> Result<bool, kube::Error> { - use kube::api::{Patch, PatchParams}; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let patch = json_patch::Patch(vec![ - json_patch::PatchOperation::Test(json_patch::TestOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "config.json"]), - value: serde_json::Value::String(expected_config.to_string()), - }), - json_patch::PatchOperation::Test(json_patch::TestOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), - value: serde_json::Value::String(expected_status.to_string()), - }), - json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), - value: serde_json::Value::String(claimed_status.to_string()), - }), - ]); - match api - .patch( - name, - &PatchParams::default(), - &Patch::Json::<ConfigMap>(patch), - ) - .await - { - Ok(_) => Ok(true), - Err(kube::Error::Api(error)) - if error.code == 404 || error.code == 409 || error.code == 422 => - { - Ok(false) - } - Err(error) => Err(error), - } - } - - pub async fn complete_engineering_source_claim( - &self, - name: &str, - expected_claimed_status: &str, - cursor: &str, - completed_status: &str, - ) -> Result<bool, kube::Error> { - use kube::api::{Patch, PatchParams}; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let patch = json_patch::Patch(vec![ - json_patch::PatchOperation::Test(json_patch::TestOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), - value: serde_json::Value::String(expected_claimed_status.to_string()), - }), - json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "cursor.json"]), - value: serde_json::Value::String(cursor.to_string()), - }), - json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), - value: serde_json::Value::String(completed_status.to_string()), - }), - ]); - match api - .patch( - name, - &PatchParams::default(), - &Patch::Json::<ConfigMap>(patch), - ) - .await - { - Ok(_) => Ok(true), - Err(kube::Error::Api(error)) - if error.code == 404 || error.code == 409 || error.code == 422 => - { - Ok(false) - } - Err(error) => Err(error), - } - } - - pub async fn delete_engineering_source(&self, name: &str) -> anyhow::Result<()> { - use kube::api::DeleteParams; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - if api.get_opt(name).await?.is_some() { - api.delete(name, &DeleteParams::default()).await?; - } - Ok(()) - } - - pub async fn replace_engineering_source( - &self, - mut current: ConfigMap, - annotations: &std::collections::BTreeMap<String, String>, - data: &std::collections::BTreeMap<String, String>, - ) -> Result<(), kube::Error> { - use kube::api::PostParams; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let name = current.metadata.name.clone().unwrap_or_default(); - current.metadata.annotations = Some(annotations.clone()); - current.data = Some(data.clone()); - api.replace(&name, &PostParams::default(), ¤t) - .await - .map(|_| ()) - } - - pub async fn delete_engineering_source_if_version( - &self, - name: &str, - resource_version: String, - ) -> Result<(), kube::Error> { - use kube::api::{DeleteParams, Preconditions}; - let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - api.delete( - name, - &DeleteParams { - preconditions: Some(Preconditions { - resource_version: Some(resource_version), - uid: None, - }), - ..DeleteParams::default() - }, - ) - .await - .map(|_| ()) - } - - /// Read a team's knowledge-commons ConfigMap (`kars-commons-<commons>`) from - /// the controller namespace. Returns the parsed index + raw entry content. - pub async fn read_commons( - &self, - commons: &str, - ) -> Option<std::collections::BTreeMap<String, String>> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let cm = cms - .get_opt(&format!("kars-commons-{commons}")) - .await - .ok()??; - cm.data - } - - /// Read a team's task backlog (raw `tasks.json`, or `[]` when unset). Shared - /// with the controller: the ConfigMap `kars-team-tasks-<team>` is the durable - /// queue the Bridge appends to and the controller drains. - pub async fn read_team_tasks(&self, team: &str) -> String { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - cms.get_opt(&format!("kars-team-tasks-{team}")) - .await - .ok() - .flatten() - .and_then(|cm| cm.data) - .and_then(|d| d.get("tasks.json").cloned()) - .unwrap_or_else(|| "[]".to_string()) - } - - /// Read the hierarchical inference-budget config (`kars-inference-budgets` - /// ConfigMap, key `budgets.json`). Returns the raw JSON string, or `"{}"` - /// when unset — the budgets route parses it into the typed hierarchy. - pub async fn read_inference_budgets(&self) -> String { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - cms.get_opt("kars-inference-budgets") - .await - .ok() - .flatten() - .and_then(|cm| cm.data) - .and_then(|d| d.get("budgets.json").cloned()) - .unwrap_or_else(|| "{}".to_string()) - } - - /// Persist the hierarchical inference-budget config (server-side apply). - pub async fn write_inference_budgets(&self, budgets_json: &str) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": "kars-inference-budgets", - "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, - }, - "data": { "budgets.json": budgets_json }, - }); - cms.patch( - "kars-inference-budgets", - &PatchParams::apply("kars-bridge/inference-budgets").force(), - &Patch::Apply(patch), - ) - .await?; - Ok(()) - } - - /// Read the cluster-wide retention-policy default (`kars-retention-policy` - /// ConfigMap, key `defaultTtlSeconds`) the controller's KarsTask retention - /// reconciler reads. `0`/absent means "never auto-delete" (the safe - /// default). Returns `0` on any read failure. - pub async fn read_retention_policy(&self) -> i64 { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - cms.get_opt("kars-retention-policy") - .await - .ok() - .flatten() - .and_then(|cm| cm.data) - .and_then(|d| { - d.get("defaultTtlSeconds") - .and_then(|v| v.parse::<i64>().ok()) - }) - .unwrap_or(0) - } - - /// Persist the cluster-wide retention-policy default (server-side apply). - pub async fn write_retention_policy(&self, ttl_seconds: i64) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": "kars-retention-policy", - "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, - }, - "data": { "defaultTtlSeconds": ttl_seconds.to_string() }, - }); - cms.patch( - "kars-retention-policy", - &PatchParams::apply("kars-bridge/retention-policy").force(), - &Patch::Apply(patch), - ) - .await?; - Ok(()) - } - - /// Which communication-channel env keys a team has configured. SECURITY: - /// returns only the *key names* (e.g. `TELEGRAM_BOT_TOKEN`), never the token - /// values — the Bridge must never echo a secret back to a browser. - pub async fn team_channel_keys( - &self, - namespace: &str, - team: &str, - ) -> Result<Vec<String>, kube::Error> { - let target = self - .credential_target(namespace, "KarsTeam", team) - .await? - .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; - self.configured_channel_keys(namespace, Some(&target)).await - } - - /// Merge channel credentials into a team's channel Secret (create if absent). - /// SECURITY: token values are written straight into a K8s Secret and are - /// never logged or returned. Existing keys not in `data` are preserved. - pub async fn merge_team_channel( - &self, - namespace: &str, - team: &str, - data: std::collections::BTreeMap<String, String>, - ) -> anyhow::Result<()> { - let target = self - .credential_target(namespace, "KarsTeam", team) - .await? - .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; - self.write_agent_credentials( - namespace, - "KarsTeam", - team, - Some(&target.uid), - data, - Vec::new(), - ) - .await?; - Ok(()) - } - - /// Remove specific channel env keys from a team's channel Secret; delete the - /// Secret entirely when no keys remain (so "disable all channels" is clean). - pub async fn remove_team_channel_keys( - &self, - namespace: &str, - team: &str, - keys: &[String], - ) -> anyhow::Result<()> { - let target = self - .credential_target(namespace, "KarsTeam", team) - .await? - .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; - self.write_agent_credentials( - namespace, - "KarsTeam", - team, - Some(&target.uid), - std::collections::BTreeMap::new(), - keys.to_vec(), - ) - .await?; - Ok(()) - } - - // ─── Workspace-level (agent-agnostic) channels ─────────────────────────── - // The same channel model as a team's, but scoped to the WORKSPACE (secret - // `kars-workspace-channels` in kars-system), configured on the Connections - // tab. The controller propagates it into EVERY run sandbox — mission or team — - // so any agent can report over Telegram/Slack/Discord/WhatsApp. - - /// The env-key names present in the workspace channel Secret (no values). - pub async fn workspace_channel_keys( - &self, - namespace: &str, - ) -> Result<Vec<String>, kube::Error> { - let mut keys = self.configured_channel_keys(namespace, None).await?; - if self.teams_configured().await? { - keys.push("TEAMS_ENABLED".into()); - } - Ok(keys) - } - - /// Merge channel credentials into the workspace channel Secret (create if - /// absent). Token values are written straight into a K8s Secret, never logged - /// or returned. Existing keys not in `data` are preserved. - pub async fn merge_workspace_channel( - &self, - namespace: &str, - data: std::collections::BTreeMap<String, String>, - ) -> anyhow::Result<()> { - self.write_agent_credentials(namespace, "Workspace", namespace, None, data, Vec::new()) - .await?; - Ok(()) - } - - /// Remove specific channel env keys from the workspace channel Secret; delete - /// the Secret entirely when no keys remain. - pub async fn remove_workspace_channel_keys( - &self, - namespace: &str, - keys: &[String], - ) -> anyhow::Result<()> { - self.write_agent_credentials( - namespace, - "Workspace", - namespace, - None, - std::collections::BTreeMap::new(), - keys.to_vec(), - ) - .await?; - Ok(()) - } - - /// `KarsReceipt` API scoped to a namespace. - pub fn receipts(&self, namespace: &str) -> Api<crate::kars::receipt::KarsReceipt> { - Api::namespaced(self.client.clone(), namespace) - } - - /// Write Teams gateway credentials into the dedicated `kars-bridge-teams` Secret. - /// This Secret is mounted ONLY by the Teams gateway pod — never propagated to - /// sandbox pods. Uses Server-Side Apply so the BFF can create-or-update idempotently. - pub async fn write_dedicated_teams_secret( - &self, - namespace: &str, - name: &str, - data: std::collections::BTreeMap<String, String>, - ) -> anyhow::Result<()> { - self.mutate_integration(namespace, name, |keys| keys.extend(data.clone())) - .await?; - Ok(()) - } - - /// Revoke Teams bot credentials while retaining the BFF-only internal - /// secret and role map required for a healthy BFF rollout. - pub async fn disable_dedicated_teams_secret( - &self, - namespace: &str, - name: &str, - ) -> anyhow::Result<()> { - self.mutate_integration(namespace, name, |keys| { - for key in ["client-id", "tenant-id", "client-secret"] { - keys.remove(key); - } - }) - .await?; - Ok(()) - } - - /// Restart BFF and enable/disable the Teams gateway so Secret and role-map - /// changes become effective immediately. - pub async fn reconcile_teams_deployments( - &self, - namespace: &str, - gateway_name: &str, - bff_name: &str, - _enabled: bool, - ) -> anyhow::Result<()> { - self.request_teams_reconcile(namespace, gateway_name, bff_name) - .await?; - Ok(()) - } - - /// `KarsApproval` API scoped to a namespace. - pub fn approvals(&self, namespace: &str) -> Api<crate::kars::approval::KarsApproval> { - Api::namespaced(self.client.clone(), namespace) - } - - /// `KarsSREAction` API, cluster-wide. This is an operator-persona, - /// platform-level surface (the kars-sre agent's proposals), not scoped to - /// a workspace namespace — mirrors the `KarsTask` `Api::all` pattern used - /// for cross-namespace operator views. - pub fn sre_actions_all(&self) -> Api<crate::kars::sre_action::KarsSREAction> { - Api::all(self.client.clone()) - } - - /// `KarsSREAction` API scoped to a namespace (for approve/reject patches, - /// which must target the CR's own namespace). - pub fn sre_actions(&self, namespace: &str) -> Api<crate::kars::sre_action::KarsSREAction> { - Api::namespaced(self.client.clone(), namespace) - } - - /// Find the name of the **Running** pod for a sandbox in its namespace. - /// A task-materialized sandbox runs in namespace `kars-<sandbox>`; its pod - /// carries `kars.azure.com/sandbox=<sandbox>`. Returns `None` if no Running - /// pod is found. - /// Whether a Deployment matching `name` exists in `namespace` (best-effort; - /// false on any API error). Used to detect optional integrations like the - /// Headlamp dashboard (`headlamp` deployment in the `headlamp` namespace). - pub async fn deployment_exists(&self, namespace: &str, name: &str) -> bool { - use k8s_openapi::api::apps::v1::Deployment; - let api: Api<Deployment> = Api::namespaced(self.client.clone(), namespace); - matches!(api.get_opt(name).await, Ok(Some(_))) - } - - /// Every pod in the kars-relevant namespaces (all `kars*` namespaces plus - /// `agentmesh`), for the operator diagnostics scan. Uses a cluster-wide list - /// then filters, so it's one API call regardless of sandbox count. - pub async fn all_pods(&self) -> Vec<k8s_openapi::api::core::v1::Pod> { - let pods: Api<Pod> = Api::all(self.client.clone()); - pods.list(&ListParams::default()) - .await - .map(|l| { - l.items - .into_iter() - .filter(|p| { - let ns = p.metadata.namespace.as_deref().unwrap_or(""); - ns.starts_with("kars") || ns == "agentmesh" - }) - .collect() - }) - .unwrap_or_default() - } - - pub async fn running_pod_for_sandbox(&self, sandbox: &str) -> Option<String> { - let ns = format!("kars-{sandbox}"); - let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); - let list = pods - .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) - .await - .ok()?; - list.items.into_iter().find_map(|p| { - let phase = p.status.as_ref().and_then(|s| s.phase.as_deref()); - if phase == Some("Running") { - p.metadata.name - } else { - None - } - }) - } - - /// Honest health of a sandbox's running pod: container readiness, restart - /// count, uptime, and node. No metrics-server dependency (no CPU/mem) — these - /// are status-derived signals that answer "is this agent healthy right now". - /// `None` when no pod is running for the sandbox. - pub async fn sandbox_pod_health(&self, sandbox: &str) -> Option<PodHealth> { - let ns = format!("kars-{sandbox}"); - let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); - let list = pods - .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) - .await - .ok()?; - let pod = list - .items - .into_iter() - .find(|p| p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running"))?; - let status = pod.status.as_ref(); - let cs = status.and_then(|s| s.container_statuses.as_ref()); - let total = cs.map(|c| c.len()).unwrap_or(0) as i32; - let ready = cs - .map(|c| c.iter().filter(|s| s.ready).count()) - .unwrap_or(0) as i32; - let restarts = cs - .map(|c| c.iter().map(|s| s.restart_count).sum()) - .unwrap_or(0); - // Uptime from the pod start time. - let uptime_seconds = status - .and_then(|s| s.start_time.as_ref()) - .map(|t| (chrono::Utc::now() - t.0).num_seconds().max(0)); - // A container stuck waiting (e.g. CrashLoopBackOff) is the honest - // unhealthy signal — surface the reason. - let waiting_reason = cs.and_then(|c| { - c.iter().find_map(|s| { - s.state - .as_ref() - .and_then(|st| st.waiting.as_ref()) - .and_then(|w| w.reason.clone()) - }) - }); - Some(PodHealth { - ready_containers: ready, - total_containers: total, - restarts, - uptime_seconds, - node: pod.spec.as_ref().and_then(|s| s.node_name.clone()), - waiting_reason, - }) - } - - /// Read recent logs from a sandbox pod container (best-effort). Powers the - /// live run-failure troubleshooter, which surfaces the REAL agent output as - /// evidence rather than pattern-matching a status string. - pub async fn read_sandbox_logs( - &self, - sandbox: &str, - container: &str, - tail: i64, - ) -> Option<String> { - let ns = format!("kars-{sandbox}"); - let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); - let list = pods - .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) - .await - .ok()?; - let pod_name = list.items.into_iter().find_map(|p| p.metadata.name)?; - let lp = kube::api::LogParams { - container: Some(container.to_string()), - tail_lines: Some(tail), - timestamps: false, - ..Default::default() - }; - pods.logs(&pod_name, &lp).await.ok() - } - - /// Per-container status for a sandbox pod (name, ready, restarts, and the - /// current state reason — Running / a waiting reason like ImagePullBackOff / - /// a terminated reason like OOMKilled). Used by the troubleshooter. - pub async fn sandbox_container_states(&self, sandbox: &str) -> Vec<ContainerState> { - let ns = format!("kars-{sandbox}"); - let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); - let Ok(list) = pods - .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) - .await - else { - return Vec::new(); - }; - let Some(pod) = list.items.into_iter().next() else { - return Vec::new(); - }; - let cs = pod - .status - .as_ref() - .and_then(|s| s.container_statuses.as_ref()); - cs.map(|list| { - list.iter() - .map(|c| { - let (state, reason) = if let Some(st) = c.state.as_ref() { - if st.running.is_some() { - ("running".to_string(), None) - } else if let Some(w) = st.waiting.as_ref() { - ("waiting".to_string(), w.reason.clone()) - } else if let Some(t) = st.terminated.as_ref() { - ("terminated".to_string(), t.reason.clone()) - } else { - ("unknown".to_string(), None) - } - } else { - ("unknown".to_string(), None) - }; - ContainerState { - name: c.name.clone(), - ready: c.ready, - restarts: c.restart_count, - state, - reason, - } - }) - .collect() - }) - .unwrap_or_default() - } - - /// Find ANY running sandbox's namespace + pod, so the Bridge can route an - /// orchestrator/composer model call through an existing secure inference - /// router (via `router_chat`). This is how the envelope composer reaches the - /// model on workload-identity clusters — without a static token, reusing the - /// same governed path agents use. Prefers a persistent sandbox; falls back - /// to any Running sandbox pod. Returns `(namespace, pod)`. - /// Ranked list of stable sandbox `(namespace, pod)` candidates whose - /// inference router the Bridge can route an orchestrator/composer model call - /// through. Excludes ephemeral standing-run sandboxes (short-lived / busy), - /// requires the router container ready, and orders freshest-first (a - /// recently (re)started pod runs the current router image with valid - /// provider auth). The caller tries them in order so a single sandbox with - /// stale auth or a warming router is skipped gracefully. - pub async fn running_sandbox_candidates(&self) -> Vec<(String, String)> { - let pods: Api<Pod> = Api::all(self.client.clone()); - let Ok(list) = pods - .list(&ListParams::default().labels("kars.azure.com/sandbox")) - .await - else { - return Vec::new(); - }; - let mut candidates: Vec<&Pod> = list - .items - .iter() - .filter(|p| { - let phase = p.status.as_ref().and_then(|s| s.phase.as_deref()); - if phase != Some("Running") { - return false; - } - let name = p.metadata.name.as_deref().unwrap_or_default(); - let sandbox = p - .metadata - .labels - .as_ref() - .and_then(|l| l.get("kars.azure.com/sandbox")) - .map(String::as_str) - .unwrap_or(name); - // Prefer stable sandboxes, but do NOT exclude ephemeral team-run - // sandboxes — on a teams-only cluster they are the ONLY inference - // path the orchestrator has. We sort them last (below) so a stable - // sandbox always wins when one exists. - let _ = sandbox; - p.status - .as_ref() - .and_then(|s| s.container_statuses.as_ref()) - .map(|cs| cs.iter().any(|c| c.name == "inference-router" && c.ready)) - .unwrap_or(false) - }) - .collect(); - candidates.sort_by(|a, b| { - // Stable sandboxes before ephemeral run sandboxes, then freshest first. - let eph = |p: &&Pod| -> bool { - let n = p.metadata.name.as_deref().unwrap_or_default(); - let sb = p - .metadata - .labels - .as_ref() - .and_then(|l| l.get("kars.azure.com/sandbox")) - .map(String::as_str) - .unwrap_or(n); - is_ephemeral_run(sb) - }; - let ta = a.metadata.creation_timestamp.as_ref().map(|t| t.0); - let tb = b.metadata.creation_timestamp.as_ref().map(|t| t.0); - eph(a).cmp(&eph(b)).then(tb.cmp(&ta)) // stable first, then freshest - }); - candidates - .into_iter() - .filter_map(|p| Some((p.metadata.namespace.clone()?, p.metadata.name.clone()?))) - .collect() - } - - /// Drive a real model call through a sandbox's secure inference router, - /// using the Kubernetes **pods/proxy subresource** — hard-scoped to one - /// pod, port 8443, and the exact `/v1/chat/completions` path. This is the - /// only proxy the BFF performs and it is NOT a generic tunnel: it cannot - /// reach any other port or path. The router still enforces content-safety, - /// token budgets, and governance on the call — the agent never sees a key. - /// - /// `ns` is the sandbox namespace (`kars-<sandbox>`), `pod` the Running pod. - /// Returns the raw response JSON text from the router. - pub async fn router_chat( - &self, - ns: &str, - pod: &str, - body: &serde_json::Value, - ) -> anyhow::Result<String> { - let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/v1/chat/completions"); - let req = http::Request::builder() - .method(http::Method::POST) - .uri(path) - .header("content-type", "application/json") - .body(serde_json::to_vec(body)?)?; - let text = self.client.request_text(req).await?; - Ok(text) - } - - /// Drive a model call through a sandbox's inference router using the NATIVE - /// Anthropic Messages endpoint (`/v1/messages`) via pods/proxy. Claude on - /// the OpenAI-compatible `/chat/completions` path returns empty content for - /// the Bridge's composer; the native path returns proper text/`tool_use` - /// content (the same reason agents use `/v1/messages`). Body is Anthropic- - /// shaped (`{model, system, messages, max_tokens}`). Returns raw response. - pub async fn router_messages( - &self, - ns: &str, - pod: &str, - body: &serde_json::Value, - ) -> anyhow::Result<String> { - let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/v1/messages"); - let req = http::Request::builder() - .method(http::Method::POST) - .uri(path) - .header("content-type", "application/json") - .header("anthropic-version", "2023-06-01") - .body(serde_json::to_vec(body)?)?; - let text = self.client.request_text(req).await?; - Ok(text) - } - - /// The live egress enforcement mode of a sandbox — read from the - /// `KarsSandbox.spec.networkPolicy.egressMode` the controller materialized. - /// `"Learn"` (default) observes + records every domain the agent reaches - /// without denying; `"Strict"` denies anything outside the allowlist. This - /// is the real, cluster-truth mode — not derived from the blueprint. - pub async fn sandbox_egress_mode(&self, sandbox: &str) -> Option<String> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", "KarsSandbox"); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), "kars-system", &ar); - let sb = api.get_opt(sandbox).await.ok().flatten()?; - Some( - sb.data - .get("spec") - .and_then(|s| s.get("networkPolicy")) - .and_then(|n| n.get("egressMode")) - .and_then(|m| m.as_str()) - .unwrap_or("Learn") - .to_string(), - ) - } - - /// Read only the declared private observation capability. The legacy - /// agent-shared admin token and apiserver header tricks are never fallbacks. - pub async fn sandbox_learned_domains(&self, sandbox: &str) -> anyhow::Result<Vec<String>> { - self.private_learned_domains(sandbox) - .await - .map_err(Into::into) - } - - /// The resolved egress allowlist the sandbox actually enforces — read from - /// the `karssandbox-<sandbox>-egress-allowlist` ConfigMap the controller - /// compiles into the sandbox namespace. Each entry is the exact host(:port) - /// the agent is permitted to reach. Empty in Learn mode (nothing pinned). - pub async fn sandbox_allowlist(&self, sandbox: &str) -> Vec<String> { - let ns = format!("kars-{sandbox}"); - let name = format!("karssandbox-{sandbox}-egress-allowlist"); - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), &ns); - let Some(cm) = cms.get_opt(&name).await.ok().flatten() else { - return Vec::new(); - }; - let Some(raw) = cm.data.and_then(|d| d.get("allowlist.json").cloned()) else { - return Vec::new(); - }; - let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&raw) else { - return Vec::new(); - }; - parsed - .get("endpoints") - .and_then(|e| e.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|e| { - let host = e.get("host").and_then(|h| h.as_str())?; - match e.get("port").and_then(|p| p.as_u64()) { - Some(p) => Some(format!("{host}:{p}")), - None => Some(host.to_string()), - } - }) - .collect() - }) - .unwrap_or_default() - } - - /// Persist a mission's run output into a namespaced ConfigMap - /// `kars-mission-output-<task>` in `kars-system`, so the deliverable is a - /// durable, readable cluster object (the §16 artifact record, minimal form). - /// Server-side apply, idempotent per task. - pub async fn write_mission_output( - &self, - task: &str, - data: std::collections::BTreeMap<String, String>, - ) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let name = format!("kars-mission-output-{task}"); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { "name": name, "labels": { "kars.azure.com/mission-output": task } }, - "data": data, - }); - cms.patch( - &name, - &PatchParams::apply("kars-bridge/mission-output").force(), - &Patch::Apply(patch), - ) - .await?; - Ok(()) - } - - /// Read a mission's persisted run output ConfigMap, if present. - pub async fn read_mission_output( - &self, - task: &str, - ) -> Option<std::collections::BTreeMap<String, String>> { - self.configmap_data(&format!("kars-mission-output-{task}")) - .await - } - - /// Read a mission's persisted artifact set — the complete file set the - /// agent produced over the mesh, written by the controller to - /// `kars-mission-artifacts-<task>`. Text artifacts come back as `data` - /// (filename → content); binary artifacts are reported by name + size via - /// the output ConfigMap's manifest (their bytes live in the ConfigMap's - /// `binaryData` and aren't inlined here). Returns `None` when the mission - /// produced no artifacts (honest empty, never fabricated). - pub async fn read_mission_artifacts( - &self, - task: &str, - ) -> Option<std::collections::BTreeMap<String, String>> { - self.configmap_data(&format!("kars-mission-artifacts-{task}")) - .await - } - - /// Read a single artifact file's raw bytes for download — text artifacts - /// from the ConfigMap's `data`, binary ones from `binaryData` (base64). The - /// filename is matched against the same sanitized key the manifest exposes. - /// Returns `(bytes, is_binary)` or `None` when the file isn't found. This is - /// the Bridge-native fetch path so operators never need `kubectl`. - pub async fn read_mission_artifact_bytes( - &self, - task: &str, - key: &str, - ) -> Option<(Vec<u8>, bool)> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let cm = cms - .get_opt(&format!("kars-mission-artifacts-{task}")) - .await - .ok() - .flatten()?; - if let Some(text) = cm.data.as_ref().and_then(|d| d.get(key)) { - return Some((text.clone().into_bytes(), false)); - } - // `binaryData` values are `ByteString`, already base64-decoded by the API - // client into raw bytes — serve them directly. - if let Some(bytes) = cm.binary_data.as_ref().and_then(|d| d.get(key)) { - return Some((bytes.0.clone(), true)); - } - None - } - - /// Read a mission's persisted execution trace — the clean per-tool audit - /// record the controller wrote to `kars-mission-trace-<task>`. Returns the - /// raw `trace.json` string (a JSON array of round/tool events) when present. - pub async fn read_mission_trace(&self, task: &str) -> Option<String> { - self.configmap_data(&format!("kars-mission-trace-{task}")) - .await - .and_then(|d| d.get("trace.json").cloned()) - } - - pub async fn read_mission_progress(&self, task: &str) -> Option<serde_json::Value> { - self.configmap_data(&format!("kars-mission-progress-{task}")) - .await - .and_then(|data| data.get("checkpoint.json").cloned()) - .and_then(|raw| serde_json::from_str(&raw).ok()) - } - - /// LIVE per-agent execution trace, straight from a running sandbox's router - /// (`GET /telemetry/trace` — a PUBLIC in-pod endpoint, reached via the - /// apiserver pod-proxy; no admin token required). Unlike the persisted - /// `kars-mission-trace-<task>` ConfigMap (written once, at delivery), this - /// ticks WHILE the agent works, so the activity stream is genuinely live. - /// Returns the router's `events` array (round/tool shape); empty on any - /// error or when the sandbox has no running pod yet. - pub async fn sandbox_live_trace(&self, sandbox: &str) -> Vec<serde_json::Value> { - let ns = format!("kars-{sandbox}"); - let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(self.client.clone(), &ns); - let pod_list = pods.list(&ListParams::default()).await; - if let Err(e) = &pod_list { - tracing::warn!(target: "kars_bridge::live_trace", %ns, error = %e, "pod list failed"); - } - let Some(pod) = pod_list - .ok() - .and_then(|l| { - l.items.into_iter().find(|p| { - p.status - .as_ref() - .and_then(|s| s.phase.as_deref()) - .map(|ph| ph == "Running") - .unwrap_or(false) - }) - }) - .and_then(|p| p.metadata.name) - else { - tracing::warn!(target: "kars_bridge::live_trace", %ns, "no running pod found"); - return Vec::new(); - }; - let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/telemetry/trace"); - let Ok(req) = http::Request::builder() - .method(http::Method::GET) - .uri(&path) - .body(Vec::new()) - else { - tracing::warn!(target: "kars_bridge::live_trace", %path, "request build failed"); - return Vec::new(); - }; - match self.client.request_text(req).await { - Ok(text) => { - let n = serde_json::from_str::<serde_json::Value>(&text) - .ok() - .and_then(|v| v.get("events").and_then(|e| e.as_array()).cloned()) - .unwrap_or_default(); - tracing::debug!(target: "kars_bridge::live_trace", %pod, events = n.len(), body_len = text.len(), "live trace ok"); - n - } - Err(e) => { - tracing::warn!(target: "kars_bridge::live_trace", %path, error = %e, "proxy request failed"); - Vec::new() - } - } - } - - /// Names of the sub-agent sandboxes a principal spawned at run time — the - /// complete transitive `kars.azure.com/parent` tree. Used to aggregate the - /// WHOLE agent tree's live activity, not just direct children. - pub async fn sub_agent_sandboxes( - &self, - namespace: &str, - parent_sandbox: &str, - ) -> Vec<DynamicObject> { - self.list_kind(namespace, "KarsSandbox") - .await - .map(|items| descendant_sandbox_objects(&items, parent_sandbox)) - .unwrap_or_default() - } - - pub async fn sub_agent_sandbox_names( - &self, - namespace: &str, - parent_sandbox: &str, - ) -> Vec<String> { - self.sub_agent_sandboxes(namespace, parent_sandbox) - .await - .iter() - .filter_map(|sandbox| sandbox.metadata.name.clone()) - .collect() - } - - /// Count missions with a real per-tool execution-trace record — the live - /// telemetry substrate. Counts `kars-mission-trace-*` ConfigMaps carrying a - /// non-empty trace, not deliverable count (the two can differ). - pub async fn count_trace_records(&self) -> usize { - use kube::api::ListParams; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - cms.list(&ListParams::default()) - .await - .map(|l| { - l.items - .iter() - .filter_map(trace_record_identity) - .collect::<std::collections::HashSet<_>>() - .len() - }) - .unwrap_or(0) - } - - /// List every mission that has produced a captured deliverable — one entry - /// per `kars-mission-output-*` ConfigMap. New records retain the full - /// evidence key in an annotation because nonce-scoped label values can - /// exceed Kubernetes' 63-byte limit; legacy records fall back to the label. - /// Returns `(task, data)` pairs so the caller can build the cross-mission - /// Artifacts index from real, durable records (never fabricated). Sorted by - /// `finishedAt` descending so the most recent deliverables surface first. - pub async fn list_mission_outputs(&self) -> Vec<MissionOutputRecord> { - use kube::api::ListParams; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let list = match cms - .list(&ListParams::default().labels("kars.azure.com/mission-output")) - .await - { - Ok(l) => l, - Err(_) => return Vec::new(), - }; - let records: Vec<( - String, - Option<String>, - std::collections::BTreeMap<String, String>, - )> = list - .items - .into_iter() - .filter_map(mission_output_candidate) - .collect(); - let mut out = select_mission_output_records(records) - .into_iter() - .map(|(evidence_key, data)| project_mission_output_record(evidence_key, data)) - .collect::<Vec<_>>(); - out.sort_by(|a, b| { - b.data - .get("finishedAt") - .cloned() - .unwrap_or_default() - .cmp(&a.data.get("finishedAt").cloned().unwrap_or_default()) - }); - out - } - - /// List each nonce-scoped execution exactly once for accounting, efficiency, - /// and historical evidence. Immutable archives/canonical records are kept; - /// task-keyed current-pointer mirrors are excluded. - pub async fn list_mission_output_evidence(&self) -> Vec<MissionOutputRecord> { - use kube::api::ListParams; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let list = match cms - .list(&ListParams::default().labels("kars.azure.com/mission-output")) - .await - { - Ok(list) => list, - Err(_) => return Vec::new(), - }; - let records = list - .items - .into_iter() - .filter_map(mission_output_candidate) - .collect(); - let mut out = select_mission_evidence_records(records) - .into_iter() - .map(|(evidence_key, data)| project_mission_output_record(evidence_key, data)) - .collect::<Vec<_>>(); - out.sort_by(|left, right| { - right - .data - .get("finishedAt") - .cloned() - .unwrap_or_default() - .cmp(&left.data.get("finishedAt").cloned().unwrap_or_default()) - }); - out - } - - /// Request a **mesh-driven agent run** of a task by stamping the - /// `kars.azure.com/run-requested` annotation with a fresh nonce. The core - /// controller (a live mesh peer) watches this annotation, discovers the - /// agent over the mesh, delivers the objective straight into the agent's - /// native loop (gated by the AGT `task:execute` policy), captures the - /// reply, writes it to `kars-mission-output-<task>`, and stamps - /// `kars.azure.com/run-completed` with the same nonce. This is the Bridge - /// *consuming* a neutral core capability — the Bridge never reaches into - /// the agent itself. Returns the nonce to correlate completion. - pub async fn request_mesh_run(&self, ns: &str, name: &str) -> anyhow::Result<String> { - use kube::api::{Patch, PatchParams}; - // In-flight guard: if a run is already pending (run-requested set to a - // nonce the controller hasn't completed yet), REUSE that nonce instead of - // stamping a fresh one. Two concurrent triggers (double-click, cadence + - // run-now) would otherwise each mint a distinct nonce; the controller acks - // only the last, the first caller's await never matches → it single-turns - // while the mesh also delivers → the task executes twice and the outputs - // clobber. Reusing the pending nonce makes both callers await the same run. - if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { - let ann = task.metadata.annotations.unwrap_or_default(); - let requested = ann.get("kars.azure.com/run-requested").cloned(); - let completed = ann.get("kars.azure.com/run-completed").cloned(); - if let Some(req) = requested.filter(|r| !r.is_empty()) - && completed.as_deref() != Some(req.as_str()) - { - return Ok(req); - } - } - let nonce = format!( - "run-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); - let patch = serde_json::json!({ - "metadata": { "annotations": { "kars.azure.com/run-requested": nonce } } - }); - self.tasks(ns) - .patch(name, &PatchParams::default(), &Patch::Merge(patch)) - .await?; - // Re-read and adopt whatever nonce actually won the annotation, so two - // truly-simultaneous triggers converge on the SAME run instead of each - // awaiting its own (last-write-wins) nonce. - if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await - && let Some(actual) = task - .metadata - .annotations - .and_then(|a| a.get("kars.azure.com/run-requested").cloned()) - .filter(|r| !r.is_empty()) - { - return Ok(actual); - } - Ok(nonce) - } - - /// Poll the task's `kars.azure.com/run-completed` annotation until it - /// equals `nonce` (the controller stamps it once the mesh round-trip is - /// done) or `timeout` elapses. Returns the freshly-written mission output - /// on completion, or `None` on timeout. - /// Outcome of awaiting a mesh run. Distinguishes "the mesh peer never picked - /// this up" (safe to fall back to a single turn) from "it acknowledged and is - /// actively delivering" (must NOT single-turn — that would race the - /// controller's deliverable write). - pub async fn await_mesh_run( - &self, - ns: &str, - name: &str, - nonce: &str, - timeout: std::time::Duration, - ) -> MeshRunOutcome { - let deadline = std::time::Instant::now() + timeout; - let mut saw_ack = false; - let mut saw_activity = false; - loop { - if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { - let ann = task.metadata.annotations.clone().unwrap_or_default(); - if ann.get("kars.azure.com/run-ack").map(String::as_str) == Some(nonce) { - saw_ack = true; - } - if ann.get("kars.azure.com/run-completed").map(String::as_str) == Some(nonce) { - return match self.read_mission_output(name).await { - Some(out) => MeshRunOutcome::Completed(out), - None => MeshRunOutcome::InProgress, - }; - } - } - // LIVE-ACTIVITY signal — the robust "a real agent loop is running" - // proof that works even against an OLD controller that never stamps - // run-ack. If the sandbox's router is emitting rounds/tool calls, a - // genuine run is in flight and we must NEVER single-turn over it - // (that produced a garbage one-shot deliverable that clobbered the - // real streaming run). Latch it once seen. - if !saw_activity && !self.sandbox_live_trace(name).await.is_empty() { - saw_activity = true; - } - if std::time::Instant::now() >= deadline { - return if saw_ack || saw_activity { - MeshRunOutcome::InProgress - } else { - MeshRunOutcome::NeverProcessed - }; - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - } - - /// Clear the pending `run-requested` annotation — used when the BFF gives up - /// on the mesh path and single-turns, so a late mesh-peer recovery doesn't - /// ALSO deliver + write the output (double-write). - pub async fn clear_run_request(&self, ns: &str, name: &str) { - use kube::api::{Patch, PatchParams}; - let patch = serde_json::json!({ - "metadata": { "annotations": { "kars.azure.com/run-requested": serde_json::Value::Null } } - }); - let _ = self - .tasks(ns) - .patch(name, &PatchParams::default(), &Patch::Merge(patch)) - .await; - } - - /// Discover a running agent's **mesh identity** from the AGT registry — the - /// harness-neutral discovery layer. Every runtime adapter registers its - /// agent under capabilities that include the sandbox name; we query - /// `/v1/discover?capability=<sandbox>` through the Kubernetes services/proxy - /// subresource (the registry is a ClusterIP service the BFF reaches via the - /// API server) and return the most-recently-seen DID + its capabilities and - /// last-seen time. This proves the agent is a real, live mesh participant - /// and is the discovery prerequisite for mesh-driven task delivery. Returns - /// `None` when the registry is unreachable or the agent isn't registered - /// (honest empty, never fabricated). - pub async fn discover_agent_identity(&self, sandbox: &str) -> Option<AgentIdentity> { - let path = format!( - "/api/v1/namespaces/agentmesh/services/agentmesh-registry:8080/proxy/v1/discover?capability={sandbox}&limit=10" - ); - let req = http::Request::builder() - .method(http::Method::GET) - .uri(path) - .body(Vec::new()) - .ok()?; - let text = self.client.request_text(req).await.ok()?; - let body: serde_json::Value = serde_json::from_str(&text).ok()?; - let results = body.get("results")?.as_array()?; - // Pick the most-recently-seen registration for this sandbox. - let best = results - .iter() - .filter(|r| { - r.get("capabilities") - .and_then(|c| c.as_array()) - .map(|caps| caps.iter().any(|c| c.as_str() == Some(sandbox))) - .unwrap_or(false) - }) - .max_by_key(|r| { - r.get("last_seen") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - })?; - Some(AgentIdentity { - did: best.get("did")?.as_str()?.to_string(), - capabilities: best - .get("capabilities") - .and_then(|c| c.as_array()) - .map(|caps| { - caps.iter() - .filter_map(|c| c.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(), - last_seen: best - .get("last_seen") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - reputation_score: best.get("reputation_score").and_then(|v| v.as_f64()), - }) - } - - /// Read a ConfigMap's `data` map from `kars-system` (e.g. the receipt - /// inclusion-log signed checkpoint). Returns `None` when absent. - pub async fn configmap_data( - &self, - name: &str, - ) -> Option<std::collections::BTreeMap<String, String>> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - cms.get_opt(name).await.ok().flatten().and_then(|c| c.data) - } - - /// Like `configmap_data` but distinguishes a genuine API error from an absent - /// ConfigMap: `Ok(None)` means "not found", `Err` means the read actually - /// failed. Use on critical read-modify-write paths so a transient cluster - /// error can't be mistaken for "no prior data" and silently clobber it. - pub async fn configmap_data_result( - &self, - name: &str, - ) -> Result<Option<std::collections::BTreeMap<String, String>>, kube::Error> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - Ok(cms.get_opt(name).await?.and_then(|c| c.data)) - } - - /// List `kars-system` ConfigMaps matching a label selector, retaining each - /// object name so callers can verify ordered segmented stores. - pub async fn configmaps_data_by_label( - &self, - selector: &str, - ) -> Result<Vec<(String, std::collections::BTreeMap<String, String>)>, kube::Error> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - Ok(cms - .list(&ListParams::default().labels(selector)) - .await? - .items - .into_iter() - .filter_map(|cm| Some((cm.metadata.name?, cm.data.unwrap_or_default()))) - .collect()) - } - - /// Read-modify-write a `kars-system` ConfigMap's `data` under OPTIMISTIC - /// CONCURRENCY: a single atomic JSON Patch (RFC 6902) — a `test` op - /// asserting `resourceVersion` hasn't moved, followed by `add`/`remove` - /// ops for the actual data changes — retried on failure. This makes - /// concurrent writers serialize instead of silently clobbering each other - /// (an SSA `force` apply of the whole `data` drops the other writer's - /// fields; a `replace()`/PUT needs the `update` RBAC verb, which the - /// BFF's ClusterRole never grants — confirmed live against the real - /// ServiceAccount: a PUT-based CAS here 403s in an RBAC-enforced - /// deployment. See `mutate_secret_keys` for the full rationale, including - /// why a plain JSON *merge* patch alone can't do this: K8s doesn't honor - /// `resourceVersion` as a precondition for merge patches, only for - /// `test`-op JSON Patches, SSA, and PUT). Use for any read-append-write - /// on a shared ConfigMap (e.g. review history). - pub async fn update_configmap_data<F>( - &self, - name: &str, - labels: &[(&str, &str)], - mut modify: F, - ) -> Result<(), kube::Error> - where - F: FnMut(&mut std::collections::BTreeMap<String, String>), - { - use k8s_openapi::api::core::v1::ConfigMap; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; - use kube::api::{Patch, PostParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let label_map: std::collections::BTreeMap<String, String> = labels - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - for _attempt in 0..6 { - let existing = cms.get_opt(name).await?; - let Some(current) = existing else { - // Doesn't exist yet — a JSON Patch has nothing to patch onto; - // create it fresh (a create-race surfaces as a 409 below). - let mut data = std::collections::BTreeMap::new(); - modify(&mut data); - let cm = ConfigMap { - metadata: ObjectMeta { - name: Some(name.to_string()), - labels: (!label_map.is_empty()).then(|| label_map.clone()), - ..Default::default() - }, - data: Some(data), - ..Default::default() - }; - match cms.create(&PostParams::default(), &cm).await { - Ok(_) => return Ok(()), - Err(kube::Error::Api(ae)) if ae.code == 409 => continue, - Err(e) => return Err(e), - } - }; - let Some(rv) = current.metadata.resource_version.clone() else { - continue; // no resourceVersion to pin to — re-read and retry. - }; - let before = current.data.clone().unwrap_or_default(); - let mut after = before.clone(); - modify(&mut after); - - let mut ops: Vec<json_patch::PatchOperation> = vec![json_patch::PatchOperation::Test( - json_patch::TestOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens([ - "metadata", - "resourceVersion", - ]), - value: serde_json::Value::String(rv), - }, - )]; - if current.data.is_none() { - ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data"]), - value: serde_json::json!({}), - })); - } - for key in before.keys() { - if !after.contains_key(key) { - ops.push(json_patch::PatchOperation::Remove( - json_patch::RemoveOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens([ - "data", - key.as_str(), - ]), - }, - )); - } - } - for (key, value) in &after { - ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["data", key.as_str()]), - value: serde_json::Value::String(value.clone()), - })); - } - if !label_map.is_empty() { - if current.metadata.labels.is_none() { - ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens(["metadata", "labels"]), - value: serde_json::json!({}), - })); - } - for (k, v) in &label_map { - ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { - path: json_patch::jsonptr::PointerBuf::from_tokens([ - "metadata", - "labels", - k.as_str(), - ]), - value: serde_json::Value::String(v.clone()), - })); - } - } - - match cms - .patch( - name, - &kube::api::PatchParams::default(), - &Patch::Json::<ConfigMap>(json_patch::Patch(ops)), - ) - .await - { - Ok(_) => return Ok(()), - // 422 = the `test` op failed (resourceVersion moved under us, - // i.e. a real concurrent writer) — re-read and retry. 409 - // covers any other conflict (e.g. a create race). - Err(kube::Error::Api(ae)) if ae.code == 422 || ae.code == 409 => continue, - Err(e) => return Err(e), - } - } - Err(kube::Error::Api(kube::core::ErrorResponse { - status: "Failure".into(), - message: format!("exhausted optimistic-concurrency retries writing {name}"), - reason: "Conflict".into(), - code: 409, - })) - } - - /// Read all team digest logs (`kars-team-digest-*`) across teams, flattened - /// and newest-first. Best-effort. - pub async fn list_team_digests(&self) -> Vec<serde_json::Value> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let lp = ListParams::default().labels("kars.azure.com/team-digest"); - let mut out: Vec<serde_json::Value> = Vec::new(); - if let Ok(list) = cms.list(&lp).await { - for cm in list.items { - if let Some(log) = cm.data.as_ref().and_then(|d| d.get("log.json")) - && let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(log) - { - out.extend(entries); - } - } - } - out.sort_by(|a, b| { - b.get("at") - .and_then(|v| v.as_str()) - .unwrap_or("") - .cmp(a.get("at").and_then(|v| v.as_str()).unwrap_or("")) - }); - out - } - - /// Read a task's review record (`kars-mission-review-<task>`), if any. - pub async fn read_review( - &self, - task: &str, - ) -> Option<std::collections::BTreeMap<String, String>> { - self.configmap_data(&format!("kars-mission-review-{task}")) - .await - } - - /// Write a task's review record (`kars-mission-review-<task>`), SSA-merged. - pub async fn write_review( - &self, - task: &str, - data: std::collections::BTreeMap<String, String>, - ) -> anyhow::Result<()> { - use kube::api::{Patch, PatchParams}; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let name = format!("kars-mission-review-{task}"); - let patch = serde_json::json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { "name": name, "labels": { "kars.azure.com/mission-review": task } }, - "data": data, - }); - cms.patch( - &name, - &PatchParams::apply("kars-bridge-bff").force(), - &Patch::Apply(patch), - ) - .await?; - Ok(()) - } - - /// Re-drive a task on reviewer feedback without mutating its immutable spec. - /// The revision objective is nonce-bound and digest-protected in annotations; - /// the controller verifies it before constructing the signed task contract. - pub async fn redrive_with_revision( - &self, - ns: &str, - name: &str, - revised_objective: &str, - ) -> anyhow::Result<String> { - use kube::api::{Patch, PatchParams}; - // In-flight guard (same rationale as request_mesh_run): if a run is - // already pending, don't stamp a second concurrent redrive — reuse the - // pending nonce so two concurrent request_changes reviews can't double- - // execute the producing agent. The revision remains nonce-scoped. - if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { - let ann = task.metadata.annotations.clone().unwrap_or_default(); - let requested = ann.get("kars.azure.com/run-requested").cloned(); - let completed = ann.get("kars.azure.com/run-completed").cloned(); - if let Some(req) = requested.filter(|r| !r.is_empty()) - && completed.as_deref() != Some(req.as_str()) - { - let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); - let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); - let patch = serde_json::json!({ - "metadata": { "annotations": { - "kars.azure.com/run-objective-nonce": req.clone(), - "kars.azure.com/run-objective-b64": encoded, - "kars.azure.com/run-objective-digest": digest - }} - }); - self.tasks(ns) - .patch(name, &PatchParams::default(), &Patch::Merge(patch)) - .await?; - return Ok(req); - } - } - let nonce = format!( - "rev-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); - let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); - let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); - let patch = serde_json::json!({ - "metadata": { "annotations": { - "kars.azure.com/run-requested": nonce.clone(), - "kars.azure.com/run-objective-nonce": nonce.clone(), - "kars.azure.com/run-objective-b64": encoded, - "kars.azure.com/run-objective-digest": digest - }} - }); - self.tasks(ns) - .patch(name, &PatchParams::default(), &Patch::Merge(patch)) - .await?; - // Adopt whichever nonce won, so concurrent redrives converge on one run. - if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await - && let Some(actual) = task - .metadata - .annotations - .and_then(|a| a.get("kars.azure.com/run-requested").cloned()) - .filter(|r| !r.is_empty()) - { - return Ok(actual); - } - Ok(nonce) - } - - /// The model deployments this cluster is configured to serve, read from the - /// controller Deployment's environment (`KARS_TASK_DEFAULT_MODEL`, - /// `AZURE_OPENAI_DEPLOYMENT`, and the comma-separated `FOUNDRY_DEPLOYMENTS`). - /// This is the authoritative "what can actually run here" fact — the same - /// values the controller stamps onto a task's InferencePolicy. Best-effort: - /// an unreadable Deployment yields an empty list (honest, not an error), so - /// the launch package degrades to the controller default rather than lying. - pub async fn controller_models(&self) -> (Option<String>, Vec<String>) { - use k8s_openapi::api::apps::v1::Deployment; - let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); - let Ok(Some(d)) = deploys.get_opt("kars-controller").await else { - return (None, Vec::new()); - }; - let mut default: Option<String> = None; - let mut catalog: Vec<String> = Vec::new(); - let envs = d - .spec - .and_then(|s| s.template.spec) - .map(|ps| ps.containers) - .unwrap_or_default() - .into_iter() - .flat_map(|c| c.env.unwrap_or_default()); - for e in envs { - let Some(val) = e.value else { continue }; - match e.name.as_str() { - "KARS_TASK_DEFAULT_MODEL" | "AZURE_OPENAI_DEPLOYMENT" if default.is_none() => { - default = Some(val); - } - "FOUNDRY_DEPLOYMENTS" | "KARS_MODEL_CATALOG" => { - catalog.extend( - val.split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()), - ); - } - _ => {} - } - } - (default, catalog) - } - - /// The GitHub token wired for GitHub Copilot — checked in BOTH places - /// Copilot can be configured: the shared providers secret (an additional - /// provider, or one signed-in via the wizard's device login) FIRST, then - /// the controller's `COPILOT_GITHUB_TOKEN` env (the cluster default). Used - /// to fetch the seat's LIVE model catalog so the Model catalogue + - /// orchestrator reflect what Copilot actually serves. `None` when unset. - pub async fn controller_copilot_token(&self) -> Option<String> { - // Wizard sign-in / additional-provider path stores it here. - if let Ok(keys) = self - .read_secret_all("kars-system", "kars-inference-providers") - .await - && let Some(t) = keys - .get("COPILOT_GITHUB_TOKEN") - .filter(|v| !v.trim().is_empty()) - { - return Some(t.clone()); - } - use k8s_openapi::api::apps::v1::Deployment; - let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); - let d = deploys.get_opt("kars-controller").await.ok().flatten()?; - d.spec - .and_then(|s| s.template.spec) - .map(|ps| ps.containers) - .unwrap_or_default() - .into_iter() - .flat_map(|c| c.env.unwrap_or_default()) - .find(|e| e.name == "COPILOT_GITHUB_TOKEN") - .and_then(|e| e.value) - .filter(|v| !v.trim().is_empty()) - } - /// fact chosen at cluster setup, NOT something the Bridge picks. kars - /// supports exactly three: GitHub Copilot, GitHub Models, and Azure AI - /// Foundry. The classification mirrors the inference-router's own endpoint - /// detection (`inference-router/src/config.rs`): a `KARS_PROVIDER` override - /// wins, otherwise the configured endpoint host decides. Returns - /// `(id, label, note)` or `None` when the controller is unreadable. - pub async fn controller_provider(&self) -> Option<(String, String, String)> { - use k8s_openapi::api::apps::v1::Deployment; - let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); - let d = deploys.get_opt("kars-controller").await.ok().flatten()?; - let mut provider_override: Option<String> = None; - let mut endpoints: Vec<String> = Vec::new(); - let mut token_hint: Option<String> = None; - let envs = d - .spec - .and_then(|s| s.template.spec) - .map(|ps| ps.containers) - .unwrap_or_default() - .into_iter() - .flat_map(|c| c.env.unwrap_or_default()); - for e in envs { - let Some(val) = e.value else { continue }; - match e.name.as_str() { - // Explicit operator declaration — the authoritative brand signal. - "KARS_PROVIDER" | "KARS_INFERENCE_PROVIDER" if !val.is_empty() => { - provider_override = Some(val) - } - "FOUNDRY_ENDPOINT" | "FOUNDRY_PROJECT_ENDPOINT" | "AZURE_OPENAI_ENDPOINT" => { - endpoints.push(val) - } - // Auth token KIND disambiguates the GitHub endpoint: a GitHub - // OAuth/user token (`gho_`/`ghu_`) is a Copilot login; a classic - // PAT (`ghp_`) is free GitHub Models. We only inspect the prefix, - // never the secret, and only when provided inline (dev profile). - "AZURE_OPENAI_API_KEY" | "GITHUB_TOKEN" | "COPILOT_GITHUB_TOKEN" - if token_hint.is_none() && !val.is_empty() => - { - token_hint = Some(val.chars().take(4).collect()); - } - _ => {} - } - } - classify_provider( - provider_override.as_deref(), - &endpoints, - token_hint.as_deref(), - ) - } - - /// Read the receipt-signing public-key anchor published by the controller - /// to the `kars-receipt-pubkey` ConfigMap in `kars-system`. This is the - /// out-of-band trust root a verifier checks against — never a key embedded - /// in a receipt. Returns `(key_id, public_key_b64, scheme)`. - pub async fn receipt_pubkey_anchor(&self) -> Option<(String, String, String)> { - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - let cm = cms.get_opt("kars-receipt-pubkey").await.ok().flatten()?; - let data = cm.data?; - Some(( - data.get("keyId")?.clone(), - data.get("publicKey")?.clone(), - data.get("scheme").cloned().unwrap_or_default(), - )) - } - - /// The orchestrator inference config this cluster already provides — read - /// from the controller Deployment env the SAME way the runtime does, so the - /// Bridge's intent→package orchestrator inherits the cluster's provider - /// instead of needing its own credentials. Returns `(endpoint, token, - /// model)` when an endpoint, a usable token, and a default model are all - /// present. `None` when the cluster authenticates via workload identity - /// (no static token the BFF can reuse) — the UI then falls back to manual - /// composition honestly. - pub async fn orchestrator_inference(&self) -> Option<(String, String, String)> { - use k8s_openapi::api::apps::v1::Deployment; - let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); - let d = deploys.get_opt("kars-controller").await.ok().flatten()?; - let mut endpoint: Option<String> = None; - let mut token: Option<String> = None; - let mut model: Option<String> = None; - let envs = d - .spec - .and_then(|s| s.template.spec) - .map(|ps| ps.containers) - .unwrap_or_default() - .into_iter() - .flat_map(|c| c.env.unwrap_or_default()); - for e in envs { - let Some(val) = e.value else { continue }; - if val.is_empty() { - continue; - } - match e.name.as_str() { - "FOUNDRY_ENDPOINT" if endpoint.is_none() => endpoint = Some(val), - "AZURE_OPENAI_ENDPOINT" if endpoint.is_none() => endpoint = Some(val), - "AZURE_OPENAI_API_KEY" | "GITHUB_TOKEN" | "COPILOT_GITHUB_TOKEN" - if token.is_none() => - { - token = Some(val) - } - "KARS_TASK_DEFAULT_MODEL" | "AZURE_OPENAI_DEPLOYMENT" if model.is_none() => { - model = Some(val) - } - _ => {} - } - } - // Normalize a bare Foundry/AOAI endpoint to its OpenAI-compatible base so - // `{endpoint}/chat/completions` resolves. GitHub Models already exposes - // `/inference` as the base; leave it intact. - let endpoint = endpoint?; - Some((endpoint, token?, model?)) - } - - /// Which agent harnesses are actually runnable on this cluster. A configured - /// image is insufficient: private images also need a controller pull secret - /// whose Docker auth covers that image registry. This keeps the composer and - /// preflight from advertising a runtime that will immediately ImagePullBackOff. - pub async fn runnable_runtimes(&self) -> std::collections::BTreeSet<String> { - use k8s_openapi::api::apps::v1::Deployment; - let mut runnable: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); - // BYO remains selectable because its image is supplied by the BYO contract. - runnable.insert("BYO".into()); - let Ok(Some(d)) = (Api::<Deployment>::namespaced(self.client.clone(), "kars-system")) - .get_opt("kars-controller") - .await - else { - return runnable; - }; - let Some(pod_spec) = d.spec.and_then(|s| s.template.spec) else { - return runnable; - }; - let configured: std::collections::BTreeMap<String, String> = pod_spec - .containers - .into_iter() - .flat_map(|c| c.env.unwrap_or_default()) - .filter_map(|e| { - e.value - .filter(|value| !value.trim().is_empty()) - .map(|value| (e.name, value)) - }) - .collect(); - // The BFF deliberately has no Secret RBAC. The controller exposes only - // the non-sensitive registry hostnames covered by its pull credentials. - let authenticated_registries = configured - .get("IMAGE_PULL_REGISTRIES") - .into_iter() - .flat_map(|value| value.split(',')) - .map(normalize_registry_host) - .filter(|registry| !registry.is_empty()) - .collect::<std::collections::BTreeSet<_>>(); - let image_is_pullable = |image: &str| { - let registry = image_registry_host(image); - public_registry(®istry) || authenticated_registries.contains(®istry) - }; - if configured - .get("SANDBOX_IMAGE") - .is_some_and(|image| image_is_pullable(image)) - { - runnable.insert("OpenClaw".into()); - } - let mapping = [ - ("OPENAI_AGENTS_RUNTIME_IMAGE", "OpenAIAgents"), - ("MAF_RUNTIME_IMAGE", "MicrosoftAgentFramework"), - ("ANTHROPIC_RUNTIME_IMAGE", "Anthropic"), - ("LANGGRAPH_RUNTIME_IMAGE", "LangGraph"), - ("LANGGRAPH_TS_RUNTIME_IMAGE", "LangGraph"), - ("PYDANTIC_AI_RUNTIME_IMAGE", "PydanticAi"), - ("HERMES_RUNTIME_IMAGE", "Hermes"), - ]; - for (env, kind) in mapping { - if configured - .get(env) - .is_some_and(|image| image_is_pullable(image)) - { - runnable.insert(kind.to_string()); - } - } - runnable - } - - /// Read-only readiness check of the required APIs, bounded across all requests. - pub async fn ping(&self, namespace: &str) -> anyhow::Result<()> { - const REQUIRED_KARS_APIS: &[(&str, &str)] = &[ - ("KarsSandbox", "karssandboxes"), - ("KarsTask", "karstasks"), - ("KarsTeam", "karsteams"), - ("KarsProfile", "karsprofiles"), - ("KarsSkill", "karsskills"), - ("KarsApproval", "karsapprovals"), - ("EgressApproval", "egressapprovals"), - ("KarsReceipt", "karsreceipts"), - ("McpServer", "mcpservers"), - ("InferencePolicy", "inferencepolicies"), - ("ToolPolicy", "toolpolicies"), - ("KarsMemory", "karsmemories"), - ("KarsEval", "karsevals"), - ("KarsSREAction", "karssreactions"), - ("KarsCredentialGrant", "karscredentialgrants"), - ]; - - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); - for (kind, plural) in REQUIRED_KARS_APIS { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let mut ar = ApiResource::from_gvk(&gvk); - ar.plural = (*plural).to_string(); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - tokio::time::timeout_at(deadline, api.list(&ListParams::default().limit(1))) - .await - .map_err(|_| { - anyhow::anyhow!( - "required Kars API kars.azure.com/v1alpha1/{kind} readiness check timed out" - ) - })? - .map_err(|error| { - anyhow::anyhow!( - "required Kars API kars.azure.com/v1alpha1/{kind} is unavailable: {error}" - ) - })?; - } - Ok(()) - } - - /// True iff a CRD with the given plural.group name is installed (e.g. - /// `karssandboxes.kars.azure.com`). Used by the System view to report - /// honest wiring status read from the cluster, not asserted. - pub async fn crd_installed(&self, name: &str) -> bool { - let crds: Api<CustomResourceDefinition> = Api::all(self.client.clone()); - crds.get_opt(name).await.ok().flatten().is_some() - } - - /// Count resources of an arbitrary kars CRD kind in a namespace, via the - /// dynamic API so the BFF need not model every CRD it merely *counts*. - /// Returns `None` when the CRD is not installed. - pub async fn count_kind(&self, namespace: &str, kind: &str) -> Option<usize> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - match api.list(&ListParams::default()).await { - Ok(list) => Some(list.items.len()), - Err(_) => None, - } - } - - /// Create a kars CRD object from a JSON spec in `namespace`. Used to file a - /// request resource (e.g. a temporary `EgressApproval`) the controller then - /// reconciles through human approval — the BFF never widens posture itself. - /// Ensure the standing **orchestrator sandbox** exists — a persistent, - /// non-ephemeral sandbox whose inference router the Bridge orchestrator - /// (compose) always routes through. Without it, compose can only borrow a - /// running agent's router, so on a teams-only cluster (all ephemeral runs) - /// it has no cold-start inference path. Idempotent SSA; safe to call on every - /// startup. - pub async fn ensure_orchestrator_sandbox(&self) -> Result<(), kube::Error> { - const NAME: &str = "bridge-orchestrator"; - const NS: &str = "kars-system"; - let inference = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "InferencePolicy", - "metadata": { "name": format!("{NAME}-inference"), "namespace": NS, - "labels": { "kars.azure.com/managed-by": "kars-bridge" } }, - "spec": { - "appliesTo": { "sandboxName": NAME }, - "modelPreference": { "primary": { "provider": "github-copilot", "deployment": "claude-opus-4.8" } }, - }, - }); - self.apply_kind(NS, "InferencePolicy", inference, true) - .await?; - let sandbox = serde_json::json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsSandbox", - "metadata": { "name": NAME, "namespace": NS, - "labels": { "kars.azure.com/managed-by": "kars-bridge", "kars.azure.com/orchestrator": "true" } }, - "spec": { - "runtime": { "kind": "OpenClaw", "openclaw": {} }, - "inferenceRef": { "name": format!("{NAME}-inference") }, - "sandbox": { "isolation": "standard" }, - "networkPolicy": { "defaultDeny": true }, - "governance": { "enabled": true, "toolPolicyRef": { "name": "kars-default" }, "trustThreshold": 0 }, - "agent": { "instructions": "Standing orchestrator inference host for the kars Bridge composer. Stay idle; your router serves compose requests." }, - }, - }); - self.apply_kind(NS, "KarsSandbox", sandbox, true).await?; - Ok(()) - } - - pub async fn create_kind( - &self, - namespace: &str, - kind: &str, - body: serde_json::Value, - ) -> Result<DynamicObject, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - let obj: DynamicObject = serde_json::from_value(body).map_err(|e| { - kube::Error::Api(kube::core::ErrorResponse { - status: "Failure".into(), - message: e.to_string(), - reason: "BadRequest".into(), - code: 400, - }) - })?; - api.create(&kube::api::PostParams::default(), &obj).await - } - - /// Server-Side Apply a `kars.azure.com` CRD — the Kubernetes-native - /// declarative upsert (the same operation `kubectl apply` performs): creates - /// the object on first apply, edits it on re-apply. The Bridge owns its - /// fields under the stable `kars-bridge` field manager, so the controller, - /// other tools, and a human's `kubectl edit` can co-own different fields - /// without clobbering each other (tracked in `metadata.managedFields`). - /// - /// `force = false` (default) surfaces a 409 field-ownership conflict when - /// another manager owns a field this apply sets — the caller decides whether - /// to override. `force = true` takes ownership of the applied fields. The - /// real authorization boundary is RBAC on the Bridge ServiceAccount + the - /// CRD's admission/CEL validation — not this method. - pub async fn apply_kind( - &self, - namespace: &str, - kind: &str, - body: serde_json::Value, - force: bool, - ) -> Result<DynamicObject, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - let obj: DynamicObject = serde_json::from_value(body).map_err(|e| { - kube::Error::Api(kube::core::ErrorResponse { - status: "Failure".into(), - message: e.to_string(), - reason: "BadRequest".into(), - code: 400, - }) - })?; - let name = obj.metadata.name.clone().unwrap_or_default(); - let mut pp = kube::api::PatchParams::apply("kars-bridge"); - if force { - pp = pp.force(); - } - api.patch(&name, &pp, &kube::api::Patch::Apply(&obj)).await - } - - /// Delete a namespaced kars CRD by kind + name. Foreground propagation so - /// the controller's finalizers run (revoking any downstream state) before - /// the object disappears. The RBAC boundary is the Bridge ServiceAccount's - /// `delete` verb on the resource; a 403/404 surfaces to the caller. - pub async fn delete_kind( - &self, - namespace: &str, - kind: &str, - name: &str, - ) -> Result<(), kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - let dp = kube::api::DeleteParams::foreground(); - api.delete(name, &dp).await?; - Ok(()) - } - - /// Delete a standing team and its owned substrate. Deleting the `KarsTeam` - /// CRD cascade-removes its runs + sandboxes (owner references); this then - /// best-effort sweeps the team's auxiliary records the controller writes - /// alongside the CRD — shared memory, task backlog, engineering source, and - /// write-only channel secret — so a deleted team leaves nothing behind. - /// Aux cleanup is best-effort: a missing aux object is not an error. - /// Delete a mission (KarsTask) and sweep the ConfigMaps the controller keyed - /// on its name — the deliverable, artifacts, live trace, and review record. - /// Without the sweep, a deleted mission's outputs keep surfacing on the - /// Artifacts page and its direct URL keeps resolving from output-only - /// history (same class of orphan the team delete sweep fixes). - pub async fn delete_task(&self, namespace: &str, name: &str) -> Result<(), kube::Error> { - // The CRD itself (foreground cascade → sandbox + child resources). - self.delete_kind(namespace, "KarsTask", name).await?; - self.sweep_mission_artifacts(name).await; - Ok(()) - } - - /// Best-effort deletion of the ConfigMaps the controller keys on a mission's - /// name (deliverable, files, trace, review). Used by mission delete and the - /// output-only cleanup path. - pub async fn sweep_mission_artifacts(&self, name: &str) { - use k8s_openapi::api::core::v1::ConfigMap; - use kube::api::DeleteParams; - let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); - for cm in [ - format!("kars-mission-output-{name}"), - format!("kars-mission-artifacts-{name}"), - format!("kars-mission-trace-{name}"), - format!("kars-mission-review-{name}"), - ] { - let _ = cms.delete(&cm, &DeleteParams::default()).await; - } - } - - pub async fn delete_team( - &self, - namespace: &str, - name: &str, - uid: &str, - version: &str, - ) -> Result<(), kube::Error> { - self.teams(namespace) - .delete( - name, - &kube::api::DeleteParams { - propagation_policy: Some(kube::api::PropagationPolicy::Foreground), - preconditions: Some(kube::api::Preconditions { - uid: Some(uid.into()), - resource_version: Some(version.into()), - }), - ..Default::default() - }, - ) - .await?; - // Core owns Team/source/commons cleanup. Historical or ambiguous - // name-keyed records are retained rather than deleting another UID's data. - Ok(()) - } - - /// Patch the controller deployment env to set the model catalog (and - /// optionally an endpoint) so an onboarded provider's models surface in the - /// launch palette. Triggers a rolling restart. Operator-gated write. - /// - /// When `key_secret` is `Some((secret_name, secret_key))`, the provider's - /// API key is wired via a `secretKeyRef` on `AZURE_OPENAI_API_KEY` — the env - /// var the controller reads and then propagates to every sandbox pod it - /// creates (see controller reconciler). This is what makes an `auth=api` - /// provider actually usable end-to-end, not merely stored. - pub async fn set_controller_catalog( - &self, - catalog: &str, - endpoint: Option<&str>, - key_secret: Option<(&str, &str)>, - ) -> Result<(), kube::Error> { - // Strategic merge on `env` (merge-key `name`) upserts these entries and - // preserves every other existing env var on the container. - let mut env = vec![serde_json::json!({"name": "KARS_MODEL_CATALOG", "value": catalog})]; - // The FIRST catalog entry is the default model — pin it as - // KARS_TASK_DEFAULT_MODEL + AZURE_OPENAI_DEPLOYMENT so switching the - // default provider (or a specific default model) actually changes what - // missions inherit, not just the offered catalog. Without this, the - // controller kept serving a STALE default model after every switch. - if let Some(default_model) = catalog.split(',').map(str::trim).find(|s| !s.is_empty()) { - env.push( - serde_json::json!({"name": "KARS_TASK_DEFAULT_MODEL", "value": default_model}), - ); - env.push( - serde_json::json!({"name": "AZURE_OPENAI_DEPLOYMENT", "value": default_model}), - ); - } - // `controller_provider()` (the "what's the current default provider" - // read used by the Configuration page's status card) checks THREE - // things it treats as stale-able: an explicit `KARS_PROVIDER` / - // `KARS_INFERENCE_PROVIDER` override (checked FIRST, absolute - // priority over everything else — typically set once at cluster - // bootstrap, e.g. `KARS_PROVIDER=github-copilot`), then - // FOUNDRY_ENDPOINT / FOUNDRY_PROJECT_ENDPOINT / AZURE_OPENAI_ENDPOINT - // as interchangeable endpoint aliases (picks whichever it finds - // FIRST). Every caller of this function is switching the cluster - // default to a NEW provider, so ALL of these must be cleared here — - // confirmed live this was a real, pre-existing bug affecting the - // ORIGINAL "Add or switch a provider" flow too, not just the new - // local-inference promote action: switching the default endpoint - // correctly patched FOUNDRY_ENDPOINT, but the Configuration page - // kept showing "GitHub Copilot" forever after, because the - // bootstrap-time `KARS_PROVIDER=github-copilot` override (checked - // before any endpoint) was never cleared by anything. Neither - // caller of this function ever wants to declare copilot/models as - // default (both explicitly reject that combination before calling - // in), so unconditionally clearing the override is correct here. - // `$patch: delete` is the standard strategic-merge-patch mechanism - // for removing one named entry from a mergeKey'd list without - // touching the rest — a no-op if the name was never present. - for stale in [ - "KARS_PROVIDER", - "KARS_INFERENCE_PROVIDER", - "AZURE_OPENAI_ENDPOINT", - "FOUNDRY_PROJECT_ENDPOINT", - ] { - env.push(serde_json::json!({"name": stale, "$patch": "delete"})); - } - if let Some(e) = endpoint { - env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "value": e})); - } else { - // No explicit endpoint (e.g. switching to GitHub Copilot/Models - // default, which reach their well-known host without one) — clear - // any previously-set FOUNDRY_ENDPOINT too, for the same reason. - env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "$patch": "delete"})); - } - if let Some((secret, key)) = key_secret { - // valueFrom.secretKeyRef replaces any prior static `value` for this - // name under strategic merge, so the key is sourced from the Secret. - env.push(serde_json::json!({ - "name": "AZURE_OPENAI_API_KEY", - "valueFrom": { "secretKeyRef": { "name": secret, "key": key } }, - })); - } else { - // The new default has no key (e.g. an unauthenticated in-cluster - // local model, or Workload Identity) — clear any key wired for a - // PRIOR default so the router doesn't keep sending a stale - // credential to an endpoint that never asked for one. - env.push(serde_json::json!({"name": "AZURE_OPENAI_API_KEY", "$patch": "delete"})); - } - self.write_controller_environment(env).await - } - - /// Make GitHub Copilot the cluster's DEFAULT provider. Copilot doesn't use - /// the endpoint+key shape `set_controller_catalog` wires — it authenticates - /// via a GitHub token exchanged for a short-lived Copilot JWT by the router - /// (`copilot_auth`). This wires exactly what Copilot-as-default needs on the - /// controller (which propagates it to every sandbox): `KARS_PROVIDER= - /// github-copilot`, `COPILOT_GITHUB_TOKEN` (the token signed in via the - /// wizard, read from the shared providers secret), the Copilot API host as - /// the `AZURE_OPENAI_ENDPOINT` sentinel (the controller refuses to - /// provision a sandbox without SOME inference endpoint), the model catalog, - /// and the default model — clearing any stale Azure/Foundry endpoint+key - /// from a prior default. Returns an error if no Copilot token is stored yet - /// (the operator must sign in first). - pub async fn set_copilot_as_default(&self, models: &str) -> Result<(), kube::Error> { - let token = self - .read_secret_all("kars-system", "kars-inference-providers") - .await? - .get("COPILOT_GITHUB_TOKEN") - .filter(|v| !v.trim().is_empty()) - .cloned(); - let Some(_token) = token else { - return Err(kube::Error::Api(kube::error::ErrorResponse { - status: "Failure".into(), - message: "no Copilot token is stored — sign in to GitHub Copilot first".into(), - reason: "BadRequest".into(), - code: 400, - })); - }; - let default_model = models - .split(',') - .next() - .map(str::trim) - .unwrap_or("") - .to_string(); - let mut env = vec![ - serde_json::json!({"name": "KARS_PROVIDER", "value": "github-copilot"}), - serde_json::json!({"name": "COPILOT_GITHUB_TOKEN", "valueFrom":{"secretKeyRef":{ - "name":"kars-inference-providers","key":"COPILOT_GITHUB_TOKEN"}}}), - serde_json::json!({"name": "AZURE_OPENAI_ENDPOINT", "value": "https://api.githubcopilot.com"}), - serde_json::json!({"name": "KARS_MODEL_CATALOG", "value": models}), - ]; - if !default_model.is_empty() { - env.push(serde_json::json!({"name": "KARS_TASK_DEFAULT_MODEL", "value": default_model.clone()})); - env.push( - serde_json::json!({"name": "AZURE_OPENAI_DEPLOYMENT", "value": default_model}), - ); - } - // Clear anything a prior (Azure/Foundry) default left behind so the - // router doesn't keep a stale endpoint/key alongside Copilot. - for stale in [ - "FOUNDRY_ENDPOINT", - "FOUNDRY_PROJECT_ENDPOINT", - "AZURE_OPENAI_API_KEY", - "KARS_INFERENCE_PROVIDER", - ] { - env.push(serde_json::json!({"name": stale, "$patch": "delete"})); - } - self.write_controller_environment(env).await - } - /// Upsert a Secret via server-side apply, merging keys without clobbering - /// existing ones. Used to store agent credentials (write-only); the value is - /// never read back through any endpoint. - pub async fn upsert_secret( - &self, - namespace: &str, - name: &str, - body: serde_json::Value, - ) -> Result<(), kube::Error> { - let data = body - .get("stringData") - .and_then(serde_json::Value::as_object) - .ok_or_else(|| super::credentials::failure("Integration update requires stringData"))?; - let values = data - .iter() - .map(|(key, value)| { - value - .as_str() - .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| { - super::credentials::failure("Integration value must be a string") - }) - }) - .collect::<Result<std::collections::BTreeMap<_, _>, _>>()?; - self.mutate_integration(namespace, name, |keys| keys.extend(values.clone())) - .await - } - - /// Delete a write-only credential Secret this Bridge authored (e.g. the - /// shared `kars-github-app` Secret on disconnect). A 404 is not an error — - /// the secret is already absent, which is the caller's desired end state. - pub async fn delete_secret(&self, namespace: &str, name: &str) -> Result<(), kube::Error> { - self.mutate_integration(namespace, name, |keys| keys.clear()) - .await - } - - /// List all objects of a kars CRD `kind` across **all** namespaces, as - /// dynamic objects the caller projects into a DTO. This is the generic - /// read the operator surfaces use so the BFF need not type every CRD it - /// merely lists. Returns `Err` only on a real API failure; an absent CRD - /// surfaces as `Ok(vec![])` so the caller can render an honest empty state. - pub async fn list_kind_all(&self, kind: &str) -> Result<Vec<DynamicObject>, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); - match api.list(&ListParams::default()).await { - Ok(list) => Ok(list.items), - // A 404 means the CRD isn't installed — honest empty, not an error. - Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), - Err(e) => Err(e), - } - } - - pub async fn list_metrics_all( - &self, - kind: &str, - plural: &str, - ) -> Result<Vec<DynamicObject>, kube::Error> { - let ar = ApiResource { - group: "metrics.k8s.io".into(), - version: "v1beta1".into(), - api_version: "metrics.k8s.io/v1beta1".into(), - kind: kind.into(), - plural: plural.into(), - }; - let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); - Ok(api.list(&ListParams::default()).await?.items) - } - - pub async fn list_nodes(&self) -> Result<Vec<Node>, kube::Error> { - let api: Api<Node> = Api::all(self.client.clone()); - Ok(api.list(&ListParams::default()).await?.items) - } - - pub async fn controller_env_value(&self, name: &str) -> Option<String> { - use k8s_openapi::api::apps::v1::Deployment; - let deployment = Api::<Deployment>::namespaced(self.client.clone(), "kars-system") - .get_opt("kars-controller") - .await - .ok() - .flatten()?; - deployment - .spec? - .template - .spec? - .containers - .first()? - .env - .as_ref()? - .iter() - .find(|entry| entry.name == name) - .and_then(|entry| entry.value.clone()) - } - - /// List objects of a kars CRD `kind` across all namespaces filtered by a - /// label selector — used to find an agent's spawned sub-agents, which the - /// inference router labels `kars.azure.com/parent=<sandbox>`. Absent CRD → - /// `Ok(vec![])`. - pub async fn list_kind_labeled( - &self, - kind: &str, - selector: &str, - ) -> Result<Vec<DynamicObject>, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); - match api.list(&ListParams::default().labels(selector)).await { - Ok(list) => Ok(list.items), - Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), - Err(e) => Err(e), - } - } - - /// List objects of a kars CRD `kind` within a namespace, as dynamic - /// objects. Absent CRD → `Ok(vec![])`. - pub async fn list_kind( - &self, - namespace: &str, - kind: &str, - ) -> Result<Vec<DynamicObject>, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - match api.list(&ListParams::default()).await { - Ok(list) => Ok(list.items), - Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), - Err(e) => Err(e), - } - } - - /// Fetch a single kars CRD object by kind + namespace + name. - /// Merge-patch annotations onto a namespaced kars CRD's metadata. Used by - /// the operator skill-admission gate to record the review verdict, the - /// approver, and the version digest the approval is locked to — a real, - /// auditable admission record on the object itself (RBAC: the Bridge SA's - /// `patch` verb). A `None` value removes the annotation. - pub async fn annotate_kind( - &self, - namespace: &str, - kind: &str, - name: &str, - annotations: &[(&str, Option<String>)], - ) -> Result<DynamicObject, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - let mut ann = serde_json::Map::new(); - for (k, v) in annotations { - ann.insert((*k).to_string(), serde_json::json!(v)); - } - let patch = serde_json::json!({ "metadata": { "annotations": ann } }); - api.patch( - name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(&patch), - ) - .await - } - - /// Apply a strategic **merge patch** to a kars CRD object — used for small, - /// in-place edits (e.g. an operator changing an InferencePolicy's token - /// budget). Unlike SSA this doesn't take field-manager ownership of the whole - /// spec, so it co-exists with the controller's own management. - pub async fn merge_patch_kind( - &self, - namespace: &str, - kind: &str, - name: &str, - patch: serde_json::Value, - ) -> Result<DynamicObject, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - api.patch( - name, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(&patch), - ) - .await - } - - /// The current Foundry connection, read live from the `kars-controller` - /// Deployment env: `(project_endpoint, inference_endpoint, memory_store_id, - /// has_api_key)`. All `None`/false when Foundry has not been onboarded. The - /// API key is NEVER returned — only whether one is wired. - pub async fn get_foundry_connection( - &self, - ) -> (Option<String>, Option<String>, Option<String>, bool) { - use k8s_openapi::api::apps::v1::Deployment; - let api: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); - let Some(dep) = api.get_opt("kars-controller").await.ok().flatten() else { - return (None, None, None, false); - }; - let mut project = None; - let mut inference = None; - let mut store = None; - let mut has_key = false; - if let Some(spec) = dep.spec.and_then(|s| s.template.spec) { - for c in spec.containers { - for env in c.env.unwrap_or_default() { - match env.name.as_str() { - "FOUNDRY_PROJECT_ENDPOINT" => project = env.value.filter(|v| !v.is_empty()), - "FOUNDRY_ENDPOINT" => inference = env.value.filter(|v| !v.is_empty()), - "FOUNDRY_MEMORY_STORE_ID" => store = env.value.filter(|v| !v.is_empty()), - "FOUNDRY_API_KEY" => { - has_key = env.value_from.is_some() - || env.value.as_ref().is_some_and(|v| !v.is_empty()); - } - _ => {} - } - } - } - } - (project, inference, store, has_key) - } - - /// Onboard a Foundry connection by patching the `kars-controller` Deployment - /// env (strategic merge on `env` by name, preserving all other vars). Sets - /// `FOUNDRY_PROJECT_ENDPOINT` (+ optional inference endpoint / memory store), - /// and for API-key auth wires `FOUNDRY_API_KEY` from a Secret via - /// `secretKeyRef`. The controller then propagates these to sandbox routers. - /// Managed-identity auth stores no key — the router uses the cluster's - /// workload identity (audience `https://ai.azure.com`). - pub async fn set_foundry_connection( - &self, - project_endpoint: &str, - inference_endpoint: Option<&str>, - memory_store_id: Option<&str>, - key_secret: Option<(&str, &str)>, - ) -> Result<(), kube::Error> { - let mut env = vec![ - serde_json::json!({"name": "FOUNDRY_PROJECT_ENDPOINT", "value": project_endpoint}), - ]; - if let Some(e) = inference_endpoint.filter(|e| !e.is_empty()) { - env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "value": e})); - } - if let Some(s) = memory_store_id.filter(|s| !s.is_empty()) { - env.push(serde_json::json!({"name": "FOUNDRY_MEMORY_STORE_ID", "value": s})); - } - if let Some((secret, key)) = key_secret { - env.push(serde_json::json!({ - "name": "FOUNDRY_API_KEY", - "valueFrom": { "secretKeyRef": { "name": secret, "key": key } }, - })); - } - self.write_controller_environment(env).await - } - - /// Read a single key's value from a Secret (base64-decoded UTF-8). `None` - /// when the secret/key is absent. Used by the Foundry preflight to make a - /// real authenticated call with the onboarded key — the key never leaves the - /// BFF process. - pub async fn read_secret_value( - &self, - namespace: &str, - secret: &str, - key: &str, - ) -> Result<Option<String>, kube::Error> { - let (_, s) = self.integration_store(namespace, secret).await?; - if let Some(v) = s.data.as_ref().and_then(|d| d.get(key)) { - return String::from_utf8(v.0.clone()) - .map(Some) - .map_err(|_| super::credentials::failure("Credential value is not UTF-8")); - } - Ok(None) - } - - /// Read every key of a Secret as UTF-8 strings (base64-decoded). Empty map - /// when the secret doesn't exist. Used for the multi-provider inference - /// Secret, whose keys ARE the literal env var names the router reads - /// (`KARS_PROVIDER_<TAG>_ENDPOINT`, `COPILOT_GITHUB_TOKEN`, ...) — listing - /// requires reading the whole key set, not one key at a time. - pub async fn read_secret_all( - &self, - namespace: &str, - secret: &str, - ) -> Result<std::collections::BTreeMap<String, String>, kube::Error> { - let (_, s) = self.integration_store(namespace, secret).await?; - Ok(Self::decode_secret_data(&s)) - } - - /// Read-modify-write a Secret's full key set under real optimistic - /// concurrency (CAS): a single atomic JSON Patch (RFC 6902) — a `test` op - /// asserting `resourceVersion` hasn't moved, followed by `add`/`remove` - /// ops for the actual key changes — retried on failure. - /// - /// Why JSON Patch specifically, not `replace()`/PUT or a plain JSON merge - /// patch: - /// - `replace()` (PUT) is the "update" RBAC verb, which the BFF's - /// ClusterRole deliberately never grants (write access here is - /// `create`/`patch` only) — using it would 403 in any real - /// RBAC-enforced deployment. Confirmed live against the actual - /// ServiceAccount (not a developer's cluster-admin kubeconfig). - /// - A plain JSON *merge* patch (RFC 7396, what this function used - /// before) uses the `patch` verb correctly, but the K8s API does NOT - /// honor `resourceVersion` as a precondition for merge patches — - /// confirmed live: a merge patch carrying a stale resourceVersion - /// still applies. So a merge patch alone has no way to detect a - /// concurrent writer. - /// - JSON Patch's `test` op DOES enforce the precondition atomically - /// alongside the real mutation (confirmed live: a stale - /// resourceVersion in a `test` op → the whole patch is rejected, - /// HTTP 422, and none of the following ops apply) — and it's still - /// the `patch` verb, so no RBAC widening is needed. - /// - Field removal still works here (unlike Server-Side-Apply, whose - /// merge semantics never remove an absent key) via an explicit - /// `remove` op per dropped key. - pub async fn mutate_secret_keys( - &self, - namespace: &str, - secret: &str, - mutate: impl Fn(&mut std::collections::BTreeMap<String, String>), - ) -> Result<(), kube::Error> { - self.mutate_integration(namespace, secret, mutate).await - } - - /// Decode a Secret's `data` (+ any pending `stringData`) into a flat map, - /// the shared helper behind both `read_secret_all` and the CAS loop above. - fn decode_secret_data( - s: &k8s_openapi::api::core::v1::Secret, - ) -> std::collections::BTreeMap<String, String> { - let mut out = std::collections::BTreeMap::new(); - if let Some(d) = s.data.as_ref() { - for (k, v) in d { - if let Ok(s) = String::from_utf8(v.0.clone()) { - out.insert(k.clone(), s); - } - } - } - if let Some(d) = s.string_data.as_ref() { - for (k, v) in d { - out.insert(k.clone(), v.clone()); - } - } - out - } - - /// The workload-identity client-id wired onto the sandbox/controller service - /// account, if any — evidence the cluster can obtain managed-identity tokens - /// (the same path Foundry data-plane access uses). `None` when not wired. - pub async fn workload_identity_client_id(&self) -> Option<String> { - use k8s_openapi::api::core::v1::ServiceAccount; - let api: Api<ServiceAccount> = Api::namespaced(self.client.clone(), "kars-system"); - for sa in ["kars-controller", "default"] { - if let Some(obj) = api.get_opt(sa).await.ok().flatten() - && let Some(cid) = obj - .metadata - .annotations - .as_ref() - .and_then(|a| a.get("azure.workload.identity/client-id")) - .filter(|v| !v.is_empty()) - { - return Some(cid.clone()); - } - } - None - } - - pub async fn get_kind( - &self, - namespace: &str, - kind: &str, - name: &str, - ) -> Result<Option<DynamicObject>, kube::Error> { - let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); - api.get_opt(name).await - } - - /// Model pinned to the persistent Bridge composer sandbox. Composition calls - /// must use this route rather than an unrelated cluster-default deployment. - pub async fn bridge_orchestrator_model(&self) -> Option<String> { - self.get_kind( - "kars-system", - "InferencePolicy", - "bridge-orchestrator-inference", - ) - .await - .ok() - .flatten()? - .data - .pointer("/spec/modelPreference/primary/deployment") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - .filter(|model| !model.trim().is_empty()) - } - - pub async fn configure_bridge_orchestrator_model( - &self, - provider: &str, - deployment: &str, - ) -> Result<(), String> { - let policy_ready = |policy: &DynamicObject| { - let generation_matches = policy - .data - .pointer("/status/observedGeneration") - .and_then(serde_json::Value::as_i64) - == policy.metadata.generation; - generation_matches - && policy - .data - .pointer("/spec/modelPreference/primary/provider") - .and_then(serde_json::Value::as_str) - == Some(provider) - && policy - .data - .pointer("/spec/modelPreference/primary/deployment") - .and_then(serde_json::Value::as_str) - == Some(deployment) - && policy - .data - .pointer("/status/conditions") - .and_then(serde_json::Value::as_array) - .is_some_and(|conditions| { - conditions.iter().any(|condition| { - condition.get("type").and_then(serde_json::Value::as_str) - == Some("Ready") - && condition.get("status").and_then(serde_json::Value::as_str) - == Some("True") - }) - }) - }; - if self - .get_kind( - "kars-system", - "InferencePolicy", - "bridge-orchestrator-inference", - ) - .await - .map_err(|error| error.to_string())? - .as_ref() - .is_some_and(&policy_ready) - { - return Ok(()); - } - self.merge_patch_kind( - "kars-system", - "InferencePolicy", - "bridge-orchestrator-inference", - serde_json::json!({ - "spec": { - "modelPreference": { - "primary": { - "provider": provider, - "deployment": deployment - }, - "fallback": [] - } - } - }), - ) - .await - .map_err(|error| error.to_string())?; - let revision = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis().to_string()) - .unwrap_or_else(|_| format!("{provider}:{deployment}")); - self.merge_patch_kind( - "kars-system", - "KarsSandbox", - "bridge-orchestrator", - serde_json::json!({ - "metadata": { - "annotations": { - "kars.azure.com/orchestrator-model-revision": revision - } - } - }), - ) - .await - .map_err(|error| error.to_string())?; - // Changing the mounted policy deliberately rolls the orchestrator pod. - // Wait for the controller's exact generation + router-echo confirmation, - // not merely the policy write or a fixed short pod-start assumption. - for _ in 0..ORCHESTRATOR_POLICY_READY_ATTEMPTS { - let ready = self - .get_kind( - "kars-system", - "InferencePolicy", - "bridge-orchestrator-inference", - ) - .await - .map_err(|error| error.to_string())? - .as_ref() - .is_some_and(&policy_ready); - if ready { - return Ok(()); - } - tokio::time::sleep(ORCHESTRATOR_POLICY_POLL_INTERVAL).await; - } - Err(format!( - "timed out waiting for the bridge orchestrator router to enforce {provider}/{deployment}" - )) - } - - /// The model every team run inherits when its blueprint pins none — the - /// controller's `KARS_TASK_DEFAULT_MODEL` env (see - /// `controller/src/kars_task_execution.rs::default_model`). Read live from - /// the `kars-controller` Deployment so the Bridge shows the *effective* - /// model, not a hardcoded guess. `None` when the controller isn't found or - /// the env is unset (the caller then labels it generically). - pub async fn controller_default_model(&self) -> Option<String> { - use k8s_openapi::api::apps::v1::Deployment; - let api: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); - let dep = api.get_opt("kars-controller").await.ok().flatten()?; - let containers = dep.spec?.template.spec?.containers; - for c in containers { - for env in c.env.unwrap_or_default() { - if env.name == "KARS_TASK_DEFAULT_MODEL" - && let Some(v) = env.value.filter(|v| !v.is_empty()) - { - return Some(v); - } - } - } - None - } - - // ─── Local (in-cluster) inference — AI Runway ModelDeployment ─────────── - // See docs/local-inference.md. kars does NOT install AI Runway/KAITO — - // an operator does that once via their own helm/kubectl, exactly like the - // GitHub App or Azure AI Foundry connection. kars-bridge only detects - // presence and manages `ModelDeployment` objects on top, in a namespace it - // owns (LOCAL_INFERENCE_NAMESPACE), never anyone else's. - - /// Whether AI Runway's `ModelDeployment` CRD is installed in this - /// cluster. A cheap, read-only check (list with a 1-item limit) — the - /// Bridge already holds `customresourcedefinitions: get/list` (used for - /// CRD-schema introspection elsewhere), so this needs no new RBAC beyond - /// the narrow `modeldeployments.airunway.ai` grant added alongside it. - pub async fn local_inference_available(&self) -> bool { - let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = - Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); - api.list(&ListParams::default().limit(1)).await.is_ok() - } - - /// Server-Side Apply a `ModelDeployment` (create-or-update), namespaced to - /// `LOCAL_INFERENCE_NAMESPACE`. Mirrors `apply_kind`'s shape but targets - /// AI Runway's own API group instead of `kars.azure.com`. Ensures the - /// namespace exists first — the Bridge's own namespace, never created by - /// AI Runway/KAITO's install, so this is the one place it needs to. - pub async fn apply_model_deployment( - &self, - name: &str, - spec: serde_json::Value, - ) -> Result<DynamicObject, kube::Error> { - let namespaces: Api<k8s_openapi::api::core::v1::Namespace> = Api::all(self.client.clone()); - if let Some(namespace) = namespaces.get_opt(LOCAL_INFERENCE_NAMESPACE).await? { - if namespace.metadata.deletion_timestamp.is_some() { - return Err(super::credentials::failure( - "Local inference namespace is terminating", - )); - } - } else { - let namespace = serde_json::from_value(serde_json::json!({ - "apiVersion":"v1","kind":"Namespace","metadata":{"name":LOCAL_INFERENCE_NAMESPACE, - "labels":{"app.kubernetes.io/managed-by":"kars-bridge"}} - })) - .map_err(|_| { - super::credentials::failure("Local inference namespace metadata invalid") - })?; - namespaces - .create(&kube::api::PostParams::default(), &namespace) - .await?; - } - let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = - Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); - let obj: DynamicObject = serde_json::from_value(serde_json::json!({ - "apiVersion": "airunway.ai/v1alpha1", - "kind": "ModelDeployment", - "metadata": { - "name": name, - "namespace": LOCAL_INFERENCE_NAMESPACE, - "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}, - }, - "spec": spec, - })) - .map_err(|e| { - kube::Error::Api(kube::core::ErrorResponse { - status: "Failure".into(), - message: e.to_string(), - reason: "BadRequest".into(), - code: 400, - }) - })?; - api.patch( - name, - &kube::api::PatchParams::apply("kars-bridge").force(), - &kube::api::Patch::Apply(&obj), - ) - .await - } - - /// List every `ModelDeployment` in the cluster. Discovery is read-only - /// across namespaces so an existing operator-managed AI Runway deployment - /// is visible without being recreated under `kars-local-inference`. - pub async fn list_model_deployments(&self) -> Result<Vec<DynamicObject>, kube::Error> { - let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); - Ok(api.list(&ListParams::default()).await?.items) - } - - /// Read one `ModelDeployment`'s current state (status included). - pub async fn get_model_deployment( - &self, - name: &str, - ) -> Result<Option<DynamicObject>, kube::Error> { - let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = - Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); - api.get_opt(name).await - } - - /// Delete a `ModelDeployment` (foreground — the provider controller's - /// owner-referenced `Workspace`/pods/Service cascade with it). - pub async fn delete_model_deployment(&self, name: &str) -> Result<(), kube::Error> { - let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); - let ar = ApiResource::from_gvk(&gvk); - let api: Api<DynamicObject> = - Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); - api.delete(name, &kube::api::DeleteParams::foreground()) - .await?; - Ok(()) - } - - /// Real-capacity GPU node scan (read-only `nodes: get/list`) so the - /// wizard can offer GPU-tier models only when the cluster can actually - /// schedule them — never a hardcoded guess. Returns the count of - /// schedulable nodes advertising `nvidia.com/gpu` capacity and the - /// distinct GPU product names found (from the `nvidia.com/gpu.product` - /// NFD/GPU-feature-discovery label, when present). - pub async fn gpu_node_summary(&self) -> Result<GpuNodeSummary, kube::Error> { - use k8s_openapi::api::core::v1::Node; - let api: Api<Node> = Api::all(self.client.clone()); - let nodes = api.list(&ListParams::default()).await?; - let mut gpu_node_count = 0u32; - let mut products = std::collections::BTreeSet::new(); - for n in &nodes.items { - let has_gpu = n - .status - .as_ref() - .and_then(|s| s.capacity.as_ref()) - .map(|c| c.contains_key("nvidia.com/gpu")) - .unwrap_or(false); - if has_gpu { - gpu_node_count += 1; - if let Some(product) = n - .metadata - .labels - .as_ref() - .and_then(|l| l.get("nvidia.com/gpu.product")) - { - products.insert(product.clone()); - } - } - } - Ok(GpuNodeSummary { - gpu_node_count, - gpu_products: products.into_iter().collect(), - }) - } - - /// Rich, LIVE status for one in-flight (or settled) local model deploy — - /// what powers the deploy progress tracker's percentage + activity feed. - /// Sourced entirely from real cluster signals (no synthetic spinner): - /// • the `ModelDeployment` CR's ordered `status.conditions` + phase, - /// • the KAITO pod(s) selected by `airunway.ai/model-deployment=<name>` - /// (container waiting reason / running / ready), and - /// • the namespace's Kubernetes Events for those pods (Pulling, Pulled, - /// Failed, BackOff, Started, …) — the actual activity feed. - /// The percentage is milestone-derived (validated → workspace → scheduled - /// → image pulled → running), so it only advances on real progress. - pub async fn local_deployment_live_status( - &self, - name: &str, - ) -> Result<LocalDeployLiveStatus, kube::Error> { - use k8s_openapi::api::core::v1::{Event, Pod}; - - let cr = self.get_model_deployment(name).await?; - let mut status = LocalDeployLiveStatus { - name: name.to_string(), - found: cr.is_some(), - ..Default::default() - }; - if let Some(cr) = &cr { - let st = cr.data.get("status"); - status.phase = st - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .map(str::to_string); - status.message = st - .and_then(|s| s.get("message")) - .and_then(|m| m.as_str()) - .map(str::to_string); - if let Some(reps) = st.and_then(|s| s.get("replicas")) { - status.replicas_desired = - reps.get("desired").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - status.replicas_ready = - reps.get("ready").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - } - if let Some(conds) = st - .and_then(|s| s.get("conditions")) - .and_then(|c| c.as_array()) - { - for c in conds { - status.conditions.push(DeployCondition { - cond_type: c - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - status: c - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - reason: c - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - message: c - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - }); - } - } - } - - // Pod(s) for this deployment (KAITO stamps airunway.ai/model-deployment). - let pods_api: Api<Pod> = Api::namespaced(self.client.clone(), LOCAL_INFERENCE_NAMESPACE); - let lp = ListParams::default().labels(&format!("airunway.ai/model-deployment={name}")); - let mut pod_names: Vec<String> = Vec::new(); - if let Ok(pods) = pods_api.list(&lp).await { - for p in &pods.items { - let pod_name = p.metadata.name.clone().unwrap_or_default(); - pod_names.push(pod_name.clone()); - let phase = p - .status - .as_ref() - .and_then(|s| s.phase.clone()) - .unwrap_or_default(); - let mut ready = false; - let mut waiting_reason: Option<String> = None; - let mut waiting_message: Option<String> = None; - let mut running = false; - if let Some(cs) = p - .status - .as_ref() - .and_then(|s| s.container_statuses.as_ref()) - { - for c in cs { - ready = ready || c.ready; - if let Some(state) = &c.state { - if let Some(w) = &state.waiting { - waiting_reason = w.reason.clone(); - waiting_message = w.message.clone(); - } - if state.running.is_some() { - running = true; - } - } - } - } - status.pods.push(DeployPodState { - name: pod_name, - phase, - ready, - running, - waiting_reason, - waiting_message, - }); - } - } - - // Real Kubernetes events for the CR + its pods — the live activity feed. - let events_api: Api<Event> = - Api::namespaced(self.client.clone(), LOCAL_INFERENCE_NAMESPACE); - if let Ok(events) = events_api.list(&ListParams::default()).await { - for e in &events.items { - let obj = e.involved_object.name.clone().unwrap_or_default(); - if obj != name && !pod_names.contains(&obj) { - continue; - } - let time = e - .last_timestamp - .as_ref() - .map(|t| t.0.to_rfc3339()) - .or_else(|| e.event_time.as_ref().map(|t| t.0.to_rfc3339())); - status.activities.push(DeployActivity { - time, - reason: e.reason.clone().unwrap_or_default(), - message: e.message.clone().unwrap_or_default(), - event_type: e.type_.clone().unwrap_or_default(), - count: e.count.unwrap_or(1), - }); - } - // Oldest → newest so the feed reads like a log. - status.activities.sort_by(|a, b| a.time.cmp(&b.time)); - } - - // Terminal failure detection from real pod container state. - for p in &status.pods { - if let Some(reason) = &p.waiting_reason - && matches!( - reason.as_str(), - "ImagePullBackOff" - | "ErrImagePull" - | "CrashLoopBackOff" - | "CreateContainerError" - | "InvalidImageName" - ) - { - status.failed = true; - status.failure_reason = Some(reason.clone()); - status.failure_message = p.waiting_message.clone(); - } - } - status.ready = status.phase.as_deref() == Some("Running") - || (status.replicas_desired > 0 && status.replicas_ready >= status.replicas_desired); - - // Milestone-derived percentage — advances only on real progress. - status.percent = compute_deploy_percent(&status); - Ok(status) - } -} - /// One `ModelDeployment.status.conditions[]` entry, browser-facing. #[derive(Debug, Clone, Default, serde::Serialize)] pub struct DeployCondition { @@ -3760,43 +140,6 @@ pub struct LocalDeployLiveStatus { pub activities: Vec<DeployActivity>, } -/// Milestone-derived deploy percentage from real signals. Each milestone the -/// deployment has genuinely reached sets a floor; nothing here advances on a -/// timer alone (the client adds a small time-based ease WITHIN the current -/// band for visible motion, but never past the next real milestone). -fn compute_deploy_percent(s: &LocalDeployLiveStatus) -> u8 { - if s.ready { - return 100; - } - let cond_true = |t: &str| { - s.conditions - .iter() - .any(|c| c.cond_type == t && c.status == "True") - }; - let mut pct: u8 = if s.found { 8 } else { 3 }; - if cond_true("Validated") { - pct = pct.max(15); - } - if cond_true("ProviderSelected") || cond_true("ProviderCompatible") { - pct = pct.max(25); - } - if cond_true("ResourceCreated") { - pct = pct.max(38); - } - // Pod exists & scheduled (has a phase beyond nothing). - if s.pods - .iter() - .any(|p| !p.phase.is_empty() && p.phase != "Unknown") - { - pct = pct.max(52); - } - // Container running (image pulled, process started) but not yet Ready. - if s.pods.iter().any(|p| p.running) { - pct = pct.max(88); - } - pct -} - /// Namespace the Bridge creates its own `ModelDeployment` objects in — never /// the operator's `default` or the AI Runway/KAITO system namespaces, so /// listing is naturally scoped to what the Bridge itself manages. @@ -3808,423 +151,20 @@ pub struct GpuNodeSummary { pub gpu_products: Vec<String>, } -#[cfg(test)] -mod provider_tests { - use super::{ - classify_provider, descendant_sandbox_objects, image_registry_host, mission_evidence_key, - mission_output_candidate, normalize_registry_host, project_mission_output_record, - public_registry, select_mission_evidence_records, select_mission_output_records, - trace_record_identity, - }; - use k8s_openapi::api::core::v1::ConfigMap; - use kube::api::DynamicObject; - use serde_json::json; - use std::collections::BTreeMap; - - fn id(r: Option<(String, String, String)>) -> Option<String> { - r.map(|(i, _, _)| i) - } - - #[test] - fn descendant_sandboxes_include_nested_agents_once() { - let sandbox = |name: &str, parent: Option<&str>| -> DynamicObject { - serde_json::from_value(json!({ - "apiVersion": "kars.azure.com/v1alpha1", - "kind": "KarsSandbox", - "metadata": { - "name": name, - "labels": parent.map(|parent| json!({"kars.azure.com/parent": parent})) - } - })) - .expect("sandbox") - }; - let items = vec![ - sandbox("child", Some("root")), - sandbox("grandchild", Some("child")), - sandbox("unrelated", Some("other")), - ]; - - let names = descendant_sandbox_objects(&items, "root") - .into_iter() - .filter_map(|sandbox| sandbox.metadata.name) - .collect::<std::collections::HashSet<_>>(); - assert_eq!( - names, - std::collections::HashSet::from(["child".to_string(), "grandchild".to_string(),]) - ); - } - - #[test] - fn registry_matching_covers_private_runtime_images() { - assert_eq!( - image_registry_host("example.azurecr.io/kars-runtime-hermes:latest"), - "example.azurecr.io" - ); - assert_eq!( - normalize_registry_host("https://example.azurecr.io/v1/"), - "example.azurecr.io" - ); - assert!(!public_registry("example.azurecr.io")); - assert!(public_registry("mcr.microsoft.com")); - } - - #[test] - fn mission_evidence_annotation_restores_long_nonce_identity() { - let full = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; - let mut config_map = ConfigMap::default(); - config_map.metadata.annotations = Some(BTreeMap::from([( - "kars.azure.com/mission-evidence-key".to_string(), - full.to_string(), - )])); - config_map.metadata.labels = Some(BTreeMap::from([( - "kars.azure.com/mission-output".to_string(), - "stock-monitor-persistent-qual-principal-assig-0123456789ab".to_string(), - )])); - - assert_eq!( - mission_evidence_key(&config_map, "kars.azure.com/mission-output").as_deref(), - Some(full) - ); - } - - #[test] - fn mission_evidence_label_remains_legacy_fallback() { - let mut config_map = ConfigMap::default(); - config_map.metadata.labels = Some(BTreeMap::from([( - "kars.azure.com/mission-output".to_string(), - "team-run-100".to_string(), - )])); - - assert_eq!( - mission_evidence_key(&config_map, "kars.azure.com/mission-output").as_deref(), - Some("team-run-100") - ); - } - - #[test] - fn legacy_principal_label_restores_stable_task_name() { - let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; - let mut config_map = ConfigMap::default(); - config_map.metadata.labels = Some(BTreeMap::from([ - ( - "kars.azure.com/mission-output".to_string(), - nonce.to_string(), - ), - ( - "kars.azure.com/mission-principal".to_string(), - "stock-monitor-persistent-qual-principal".to_string(), - ), - ])); - config_map.data = Some(BTreeMap::from([( - "assignmentNonce".to_string(), - nonce.to_string(), - )])); - - let (_, _, data) = mission_output_candidate(config_map).expect("candidate"); - assert_eq!( - data.get("taskName").map(String::as_str), - Some("stock-monitor-persistent-qual-principal") - ); - } - - #[test] - fn ordinary_mission_enumeration_keeps_the_task_pointer() { - let first_nonce = "run-1784912062312097896"; - let latest_nonce = "run-1784915840189332732"; - let first = BTreeMap::from([ - ("assignmentNonce".to_string(), first_nonce.to_string()), - ("taskName".to_string(), "kompli-research".to_string()), - ]); - let latest = BTreeMap::from([ - ("assignmentNonce".to_string(), latest_nonce.to_string()), - ("taskName".to_string(), "kompli-research".to_string()), - ]); - let selected = select_mission_output_records(vec![ - (first_nonce.to_string(), Some("archive".to_string()), first), - ( - latest_nonce.to_string(), - Some("archive".to_string()), - latest.clone(), - ), - ( - "kompli-research".to_string(), - Some("current".to_string()), - latest, - ), - ]); - - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].0, "kompli-research"); - } - - #[test] - fn explicit_current_pointer_beats_legacy_archive_for_same_task() { - let old_nonce = "rev-1"; - let latest_nonce = "rev-2"; - let legacy = BTreeMap::from([ - ("assignmentNonce".to_string(), old_nonce.to_string()), - ("taskName".to_string(), "kompli-research".to_string()), - ]); - let current = BTreeMap::from([ - ("assignmentNonce".to_string(), latest_nonce.to_string()), - ("taskName".to_string(), "kompli-research".to_string()), - ]); - let selected = select_mission_output_records(vec![ - (old_nonce.to_string(), None, legacy), - ( - "kompli-research".to_string(), - Some("current".to_string()), - current, - ), - ]); - - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].0, "kompli-research"); - } - - #[test] - fn legacy_ordinary_run_archives_do_not_become_phantom_tasks() { - let old_nonce = "run-1784912062312097896"; - let latest_nonce = "run-1784915840189332732"; - let mut archive = ConfigMap::default(); - archive.metadata.labels = Some(BTreeMap::from([ - ( - "kars.azure.com/mission-output".to_string(), - old_nonce.to_string(), - ), - ( - "kars.azure.com/mission-principal".to_string(), - "kompli-research".to_string(), - ), - ])); - archive.data = Some(BTreeMap::from([( - "assignmentNonce".to_string(), - old_nonce.to_string(), - )])); - let mut current = ConfigMap::default(); - current.metadata.labels = Some(BTreeMap::from([( - "kars.azure.com/mission-output".to_string(), - "kompli-research".to_string(), - )])); - current.data = Some(BTreeMap::from([( - "assignmentNonce".to_string(), - latest_nonce.to_string(), - )])); - let selected = select_mission_output_records(vec![ - mission_output_candidate(archive).expect("archive"), - mission_output_candidate(current).expect("current"), - ]); - - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].0, "kompli-research"); - } - - #[test] - fn persistent_team_latest_enumeration_keeps_the_current_pointer() { - let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; - let data = BTreeMap::from([ - ("assignmentNonce".to_string(), nonce.to_string()), - ( - "taskName".to_string(), - "stock-monitor-persistent-qual-principal".to_string(), - ), - ( - "team".to_string(), - "stock-monitor-persistent-qual".to_string(), - ), - ]); - let selected = select_mission_output_records(vec![ - (nonce.to_string(), Some("archive".to_string()), data.clone()), - ( - "stock-monitor-persistent-qual-principal".to_string(), - Some("current".to_string()), - data, - ), - ]); - - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].0, "stock-monitor-persistent-qual-principal"); - } - - #[test] - fn accounting_enumeration_keeps_archives_and_drops_current_pointers() { - let nonce = "run-1784915840189332732"; - let data = BTreeMap::from([ - ("assignmentNonce".to_string(), nonce.to_string()), - ("taskName".to_string(), "kompli-research".to_string()), - ]); - let selected = select_mission_evidence_records(vec![ - (nonce.to_string(), Some("archive".to_string()), data.clone()), - ( - "kompli-research".to_string(), - Some("current".to_string()), - data, - ), - ]); - - assert_eq!(selected.len(), 1); - assert_eq!(selected[0].0, nonce); - } - - #[test] - fn accounting_enumeration_keeps_each_rerun_archive() { - let first_nonce = "rev-1"; - let latest_nonce = "rev-2"; - let selected = select_mission_evidence_records(vec![ - ( - first_nonce.to_string(), - Some("archive".to_string()), - BTreeMap::from([("assignmentNonce".to_string(), first_nonce.to_string())]), - ), - ( - latest_nonce.to_string(), - Some("archive".to_string()), - BTreeMap::from([("assignmentNonce".to_string(), latest_nonce.to_string())]), - ), - ( - "kompli-research".to_string(), - Some("current".to_string()), - BTreeMap::from([("assignmentNonce".to_string(), latest_nonce.to_string())]), - ), - ]); - - assert_eq!(selected.len(), 2); - assert!(selected.iter().any(|(key, _)| key == first_nonce)); - assert!(selected.iter().any(|(key, _)| key == latest_nonce)); - } - - #[test] - fn persistent_archive_projects_stable_task_and_separate_evidence_key() { - let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; - let data = BTreeMap::from([ - ("assignmentNonce".to_string(), nonce.to_string()), - ( - "taskName".to_string(), - "stock-monitor-persistent-qual-principal".to_string(), - ), - ( - "team".to_string(), - "stock-monitor-persistent-qual".to_string(), - ), - ]); - let projected = project_mission_output_record(nonce.to_string(), data); - - assert_eq!( - projected.task_name, - "stock-monitor-persistent-qual-principal" - ); - assert_eq!(projected.evidence_key, nonce); - } - - #[test] - fn mirrored_trace_records_share_one_counting_identity() { - let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; - let data = BTreeMap::from([ - ("assignmentNonce".to_string(), nonce.to_string()), - ( - "trace.json".to_string(), - r#"[{"kind":"round"}]"#.to_string(), - ), - ("capturedAt".to_string(), "2026-07-24T19:00:00Z".to_string()), - ]); - let mut archive = ConfigMap::default(); - archive.metadata.name = Some(format!("kars-mission-trace-{nonce}")); - archive.metadata.annotations = Some(BTreeMap::from([( - "kars.azure.com/mission-evidence-role".to_string(), - "archive".to_string(), - )])); - archive.data = Some(data.clone()); - let mut current = ConfigMap::default(); - current.metadata.name = - Some("kars-mission-trace-stock-monitor-persistent-qual-principal".to_string()); - current.metadata.annotations = Some(BTreeMap::from([( - "kars.azure.com/mission-evidence-role".to_string(), - "current".to_string(), - )])); - current.data = Some(data); - - assert!(trace_record_identity(&archive).is_some()); - assert!(trace_record_identity(¤t).is_none()); - } - - #[test] - fn explicit_override_wins() { - let eps = vec!["https://models.github.ai/inference".to_string()]; - assert_eq!( - id(classify_provider(Some("github-copilot"), &eps, None)).as_deref(), - Some("github-copilot") - ); - assert_eq!( - id(classify_provider( - Some("github-models"), - &eps, - Some("gho_x") - )) - .as_deref(), - Some("github-models") - ); - assert_eq!( - id(classify_provider(Some("foundry"), &[], None)).as_deref(), - Some("azure-foundry") - ); - } - - #[test] - fn github_endpoint_with_oauth_token_is_copilot() { - // The real localkarstest shape: models.github.ai + a gho_ OAuth token. - let eps = vec!["https://models.github.ai/inference".to_string()]; - assert_eq!( - id(classify_provider(None, &eps, Some("gho_"))).as_deref(), - Some("github-copilot") - ); - assert_eq!( - id(classify_provider(None, &eps, Some("ghu_"))).as_deref(), - Some("github-copilot") - ); - } - - #[test] - fn github_endpoint_with_pat_is_models() { - let eps = vec!["https://models.github.ai/inference".to_string()]; - assert_eq!( - id(classify_provider(None, &eps, Some("ghp_"))).as_deref(), - Some("github-models") - ); - assert_eq!( - id(classify_provider(None, &eps, None)).as_deref(), - Some("github-models") - ); - } - - #[test] - fn copilot_endpoint_is_copilot() { - let eps = vec!["https://api.githubcopilot.com".to_string()]; - assert_eq!( - id(classify_provider(None, &eps, None)).as_deref(), - Some("github-copilot") - ); - } - - #[test] - fn foundry_endpoint_and_empty() { - let eps = vec!["https://my-proj.openai.azure.com".to_string()]; - assert_eq!( - id(classify_provider(None, &eps, None)).as_deref(), - Some("azure-foundry") - ); - assert_eq!(id(classify_provider(None, &[], None)), None); +impl Cluster { + #[cfg(test)] + pub(crate) fn for_test_client(client: Client) -> Self { + Self { client } } - #[test] - fn local_inference_endpoint_is_not_mislabeled_as_foundry() { - // A promoted local model's endpoint is always a Service DNS name in - // the Bridge-owned kars-local-inference namespace — must be labeled - // distinctly, not fall into the generic Foundry bucket every other - // unrecognized endpoint gets. - let eps = vec!["http://my-model.kars-local-inference.svc.cluster.local:80".to_string()]; - assert_eq!( - id(classify_provider(None, &eps, None)).as_deref(), - Some("local-inference") - ); + /// Build a cluster handle from the ambient configuration (kubeconfig + /// locally, in-cluster ServiceAccount in production). Returns `None`-style + /// errors as `anyhow` so the readiness probe can report honest status. + pub async fn connect() -> anyhow::Result<Self> { + let client = Client::try_default().await?; + Ok(Self { client }) } } + +#[cfg(test)] +mod provider_tests; diff --git a/bridge/bff/src/kars/cluster/configuration.rs b/bridge/bff/src/kars/cluster/configuration.rs new file mode 100644 index 000000000..b15a2fe4e --- /dev/null +++ b/bridge/bff/src/kars/cluster/configuration.rs @@ -0,0 +1,392 @@ +use super::Cluster; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::api::{Api, ListParams}; + +impl Cluster { + /// Read the operator-curated MCP profiles (named vetted server bundles), + /// stored as `profiles.json` in the `kars-mcp-profiles` ConfigMap. Returns + /// `[]` when unset. A profile is `{name, summary, servers:[mcpserver names]}`. + pub async fn read_mcp_profiles(&self) -> String { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt("kars-mcp-profiles") + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| d.get("profiles.json").cloned()) + .unwrap_or_else(|| "[]".to_string()) + } + + /// Persist the operator-curated MCP profiles (server-side apply). + pub async fn write_mcp_profiles(&self, profiles_json: &str) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { "name": "kars-mcp-profiles", "labels": { "app.kubernetes.io/managed-by": "kars-bridge" } }, + "data": { "profiles.json": profiles_json }, + }); + cms.patch( + "kars-mcp-profiles", + &PatchParams::apply("kars-bridge/mcp-profiles").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Persist a skill PACKAGE's files as the `karsskill-<name>` ConfigMap in + /// kars-system. Each entry is `<flat filename> -> <content>` (SKILL.md + + /// scripts). The controller mirrors this ConfigMap into a granting sandbox's + /// namespace and mounts it into the agent's skills dir. + pub async fn write_skill_package( + &self, + skill_name: &str, + files: &std::collections::BTreeMap<String, String>, + package_digest: &str, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let cm_name = format!("karsskill-{skill_name}"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": cm_name, + "labels": { + "app.kubernetes.io/managed-by": "kars-bridge", + "kars.azure.com/skill": skill_name, + }, + "annotations": { + "kars.azure.com/package-digest": package_digest, + }, + }, + "data": files, + }); + cms.patch( + &cm_name, + &PatchParams::apply("kars-bridge/skill-package").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Read a team's knowledge-commons ConfigMap (`kars-commons-<commons>`) from + /// the controller namespace. Returns the parsed index + raw entry content. + pub async fn read_commons( + &self, + commons: &str, + ) -> Option<std::collections::BTreeMap<String, String>> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let cm = cms + .get_opt(&format!("kars-commons-{commons}")) + .await + .ok()??; + cm.data + } + + /// Read a team's task backlog (raw `tasks.json`, or `[]` when unset). Shared + /// with the controller: the ConfigMap `kars-team-tasks-<team>` is the durable + /// queue the Bridge appends to and the controller drains. + pub async fn read_team_tasks(&self, team: &str) -> String { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt(&format!("kars-team-tasks-{team}")) + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| d.get("tasks.json").cloned()) + .unwrap_or_else(|| "[]".to_string()) + } + + /// Read the hierarchical inference-budget config (`kars-inference-budgets` + /// ConfigMap, key `budgets.json`). Returns the raw JSON string, or `"{}"` + /// when unset — the budgets route parses it into the typed hierarchy. + pub async fn read_inference_budgets(&self) -> String { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt("kars-inference-budgets") + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| d.get("budgets.json").cloned()) + .unwrap_or_else(|| "{}".to_string()) + } + + /// Persist the hierarchical inference-budget config (server-side apply). + pub async fn write_inference_budgets(&self, budgets_json: &str) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "kars-inference-budgets", + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + }, + "data": { "budgets.json": budgets_json }, + }); + cms.patch( + "kars-inference-budgets", + &PatchParams::apply("kars-bridge/inference-budgets").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Read the cluster-wide retention-policy default (`kars-retention-policy` + /// ConfigMap, key `defaultTtlSeconds`) the controller's KarsTask retention + /// reconciler reads. `0`/absent means "never auto-delete" (the safe + /// default). Returns `0` on any read failure. + pub async fn read_retention_policy(&self) -> i64 { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt("kars-retention-policy") + .await + .ok() + .flatten() + .and_then(|cm| cm.data) + .and_then(|d| { + d.get("defaultTtlSeconds") + .and_then(|v| v.parse::<i64>().ok()) + }) + .unwrap_or(0) + } + + /// Persist the cluster-wide retention-policy default (server-side apply). + pub async fn write_retention_policy(&self, ttl_seconds: i64) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": "kars-retention-policy", + "labels": { "app.kubernetes.io/managed-by": "kars-bridge" }, + }, + "data": { "defaultTtlSeconds": ttl_seconds.to_string() }, + }); + cms.patch( + "kars-retention-policy", + &PatchParams::apply("kars-bridge/retention-policy").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Read a ConfigMap's `data` map from `kars-system` (e.g. the receipt + /// inclusion-log signed checkpoint). Returns `None` when absent. + pub async fn configmap_data( + &self, + name: &str, + ) -> Option<std::collections::BTreeMap<String, String>> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + cms.get_opt(name).await.ok().flatten().and_then(|c| c.data) + } + + /// Like `configmap_data` but distinguishes a genuine API error from an absent + /// ConfigMap: `Ok(None)` means "not found", `Err` means the read actually + /// failed. Use on critical read-modify-write paths so a transient cluster + /// error can't be mistaken for "no prior data" and silently clobber it. + pub async fn configmap_data_result( + &self, + name: &str, + ) -> Result<Option<std::collections::BTreeMap<String, String>>, kube::Error> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + Ok(cms.get_opt(name).await?.and_then(|c| c.data)) + } + + /// List `kars-system` ConfigMaps matching a label selector, retaining each + /// object name so callers can verify ordered segmented stores. + pub async fn configmaps_data_by_label( + &self, + selector: &str, + ) -> Result<Vec<(String, std::collections::BTreeMap<String, String>)>, kube::Error> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + Ok(cms + .list(&ListParams::default().labels(selector)) + .await? + .items + .into_iter() + .filter_map(|cm| Some((cm.metadata.name?, cm.data.unwrap_or_default()))) + .collect()) + } + + /// Read-modify-write a `kars-system` ConfigMap's `data` under OPTIMISTIC + /// CONCURRENCY: a single atomic JSON Patch (RFC 6902) — a `test` op + /// asserting `resourceVersion` hasn't moved, followed by `add`/`remove` + /// ops for the actual data changes — retried on failure. This makes + /// concurrent writers serialize instead of silently clobbering each other + /// (an SSA `force` apply of the whole `data` drops the other writer's + /// fields; a `replace()`/PUT needs the `update` RBAC verb, which the + /// BFF's ClusterRole never grants — confirmed live against the real + /// ServiceAccount: a PUT-based CAS here 403s in an RBAC-enforced + /// deployment. See `mutate_secret_keys` for the full rationale, including + /// why a plain JSON *merge* patch alone can't do this: K8s doesn't honor + /// `resourceVersion` as a precondition for merge patches, only for + /// `test`-op JSON Patches, SSA, and PUT). Use for any read-append-write + /// on a shared ConfigMap (e.g. review history). + pub async fn update_configmap_data<F>( + &self, + name: &str, + labels: &[(&str, &str)], + mut modify: F, + ) -> Result<(), kube::Error> + where + F: FnMut(&mut std::collections::BTreeMap<String, String>), + { + use k8s_openapi::api::core::v1::ConfigMap; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use kube::api::{Patch, PostParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let label_map: std::collections::BTreeMap<String, String> = labels + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + for _attempt in 0..6 { + let existing = cms.get_opt(name).await?; + let Some(current) = existing else { + // Doesn't exist yet — a JSON Patch has nothing to patch onto; + // create it fresh (a create-race surfaces as a 409 below). + let mut data = std::collections::BTreeMap::new(); + modify(&mut data); + let cm = ConfigMap { + metadata: ObjectMeta { + name: Some(name.to_string()), + labels: (!label_map.is_empty()).then(|| label_map.clone()), + ..Default::default() + }, + data: Some(data), + ..Default::default() + }; + match cms.create(&PostParams::default(), &cm).await { + Ok(_) => return Ok(()), + Err(kube::Error::Api(ae)) if ae.code == 409 => continue, + Err(e) => return Err(e), + } + }; + let Some(rv) = current.metadata.resource_version.clone() else { + continue; // no resourceVersion to pin to — re-read and retry. + }; + let before = current.data.clone().unwrap_or_default(); + let mut after = before.clone(); + modify(&mut after); + + let mut ops: Vec<json_patch::PatchOperation> = vec![json_patch::PatchOperation::Test( + json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "metadata", + "resourceVersion", + ]), + value: serde_json::Value::String(rv), + }, + )]; + if current.data.is_none() { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data"]), + value: serde_json::json!({}), + })); + } + for key in before.keys() { + if !after.contains_key(key) { + ops.push(json_patch::PatchOperation::Remove( + json_patch::RemoveOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "data", + key.as_str(), + ]), + }, + )); + } + } + for (key, value) in &after { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", key.as_str()]), + value: serde_json::Value::String(value.clone()), + })); + } + if !label_map.is_empty() { + if current.metadata.labels.is_none() { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["metadata", "labels"]), + value: serde_json::json!({}), + })); + } + for (k, v) in &label_map { + ops.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens([ + "metadata", + "labels", + k.as_str(), + ]), + value: serde_json::Value::String(v.clone()), + })); + } + } + + match cms + .patch( + name, + &kube::api::PatchParams::default(), + &Patch::Json::<ConfigMap>(json_patch::Patch(ops)), + ) + .await + { + Ok(_) => return Ok(()), + // 422 = the `test` op failed (resourceVersion moved under us, + // i.e. a real concurrent writer) — re-read and retry. 409 + // covers any other conflict (e.g. a create race). + Err(kube::Error::Api(ae)) if ae.code == 422 || ae.code == 409 => continue, + Err(e) => return Err(e), + } + } + Err(kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: format!("exhausted optimistic-concurrency retries writing {name}"), + reason: "Conflict".into(), + code: 409, + })) + } + + /// Read all team digest logs (`kars-team-digest-*`) across teams, flattened + /// and newest-first. Best-effort. + pub async fn list_team_digests(&self) -> Vec<serde_json::Value> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let lp = ListParams::default().labels("kars.azure.com/team-digest"); + let mut out: Vec<serde_json::Value> = Vec::new(); + if let Ok(list) = cms.list(&lp).await { + for cm in list.items { + if let Some(log) = cm.data.as_ref().and_then(|d| d.get("log.json")) + && let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(log) + { + out.extend(entries); + } + } + } + out.sort_by(|a, b| { + b.get("at") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(a.get("at").and_then(|v| v.as_str()).unwrap_or("")) + }); + out + } + + /// Read the receipt-signing public-key anchor published by the controller + /// to the `kars-receipt-pubkey` ConfigMap in `kars-system`. This is the + /// out-of-band trust root a verifier checks against — never a key embedded + /// in a receipt. Returns `(key_id, public_key_b64, scheme)`. + pub async fn receipt_pubkey_anchor(&self) -> Option<(String, String, String)> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let cm = cms.get_opt("kars-receipt-pubkey").await.ok().flatten()?; + let data = cm.data?; + Some(( + data.get("keyId")?.clone(), + data.get("publicKey")?.clone(), + data.get("scheme").cloned().unwrap_or_default(), + )) + } +} diff --git a/bridge/bff/src/kars/cluster/connections.rs b/bridge/bff/src/kars/cluster/connections.rs new file mode 100644 index 000000000..bae7575d3 --- /dev/null +++ b/bridge/bff/src/kars/cluster/connections.rs @@ -0,0 +1,429 @@ +use super::Cluster; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::ResourceExt; +use kube::api::Api; + +impl Cluster { + // ── Keyless git write: shared App + per-principal connections (§14) ────── + + /// The cluster-shared kars GitHub App credentials (App id + PEM private key) + /// from `Secret kars-github-app` in kars-system. `None` when the operator + /// hasn't configured the App — git write is simply off (fail-closed). + pub async fn github_app_creds(&self) -> Result<Option<(String, String)>, kube::Error> { + let (_, s) = self + .integration_store(&self.core_namespace(), "kars-github-app") + .await?; + let Some(data) = s.data else { return Ok(None) }; + let read = |k: &str| -> Option<String> { + data.get(k) + .and_then(|v| String::from_utf8(v.0.clone()).ok()) + }; + let Some(id) = read("GITHUB_APP_ID") else { + return Ok(None); + }; + let Some(key) = read("GITHUB_APP_PRIVATE_KEY") else { + return Ok(None); + }; + if id.trim().is_empty() || key.trim().is_empty() { + return Ok(None); + } + Ok(Some((id.trim().to_string(), key))) + } + + /// Read a principal's GitHub connection ConfigMap in the namespace. + pub async fn read_github_connection( + &self, + ns: &str, + connection_name: &str, + ) -> Option<(String, String, Vec<String>)> { + self.read_github_connection_result(ns, connection_name) + .await + .ok() + .flatten() + } + + /// Read a principal GitHub connection while preserving Kubernetes API + /// failures for background jobs that must report an honest source status. + pub async fn read_github_connection_result( + &self, + ns: &str, + connection_name: &str, + ) -> Result<Option<(String, String, Vec<String>)>, kube::Error> { + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns); + let Some(data) = api.get_opt(connection_name).await?.and_then(|cm| cm.data) else { + return Ok(None); + }; + let read = |key: &str| data.get(key).cloned(); + let Some(installation_id) = read("installation_id") else { + return Ok(None); + }; + let account = read("account").unwrap_or_default(); + let repos = read("repos") + .and_then(|r| serde_json::from_str::<Vec<String>>(&r).ok()) + .unwrap_or_default(); + Ok(Some((installation_id, account, repos))) + } + + /// Store a principal's GitHub connection. No token or credential is stored. + pub async fn write_github_connection( + &self, + ns: &str, + connection_name: &str, + installation_id: &str, + account: &str, + repos: &[String], + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams, PostParams}; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns); + let data = std::collections::BTreeMap::from([ + ("installation_id".to_string(), installation_id.to_string()), + ("account".to_string(), account.to_string()), + ("repos".to_string(), serde_json::to_string(repos)?), + ]); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": connection_name, + "namespace": ns, + "labels": { "app.kubernetes.io/managed-by": "kars-bridge", "kars.azure.com/github-connection": "true" }, + }, + "data": data, + }); + if let Some(current) = api.get_opt(connection_name).await? { + let grant = self.credential_grant(ns).await?; + if current.metadata.deletion_timestamp.is_some() + || current.uid().is_none() + || !grant.document.data["spec"]["githubConnections"] + .as_array() + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry["connection"]["name"] == connection_name + && entry["connection"]["uid"] + == serde_json::json!(current.metadata.uid) + }) + }) + { + anyhow::bail!( + "Existing GitHub connection requires exact operator UID enrollment before mutation; no adoption" + ); + } + api.patch(connection_name,&PatchParams::default(),&Patch::Merge(serde_json::json!({ + "metadata":{"uid":current.metadata.uid,"resourceVersion":current.metadata.resource_version},"data":data + }))).await?; + } else { + let created: ConfigMap = serde_json::from_value(patch)?; + api.create(&PostParams::default(), &created).await?; + } + Ok(()) + } + + /// Remove only the named principal GitHub connection. + pub async fn delete_github_connection( + &self, + ns: &str, + connection_name: &str, + ) -> anyhow::Result<()> { + use kube::api::{DeleteParams, Preconditions}; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), ns); + if let Some(current) = api.get_opt(connection_name).await? { + if current.uid().is_none() || current.resource_version().is_none() { + anyhow::bail!("GitHub connection identity is unavailable; no deletion"); + } + api.delete( + connection_name, + &DeleteParams { + preconditions: Some(Preconditions { + uid: current.metadata.uid, + resource_version: current.metadata.resource_version, + }), + ..Default::default() + }, + ) + .await?; + } + Ok(()) + } + + /// Which communication-channel env keys a team has configured. SECURITY: + /// returns only the *key names* (e.g. `TELEGRAM_BOT_TOKEN`), never the token + /// values — the Bridge must never echo a secret back to a browser. + pub async fn team_channel_keys( + &self, + namespace: &str, + team: &str, + ) -> Result<Vec<String>, kube::Error> { + let target = self + .credential_target(namespace, "KarsTeam", team) + .await? + .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; + self.configured_channel_keys(namespace, Some(&target)).await + } + + /// Merge channel credentials into a team's channel Secret (create if absent). + /// SECURITY: token values are written straight into a K8s Secret and are + /// never logged or returned. Existing keys not in `data` are preserved. + pub async fn merge_team_channel( + &self, + namespace: &str, + team: &str, + data: std::collections::BTreeMap<String, String>, + ) -> anyhow::Result<()> { + let target = self + .credential_target(namespace, "KarsTeam", team) + .await? + .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; + self.write_agent_credentials( + namespace, + "KarsTeam", + team, + Some(&target.uid), + data, + Vec::new(), + ) + .await?; + Ok(()) + } + + /// Remove specific channel env keys from a team's channel Secret; delete the + /// Secret entirely when no keys remain (so "disable all channels" is clean). + pub async fn remove_team_channel_keys( + &self, + namespace: &str, + team: &str, + keys: &[String], + ) -> anyhow::Result<()> { + let target = self + .credential_target(namespace, "KarsTeam", team) + .await? + .ok_or_else(|| super::credentials::failure("Team credential target does not exist"))?; + self.write_agent_credentials( + namespace, + "KarsTeam", + team, + Some(&target.uid), + std::collections::BTreeMap::new(), + keys.to_vec(), + ) + .await?; + Ok(()) + } + + // ─── Workspace-level (agent-agnostic) channels ─────────────────────────── + // The same channel model as a team's, but scoped to the WORKSPACE (secret + // `kars-workspace-channels` in kars-system), configured on the Connections + // tab. The controller propagates it into EVERY run sandbox — mission or team — + // so any agent can report over Telegram/Slack/Discord/WhatsApp. + + /// The env-key names present in the workspace channel Secret (no values). + pub async fn workspace_channel_keys( + &self, + namespace: &str, + ) -> Result<Vec<String>, kube::Error> { + let mut keys = self.configured_channel_keys(namespace, None).await?; + if self.teams_configured().await? { + keys.push("TEAMS_ENABLED".into()); + } + Ok(keys) + } + + /// Merge channel credentials into the workspace channel Secret (create if + /// absent). Token values are written straight into a K8s Secret, never logged + /// or returned. Existing keys not in `data` are preserved. + pub async fn merge_workspace_channel( + &self, + namespace: &str, + data: std::collections::BTreeMap<String, String>, + ) -> anyhow::Result<()> { + self.write_agent_credentials(namespace, "Workspace", namespace, None, data, Vec::new()) + .await?; + Ok(()) + } + + /// Remove specific channel env keys from the workspace channel Secret; delete + /// the Secret entirely when no keys remain. + pub async fn remove_workspace_channel_keys( + &self, + namespace: &str, + keys: &[String], + ) -> anyhow::Result<()> { + self.write_agent_credentials( + namespace, + "Workspace", + namespace, + None, + std::collections::BTreeMap::new(), + keys.to_vec(), + ) + .await?; + Ok(()) + } + + /// Write Teams gateway credentials into the dedicated `kars-bridge-teams` Secret. + /// This Secret is mounted ONLY by the Teams gateway pod — never propagated to + /// sandbox pods. Uses Server-Side Apply so the BFF can create-or-update idempotently. + pub async fn write_dedicated_teams_secret( + &self, + namespace: &str, + name: &str, + data: std::collections::BTreeMap<String, String>, + ) -> anyhow::Result<()> { + self.mutate_integration(namespace, name, |keys| keys.extend(data.clone())) + .await?; + Ok(()) + } + + /// Revoke Teams bot credentials while retaining the BFF-only internal + /// secret and role map required for a healthy BFF rollout. + pub async fn disable_dedicated_teams_secret( + &self, + namespace: &str, + name: &str, + ) -> anyhow::Result<()> { + self.mutate_integration(namespace, name, |keys| { + for key in ["client-id", "tenant-id", "client-secret"] { + keys.remove(key); + } + }) + .await?; + Ok(()) + } + + /// Restart BFF and enable/disable the Teams gateway so Secret and role-map + /// changes become effective immediately. + pub async fn reconcile_teams_deployments( + &self, + namespace: &str, + gateway_name: &str, + bff_name: &str, + _enabled: bool, + ) -> anyhow::Result<()> { + self.request_teams_reconcile(namespace, gateway_name, bff_name) + .await?; + Ok(()) + } + /// Upsert a Secret via server-side apply, merging keys without clobbering + /// existing ones. Used to store agent credentials (write-only); the value is + /// never read back through any endpoint. + pub async fn upsert_secret( + &self, + namespace: &str, + name: &str, + body: serde_json::Value, + ) -> Result<(), kube::Error> { + let data = body + .get("stringData") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| super::credentials::failure("Integration update requires stringData"))?; + let values = data + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| { + super::credentials::failure("Integration value must be a string") + }) + }) + .collect::<Result<std::collections::BTreeMap<_, _>, _>>()?; + self.mutate_integration(namespace, name, |keys| keys.extend(values.clone())) + .await + } + + /// Delete a write-only credential Secret this Bridge authored (e.g. the + /// shared `kars-github-app` Secret on disconnect). A 404 is not an error — + /// the secret is already absent, which is the caller's desired end state. + pub async fn delete_secret(&self, namespace: &str, name: &str) -> Result<(), kube::Error> { + self.mutate_integration(namespace, name, |keys| keys.clear()) + .await + } + + /// Read a single key's value from a Secret (base64-decoded UTF-8). `None` + /// when the secret/key is absent. Used by the Foundry preflight to make a + /// real authenticated call with the onboarded key — the key never leaves the + /// BFF process. + pub async fn read_secret_value( + &self, + namespace: &str, + secret: &str, + key: &str, + ) -> Result<Option<String>, kube::Error> { + let (_, s) = self.integration_store(namespace, secret).await?; + if let Some(v) = s.data.as_ref().and_then(|d| d.get(key)) { + return String::from_utf8(v.0.clone()) + .map(Some) + .map_err(|_| super::credentials::failure("Credential value is not UTF-8")); + } + Ok(None) + } + + /// Read every key of a Secret as UTF-8 strings (base64-decoded). Empty map + /// when the secret doesn't exist. Used for the multi-provider inference + /// Secret, whose keys ARE the literal env var names the router reads + /// (`KARS_PROVIDER_<TAG>_ENDPOINT`, `COPILOT_GITHUB_TOKEN`, ...) — listing + /// requires reading the whole key set, not one key at a time. + pub async fn read_secret_all( + &self, + namespace: &str, + secret: &str, + ) -> Result<std::collections::BTreeMap<String, String>, kube::Error> { + let (_, s) = self.integration_store(namespace, secret).await?; + Ok(Self::decode_secret_data(&s)) + } + + /// Read-modify-write a Secret's full key set under real optimistic + /// concurrency (CAS): a single atomic JSON Patch (RFC 6902) — a `test` op + /// asserting `resourceVersion` hasn't moved, followed by `add`/`remove` + /// ops for the actual key changes — retried on failure. + /// + /// Why JSON Patch specifically, not `replace()`/PUT or a plain JSON merge + /// patch: + /// - `replace()` (PUT) is the "update" RBAC verb, which the BFF's + /// ClusterRole deliberately never grants (write access here is + /// `create`/`patch` only) — using it would 403 in any real + /// RBAC-enforced deployment. Confirmed live against the actual + /// ServiceAccount (not a developer's cluster-admin kubeconfig). + /// - A plain JSON *merge* patch (RFC 7396, what this function used + /// before) uses the `patch` verb correctly, but the K8s API does NOT + /// honor `resourceVersion` as a precondition for merge patches — + /// confirmed live: a merge patch carrying a stale resourceVersion + /// still applies. So a merge patch alone has no way to detect a + /// concurrent writer. + /// - JSON Patch's `test` op DOES enforce the precondition atomically + /// alongside the real mutation (confirmed live: a stale + /// resourceVersion in a `test` op → the whole patch is rejected, + /// HTTP 422, and none of the following ops apply) — and it's still + /// the `patch` verb, so no RBAC widening is needed. + /// - Field removal still works here (unlike Server-Side-Apply, whose + /// merge semantics never remove an absent key) via an explicit + /// `remove` op per dropped key. + pub async fn mutate_secret_keys( + &self, + namespace: &str, + secret: &str, + mutate: impl Fn(&mut std::collections::BTreeMap<String, String>), + ) -> Result<(), kube::Error> { + self.mutate_integration(namespace, secret, mutate).await + } + + /// Decode a Secret's `data` (+ any pending `stringData`) into a flat map, + /// the shared helper behind both `read_secret_all` and the CAS loop above. + fn decode_secret_data( + s: &k8s_openapi::api::core::v1::Secret, + ) -> std::collections::BTreeMap<String, String> { + let mut out = std::collections::BTreeMap::new(); + if let Some(d) = s.data.as_ref() { + for (k, v) in d { + if let Ok(s) = String::from_utf8(v.0.clone()) { + out.insert(k.clone(), s); + } + } + } + if let Some(d) = s.string_data.as_ref() { + for (k, v) in d { + out.insert(k.clone(), v.clone()); + } + } + out + } +} diff --git a/bridge/bff/src/kars/cluster/engineering_sources.rs b/bridge/bff/src/kars/cluster/engineering_sources.rs new file mode 100644 index 000000000..a0e269116 --- /dev/null +++ b/bridge/bff/src/kars/cluster/engineering_sources.rs @@ -0,0 +1,198 @@ +use super::Cluster; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::api::{Api, ListParams}; + +impl Cluster { + // ── Bridge engineering intake sources ─────────────────────────────────── + + pub async fn read_engineering_source( + &self, + name: &str, + ) -> Result<Option<ConfigMap>, kube::Error> { + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + api.get_opt(name).await + } + + pub async fn list_engineering_sources( + &self, + limit: u32, + ) -> Result<Vec<ConfigMap>, kube::Error> { + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let params = ListParams::default() + .labels("bridge.kars.azure.com/engineering-source=true") + .limit(limit); + Ok(api.list(¶ms).await?.items) + } + + pub async fn create_engineering_source( + &self, + name: &str, + annotations: &std::collections::BTreeMap<String, String>, + data: &std::collections::BTreeMap<String, String>, + ) -> Result<(), kube::Error> { + use kube::api::PostParams; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let config_map: ConfigMap = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "name": name, + "namespace": "kars-system", + "labels": { + "app.kubernetes.io/managed-by": "kars-bridge", + "bridge.kars.azure.com/engineering-source": "true", + }, + "annotations": annotations, + }, + "data": data, + })) + .expect("engineering source ConfigMap is valid"); + api.create(&PostParams::default(), &config_map) + .await + .map(|_| ()) + } + + pub async fn patch_engineering_source_data( + &self, + name: &str, + data: &std::collections::BTreeMap<String, String>, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + api.patch( + name, + &PatchParams::default(), + &Patch::Merge(serde_json::json!({ "data": data })), + ) + .await?; + Ok(()) + } + + pub async fn claim_engineering_source( + &self, + name: &str, + expected_config: &str, + expected_status: &str, + claimed_status: &str, + ) -> Result<bool, kube::Error> { + use kube::api::{Patch, PatchParams}; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let patch = json_patch::Patch(vec![ + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "config.json"]), + value: serde_json::Value::String(expected_config.to_string()), + }), + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(expected_status.to_string()), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(claimed_status.to_string()), + }), + ]); + match api + .patch( + name, + &PatchParams::default(), + &Patch::Json::<ConfigMap>(patch), + ) + .await + { + Ok(_) => Ok(true), + Err(kube::Error::Api(error)) + if error.code == 404 || error.code == 409 || error.code == 422 => + { + Ok(false) + } + Err(error) => Err(error), + } + } + + pub async fn complete_engineering_source_claim( + &self, + name: &str, + expected_claimed_status: &str, + cursor: &str, + completed_status: &str, + ) -> Result<bool, kube::Error> { + use kube::api::{Patch, PatchParams}; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let patch = json_patch::Patch(vec![ + json_patch::PatchOperation::Test(json_patch::TestOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(expected_claimed_status.to_string()), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "cursor.json"]), + value: serde_json::Value::String(cursor.to_string()), + }), + json_patch::PatchOperation::Add(json_patch::AddOperation { + path: json_patch::jsonptr::PointerBuf::from_tokens(["data", "status.json"]), + value: serde_json::Value::String(completed_status.to_string()), + }), + ]); + match api + .patch( + name, + &PatchParams::default(), + &Patch::Json::<ConfigMap>(patch), + ) + .await + { + Ok(_) => Ok(true), + Err(kube::Error::Api(error)) + if error.code == 404 || error.code == 409 || error.code == 422 => + { + Ok(false) + } + Err(error) => Err(error), + } + } + + pub async fn delete_engineering_source(&self, name: &str) -> anyhow::Result<()> { + use kube::api::DeleteParams; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + if api.get_opt(name).await?.is_some() { + api.delete(name, &DeleteParams::default()).await?; + } + Ok(()) + } + + pub async fn replace_engineering_source( + &self, + mut current: ConfigMap, + annotations: &std::collections::BTreeMap<String, String>, + data: &std::collections::BTreeMap<String, String>, + ) -> Result<(), kube::Error> { + use kube::api::PostParams; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let name = current.metadata.name.clone().unwrap_or_default(); + current.metadata.annotations = Some(annotations.clone()); + current.data = Some(data.clone()); + api.replace(&name, &PostParams::default(), ¤t) + .await + .map(|_| ()) + } + + pub async fn delete_engineering_source_if_version( + &self, + name: &str, + resource_version: String, + ) -> Result<(), kube::Error> { + use kube::api::{DeleteParams, Preconditions}; + let api: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + api.delete( + name, + &DeleteParams { + preconditions: Some(Preconditions { + resource_version: Some(resource_version), + uid: None, + }), + ..DeleteParams::default() + }, + ) + .await + .map(|_| ()) + } +} diff --git a/bridge/bff/src/kars/cluster/local_inference.rs b/bridge/bff/src/kars/cluster/local_inference.rs new file mode 100644 index 000000000..f2e79b6af --- /dev/null +++ b/bridge/bff/src/kars/cluster/local_inference.rs @@ -0,0 +1,361 @@ +use super::{ + Cluster, DeployActivity, DeployCondition, DeployPodState, GpuNodeSummary, + LOCAL_INFERENCE_NAMESPACE, LocalDeployLiveStatus, +}; +use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams}; +use kube::core::ApiResource; + +/// Milestone-derived deploy percentage from real signals. Each milestone the +/// deployment has genuinely reached sets a floor; nothing here advances on a +/// timer alone (the client adds a small time-based ease WITHIN the current +/// band for visible motion, but never past the next real milestone). +fn compute_deploy_percent(s: &LocalDeployLiveStatus) -> u8 { + if s.ready { + return 100; + } + let cond_true = |t: &str| { + s.conditions + .iter() + .any(|c| c.cond_type == t && c.status == "True") + }; + let mut pct: u8 = if s.found { 8 } else { 3 }; + if cond_true("Validated") { + pct = pct.max(15); + } + if cond_true("ProviderSelected") || cond_true("ProviderCompatible") { + pct = pct.max(25); + } + if cond_true("ResourceCreated") { + pct = pct.max(38); + } + // Pod exists & scheduled (has a phase beyond nothing). + if s.pods + .iter() + .any(|p| !p.phase.is_empty() && p.phase != "Unknown") + { + pct = pct.max(52); + } + // Container running (image pulled, process started) but not yet Ready. + if s.pods.iter().any(|p| p.running) { + pct = pct.max(88); + } + pct +} + +impl Cluster { + // ─── Local (in-cluster) inference — AI Runway ModelDeployment ─────────── + // See docs/local-inference.md. kars does NOT install AI Runway/KAITO — + // an operator does that once via their own helm/kubectl, exactly like the + // GitHub App or Azure AI Foundry connection. kars-bridge only detects + // presence and manages `ModelDeployment` objects on top, in a namespace it + // owns (LOCAL_INFERENCE_NAMESPACE), never anyone else's. + + /// Whether AI Runway's `ModelDeployment` CRD is installed in this + /// cluster. A cheap, read-only check (list with a 1-item limit) — the + /// Bridge already holds `customresourcedefinitions: get/list` (used for + /// CRD-schema introspection elsewhere), so this needs no new RBAC beyond + /// the narrow `modeldeployments.airunway.ai` grant added alongside it. + pub async fn local_inference_available(&self) -> bool { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + api.list(&ListParams::default().limit(1)).await.is_ok() + } + + /// Server-Side Apply a `ModelDeployment` (create-or-update), namespaced to + /// `LOCAL_INFERENCE_NAMESPACE`. Mirrors `apply_kind`'s shape but targets + /// AI Runway's own API group instead of `kars.azure.com`. Ensures the + /// namespace exists first — the Bridge's own namespace, never created by + /// AI Runway/KAITO's install, so this is the one place it needs to. + pub async fn apply_model_deployment( + &self, + name: &str, + spec: serde_json::Value, + ) -> Result<DynamicObject, kube::Error> { + let namespaces: Api<k8s_openapi::api::core::v1::Namespace> = Api::all(self.client.clone()); + if let Some(namespace) = namespaces.get_opt(LOCAL_INFERENCE_NAMESPACE).await? { + if namespace.metadata.deletion_timestamp.is_some() { + return Err(super::credentials::failure( + "Local inference namespace is terminating", + )); + } + } else { + let namespace = serde_json::from_value(serde_json::json!({ + "apiVersion":"v1","kind":"Namespace","metadata":{"name":LOCAL_INFERENCE_NAMESPACE, + "labels":{"app.kubernetes.io/managed-by":"kars-bridge"}} + })) + .map_err(|_| { + super::credentials::failure("Local inference namespace metadata invalid") + })?; + namespaces + .create(&kube::api::PostParams::default(), &namespace) + .await?; + } + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + let obj: DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "airunway.ai/v1alpha1", + "kind": "ModelDeployment", + "metadata": { + "name": name, + "namespace": LOCAL_INFERENCE_NAMESPACE, + "labels": {"app.kubernetes.io/managed-by": "kars-bridge"}, + }, + "spec": spec, + })) + .map_err(|e| { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: e.to_string(), + reason: "BadRequest".into(), + code: 400, + }) + })?; + api.patch( + name, + &kube::api::PatchParams::apply("kars-bridge").force(), + &kube::api::Patch::Apply(&obj), + ) + .await + } + + /// List every `ModelDeployment` in the cluster. Discovery is read-only + /// across namespaces so an existing operator-managed AI Runway deployment + /// is visible without being recreated under `kars-local-inference`. + pub async fn list_model_deployments(&self) -> Result<Vec<DynamicObject>, kube::Error> { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); + Ok(api.list(&ListParams::default()).await?.items) + } + + /// Read one `ModelDeployment`'s current state (status included). + pub async fn get_model_deployment( + &self, + name: &str, + ) -> Result<Option<DynamicObject>, kube::Error> { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + api.get_opt(name).await + } + + /// Delete a `ModelDeployment` (foreground — the provider controller's + /// owner-referenced `Workspace`/pods/Service cascade with it). + pub async fn delete_model_deployment(&self, name: &str) -> Result<(), kube::Error> { + let gvk = GroupVersionKind::gvk("airunway.ai", "v1alpha1", "ModelDeployment"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = + Api::namespaced_with(self.client.clone(), LOCAL_INFERENCE_NAMESPACE, &ar); + api.delete(name, &kube::api::DeleteParams::foreground()) + .await?; + Ok(()) + } + + /// Real-capacity GPU node scan (read-only `nodes: get/list`) so the + /// wizard can offer GPU-tier models only when the cluster can actually + /// schedule them — never a hardcoded guess. Returns the count of + /// schedulable nodes advertising `nvidia.com/gpu` capacity and the + /// distinct GPU product names found (from the `nvidia.com/gpu.product` + /// NFD/GPU-feature-discovery label, when present). + pub async fn gpu_node_summary(&self) -> Result<GpuNodeSummary, kube::Error> { + use k8s_openapi::api::core::v1::Node; + let api: Api<Node> = Api::all(self.client.clone()); + let nodes = api.list(&ListParams::default()).await?; + let mut gpu_node_count = 0u32; + let mut products = std::collections::BTreeSet::new(); + for n in &nodes.items { + let has_gpu = n + .status + .as_ref() + .and_then(|s| s.capacity.as_ref()) + .map(|c| c.contains_key("nvidia.com/gpu")) + .unwrap_or(false); + if has_gpu { + gpu_node_count += 1; + if let Some(product) = n + .metadata + .labels + .as_ref() + .and_then(|l| l.get("nvidia.com/gpu.product")) + { + products.insert(product.clone()); + } + } + } + Ok(GpuNodeSummary { + gpu_node_count, + gpu_products: products.into_iter().collect(), + }) + } + + /// Rich, LIVE status for one in-flight (or settled) local model deploy — + /// what powers the deploy progress tracker's percentage + activity feed. + /// Sourced entirely from real cluster signals (no synthetic spinner): + /// • the `ModelDeployment` CR's ordered `status.conditions` + phase, + /// • the KAITO pod(s) selected by `airunway.ai/model-deployment=<name>` + /// (container waiting reason / running / ready), and + /// • the namespace's Kubernetes Events for those pods (Pulling, Pulled, + /// Failed, BackOff, Started, …) — the actual activity feed. + /// The percentage is milestone-derived (validated → workspace → scheduled + /// → image pulled → running), so it only advances on real progress. + pub async fn local_deployment_live_status( + &self, + name: &str, + ) -> Result<LocalDeployLiveStatus, kube::Error> { + use k8s_openapi::api::core::v1::{Event, Pod}; + + let cr = self.get_model_deployment(name).await?; + let mut status = LocalDeployLiveStatus { + name: name.to_string(), + found: cr.is_some(), + ..Default::default() + }; + if let Some(cr) = &cr { + let st = cr.data.get("status"); + status.phase = st + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(str::to_string); + status.message = st + .and_then(|s| s.get("message")) + .and_then(|m| m.as_str()) + .map(str::to_string); + if let Some(reps) = st.and_then(|s| s.get("replicas")) { + status.replicas_desired = + reps.get("desired").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + status.replicas_ready = + reps.get("ready").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + } + if let Some(conds) = st + .and_then(|s| s.get("conditions")) + .and_then(|c| c.as_array()) + { + for c in conds { + status.conditions.push(DeployCondition { + cond_type: c + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + status: c + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + reason: c + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + message: c + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }); + } + } + } + + // Pod(s) for this deployment (KAITO stamps airunway.ai/model-deployment). + let pods_api: Api<Pod> = Api::namespaced(self.client.clone(), LOCAL_INFERENCE_NAMESPACE); + let lp = ListParams::default().labels(&format!("airunway.ai/model-deployment={name}")); + let mut pod_names: Vec<String> = Vec::new(); + if let Ok(pods) = pods_api.list(&lp).await { + for p in &pods.items { + let pod_name = p.metadata.name.clone().unwrap_or_default(); + pod_names.push(pod_name.clone()); + let phase = p + .status + .as_ref() + .and_then(|s| s.phase.clone()) + .unwrap_or_default(); + let mut ready = false; + let mut waiting_reason: Option<String> = None; + let mut waiting_message: Option<String> = None; + let mut running = false; + if let Some(cs) = p + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + { + for c in cs { + ready = ready || c.ready; + if let Some(state) = &c.state { + if let Some(w) = &state.waiting { + waiting_reason = w.reason.clone(); + waiting_message = w.message.clone(); + } + if state.running.is_some() { + running = true; + } + } + } + } + status.pods.push(DeployPodState { + name: pod_name, + phase, + ready, + running, + waiting_reason, + waiting_message, + }); + } + } + + // Real Kubernetes events for the CR + its pods — the live activity feed. + let events_api: Api<Event> = + Api::namespaced(self.client.clone(), LOCAL_INFERENCE_NAMESPACE); + if let Ok(events) = events_api.list(&ListParams::default()).await { + for e in &events.items { + let obj = e.involved_object.name.clone().unwrap_or_default(); + if obj != name && !pod_names.contains(&obj) { + continue; + } + let time = e + .last_timestamp + .as_ref() + .map(|t| t.0.to_rfc3339()) + .or_else(|| e.event_time.as_ref().map(|t| t.0.to_rfc3339())); + status.activities.push(DeployActivity { + time, + reason: e.reason.clone().unwrap_or_default(), + message: e.message.clone().unwrap_or_default(), + event_type: e.type_.clone().unwrap_or_default(), + count: e.count.unwrap_or(1), + }); + } + // Oldest → newest so the feed reads like a log. + status.activities.sort_by(|a, b| a.time.cmp(&b.time)); + } + + // Terminal failure detection from real pod container state. + for p in &status.pods { + if let Some(reason) = &p.waiting_reason + && matches!( + reason.as_str(), + "ImagePullBackOff" + | "ErrImagePull" + | "CrashLoopBackOff" + | "CreateContainerError" + | "InvalidImageName" + ) + { + status.failed = true; + status.failure_reason = Some(reason.clone()); + status.failure_message = p.waiting_message.clone(); + } + } + status.ready = status.phase.as_deref() == Some("Running") + || (status.replicas_desired > 0 && status.replicas_ready >= status.replicas_desired); + + // Milestone-derived percentage — advances only on real progress. + status.percent = compute_deploy_percent(&status); + Ok(status) + } +} diff --git a/bridge/bff/src/kars/cluster/mission_records.rs b/bridge/bff/src/kars/cluster/mission_records.rs new file mode 100644 index 000000000..99e95905a --- /dev/null +++ b/bridge/bff/src/kars/cluster/mission_records.rs @@ -0,0 +1,414 @@ +use super::{Cluster, MissionOutputRecord}; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::api::Api; +use sha2::{Digest, Sha256}; + +pub(super) fn mission_evidence_key(cm: &ConfigMap, legacy_label: &str) -> Option<String> { + cm.metadata + .annotations + .as_ref() + .and_then(|annotations| { + annotations + .get("kars.azure.com/mission-evidence-key") + .filter(|value| !value.trim().is_empty()) + .cloned() + }) + .or_else(|| { + cm.metadata + .labels + .as_ref() + .and_then(|labels| labels.get(legacy_label).cloned()) + }) +} + +fn mission_evidence_role(cm: &ConfigMap) -> Option<String> { + cm.metadata.annotations.as_ref().and_then(|annotations| { + annotations + .get("kars.azure.com/mission-evidence-role") + .filter(|value| !value.trim().is_empty()) + .cloned() + }) +} + +fn mission_principal_name(cm: &ConfigMap) -> Option<String> { + cm.metadata + .annotations + .as_ref() + .and_then(|annotations| { + annotations + .get("kars.azure.com/mission-principal-name") + .filter(|value| !value.trim().is_empty()) + .cloned() + }) + .or_else(|| { + cm.metadata + .labels + .as_ref() + .and_then(|labels| labels.get("kars.azure.com/mission-principal").cloned()) + }) +} + +pub(super) fn mission_output_candidate( + cm: ConfigMap, +) -> Option<( + String, + Option<String>, + std::collections::BTreeMap<String, String>, +)> { + let evidence_key = mission_evidence_key(&cm, "kars.azure.com/mission-output")?; + let role = mission_evidence_role(&cm); + let principal_name = mission_principal_name(&cm); + let mut data = cm.data.unwrap_or_default(); + if !data.contains_key("taskName") { + if let Some(principal_name) = principal_name { + data.insert("taskName".to_string(), principal_name); + } else if data + .get("assignmentNonce") + .is_some_and(|nonce| nonce != &evidence_key) + { + data.insert("taskName".to_string(), evidence_key.clone()); + } + } + Some((evidence_key, role, data)) +} + +pub(super) fn select_mission_output_records( + records: Vec<( + String, + Option<String>, + std::collections::BTreeMap<String, String>, + )>, +) -> Vec<(String, std::collections::BTreeMap<String, String>)> { + let mut grouped = std::collections::BTreeMap::< + String, + Vec<( + String, + Option<String>, + std::collections::BTreeMap<String, String>, + )>, + >::new(); + for (key, role, data) in records { + let task_name = data.get("taskName").cloned().unwrap_or_else(|| key.clone()); + grouped + .entry(task_name) + .or_default() + .push((key, role, data)); + } + grouped + .into_values() + .filter_map(|group| { + group + .into_iter() + .max_by_key(|(key, role, data)| match role.as_deref() { + Some("current") => 4, + Some("canonical") => 3, + Some("archive") => 1, + Some(_) => 0, + None if data.get("assignmentNonce").is_none() => 3, + None if data.get("assignmentNonce") != Some(key) => 2, + None => 1, + }) + .map(|(key, _, data)| (key, data)) + }) + .collect() +} + +pub(super) fn select_mission_evidence_records( + records: Vec<( + String, + Option<String>, + std::collections::BTreeMap<String, String>, + )>, +) -> Vec<(String, std::collections::BTreeMap<String, String>)> { + let mut grouped = std::collections::BTreeMap::< + String, + Vec<( + String, + Option<String>, + std::collections::BTreeMap<String, String>, + )>, + >::new(); + for (key, role, data) in records { + let identity = data + .get("assignmentNonce") + .cloned() + .unwrap_or_else(|| key.clone()); + grouped.entry(identity).or_default().push((key, role, data)); + } + grouped + .into_values() + .filter_map(|group| { + group + .into_iter() + .max_by_key(|(key, role, data)| match role.as_deref() { + Some("archive" | "canonical") => 3, + Some("current") => 1, + Some(_) => 0, + None if data.get("assignmentNonce") == Some(key) => 2, + None => 1, + }) + .map(|(key, _, data)| (key, data)) + }) + .collect() +} + +pub(super) fn project_mission_output_record( + evidence_key: String, + data: std::collections::BTreeMap<String, String>, +) -> MissionOutputRecord { + let task_name = data + .get("taskName") + .cloned() + .unwrap_or_else(|| evidence_key.clone()); + MissionOutputRecord { + task_name, + evidence_key, + data, + } +} + +pub(super) fn trace_record_identity(cm: &ConfigMap) -> Option<String> { + if !cm + .metadata + .name + .as_deref() + .is_some_and(|name| name.starts_with("kars-mission-trace-")) + { + return None; + } + if mission_evidence_role(cm).as_deref() == Some("current") { + return None; + } + let data = cm.data.as_ref()?; + let trace = data.get("trace.json").filter(|trace| trace.len() > 2)?; + if let Some(nonce) = data.get("assignmentNonce") { + return Some(format!("nonce:{nonce}")); + } + let captured_at = data.get("capturedAt").map(String::as_str).unwrap_or(""); + Some(format!( + "legacy:{captured_at}:{:x}", + Sha256::digest(trace.as_bytes()) + )) +} + +impl Cluster { + /// Persist a mission's run output into a namespaced ConfigMap + /// `kars-mission-output-<task>` in `kars-system`, so the deliverable is a + /// durable, readable cluster object (the §16 artifact record, minimal form). + /// Server-side apply, idempotent per task. + pub async fn write_mission_output( + &self, + task: &str, + data: std::collections::BTreeMap<String, String>, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let name = format!("kars-mission-output-{task}"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { "name": name, "labels": { "kars.azure.com/mission-output": task } }, + "data": data, + }); + cms.patch( + &name, + &PatchParams::apply("kars-bridge/mission-output").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Read a mission's persisted run output ConfigMap, if present. + pub async fn read_mission_output( + &self, + task: &str, + ) -> Option<std::collections::BTreeMap<String, String>> { + self.configmap_data(&format!("kars-mission-output-{task}")) + .await + } + + /// Read a mission's persisted artifact set — the complete file set the + /// agent produced over the mesh, written by the controller to + /// `kars-mission-artifacts-<task>`. Text artifacts come back as `data` + /// (filename → content); binary artifacts are reported by name + size via + /// the output ConfigMap's manifest (their bytes live in the ConfigMap's + /// `binaryData` and aren't inlined here). Returns `None` when the mission + /// produced no artifacts (honest empty, never fabricated). + pub async fn read_mission_artifacts( + &self, + task: &str, + ) -> Option<std::collections::BTreeMap<String, String>> { + self.configmap_data(&format!("kars-mission-artifacts-{task}")) + .await + } + + /// Read a single artifact file's raw bytes for download — text artifacts + /// from the ConfigMap's `data`, binary ones from `binaryData` (base64). The + /// filename is matched against the same sanitized key the manifest exposes. + /// Returns `(bytes, is_binary)` or `None` when the file isn't found. This is + /// the Bridge-native fetch path so operators never need `kubectl`. + pub async fn read_mission_artifact_bytes( + &self, + task: &str, + key: &str, + ) -> Option<(Vec<u8>, bool)> { + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let cm = cms + .get_opt(&format!("kars-mission-artifacts-{task}")) + .await + .ok() + .flatten()?; + if let Some(text) = cm.data.as_ref().and_then(|d| d.get(key)) { + return Some((text.clone().into_bytes(), false)); + } + // `binaryData` values are `ByteString`, already base64-decoded by the API + // client into raw bytes — serve them directly. + if let Some(bytes) = cm.binary_data.as_ref().and_then(|d| d.get(key)) { + return Some((bytes.0.clone(), true)); + } + None + } + + /// Read a mission's persisted execution trace — the clean per-tool audit + /// record the controller wrote to `kars-mission-trace-<task>`. Returns the + /// raw `trace.json` string (a JSON array of round/tool events) when present. + pub async fn read_mission_trace(&self, task: &str) -> Option<String> { + self.configmap_data(&format!("kars-mission-trace-{task}")) + .await + .and_then(|d| d.get("trace.json").cloned()) + } + + pub async fn read_mission_progress(&self, task: &str) -> Option<serde_json::Value> { + self.configmap_data(&format!("kars-mission-progress-{task}")) + .await + .and_then(|data| data.get("checkpoint.json").cloned()) + .and_then(|raw| serde_json::from_str(&raw).ok()) + } + + /// Count missions with a real per-tool execution-trace record — the live + /// telemetry substrate. Counts `kars-mission-trace-*` ConfigMaps carrying a + /// non-empty trace, not deliverable count (the two can differ). + pub async fn count_trace_records(&self) -> usize { + use kube::api::ListParams; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + cms.list(&ListParams::default()) + .await + .map(|l| { + l.items + .iter() + .filter_map(trace_record_identity) + .collect::<std::collections::HashSet<_>>() + .len() + }) + .unwrap_or(0) + } + + /// List every mission that has produced a captured deliverable — one entry + /// per `kars-mission-output-*` ConfigMap. New records retain the full + /// evidence key in an annotation because nonce-scoped label values can + /// exceed Kubernetes' 63-byte limit; legacy records fall back to the label. + /// Returns `(task, data)` pairs so the caller can build the cross-mission + /// Artifacts index from real, durable records (never fabricated). Sorted by + /// `finishedAt` descending so the most recent deliverables surface first. + pub async fn list_mission_outputs(&self) -> Vec<MissionOutputRecord> { + use kube::api::ListParams; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let list = match cms + .list(&ListParams::default().labels("kars.azure.com/mission-output")) + .await + { + Ok(l) => l, + Err(_) => return Vec::new(), + }; + let records: Vec<( + String, + Option<String>, + std::collections::BTreeMap<String, String>, + )> = list + .items + .into_iter() + .filter_map(mission_output_candidate) + .collect(); + let mut out = select_mission_output_records(records) + .into_iter() + .map(|(evidence_key, data)| project_mission_output_record(evidence_key, data)) + .collect::<Vec<_>>(); + out.sort_by(|a, b| { + b.data + .get("finishedAt") + .cloned() + .unwrap_or_default() + .cmp(&a.data.get("finishedAt").cloned().unwrap_or_default()) + }); + out + } + + /// List each nonce-scoped execution exactly once for accounting, efficiency, + /// and historical evidence. Immutable archives/canonical records are kept; + /// task-keyed current-pointer mirrors are excluded. + pub async fn list_mission_output_evidence(&self) -> Vec<MissionOutputRecord> { + use kube::api::ListParams; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let list = match cms + .list(&ListParams::default().labels("kars.azure.com/mission-output")) + .await + { + Ok(list) => list, + Err(_) => return Vec::new(), + }; + let records = list + .items + .into_iter() + .filter_map(mission_output_candidate) + .collect(); + let mut out = select_mission_evidence_records(records) + .into_iter() + .map(|(evidence_key, data)| project_mission_output_record(evidence_key, data)) + .collect::<Vec<_>>(); + out.sort_by(|left, right| { + right + .data + .get("finishedAt") + .cloned() + .unwrap_or_default() + .cmp(&left.data.get("finishedAt").cloned().unwrap_or_default()) + }); + out + } + + /// Delete a standing team and its owned substrate. Deleting the `KarsTeam` + /// CRD cascade-removes its runs + sandboxes (owner references); this then + /// best-effort sweeps the team's auxiliary records the controller writes + /// alongside the CRD — shared memory, task backlog, engineering source, and + /// write-only channel secret — so a deleted team leaves nothing behind. + /// Aux cleanup is best-effort: a missing aux object is not an error. + /// Delete a mission (KarsTask) and sweep the ConfigMaps the controller keyed + /// on its name — the deliverable, artifacts, live trace, and review record. + /// Without the sweep, a deleted mission's outputs keep surfacing on the + /// Artifacts page and its direct URL keeps resolving from output-only + /// history (same class of orphan the team delete sweep fixes). + pub async fn delete_task(&self, namespace: &str, name: &str) -> Result<(), kube::Error> { + // The CRD itself (foreground cascade → sandbox + child resources). + self.delete_kind(namespace, "KarsTask", name).await?; + self.sweep_mission_artifacts(name).await; + Ok(()) + } + + /// Best-effort deletion of the ConfigMaps the controller keys on a mission's + /// name (deliverable, files, trace, review). Used by mission delete and the + /// output-only cleanup path. + pub async fn sweep_mission_artifacts(&self, name: &str) { + use k8s_openapi::api::core::v1::ConfigMap; + use kube::api::DeleteParams; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + for cm in [ + format!("kars-mission-output-{name}"), + format!("kars-mission-artifacts-{name}"), + format!("kars-mission-trace-{name}"), + format!("kars-mission-review-{name}"), + ] { + let _ = cms.delete(&cm, &DeleteParams::default()).await; + } + } +} diff --git a/bridge/bff/src/kars/cluster/mission_runs.rs b/bridge/bff/src/kars/cluster/mission_runs.rs new file mode 100644 index 000000000..45abadf6c --- /dev/null +++ b/bridge/bff/src/kars/cluster/mission_runs.rs @@ -0,0 +1,287 @@ +use super::{AgentIdentity, Cluster, MeshRunOutcome}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::api::Api; +use sha2::{Digest, Sha256}; + +impl Cluster { + /// Request a **mesh-driven agent run** of a task by stamping the + /// `kars.azure.com/run-requested` annotation with a fresh nonce. The core + /// controller (a live mesh peer) watches this annotation, discovers the + /// agent over the mesh, delivers the objective straight into the agent's + /// native loop (gated by the AGT `task:execute` policy), captures the + /// reply, writes it to `kars-mission-output-<task>`, and stamps + /// `kars.azure.com/run-completed` with the same nonce. This is the Bridge + /// *consuming* a neutral core capability — the Bridge never reaches into + /// the agent itself. Returns the nonce to correlate completion. + pub async fn request_mesh_run(&self, ns: &str, name: &str) -> anyhow::Result<String> { + use kube::api::{Patch, PatchParams}; + // In-flight guard: if a run is already pending (run-requested set to a + // nonce the controller hasn't completed yet), REUSE that nonce instead of + // stamping a fresh one. Two concurrent triggers (double-click, cadence + + // run-now) would otherwise each mint a distinct nonce; the controller acks + // only the last, the first caller's await never matches → it single-turns + // while the mesh also delivers → the task executes twice and the outputs + // clobber. Reusing the pending nonce makes both callers await the same run. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { + let ann = task.metadata.annotations.unwrap_or_default(); + let requested = ann.get("kars.azure.com/run-requested").cloned(); + let completed = ann.get("kars.azure.com/run-completed").cloned(); + if let Some(req) = requested.filter(|r| !r.is_empty()) + && completed.as_deref() != Some(req.as_str()) + { + return Ok(req); + } + } + let nonce = format!( + "run-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/run-requested": nonce } } + }); + self.tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + // Re-read and adopt whatever nonce actually won the annotation, so two + // truly-simultaneous triggers converge on the SAME run instead of each + // awaiting its own (last-write-wins) nonce. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await + && let Some(actual) = task + .metadata + .annotations + .and_then(|a| a.get("kars.azure.com/run-requested").cloned()) + .filter(|r| !r.is_empty()) + { + return Ok(actual); + } + Ok(nonce) + } + + /// Poll the task's `kars.azure.com/run-completed` annotation until it + /// equals `nonce` (the controller stamps it once the mesh round-trip is + /// done) or `timeout` elapses. Returns the freshly-written mission output + /// on completion, or `None` on timeout. + /// Outcome of awaiting a mesh run. Distinguishes "the mesh peer never picked + /// this up" (safe to fall back to a single turn) from "it acknowledged and is + /// actively delivering" (must NOT single-turn — that would race the + /// controller's deliverable write). + pub async fn await_mesh_run( + &self, + ns: &str, + name: &str, + nonce: &str, + timeout: std::time::Duration, + ) -> MeshRunOutcome { + let deadline = std::time::Instant::now() + timeout; + let mut saw_ack = false; + let mut saw_activity = false; + loop { + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { + let ann = task.metadata.annotations.clone().unwrap_or_default(); + if ann.get("kars.azure.com/run-ack").map(String::as_str) == Some(nonce) { + saw_ack = true; + } + if ann.get("kars.azure.com/run-completed").map(String::as_str) == Some(nonce) { + return match self.read_mission_output(name).await { + Some(out) => MeshRunOutcome::Completed(out), + None => MeshRunOutcome::InProgress, + }; + } + } + // LIVE-ACTIVITY signal — the robust "a real agent loop is running" + // proof that works even against an OLD controller that never stamps + // run-ack. If the sandbox's router is emitting rounds/tool calls, a + // genuine run is in flight and we must NEVER single-turn over it + // (that produced a garbage one-shot deliverable that clobbered the + // real streaming run). Latch it once seen. + if !saw_activity && !self.sandbox_live_trace(name).await.is_empty() { + saw_activity = true; + } + if std::time::Instant::now() >= deadline { + return if saw_ack || saw_activity { + MeshRunOutcome::InProgress + } else { + MeshRunOutcome::NeverProcessed + }; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + + /// Clear the pending `run-requested` annotation — used when the BFF gives up + /// on the mesh path and single-turns, so a late mesh-peer recovery doesn't + /// ALSO deliver + write the output (double-write). + pub async fn clear_run_request(&self, ns: &str, name: &str) { + use kube::api::{Patch, PatchParams}; + let patch = serde_json::json!({ + "metadata": { "annotations": { "kars.azure.com/run-requested": serde_json::Value::Null } } + }); + let _ = self + .tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await; + } + + /// Discover a running agent's **mesh identity** from the AGT registry — the + /// harness-neutral discovery layer. Every runtime adapter registers its + /// agent under capabilities that include the sandbox name; we query + /// `/v1/discover?capability=<sandbox>` through the Kubernetes services/proxy + /// subresource (the registry is a ClusterIP service the BFF reaches via the + /// API server) and return the most-recently-seen DID + its capabilities and + /// last-seen time. This proves the agent is a real, live mesh participant + /// and is the discovery prerequisite for mesh-driven task delivery. Returns + /// `None` when the registry is unreachable or the agent isn't registered + /// (honest empty, never fabricated). + pub async fn discover_agent_identity(&self, sandbox: &str) -> Option<AgentIdentity> { + let path = format!( + "/api/v1/namespaces/agentmesh/services/agentmesh-registry:8080/proxy/v1/discover?capability={sandbox}&limit=10" + ); + let req = http::Request::builder() + .method(http::Method::GET) + .uri(path) + .body(Vec::new()) + .ok()?; + let text = self.client.request_text(req).await.ok()?; + let body: serde_json::Value = serde_json::from_str(&text).ok()?; + let results = body.get("results")?.as_array()?; + // Pick the most-recently-seen registration for this sandbox. + let best = results + .iter() + .filter(|r| { + r.get("capabilities") + .and_then(|c| c.as_array()) + .map(|caps| caps.iter().any(|c| c.as_str() == Some(sandbox))) + .unwrap_or(false) + }) + .max_by_key(|r| { + r.get("last_seen") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + })?; + Some(AgentIdentity { + did: best.get("did")?.as_str()?.to_string(), + capabilities: best + .get("capabilities") + .and_then(|c| c.as_array()) + .map(|caps| { + caps.iter() + .filter_map(|c| c.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + last_seen: best + .get("last_seen") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + reputation_score: best.get("reputation_score").and_then(|v| v.as_f64()), + }) + } + + /// Read a task's review record (`kars-mission-review-<task>`), if any. + pub async fn read_review( + &self, + task: &str, + ) -> Option<std::collections::BTreeMap<String, String>> { + self.configmap_data(&format!("kars-mission-review-{task}")) + .await + } + + /// Write a task's review record (`kars-mission-review-<task>`), SSA-merged. + pub async fn write_review( + &self, + task: &str, + data: std::collections::BTreeMap<String, String>, + ) -> anyhow::Result<()> { + use kube::api::{Patch, PatchParams}; + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), "kars-system"); + let name = format!("kars-mission-review-{task}"); + let patch = serde_json::json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { "name": name, "labels": { "kars.azure.com/mission-review": task } }, + "data": data, + }); + cms.patch( + &name, + &PatchParams::apply("kars-bridge-bff").force(), + &Patch::Apply(patch), + ) + .await?; + Ok(()) + } + + /// Re-drive a task on reviewer feedback without mutating its immutable spec. + /// The revision objective is nonce-bound and digest-protected in annotations; + /// the controller verifies it before constructing the signed task contract. + pub async fn redrive_with_revision( + &self, + ns: &str, + name: &str, + revised_objective: &str, + ) -> anyhow::Result<String> { + use kube::api::{Patch, PatchParams}; + // In-flight guard (same rationale as request_mesh_run): if a run is + // already pending, don't stamp a second concurrent redrive — reuse the + // pending nonce so two concurrent request_changes reviews can't double- + // execute the producing agent. The revision remains nonce-scoped. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await { + let ann = task.metadata.annotations.clone().unwrap_or_default(); + let requested = ann.get("kars.azure.com/run-requested").cloned(); + let completed = ann.get("kars.azure.com/run-completed").cloned(); + if let Some(req) = requested.filter(|r| !r.is_empty()) + && completed.as_deref() != Some(req.as_str()) + { + let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); + let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); + let patch = serde_json::json!({ + "metadata": { "annotations": { + "kars.azure.com/run-objective-nonce": req.clone(), + "kars.azure.com/run-objective-b64": encoded, + "kars.azure.com/run-objective-digest": digest + }} + }); + self.tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + return Ok(req); + } + } + let nonce = format!( + "rev-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ); + let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); + let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); + let patch = serde_json::json!({ + "metadata": { "annotations": { + "kars.azure.com/run-requested": nonce.clone(), + "kars.azure.com/run-objective-nonce": nonce.clone(), + "kars.azure.com/run-objective-b64": encoded, + "kars.azure.com/run-objective-digest": digest + }} + }); + self.tasks(ns) + .patch(name, &PatchParams::default(), &Patch::Merge(patch)) + .await?; + // Adopt whichever nonce won, so concurrent redrives converge on one run. + if let Ok(Some(task)) = self.tasks(ns).get_opt(name).await + && let Some(actual) = task + .metadata + .annotations + .and_then(|a| a.get("kars.azure.com/run-requested").cloned()) + .filter(|r| !r.is_empty()) + { + return Ok(actual); + } + Ok(nonce) + } +} diff --git a/bridge/bff/src/kars/cluster/orchestrator.rs b/bridge/bff/src/kars/cluster/orchestrator.rs new file mode 100644 index 000000000..dd3b00629 --- /dev/null +++ b/bridge/bff/src/kars/cluster/orchestrator.rs @@ -0,0 +1,308 @@ +use super::Cluster; +use k8s_openapi::api::core::v1::Pod; +use kube::api::{Api, DynamicObject, ListParams}; + +const ORCHESTRATOR_POLICY_READY_ATTEMPTS: usize = 180; +const ORCHESTRATOR_POLICY_POLL_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(500); + +/// True when a sandbox name denotes an ephemeral standing-run sandbox +/// (`<team>-run-<timestamp>`), which is short-lived and often mid-execution — +/// not a stable target for routing the Bridge's orchestrator inference through. +fn is_ephemeral_run(sandbox: &str) -> bool { + if let Some(idx) = sandbox.rfind("-run-") { + let suffix = &sandbox[idx + 5..]; + return !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()); + } + false +} + +impl Cluster { + /// Find ANY running sandbox's namespace + pod, so the Bridge can route an + /// orchestrator/composer model call through an existing secure inference + /// router (via `router_chat`). This is how the envelope composer reaches the + /// model on workload-identity clusters — without a static token, reusing the + /// same governed path agents use. Prefers a persistent sandbox; falls back + /// to any Running sandbox pod. Returns `(namespace, pod)`. + /// Ranked list of stable sandbox `(namespace, pod)` candidates whose + /// inference router the Bridge can route an orchestrator/composer model call + /// through. Excludes ephemeral standing-run sandboxes (short-lived / busy), + /// requires the router container ready, and orders freshest-first (a + /// recently (re)started pod runs the current router image with valid + /// provider auth). The caller tries them in order so a single sandbox with + /// stale auth or a warming router is skipped gracefully. + pub async fn running_sandbox_candidates(&self) -> Vec<(String, String)> { + let pods: Api<Pod> = Api::all(self.client.clone()); + let Ok(list) = pods + .list(&ListParams::default().labels("kars.azure.com/sandbox")) + .await + else { + return Vec::new(); + }; + let mut candidates: Vec<&Pod> = list + .items + .iter() + .filter(|p| { + let phase = p.status.as_ref().and_then(|s| s.phase.as_deref()); + if phase != Some("Running") { + return false; + } + let name = p.metadata.name.as_deref().unwrap_or_default(); + let sandbox = p + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/sandbox")) + .map(String::as_str) + .unwrap_or(name); + // Prefer stable sandboxes, but do NOT exclude ephemeral team-run + // sandboxes — on a teams-only cluster they are the ONLY inference + // path the orchestrator has. We sort them last (below) so a stable + // sandbox always wins when one exists. + let _ = sandbox; + p.status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .map(|cs| cs.iter().any(|c| c.name == "inference-router" && c.ready)) + .unwrap_or(false) + }) + .collect(); + candidates.sort_by(|a, b| { + // Stable sandboxes before ephemeral run sandboxes, then freshest first. + let eph = |p: &&Pod| -> bool { + let n = p.metadata.name.as_deref().unwrap_or_default(); + let sb = p + .metadata + .labels + .as_ref() + .and_then(|l| l.get("kars.azure.com/sandbox")) + .map(String::as_str) + .unwrap_or(n); + is_ephemeral_run(sb) + }; + let ta = a.metadata.creation_timestamp.as_ref().map(|t| t.0); + let tb = b.metadata.creation_timestamp.as_ref().map(|t| t.0); + eph(a).cmp(&eph(b)).then(tb.cmp(&ta)) // stable first, then freshest + }); + candidates + .into_iter() + .filter_map(|p| Some((p.metadata.namespace.clone()?, p.metadata.name.clone()?))) + .collect() + } + + /// Drive a real model call through a sandbox's secure inference router, + /// using the Kubernetes **pods/proxy subresource** — hard-scoped to one + /// pod, port 8443, and the exact `/v1/chat/completions` path. This is the + /// only proxy the BFF performs and it is NOT a generic tunnel: it cannot + /// reach any other port or path. The router still enforces content-safety, + /// token budgets, and governance on the call — the agent never sees a key. + /// + /// `ns` is the sandbox namespace (`kars-<sandbox>`), `pod` the Running pod. + /// Returns the raw response JSON text from the router. + pub async fn router_chat( + &self, + ns: &str, + pod: &str, + body: &serde_json::Value, + ) -> anyhow::Result<String> { + let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/v1/chat/completions"); + let req = http::Request::builder() + .method(http::Method::POST) + .uri(path) + .header("content-type", "application/json") + .body(serde_json::to_vec(body)?)?; + let text = self.client.request_text(req).await?; + Ok(text) + } + + /// Drive a model call through a sandbox's inference router using the NATIVE + /// Anthropic Messages endpoint (`/v1/messages`) via pods/proxy. Claude on + /// the OpenAI-compatible `/chat/completions` path returns empty content for + /// the Bridge's composer; the native path returns proper text/`tool_use` + /// content (the same reason agents use `/v1/messages`). Body is Anthropic- + /// shaped (`{model, system, messages, max_tokens}`). Returns raw response. + pub async fn router_messages( + &self, + ns: &str, + pod: &str, + body: &serde_json::Value, + ) -> anyhow::Result<String> { + let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/v1/messages"); + let req = http::Request::builder() + .method(http::Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("anthropic-version", "2023-06-01") + .body(serde_json::to_vec(body)?)?; + let text = self.client.request_text(req).await?; + Ok(text) + } + + /// Create a kars CRD object from a JSON spec in `namespace`. Used to file a + /// request resource (e.g. a temporary `EgressApproval`) the controller then + /// reconciles through human approval — the BFF never widens posture itself. + /// Ensure the standing **orchestrator sandbox** exists — a persistent, + /// non-ephemeral sandbox whose inference router the Bridge orchestrator + /// (compose) always routes through. Without it, compose can only borrow a + /// running agent's router, so on a teams-only cluster (all ephemeral runs) + /// it has no cold-start inference path. Idempotent SSA; safe to call on every + /// startup. + pub async fn ensure_orchestrator_sandbox(&self) -> Result<(), kube::Error> { + const NAME: &str = "bridge-orchestrator"; + const NS: &str = "kars-system"; + let inference = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "InferencePolicy", + "metadata": { "name": format!("{NAME}-inference"), "namespace": NS, + "labels": { "kars.azure.com/managed-by": "kars-bridge" } }, + "spec": { + "appliesTo": { "sandboxName": NAME }, + "modelPreference": { "primary": { "provider": "github-copilot", "deployment": "claude-opus-4.8" } }, + }, + }); + self.apply_kind(NS, "InferencePolicy", inference, true) + .await?; + let sandbox = serde_json::json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { "name": NAME, "namespace": NS, + "labels": { "kars.azure.com/managed-by": "kars-bridge", "kars.azure.com/orchestrator": "true" } }, + "spec": { + "runtime": { "kind": "OpenClaw", "openclaw": {} }, + "inferenceRef": { "name": format!("{NAME}-inference") }, + "sandbox": { "isolation": "standard" }, + "networkPolicy": { "defaultDeny": true }, + "governance": { "enabled": true, "toolPolicyRef": { "name": "kars-default" }, "trustThreshold": 0 }, + "agent": { "instructions": "Standing orchestrator inference host for the kars Bridge composer. Stay idle; your router serves compose requests." }, + }, + }); + self.apply_kind(NS, "KarsSandbox", sandbox, true).await?; + Ok(()) + } + + /// Model pinned to the persistent Bridge composer sandbox. Composition calls + /// must use this route rather than an unrelated cluster-default deployment. + pub async fn bridge_orchestrator_model(&self) -> Option<String> { + self.get_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + ) + .await + .ok() + .flatten()? + .data + .pointer("/spec/modelPreference/primary/deployment") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .filter(|model| !model.trim().is_empty()) + } + + pub async fn configure_bridge_orchestrator_model( + &self, + provider: &str, + deployment: &str, + ) -> Result<(), String> { + let policy_ready = |policy: &DynamicObject| { + let generation_matches = policy + .data + .pointer("/status/observedGeneration") + .and_then(serde_json::Value::as_i64) + == policy.metadata.generation; + generation_matches + && policy + .data + .pointer("/spec/modelPreference/primary/provider") + .and_then(serde_json::Value::as_str) + == Some(provider) + && policy + .data + .pointer("/spec/modelPreference/primary/deployment") + .and_then(serde_json::Value::as_str) + == Some(deployment) + && policy + .data + .pointer("/status/conditions") + .and_then(serde_json::Value::as_array) + .is_some_and(|conditions| { + conditions.iter().any(|condition| { + condition.get("type").and_then(serde_json::Value::as_str) + == Some("Ready") + && condition.get("status").and_then(serde_json::Value::as_str) + == Some("True") + }) + }) + }; + if self + .get_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + ) + .await + .map_err(|error| error.to_string())? + .as_ref() + .is_some_and(&policy_ready) + { + return Ok(()); + } + self.merge_patch_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + serde_json::json!({ + "spec": { + "modelPreference": { + "primary": { + "provider": provider, + "deployment": deployment + }, + "fallback": [] + } + } + }), + ) + .await + .map_err(|error| error.to_string())?; + let revision = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().to_string()) + .unwrap_or_else(|_| format!("{provider}:{deployment}")); + self.merge_patch_kind( + "kars-system", + "KarsSandbox", + "bridge-orchestrator", + serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/orchestrator-model-revision": revision + } + } + }), + ) + .await + .map_err(|error| error.to_string())?; + // Changing the mounted policy deliberately rolls the orchestrator pod. + // Wait for the controller's exact generation + router-echo confirmation, + // not merely the policy write or a fixed short pod-start assumption. + for _ in 0..ORCHESTRATOR_POLICY_READY_ATTEMPTS { + let ready = self + .get_kind( + "kars-system", + "InferencePolicy", + "bridge-orchestrator-inference", + ) + .await + .map_err(|error| error.to_string())? + .as_ref() + .is_some_and(&policy_ready); + if ready { + return Ok(()); + } + tokio::time::sleep(ORCHESTRATOR_POLICY_POLL_INTERVAL).await; + } + Err(format!( + "timed out waiting for the bridge orchestrator router to enforce {provider}/{deployment}" + )) + } +} diff --git a/bridge/bff/src/kars/cluster/provider_tests.rs b/bridge/bff/src/kars/cluster/provider_tests.rs new file mode 100644 index 000000000..2a750efbc --- /dev/null +++ b/bridge/bff/src/kars/cluster/provider_tests.rs @@ -0,0 +1,419 @@ +use super::mission_records::{ + mission_evidence_key, mission_output_candidate, project_mission_output_record, + select_mission_evidence_records, select_mission_output_records, trace_record_identity, +}; +use super::providers::{ + classify_provider, image_registry_host, normalize_registry_host, public_registry, +}; +use super::sandboxes::descendant_sandbox_objects; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::api::DynamicObject; +use serde_json::json; +use std::collections::BTreeMap; + +fn id(r: Option<(String, String, String)>) -> Option<String> { + r.map(|(i, _, _)| i) +} + +#[test] +fn descendant_sandboxes_include_nested_agents_once() { + let sandbox = |name: &str, parent: Option<&str>| -> DynamicObject { + serde_json::from_value(json!({ + "apiVersion": "kars.azure.com/v1alpha1", + "kind": "KarsSandbox", + "metadata": { + "name": name, + "labels": parent.map(|parent| json!({"kars.azure.com/parent": parent})) + } + })) + .expect("sandbox") + }; + let items = vec![ + sandbox("child", Some("root")), + sandbox("grandchild", Some("child")), + sandbox("unrelated", Some("other")), + ]; + + let names = descendant_sandbox_objects(&items, "root") + .into_iter() + .filter_map(|sandbox| sandbox.metadata.name) + .collect::<std::collections::HashSet<_>>(); + assert_eq!( + names, + std::collections::HashSet::from(["child".to_string(), "grandchild".to_string(),]) + ); +} + +#[test] +fn registry_matching_covers_private_runtime_images() { + assert_eq!( + image_registry_host("example.azurecr.io/kars-runtime-hermes:latest"), + "example.azurecr.io" + ); + assert_eq!( + normalize_registry_host("https://example.azurecr.io/v1/"), + "example.azurecr.io" + ); + assert!(!public_registry("example.azurecr.io")); + assert!(public_registry("mcr.microsoft.com")); +} + +#[test] +fn mission_evidence_annotation_restores_long_nonce_identity() { + let full = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let mut config_map = ConfigMap::default(); + config_map.metadata.annotations = Some(BTreeMap::from([( + "kars.azure.com/mission-evidence-key".to_string(), + full.to_string(), + )])); + config_map.metadata.labels = Some(BTreeMap::from([( + "kars.azure.com/mission-output".to_string(), + "stock-monitor-persistent-qual-principal-assig-0123456789ab".to_string(), + )])); + + assert_eq!( + mission_evidence_key(&config_map, "kars.azure.com/mission-output").as_deref(), + Some(full) + ); +} + +#[test] +fn mission_evidence_label_remains_legacy_fallback() { + let mut config_map = ConfigMap::default(); + config_map.metadata.labels = Some(BTreeMap::from([( + "kars.azure.com/mission-output".to_string(), + "team-run-100".to_string(), + )])); + + assert_eq!( + mission_evidence_key(&config_map, "kars.azure.com/mission-output").as_deref(), + Some("team-run-100") + ); +} + +#[test] +fn legacy_principal_label_restores_stable_task_name() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let mut config_map = ConfigMap::default(); + config_map.metadata.labels = Some(BTreeMap::from([ + ( + "kars.azure.com/mission-output".to_string(), + nonce.to_string(), + ), + ( + "kars.azure.com/mission-principal".to_string(), + "stock-monitor-persistent-qual-principal".to_string(), + ), + ])); + config_map.data = Some(BTreeMap::from([( + "assignmentNonce".to_string(), + nonce.to_string(), + )])); + + let (_, _, data) = mission_output_candidate(config_map).expect("candidate"); + assert_eq!( + data.get("taskName").map(String::as_str), + Some("stock-monitor-persistent-qual-principal") + ); +} + +#[test] +fn ordinary_mission_enumeration_keeps_the_task_pointer() { + let first_nonce = "run-1784912062312097896"; + let latest_nonce = "run-1784915840189332732"; + let first = BTreeMap::from([ + ("assignmentNonce".to_string(), first_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let latest = BTreeMap::from([ + ("assignmentNonce".to_string(), latest_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let selected = select_mission_output_records(vec![ + (first_nonce.to_string(), Some("archive".to_string()), first), + ( + latest_nonce.to_string(), + Some("archive".to_string()), + latest.clone(), + ), + ( + "kompli-research".to_string(), + Some("current".to_string()), + latest, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "kompli-research"); +} + +#[test] +fn explicit_current_pointer_beats_legacy_archive_for_same_task() { + let old_nonce = "rev-1"; + let latest_nonce = "rev-2"; + let legacy = BTreeMap::from([ + ("assignmentNonce".to_string(), old_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let current = BTreeMap::from([ + ("assignmentNonce".to_string(), latest_nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let selected = select_mission_output_records(vec![ + (old_nonce.to_string(), None, legacy), + ( + "kompli-research".to_string(), + Some("current".to_string()), + current, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "kompli-research"); +} + +#[test] +fn legacy_ordinary_run_archives_do_not_become_phantom_tasks() { + let old_nonce = "run-1784912062312097896"; + let latest_nonce = "run-1784915840189332732"; + let mut archive = ConfigMap::default(); + archive.metadata.labels = Some(BTreeMap::from([ + ( + "kars.azure.com/mission-output".to_string(), + old_nonce.to_string(), + ), + ( + "kars.azure.com/mission-principal".to_string(), + "kompli-research".to_string(), + ), + ])); + archive.data = Some(BTreeMap::from([( + "assignmentNonce".to_string(), + old_nonce.to_string(), + )])); + let mut current = ConfigMap::default(); + current.metadata.labels = Some(BTreeMap::from([( + "kars.azure.com/mission-output".to_string(), + "kompli-research".to_string(), + )])); + current.data = Some(BTreeMap::from([( + "assignmentNonce".to_string(), + latest_nonce.to_string(), + )])); + let selected = select_mission_output_records(vec![ + mission_output_candidate(archive).expect("archive"), + mission_output_candidate(current).expect("current"), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "kompli-research"); +} + +#[test] +fn persistent_team_latest_enumeration_keeps_the_current_pointer() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ( + "taskName".to_string(), + "stock-monitor-persistent-qual-principal".to_string(), + ), + ( + "team".to_string(), + "stock-monitor-persistent-qual".to_string(), + ), + ]); + let selected = select_mission_output_records(vec![ + (nonce.to_string(), Some("archive".to_string()), data.clone()), + ( + "stock-monitor-persistent-qual-principal".to_string(), + Some("current".to_string()), + data, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, "stock-monitor-persistent-qual-principal"); +} + +#[test] +fn accounting_enumeration_keeps_archives_and_drops_current_pointers() { + let nonce = "run-1784915840189332732"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ("taskName".to_string(), "kompli-research".to_string()), + ]); + let selected = select_mission_evidence_records(vec![ + (nonce.to_string(), Some("archive".to_string()), data.clone()), + ( + "kompli-research".to_string(), + Some("current".to_string()), + data, + ), + ]); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].0, nonce); +} + +#[test] +fn accounting_enumeration_keeps_each_rerun_archive() { + let first_nonce = "rev-1"; + let latest_nonce = "rev-2"; + let selected = select_mission_evidence_records(vec![ + ( + first_nonce.to_string(), + Some("archive".to_string()), + BTreeMap::from([("assignmentNonce".to_string(), first_nonce.to_string())]), + ), + ( + latest_nonce.to_string(), + Some("archive".to_string()), + BTreeMap::from([("assignmentNonce".to_string(), latest_nonce.to_string())]), + ), + ( + "kompli-research".to_string(), + Some("current".to_string()), + BTreeMap::from([("assignmentNonce".to_string(), latest_nonce.to_string())]), + ), + ]); + + assert_eq!(selected.len(), 2); + assert!(selected.iter().any(|(key, _)| key == first_nonce)); + assert!(selected.iter().any(|(key, _)| key == latest_nonce)); +} + +#[test] +fn persistent_archive_projects_stable_task_and_separate_evidence_key() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ( + "taskName".to_string(), + "stock-monitor-persistent-qual-principal".to_string(), + ), + ( + "team".to_string(), + "stock-monitor-persistent-qual".to_string(), + ), + ]); + let projected = project_mission_output_record(nonce.to_string(), data); + + assert_eq!( + projected.task_name, + "stock-monitor-persistent-qual-principal" + ); + assert_eq!(projected.evidence_key, nonce); +} + +#[test] +fn mirrored_trace_records_share_one_counting_identity() { + let nonce = "stock-monitor-persistent-qual-principal-assign-1784912607085271247"; + let data = BTreeMap::from([ + ("assignmentNonce".to_string(), nonce.to_string()), + ( + "trace.json".to_string(), + r#"[{"kind":"round"}]"#.to_string(), + ), + ("capturedAt".to_string(), "2026-07-24T19:00:00Z".to_string()), + ]); + let mut archive = ConfigMap::default(); + archive.metadata.name = Some(format!("kars-mission-trace-{nonce}")); + archive.metadata.annotations = Some(BTreeMap::from([( + "kars.azure.com/mission-evidence-role".to_string(), + "archive".to_string(), + )])); + archive.data = Some(data.clone()); + let mut current = ConfigMap::default(); + current.metadata.name = + Some("kars-mission-trace-stock-monitor-persistent-qual-principal".to_string()); + current.metadata.annotations = Some(BTreeMap::from([( + "kars.azure.com/mission-evidence-role".to_string(), + "current".to_string(), + )])); + current.data = Some(data); + + assert!(trace_record_identity(&archive).is_some()); + assert!(trace_record_identity(¤t).is_none()); +} + +#[test] +fn explicit_override_wins() { + let eps = vec!["https://models.github.ai/inference".to_string()]; + assert_eq!( + id(classify_provider(Some("github-copilot"), &eps, None)).as_deref(), + Some("github-copilot") + ); + assert_eq!( + id(classify_provider( + Some("github-models"), + &eps, + Some("gho_x") + )) + .as_deref(), + Some("github-models") + ); + assert_eq!( + id(classify_provider(Some("foundry"), &[], None)).as_deref(), + Some("azure-foundry") + ); +} + +#[test] +fn github_endpoint_with_oauth_token_is_copilot() { + // The real localkarstest shape: models.github.ai + a gho_ OAuth token. + let eps = vec!["https://models.github.ai/inference".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, Some("gho_"))).as_deref(), + Some("github-copilot") + ); + assert_eq!( + id(classify_provider(None, &eps, Some("ghu_"))).as_deref(), + Some("github-copilot") + ); +} + +#[test] +fn github_endpoint_with_pat_is_models() { + let eps = vec!["https://models.github.ai/inference".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, Some("ghp_"))).as_deref(), + Some("github-models") + ); + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("github-models") + ); +} + +#[test] +fn copilot_endpoint_is_copilot() { + let eps = vec!["https://api.githubcopilot.com".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("github-copilot") + ); +} + +#[test] +fn foundry_endpoint_and_empty() { + let eps = vec!["https://my-proj.openai.azure.com".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("azure-foundry") + ); + assert_eq!(id(classify_provider(None, &[], None)), None); +} + +#[test] +fn local_inference_endpoint_is_not_mislabeled_as_foundry() { + // A promoted local model's endpoint is always a Service DNS name in + // the Bridge-owned kars-local-inference namespace — must be labeled + // distinctly, not fall into the generic Foundry bucket every other + // unrecognized endpoint gets. + let eps = vec!["http://my-model.kars-local-inference.svc.cluster.local:80".to_string()]; + assert_eq!( + id(classify_provider(None, &eps, None)).as_deref(), + Some("local-inference") + ); +} diff --git a/bridge/bff/src/kars/cluster/providers.rs b/bridge/bff/src/kars/cluster/providers.rs new file mode 100644 index 000000000..730771941 --- /dev/null +++ b/bridge/bff/src/kars/cluster/providers.rs @@ -0,0 +1,601 @@ +use super::Cluster; +use kube::api::Api; + +pub(super) fn normalize_registry_host(value: &str) -> String { + value + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} + +pub(super) fn image_registry_host(image: &str) -> String { + let first = image.trim().split('/').next().unwrap_or_default(); + if first.contains('.') || first.contains(':') || first == "localhost" { + first.to_ascii_lowercase() + } else { + "docker.io".to_string() + } +} + +pub(super) fn public_registry(registry: &str) -> bool { + matches!( + registry, + "docker.io" | "registry-1.docker.io" | "mcr.microsoft.com" | "public.ecr.aws" + ) +} + +/// Classify the inherited inference provider from an optional `KARS_PROVIDER` +/// override plus the configured endpoint hosts. Mirrors the router's detection +/// (`inference-router/src/config.rs`): the three providers kars supports are +/// GitHub Copilot, GitHub Models, and Azure AI Foundry. Returns `(id, label, +/// note)`, or `None` when nothing identifiable is configured. +pub(super) fn classify_provider( + override_val: Option<&str>, + endpoints: &[String], + token_hint: Option<&str>, +) -> Option<(String, String, String)> { + let host_has = |needle: &str| endpoints.iter().any(|e| e.contains(needle)); + let copilot = ( + "github-copilot", + "GitHub Copilot", + "Models served through your GitHub Copilot subscription (GitHub-hosted inference).", + ); + let gh_models = ( + "github-models", + "GitHub Models", + "Models served through GitHub Models (OpenAI-compatible, GitHub-hosted).", + ); + let foundry = ( + "azure-foundry", + "Azure AI Foundry", + "Models served through your Azure AI Foundry project.", + ); + // A local in-cluster model deployed via the "Local model" wizard — + // its endpoint is always a Service DNS name inside the Bridge-owned + // kars-local-inference namespace (see docs/local-inference.md). Checked + // before the generic Foundry fallback so promoting one to the cluster + // default doesn't display as a misleading "Azure AI Foundry" label. + let local = ( + "local-inference", + "Local model (in-cluster)", + "Models served by an in-cluster deployment — no external API, no per-token billing.", + ); + let is_local_host = host_has(".kars-local-inference.svc.cluster.local"); + // A GitHub OAuth/user token (`gho_`/`ghu_`) indicates a Copilot login; a + // classic PAT (`ghp_`) indicates free GitHub Models. + let is_oauth_token = + matches!(token_hint, Some(t) if t.starts_with("gho_") || t.starts_with("ghu_")); + let on_github = host_has("models.github.ai") || host_has("models.inference.ai.azure.com"); + let pick = match override_val { + // Explicit operator declaration is authoritative. + Some("github-copilot") | Some("copilot") => copilot, + Some("github-models") => gh_models, + Some("foundry") | Some("azure-openai") | Some("azure-foundry") => foundry, + // Otherwise infer from endpoint + token kind. + _ if host_has("api.githubcopilot.com") => copilot, + _ if on_github && is_oauth_token => copilot, + _ if on_github => gh_models, + _ if is_local_host => local, + _ if !endpoints.is_empty() => foundry, + _ => return None, + }; + Some((pick.0.to_string(), pick.1.to_string(), pick.2.to_string())) +} + +impl Cluster { + /// The model deployments this cluster is configured to serve, read from the + /// controller Deployment's environment (`KARS_TASK_DEFAULT_MODEL`, + /// `AZURE_OPENAI_DEPLOYMENT`, and the comma-separated `FOUNDRY_DEPLOYMENTS`). + /// This is the authoritative "what can actually run here" fact — the same + /// values the controller stamps onto a task's InferencePolicy. Best-effort: + /// an unreadable Deployment yields an empty list (honest, not an error), so + /// the launch package degrades to the controller default rather than lying. + pub async fn controller_models(&self) -> (Option<String>, Vec<String>) { + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); + let Ok(Some(d)) = deploys.get_opt("kars-controller").await else { + return (None, Vec::new()); + }; + let mut default: Option<String> = None; + let mut catalog: Vec<String> = Vec::new(); + let envs = d + .spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()); + for e in envs { + let Some(val) = e.value else { continue }; + match e.name.as_str() { + "KARS_TASK_DEFAULT_MODEL" | "AZURE_OPENAI_DEPLOYMENT" if default.is_none() => { + default = Some(val); + } + "FOUNDRY_DEPLOYMENTS" | "KARS_MODEL_CATALOG" => { + catalog.extend( + val.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + ); + } + _ => {} + } + } + (default, catalog) + } + + /// The GitHub token wired for GitHub Copilot — checked in BOTH places + /// Copilot can be configured: the shared providers secret (an additional + /// provider, or one signed-in via the wizard's device login) FIRST, then + /// the controller's `COPILOT_GITHUB_TOKEN` env (the cluster default). Used + /// to fetch the seat's LIVE model catalog so the Model catalogue + + /// orchestrator reflect what Copilot actually serves. `None` when unset. + pub async fn controller_copilot_token(&self) -> Option<String> { + // Wizard sign-in / additional-provider path stores it here. + if let Ok(keys) = self + .read_secret_all("kars-system", "kars-inference-providers") + .await + && let Some(t) = keys + .get("COPILOT_GITHUB_TOKEN") + .filter(|v| !v.trim().is_empty()) + { + return Some(t.clone()); + } + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); + let d = deploys.get_opt("kars-controller").await.ok().flatten()?; + d.spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()) + .find(|e| e.name == "COPILOT_GITHUB_TOKEN") + .and_then(|e| e.value) + .filter(|v| !v.trim().is_empty()) + } + /// fact chosen at cluster setup, NOT something the Bridge picks. kars + /// supports exactly three: GitHub Copilot, GitHub Models, and Azure AI + /// Foundry. The classification mirrors the inference-router's own endpoint + /// detection (`inference-router/src/config.rs`): a `KARS_PROVIDER` override + /// wins, otherwise the configured endpoint host decides. Returns + /// `(id, label, note)` or `None` when the controller is unreadable. + pub async fn controller_provider(&self) -> Option<(String, String, String)> { + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); + let d = deploys.get_opt("kars-controller").await.ok().flatten()?; + let mut provider_override: Option<String> = None; + let mut endpoints: Vec<String> = Vec::new(); + let mut token_hint: Option<String> = None; + let envs = d + .spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()); + for e in envs { + let Some(val) = e.value else { continue }; + match e.name.as_str() { + // Explicit operator declaration — the authoritative brand signal. + "KARS_PROVIDER" | "KARS_INFERENCE_PROVIDER" if !val.is_empty() => { + provider_override = Some(val) + } + "FOUNDRY_ENDPOINT" | "FOUNDRY_PROJECT_ENDPOINT" | "AZURE_OPENAI_ENDPOINT" => { + endpoints.push(val) + } + // Auth token KIND disambiguates the GitHub endpoint: a GitHub + // OAuth/user token (`gho_`/`ghu_`) is a Copilot login; a classic + // PAT (`ghp_`) is free GitHub Models. We only inspect the prefix, + // never the secret, and only when provided inline (dev profile). + "AZURE_OPENAI_API_KEY" | "GITHUB_TOKEN" | "COPILOT_GITHUB_TOKEN" + if token_hint.is_none() && !val.is_empty() => + { + token_hint = Some(val.chars().take(4).collect()); + } + _ => {} + } + } + classify_provider( + provider_override.as_deref(), + &endpoints, + token_hint.as_deref(), + ) + } + + /// The orchestrator inference config this cluster already provides — read + /// from the controller Deployment env the SAME way the runtime does, so the + /// Bridge's intent→package orchestrator inherits the cluster's provider + /// instead of needing its own credentials. Returns `(endpoint, token, + /// model)` when an endpoint, a usable token, and a default model are all + /// present. `None` when the cluster authenticates via workload identity + /// (no static token the BFF can reuse) — the UI then falls back to manual + /// composition honestly. + pub async fn orchestrator_inference(&self) -> Option<(String, String, String)> { + use k8s_openapi::api::apps::v1::Deployment; + let deploys: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); + let d = deploys.get_opt("kars-controller").await.ok().flatten()?; + let mut endpoint: Option<String> = None; + let mut token: Option<String> = None; + let mut model: Option<String> = None; + let envs = d + .spec + .and_then(|s| s.template.spec) + .map(|ps| ps.containers) + .unwrap_or_default() + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()); + for e in envs { + let Some(val) = e.value else { continue }; + if val.is_empty() { + continue; + } + match e.name.as_str() { + "FOUNDRY_ENDPOINT" if endpoint.is_none() => endpoint = Some(val), + "AZURE_OPENAI_ENDPOINT" if endpoint.is_none() => endpoint = Some(val), + "AZURE_OPENAI_API_KEY" | "GITHUB_TOKEN" | "COPILOT_GITHUB_TOKEN" + if token.is_none() => + { + token = Some(val) + } + "KARS_TASK_DEFAULT_MODEL" | "AZURE_OPENAI_DEPLOYMENT" if model.is_none() => { + model = Some(val) + } + _ => {} + } + } + // Normalize a bare Foundry/AOAI endpoint to its OpenAI-compatible base so + // `{endpoint}/chat/completions` resolves. GitHub Models already exposes + // `/inference` as the base; leave it intact. + let endpoint = endpoint?; + Some((endpoint, token?, model?)) + } + + /// Which agent harnesses are actually runnable on this cluster. A configured + /// image is insufficient: private images also need a controller pull secret + /// whose Docker auth covers that image registry. This keeps the composer and + /// preflight from advertising a runtime that will immediately ImagePullBackOff. + pub async fn runnable_runtimes(&self) -> std::collections::BTreeSet<String> { + use k8s_openapi::api::apps::v1::Deployment; + let mut runnable: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); + // BYO remains selectable because its image is supplied by the BYO contract. + runnable.insert("BYO".into()); + let Ok(Some(d)) = (Api::<Deployment>::namespaced(self.client.clone(), "kars-system")) + .get_opt("kars-controller") + .await + else { + return runnable; + }; + let Some(pod_spec) = d.spec.and_then(|s| s.template.spec) else { + return runnable; + }; + let configured: std::collections::BTreeMap<String, String> = pod_spec + .containers + .into_iter() + .flat_map(|c| c.env.unwrap_or_default()) + .filter_map(|e| { + e.value + .filter(|value| !value.trim().is_empty()) + .map(|value| (e.name, value)) + }) + .collect(); + // The BFF deliberately has no Secret RBAC. The controller exposes only + // the non-sensitive registry hostnames covered by its pull credentials. + let authenticated_registries = configured + .get("IMAGE_PULL_REGISTRIES") + .into_iter() + .flat_map(|value| value.split(',')) + .map(normalize_registry_host) + .filter(|registry| !registry.is_empty()) + .collect::<std::collections::BTreeSet<_>>(); + let image_is_pullable = |image: &str| { + let registry = image_registry_host(image); + public_registry(®istry) || authenticated_registries.contains(®istry) + }; + if configured + .get("SANDBOX_IMAGE") + .is_some_and(|image| image_is_pullable(image)) + { + runnable.insert("OpenClaw".into()); + } + let mapping = [ + ("OPENAI_AGENTS_RUNTIME_IMAGE", "OpenAIAgents"), + ("MAF_RUNTIME_IMAGE", "MicrosoftAgentFramework"), + ("ANTHROPIC_RUNTIME_IMAGE", "Anthropic"), + ("LANGGRAPH_RUNTIME_IMAGE", "LangGraph"), + ("LANGGRAPH_TS_RUNTIME_IMAGE", "LangGraph"), + ("PYDANTIC_AI_RUNTIME_IMAGE", "PydanticAi"), + ("HERMES_RUNTIME_IMAGE", "Hermes"), + ]; + for (env, kind) in mapping { + if configured + .get(env) + .is_some_and(|image| image_is_pullable(image)) + { + runnable.insert(kind.to_string()); + } + } + runnable + } + + /// Patch the controller deployment env to set the model catalog (and + /// optionally an endpoint) so an onboarded provider's models surface in the + /// launch palette. Triggers a rolling restart. Operator-gated write. + /// + /// When `key_secret` is `Some((secret_name, secret_key))`, the provider's + /// API key is wired via a `secretKeyRef` on `AZURE_OPENAI_API_KEY` — the env + /// var the controller reads and then propagates to every sandbox pod it + /// creates (see controller reconciler). This is what makes an `auth=api` + /// provider actually usable end-to-end, not merely stored. + pub async fn set_controller_catalog( + &self, + catalog: &str, + endpoint: Option<&str>, + key_secret: Option<(&str, &str)>, + ) -> Result<(), kube::Error> { + // Strategic merge on `env` (merge-key `name`) upserts these entries and + // preserves every other existing env var on the container. + let mut env = vec![serde_json::json!({"name": "KARS_MODEL_CATALOG", "value": catalog})]; + // The FIRST catalog entry is the default model — pin it as + // KARS_TASK_DEFAULT_MODEL + AZURE_OPENAI_DEPLOYMENT so switching the + // default provider (or a specific default model) actually changes what + // missions inherit, not just the offered catalog. Without this, the + // controller kept serving a STALE default model after every switch. + if let Some(default_model) = catalog.split(',').map(str::trim).find(|s| !s.is_empty()) { + env.push( + serde_json::json!({"name": "KARS_TASK_DEFAULT_MODEL", "value": default_model}), + ); + env.push( + serde_json::json!({"name": "AZURE_OPENAI_DEPLOYMENT", "value": default_model}), + ); + } + // `controller_provider()` (the "what's the current default provider" + // read used by the Configuration page's status card) checks THREE + // things it treats as stale-able: an explicit `KARS_PROVIDER` / + // `KARS_INFERENCE_PROVIDER` override (checked FIRST, absolute + // priority over everything else — typically set once at cluster + // bootstrap, e.g. `KARS_PROVIDER=github-copilot`), then + // FOUNDRY_ENDPOINT / FOUNDRY_PROJECT_ENDPOINT / AZURE_OPENAI_ENDPOINT + // as interchangeable endpoint aliases (picks whichever it finds + // FIRST). Every caller of this function is switching the cluster + // default to a NEW provider, so ALL of these must be cleared here — + // confirmed live this was a real, pre-existing bug affecting the + // ORIGINAL "Add or switch a provider" flow too, not just the new + // local-inference promote action: switching the default endpoint + // correctly patched FOUNDRY_ENDPOINT, but the Configuration page + // kept showing "GitHub Copilot" forever after, because the + // bootstrap-time `KARS_PROVIDER=github-copilot` override (checked + // before any endpoint) was never cleared by anything. Neither + // caller of this function ever wants to declare copilot/models as + // default (both explicitly reject that combination before calling + // in), so unconditionally clearing the override is correct here. + // `$patch: delete` is the standard strategic-merge-patch mechanism + // for removing one named entry from a mergeKey'd list without + // touching the rest — a no-op if the name was never present. + for stale in [ + "KARS_PROVIDER", + "KARS_INFERENCE_PROVIDER", + "AZURE_OPENAI_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", + ] { + env.push(serde_json::json!({"name": stale, "$patch": "delete"})); + } + if let Some(e) = endpoint { + env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "value": e})); + } else { + // No explicit endpoint (e.g. switching to GitHub Copilot/Models + // default, which reach their well-known host without one) — clear + // any previously-set FOUNDRY_ENDPOINT too, for the same reason. + env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "$patch": "delete"})); + } + if let Some((secret, key)) = key_secret { + // valueFrom.secretKeyRef replaces any prior static `value` for this + // name under strategic merge, so the key is sourced from the Secret. + env.push(serde_json::json!({ + "name": "AZURE_OPENAI_API_KEY", + "valueFrom": { "secretKeyRef": { "name": secret, "key": key } }, + })); + } else { + // The new default has no key (e.g. an unauthenticated in-cluster + // local model, or Workload Identity) — clear any key wired for a + // PRIOR default so the router doesn't keep sending a stale + // credential to an endpoint that never asked for one. + env.push(serde_json::json!({"name": "AZURE_OPENAI_API_KEY", "$patch": "delete"})); + } + self.write_controller_environment(env).await + } + + /// Make GitHub Copilot the cluster's DEFAULT provider. Copilot doesn't use + /// the endpoint+key shape `set_controller_catalog` wires — it authenticates + /// via a GitHub token exchanged for a short-lived Copilot JWT by the router + /// (`copilot_auth`). This wires exactly what Copilot-as-default needs on the + /// controller (which propagates it to every sandbox): `KARS_PROVIDER= + /// github-copilot`, `COPILOT_GITHUB_TOKEN` (the token signed in via the + /// wizard, read from the shared providers secret), the Copilot API host as + /// the `AZURE_OPENAI_ENDPOINT` sentinel (the controller refuses to + /// provision a sandbox without SOME inference endpoint), the model catalog, + /// and the default model — clearing any stale Azure/Foundry endpoint+key + /// from a prior default. Returns an error if no Copilot token is stored yet + /// (the operator must sign in first). + pub async fn set_copilot_as_default(&self, models: &str) -> Result<(), kube::Error> { + let token = self + .read_secret_all("kars-system", "kars-inference-providers") + .await? + .get("COPILOT_GITHUB_TOKEN") + .filter(|v| !v.trim().is_empty()) + .cloned(); + let Some(_token) = token else { + return Err(kube::Error::Api(kube::error::ErrorResponse { + status: "Failure".into(), + message: "no Copilot token is stored — sign in to GitHub Copilot first".into(), + reason: "BadRequest".into(), + code: 400, + })); + }; + let default_model = models + .split(',') + .next() + .map(str::trim) + .unwrap_or("") + .to_string(); + let mut env = vec![ + serde_json::json!({"name": "KARS_PROVIDER", "value": "github-copilot"}), + serde_json::json!({"name": "COPILOT_GITHUB_TOKEN", "valueFrom":{"secretKeyRef":{ + "name":"kars-inference-providers","key":"COPILOT_GITHUB_TOKEN"}}}), + serde_json::json!({"name": "AZURE_OPENAI_ENDPOINT", "value": "https://api.githubcopilot.com"}), + serde_json::json!({"name": "KARS_MODEL_CATALOG", "value": models}), + ]; + if !default_model.is_empty() { + env.push(serde_json::json!({"name": "KARS_TASK_DEFAULT_MODEL", "value": default_model.clone()})); + env.push( + serde_json::json!({"name": "AZURE_OPENAI_DEPLOYMENT", "value": default_model}), + ); + } + // Clear anything a prior (Azure/Foundry) default left behind so the + // router doesn't keep a stale endpoint/key alongside Copilot. + for stale in [ + "FOUNDRY_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", + "AZURE_OPENAI_API_KEY", + "KARS_INFERENCE_PROVIDER", + ] { + env.push(serde_json::json!({"name": stale, "$patch": "delete"})); + } + self.write_controller_environment(env).await + } + + pub async fn controller_env_value(&self, name: &str) -> Option<String> { + use k8s_openapi::api::apps::v1::Deployment; + let deployment = Api::<Deployment>::namespaced(self.client.clone(), "kars-system") + .get_opt("kars-controller") + .await + .ok() + .flatten()?; + deployment + .spec? + .template + .spec? + .containers + .first()? + .env + .as_ref()? + .iter() + .find(|entry| entry.name == name) + .and_then(|entry| entry.value.clone()) + } + + /// The current Foundry connection, read live from the `kars-controller` + /// Deployment env: `(project_endpoint, inference_endpoint, memory_store_id, + /// has_api_key)`. All `None`/false when Foundry has not been onboarded. The + /// API key is NEVER returned — only whether one is wired. + pub async fn get_foundry_connection( + &self, + ) -> (Option<String>, Option<String>, Option<String>, bool) { + use k8s_openapi::api::apps::v1::Deployment; + let api: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); + let Some(dep) = api.get_opt("kars-controller").await.ok().flatten() else { + return (None, None, None, false); + }; + let mut project = None; + let mut inference = None; + let mut store = None; + let mut has_key = false; + if let Some(spec) = dep.spec.and_then(|s| s.template.spec) { + for c in spec.containers { + for env in c.env.unwrap_or_default() { + match env.name.as_str() { + "FOUNDRY_PROJECT_ENDPOINT" => project = env.value.filter(|v| !v.is_empty()), + "FOUNDRY_ENDPOINT" => inference = env.value.filter(|v| !v.is_empty()), + "FOUNDRY_MEMORY_STORE_ID" => store = env.value.filter(|v| !v.is_empty()), + "FOUNDRY_API_KEY" => { + has_key = env.value_from.is_some() + || env.value.as_ref().is_some_and(|v| !v.is_empty()); + } + _ => {} + } + } + } + } + (project, inference, store, has_key) + } + + /// Onboard a Foundry connection by patching the `kars-controller` Deployment + /// env (strategic merge on `env` by name, preserving all other vars). Sets + /// `FOUNDRY_PROJECT_ENDPOINT` (+ optional inference endpoint / memory store), + /// and for API-key auth wires `FOUNDRY_API_KEY` from a Secret via + /// `secretKeyRef`. The controller then propagates these to sandbox routers. + /// Managed-identity auth stores no key — the router uses the cluster's + /// workload identity (audience `https://ai.azure.com`). + pub async fn set_foundry_connection( + &self, + project_endpoint: &str, + inference_endpoint: Option<&str>, + memory_store_id: Option<&str>, + key_secret: Option<(&str, &str)>, + ) -> Result<(), kube::Error> { + let mut env = vec![ + serde_json::json!({"name": "FOUNDRY_PROJECT_ENDPOINT", "value": project_endpoint}), + ]; + if let Some(e) = inference_endpoint.filter(|e| !e.is_empty()) { + env.push(serde_json::json!({"name": "FOUNDRY_ENDPOINT", "value": e})); + } + if let Some(s) = memory_store_id.filter(|s| !s.is_empty()) { + env.push(serde_json::json!({"name": "FOUNDRY_MEMORY_STORE_ID", "value": s})); + } + if let Some((secret, key)) = key_secret { + env.push(serde_json::json!({ + "name": "FOUNDRY_API_KEY", + "valueFrom": { "secretKeyRef": { "name": secret, "key": key } }, + })); + } + self.write_controller_environment(env).await + } + + /// The workload-identity client-id wired onto the sandbox/controller service + /// account, if any — evidence the cluster can obtain managed-identity tokens + /// (the same path Foundry data-plane access uses). `None` when not wired. + pub async fn workload_identity_client_id(&self) -> Option<String> { + use k8s_openapi::api::core::v1::ServiceAccount; + let api: Api<ServiceAccount> = Api::namespaced(self.client.clone(), "kars-system"); + for sa in ["kars-controller", "default"] { + if let Some(obj) = api.get_opt(sa).await.ok().flatten() + && let Some(cid) = obj + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("azure.workload.identity/client-id")) + .filter(|v| !v.is_empty()) + { + return Some(cid.clone()); + } + } + None + } + + /// The model every team run inherits when its blueprint pins none — the + /// controller's `KARS_TASK_DEFAULT_MODEL` env (see + /// `controller/src/kars_task_execution.rs::default_model`). Read live from + /// the `kars-controller` Deployment so the Bridge shows the *effective* + /// model, not a hardcoded guess. `None` when the controller isn't found or + /// the env is unset (the caller then labels it generically). + pub async fn controller_default_model(&self) -> Option<String> { + use k8s_openapi::api::apps::v1::Deployment; + let api: Api<Deployment> = Api::namespaced(self.client.clone(), &self.core_namespace()); + let dep = api.get_opt("kars-controller").await.ok().flatten()?; + let containers = dep.spec?.template.spec?.containers; + for c in containers { + for env in c.env.unwrap_or_default() { + if env.name == "KARS_TASK_DEFAULT_MODEL" + && let Some(v) = env.value.filter(|v| !v.is_empty()) + { + return Some(v); + } + } + } + None + } +} diff --git a/bridge/bff/src/kars/cluster/resources.rs b/bridge/bff/src/kars/cluster/resources.rs new file mode 100644 index 000000000..ca4d54835 --- /dev/null +++ b/bridge/bff/src/kars/cluster/resources.rs @@ -0,0 +1,373 @@ +use super::Cluster; +use crate::kars::task::KarsTask; +use k8s_openapi::api::core::v1::Node; +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; +use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams}; +use kube::core::ApiResource; + +impl Cluster { + /// `KarsTask` API scoped to a namespace. + pub fn tasks(&self, namespace: &str) -> Api<KarsTask> { + Api::namespaced(self.client.clone(), namespace) + } + + /// Task metadata for usage attribution: `name -> (namespace, created_by)`, + /// listed across ALL namespaces so per-workspace (namespace) and per-user + /// (the `kars.azure.com/created-by` annotation the Bridge stamps) budgets can + /// attribute a run's tokens to the tenant that owns it. Team runs + /// (`<team>-run-<epoch>`) are attributed to the parent team's creator. + pub async fn list_task_meta(&self) -> std::collections::HashMap<String, (String, String)> { + use kube::api::ListParams; + let api: Api<KarsTask> = Api::all(self.client.clone()); + let mut out = std::collections::HashMap::new(); + let Ok(list) = api.list(&ListParams::default()).await else { + return out; + }; + for t in list.items { + let name = t.metadata.name.clone().unwrap_or_default(); + let ns = t + .metadata + .namespace + .clone() + .unwrap_or_else(|| "kars-system".into()); + let created_by = t + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/created-by").cloned()) + .unwrap_or_else(|| "unattributed".into()); + out.insert(name, (ns, created_by)); + } + out + } + + /// `KarsTeam` API scoped to a namespace. + pub fn teams(&self, namespace: &str) -> Api<crate::kars::team::KarsTeam> { + Api::namespaced(self.client.clone(), namespace) + } + + /// `KarsReceipt` API scoped to a namespace. + pub fn receipts(&self, namespace: &str) -> Api<crate::kars::receipt::KarsReceipt> { + Api::namespaced(self.client.clone(), namespace) + } + + /// `KarsApproval` API scoped to a namespace. + pub fn approvals(&self, namespace: &str) -> Api<crate::kars::approval::KarsApproval> { + Api::namespaced(self.client.clone(), namespace) + } + + /// `KarsSREAction` API, cluster-wide. This is an operator-persona, + /// platform-level surface (the kars-sre agent's proposals), not scoped to + /// a workspace namespace — mirrors the `KarsTask` `Api::all` pattern used + /// for cross-namespace operator views. + pub fn sre_actions_all(&self) -> Api<crate::kars::sre_action::KarsSREAction> { + Api::all(self.client.clone()) + } + + /// `KarsSREAction` API scoped to a namespace (for approve/reject patches, + /// which must target the CR's own namespace). + pub fn sre_actions(&self, namespace: &str) -> Api<crate::kars::sre_action::KarsSREAction> { + Api::namespaced(self.client.clone(), namespace) + } + + /// Read-only readiness check of the required APIs, bounded across all requests. + pub async fn ping(&self, namespace: &str) -> anyhow::Result<()> { + const REQUIRED_KARS_APIS: &[(&str, &str)] = &[ + ("KarsSandbox", "karssandboxes"), + ("KarsTask", "karstasks"), + ("KarsTeam", "karsteams"), + ("KarsProfile", "karsprofiles"), + ("KarsSkill", "karsskills"), + ("KarsApproval", "karsapprovals"), + ("EgressApproval", "egressapprovals"), + ("KarsReceipt", "karsreceipts"), + ("McpServer", "mcpservers"), + ("InferencePolicy", "inferencepolicies"), + ("ToolPolicy", "toolpolicies"), + ("KarsMemory", "karsmemories"), + ("KarsEval", "karsevals"), + ("KarsSREAction", "karssreactions"), + ("KarsCredentialGrant", "karscredentialgrants"), + ]; + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + for (kind, plural) in REQUIRED_KARS_APIS { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let mut ar = ApiResource::from_gvk(&gvk); + ar.plural = (*plural).to_string(); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + tokio::time::timeout_at(deadline, api.list(&ListParams::default().limit(1))) + .await + .map_err(|_| { + anyhow::anyhow!( + "required Kars API kars.azure.com/v1alpha1/{kind} readiness check timed out" + ) + })? + .map_err(|error| { + anyhow::anyhow!( + "required Kars API kars.azure.com/v1alpha1/{kind} is unavailable: {error}" + ) + })?; + } + Ok(()) + } + + /// True iff a CRD with the given plural.group name is installed (e.g. + /// `karssandboxes.kars.azure.com`). Used by the System view to report + /// honest wiring status read from the cluster, not asserted. + pub async fn crd_installed(&self, name: &str) -> bool { + let crds: Api<CustomResourceDefinition> = Api::all(self.client.clone()); + crds.get_opt(name).await.ok().flatten().is_some() + } + + /// Count resources of an arbitrary kars CRD kind in a namespace, via the + /// dynamic API so the BFF need not model every CRD it merely *counts*. + /// Returns `None` when the CRD is not installed. + pub async fn count_kind(&self, namespace: &str, kind: &str) -> Option<usize> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + match api.list(&ListParams::default()).await { + Ok(list) => Some(list.items.len()), + Err(_) => None, + } + } + + pub async fn create_kind( + &self, + namespace: &str, + kind: &str, + body: serde_json::Value, + ) -> Result<DynamicObject, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + let obj: DynamicObject = serde_json::from_value(body).map_err(|e| { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: e.to_string(), + reason: "BadRequest".into(), + code: 400, + }) + })?; + api.create(&kube::api::PostParams::default(), &obj).await + } + + /// Server-Side Apply a `kars.azure.com` CRD — the Kubernetes-native + /// declarative upsert (the same operation `kubectl apply` performs): creates + /// the object on first apply, edits it on re-apply. The Bridge owns its + /// fields under the stable `kars-bridge` field manager, so the controller, + /// other tools, and a human's `kubectl edit` can co-own different fields + /// without clobbering each other (tracked in `metadata.managedFields`). + /// + /// `force = false` (default) surfaces a 409 field-ownership conflict when + /// another manager owns a field this apply sets — the caller decides whether + /// to override. `force = true` takes ownership of the applied fields. The + /// real authorization boundary is RBAC on the Bridge ServiceAccount + the + /// CRD's admission/CEL validation — not this method. + pub async fn apply_kind( + &self, + namespace: &str, + kind: &str, + body: serde_json::Value, + force: bool, + ) -> Result<DynamicObject, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + let obj: DynamicObject = serde_json::from_value(body).map_err(|e| { + kube::Error::Api(kube::core::ErrorResponse { + status: "Failure".into(), + message: e.to_string(), + reason: "BadRequest".into(), + code: 400, + }) + })?; + let name = obj.metadata.name.clone().unwrap_or_default(); + let mut pp = kube::api::PatchParams::apply("kars-bridge"); + if force { + pp = pp.force(); + } + api.patch(&name, &pp, &kube::api::Patch::Apply(&obj)).await + } + + /// Delete a namespaced kars CRD by kind + name. Foreground propagation so + /// the controller's finalizers run (revoking any downstream state) before + /// the object disappears. The RBAC boundary is the Bridge ServiceAccount's + /// `delete` verb on the resource; a 403/404 surfaces to the caller. + pub async fn delete_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + ) -> Result<(), kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + let dp = kube::api::DeleteParams::foreground(); + api.delete(name, &dp).await?; + Ok(()) + } + + pub async fn delete_team( + &self, + namespace: &str, + name: &str, + uid: &str, + version: &str, + ) -> Result<(), kube::Error> { + self.teams(namespace) + .delete( + name, + &kube::api::DeleteParams { + propagation_policy: Some(kube::api::PropagationPolicy::Foreground), + preconditions: Some(kube::api::Preconditions { + uid: Some(uid.into()), + resource_version: Some(version.into()), + }), + ..Default::default() + }, + ) + .await?; + // Core owns Team/source/commons cleanup. Historical or ambiguous + // name-keyed records are retained rather than deleting another UID's data. + Ok(()) + } + + /// List all objects of a kars CRD `kind` across **all** namespaces, as + /// dynamic objects the caller projects into a DTO. This is the generic + /// read the operator surfaces use so the BFF need not type every CRD it + /// merely lists. Returns `Err` only on a real API failure; an absent CRD + /// surfaces as `Ok(vec![])` so the caller can render an honest empty state. + pub async fn list_kind_all(&self, kind: &str) -> Result<Vec<DynamicObject>, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); + match api.list(&ListParams::default()).await { + Ok(list) => Ok(list.items), + // A 404 means the CRD isn't installed — honest empty, not an error. + Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + pub async fn list_metrics_all( + &self, + kind: &str, + plural: &str, + ) -> Result<Vec<DynamicObject>, kube::Error> { + let ar = ApiResource { + group: "metrics.k8s.io".into(), + version: "v1beta1".into(), + api_version: "metrics.k8s.io/v1beta1".into(), + kind: kind.into(), + plural: plural.into(), + }; + let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); + Ok(api.list(&ListParams::default()).await?.items) + } + + pub async fn list_nodes(&self) -> Result<Vec<Node>, kube::Error> { + let api: Api<Node> = Api::all(self.client.clone()); + Ok(api.list(&ListParams::default()).await?.items) + } + + /// List objects of a kars CRD `kind` across all namespaces filtered by a + /// label selector — used to find an agent's spawned sub-agents, which the + /// inference router labels `kars.azure.com/parent=<sandbox>`. Absent CRD → + /// `Ok(vec![])`. + pub async fn list_kind_labeled( + &self, + kind: &str, + selector: &str, + ) -> Result<Vec<DynamicObject>, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::all_with(self.client.clone(), &ar); + match api.list(&ListParams::default().labels(selector)).await { + Ok(list) => Ok(list.items), + Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + /// List objects of a kars CRD `kind` within a namespace, as dynamic + /// objects. Absent CRD → `Ok(vec![])`. + pub async fn list_kind( + &self, + namespace: &str, + kind: &str, + ) -> Result<Vec<DynamicObject>, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + match api.list(&ListParams::default()).await { + Ok(list) => Ok(list.items), + Err(kube::Error::Api(ae)) if ae.code == 404 => Ok(Vec::new()), + Err(e) => Err(e), + } + } + + /// Fetch a single kars CRD object by kind + namespace + name. + /// Merge-patch annotations onto a namespaced kars CRD's metadata. Used by + /// the operator skill-admission gate to record the review verdict, the + /// approver, and the version digest the approval is locked to — a real, + /// auditable admission record on the object itself (RBAC: the Bridge SA's + /// `patch` verb). A `None` value removes the annotation. + pub async fn annotate_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + annotations: &[(&str, Option<String>)], + ) -> Result<DynamicObject, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + let mut ann = serde_json::Map::new(); + for (k, v) in annotations { + ann.insert((*k).to_string(), serde_json::json!(v)); + } + let patch = serde_json::json!({ "metadata": { "annotations": ann } }); + api.patch( + name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&patch), + ) + .await + } + + /// Apply a strategic **merge patch** to a kars CRD object — used for small, + /// in-place edits (e.g. an operator changing an InferencePolicy's token + /// budget). Unlike SSA this doesn't take field-manager ownership of the whole + /// spec, so it co-exists with the controller's own management. + pub async fn merge_patch_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + patch: serde_json::Value, + ) -> Result<DynamicObject, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + api.patch( + name, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(&patch), + ) + .await + } + + pub async fn get_kind( + &self, + namespace: &str, + kind: &str, + name: &str, + ) -> Result<Option<DynamicObject>, kube::Error> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), namespace, &ar); + api.get_opt(name).await + } +} diff --git a/bridge/bff/src/kars/cluster/sandboxes.rs b/bridge/bff/src/kars/cluster/sandboxes.rs new file mode 100644 index 000000000..394178614 --- /dev/null +++ b/bridge/bff/src/kars/cluster/sandboxes.rs @@ -0,0 +1,347 @@ +use super::{Cluster, ContainerState, PodHealth}; +use k8s_openapi::api::core::v1::{ConfigMap, Pod}; +use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams}; +use kube::core::ApiResource; + +pub(super) fn descendant_sandbox_objects( + sandboxes: &[DynamicObject], + root: &str, +) -> Vec<DynamicObject> { + let mut descendants = Vec::new(); + let mut frontier = vec![root.to_string()]; + let mut seen = std::collections::HashSet::from([root.to_string()]); + while let Some(parent) = frontier.pop() { + for sandbox in sandboxes { + let Some(name) = sandbox.metadata.name.as_ref() else { + continue; + }; + let is_child = sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("kars.azure.com/parent")) + == Some(&parent); + if is_child && seen.insert(name.clone()) { + descendants.push(sandbox.clone()); + frontier.push(name.clone()); + } + } + } + descendants +} + +impl Cluster { + /// Find the name of the **Running** pod for a sandbox in its namespace. + /// A task-materialized sandbox runs in namespace `kars-<sandbox>`; its pod + /// carries `kars.azure.com/sandbox=<sandbox>`. Returns `None` if no Running + /// pod is found. + /// Whether a Deployment matching `name` exists in `namespace` (best-effort; + /// false on any API error). Used to detect optional integrations like the + /// Headlamp dashboard (`headlamp` deployment in the `headlamp` namespace). + pub async fn deployment_exists(&self, namespace: &str, name: &str) -> bool { + use k8s_openapi::api::apps::v1::Deployment; + let api: Api<Deployment> = Api::namespaced(self.client.clone(), namespace); + matches!(api.get_opt(name).await, Ok(Some(_))) + } + + /// Every pod in the kars-relevant namespaces (all `kars*` namespaces plus + /// `agentmesh`), for the operator diagnostics scan. Uses a cluster-wide list + /// then filters, so it's one API call regardless of sandbox count. + pub async fn all_pods(&self) -> Vec<k8s_openapi::api::core::v1::Pod> { + let pods: Api<Pod> = Api::all(self.client.clone()); + pods.list(&ListParams::default()) + .await + .map(|l| { + l.items + .into_iter() + .filter(|p| { + let ns = p.metadata.namespace.as_deref().unwrap_or(""); + ns.starts_with("kars") || ns == "agentmesh" + }) + .collect() + }) + .unwrap_or_default() + } + + pub async fn running_pod_for_sandbox(&self, sandbox: &str) -> Option<String> { + let ns = format!("kars-{sandbox}"); + let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); + let list = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + .ok()?; + list.items.into_iter().find_map(|p| { + let phase = p.status.as_ref().and_then(|s| s.phase.as_deref()); + if phase == Some("Running") { + p.metadata.name + } else { + None + } + }) + } + + /// Honest health of a sandbox's running pod: container readiness, restart + /// count, uptime, and node. No metrics-server dependency (no CPU/mem) — these + /// are status-derived signals that answer "is this agent healthy right now". + /// `None` when no pod is running for the sandbox. + pub async fn sandbox_pod_health(&self, sandbox: &str) -> Option<PodHealth> { + let ns = format!("kars-{sandbox}"); + let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); + let list = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + .ok()?; + let pod = list + .items + .into_iter() + .find(|p| p.status.as_ref().and_then(|s| s.phase.as_deref()) == Some("Running"))?; + let status = pod.status.as_ref(); + let cs = status.and_then(|s| s.container_statuses.as_ref()); + let total = cs.map(|c| c.len()).unwrap_or(0) as i32; + let ready = cs + .map(|c| c.iter().filter(|s| s.ready).count()) + .unwrap_or(0) as i32; + let restarts = cs + .map(|c| c.iter().map(|s| s.restart_count).sum()) + .unwrap_or(0); + // Uptime from the pod start time. + let uptime_seconds = status + .and_then(|s| s.start_time.as_ref()) + .map(|t| (chrono::Utc::now() - t.0).num_seconds().max(0)); + // A container stuck waiting (e.g. CrashLoopBackOff) is the honest + // unhealthy signal — surface the reason. + let waiting_reason = cs.and_then(|c| { + c.iter().find_map(|s| { + s.state + .as_ref() + .and_then(|st| st.waiting.as_ref()) + .and_then(|w| w.reason.clone()) + }) + }); + Some(PodHealth { + ready_containers: ready, + total_containers: total, + restarts, + uptime_seconds, + node: pod.spec.as_ref().and_then(|s| s.node_name.clone()), + waiting_reason, + }) + } + + /// Read recent logs from a sandbox pod container (best-effort). Powers the + /// live run-failure troubleshooter, which surfaces the REAL agent output as + /// evidence rather than pattern-matching a status string. + pub async fn read_sandbox_logs( + &self, + sandbox: &str, + container: &str, + tail: i64, + ) -> Option<String> { + let ns = format!("kars-{sandbox}"); + let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); + let list = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + .ok()?; + let pod_name = list.items.into_iter().find_map(|p| p.metadata.name)?; + let lp = kube::api::LogParams { + container: Some(container.to_string()), + tail_lines: Some(tail), + timestamps: false, + ..Default::default() + }; + pods.logs(&pod_name, &lp).await.ok() + } + + /// Per-container status for a sandbox pod (name, ready, restarts, and the + /// current state reason — Running / a waiting reason like ImagePullBackOff / + /// a terminated reason like OOMKilled). Used by the troubleshooter. + pub async fn sandbox_container_states(&self, sandbox: &str) -> Vec<ContainerState> { + let ns = format!("kars-{sandbox}"); + let pods: Api<Pod> = Api::namespaced(self.client.clone(), &ns); + let Ok(list) = pods + .list(&ListParams::default().labels(&format!("kars.azure.com/sandbox={sandbox}"))) + .await + else { + return Vec::new(); + }; + let Some(pod) = list.items.into_iter().next() else { + return Vec::new(); + }; + let cs = pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()); + cs.map(|list| { + list.iter() + .map(|c| { + let (state, reason) = if let Some(st) = c.state.as_ref() { + if st.running.is_some() { + ("running".to_string(), None) + } else if let Some(w) = st.waiting.as_ref() { + ("waiting".to_string(), w.reason.clone()) + } else if let Some(t) = st.terminated.as_ref() { + ("terminated".to_string(), t.reason.clone()) + } else { + ("unknown".to_string(), None) + } + } else { + ("unknown".to_string(), None) + }; + ContainerState { + name: c.name.clone(), + ready: c.ready, + restarts: c.restart_count, + state, + reason, + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// The live egress enforcement mode of a sandbox — read from the + /// `KarsSandbox.spec.networkPolicy.egressMode` the controller materialized. + /// `"Learn"` (default) observes + records every domain the agent reaches + /// without denying; `"Strict"` denies anything outside the allowlist. This + /// is the real, cluster-truth mode — not derived from the blueprint. + pub async fn sandbox_egress_mode(&self, sandbox: &str) -> Option<String> { + let gvk = GroupVersionKind::gvk("kars.azure.com", "v1alpha1", "KarsSandbox"); + let ar = ApiResource::from_gvk(&gvk); + let api: Api<DynamicObject> = Api::namespaced_with(self.client.clone(), "kars-system", &ar); + let sb = api.get_opt(sandbox).await.ok().flatten()?; + Some( + sb.data + .get("spec") + .and_then(|s| s.get("networkPolicy")) + .and_then(|n| n.get("egressMode")) + .and_then(|m| m.as_str()) + .unwrap_or("Learn") + .to_string(), + ) + } + + /// Read only the declared private observation capability. The legacy + /// agent-shared admin token and apiserver header tricks are never fallbacks. + pub async fn sandbox_learned_domains(&self, sandbox: &str) -> anyhow::Result<Vec<String>> { + self.private_learned_domains(sandbox) + .await + .map_err(Into::into) + } + + /// The resolved egress allowlist the sandbox actually enforces — read from + /// the `karssandbox-<sandbox>-egress-allowlist` ConfigMap the controller + /// compiles into the sandbox namespace. Each entry is the exact host(:port) + /// the agent is permitted to reach. Empty in Learn mode (nothing pinned). + pub async fn sandbox_allowlist(&self, sandbox: &str) -> Vec<String> { + let ns = format!("kars-{sandbox}"); + let name = format!("karssandbox-{sandbox}-egress-allowlist"); + let cms: Api<ConfigMap> = Api::namespaced(self.client.clone(), &ns); + let Some(cm) = cms.get_opt(&name).await.ok().flatten() else { + return Vec::new(); + }; + let Some(raw) = cm.data.and_then(|d| d.get("allowlist.json").cloned()) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&raw) else { + return Vec::new(); + }; + parsed + .get("endpoints") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let host = e.get("host").and_then(|h| h.as_str())?; + match e.get("port").and_then(|p| p.as_u64()) { + Some(p) => Some(format!("{host}:{p}")), + None => Some(host.to_string()), + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// LIVE per-agent execution trace, straight from a running sandbox's router + /// (`GET /telemetry/trace` — a PUBLIC in-pod endpoint, reached via the + /// apiserver pod-proxy; no admin token required). Unlike the persisted + /// `kars-mission-trace-<task>` ConfigMap (written once, at delivery), this + /// ticks WHILE the agent works, so the activity stream is genuinely live. + /// Returns the router's `events` array (round/tool shape); empty on any + /// error or when the sandbox has no running pod yet. + pub async fn sandbox_live_trace(&self, sandbox: &str) -> Vec<serde_json::Value> { + let ns = format!("kars-{sandbox}"); + let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::namespaced(self.client.clone(), &ns); + let pod_list = pods.list(&ListParams::default()).await; + if let Err(e) = &pod_list { + tracing::warn!(target: "kars_bridge::live_trace", %ns, error = %e, "pod list failed"); + } + let Some(pod) = pod_list + .ok() + .and_then(|l| { + l.items.into_iter().find(|p| { + p.status + .as_ref() + .and_then(|s| s.phase.as_deref()) + .map(|ph| ph == "Running") + .unwrap_or(false) + }) + }) + .and_then(|p| p.metadata.name) + else { + tracing::warn!(target: "kars_bridge::live_trace", %ns, "no running pod found"); + return Vec::new(); + }; + let path = format!("/api/v1/namespaces/{ns}/pods/{pod}:8443/proxy/telemetry/trace"); + let Ok(req) = http::Request::builder() + .method(http::Method::GET) + .uri(&path) + .body(Vec::new()) + else { + tracing::warn!(target: "kars_bridge::live_trace", %path, "request build failed"); + return Vec::new(); + }; + match self.client.request_text(req).await { + Ok(text) => { + let n = serde_json::from_str::<serde_json::Value>(&text) + .ok() + .and_then(|v| v.get("events").and_then(|e| e.as_array()).cloned()) + .unwrap_or_default(); + tracing::debug!(target: "kars_bridge::live_trace", %pod, events = n.len(), body_len = text.len(), "live trace ok"); + n + } + Err(e) => { + tracing::warn!(target: "kars_bridge::live_trace", %path, error = %e, "proxy request failed"); + Vec::new() + } + } + } + + /// Names of the sub-agent sandboxes a principal spawned at run time — the + /// complete transitive `kars.azure.com/parent` tree. Used to aggregate the + /// WHOLE agent tree's live activity, not just direct children. + pub async fn sub_agent_sandboxes( + &self, + namespace: &str, + parent_sandbox: &str, + ) -> Vec<DynamicObject> { + self.list_kind(namespace, "KarsSandbox") + .await + .map(|items| descendant_sandbox_objects(&items, parent_sandbox)) + .unwrap_or_default() + } + + pub async fn sub_agent_sandbox_names( + &self, + namespace: &str, + parent_sandbox: &str, + ) -> Vec<String> { + self.sub_agent_sandboxes(namespace, parent_sandbox) + .await + .iter() + .filter_map(|sandbox| sandbox.metadata.name.clone()) + .collect() + } +} From e573ceaa9a7b1bac7a5076105d68cb6f0366001b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:34:55 +0200 Subject: [PATCH 021/111] Preserve statement tests' receipt framing helper access Restore the test-only PAE helper facade required by existing signed-statement regressions after verifier extraction. Keep framing unchanged and avoid widening the production API or suppressing compiler checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/receipts.rs | 2 ++ bridge/bff/src/routes/receipts/verification.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bridge/bff/src/routes/receipts.rs b/bridge/bff/src/routes/receipts.rs index b9ebbf3ad..8d1c07d82 100644 --- a/bridge/bff/src/routes/receipts.rs +++ b/bridge/bff/src/routes/receipts.rs @@ -24,6 +24,8 @@ mod statement; mod verification; pub(crate) use anchor::AnchorPins; +#[cfg(test)] +use verification::pae; use verification::sha256_hex; pub(crate) use verification::verify_log_integrity; pub use verification::{ diff --git a/bridge/bff/src/routes/receipts/verification.rs b/bridge/bff/src/routes/receipts/verification.rs index 8cdef7032..c1ef05bb9 100644 --- a/bridge/bff/src/routes/receipts/verification.rs +++ b/bridge/bff/src/routes/receipts/verification.rs @@ -218,7 +218,7 @@ pub(crate) fn verify_log_integrity_with_pins( /// DSSE Pre-Authentication Encoding — byte-for-byte the same framing the /// controller signs (`controller/src/providers/signing.rs::pae`): /// `"DSSEv1" SP len(type) SP type SP len(body) SP body`. -fn pae(payload_type: &str, body: &[u8]) -> Vec<u8> { +pub(super) fn pae(payload_type: &str, body: &[u8]) -> Vec<u8> { let mut out = Vec::with_capacity(payload_type.len() + body.len() + 32); out.extend_from_slice(b"DSSEv1 "); out.extend_from_slice(payload_type.len().to_string().as_bytes()); From e89df619050af04d0df3131412fd1e96f25cf24b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:43:09 +0200 Subject: [PATCH 022/111] Centralize standard content digests without changing public identities Keep caller-owned byte framing, case semantics and digest widths, consolidate GitHub connection naming, and require independent known-answer tests in hosted inventory. Credential V1 secret-key derivation and signature verification are not reclassified or changed. No scanner exception added; Rust qualification remains pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 7 ++- .../bff/src/kars/cluster/mission_records.rs | 6 +-- bridge/bff/src/kars/cluster/mission_runs.rs | 6 +-- bridge/bff/src/kars/credential_review.rs | 32 +++++++++++++- bridge/bff/src/kars/github_grants.rs | 14 +++--- bridge/bff/src/kars/mod.rs | 1 + bridge/bff/src/kars/receipt_log.rs | 38 +++++++++++----- bridge/bff/src/lib.rs | 1 + bridge/bff/src/providers.rs | 4 ++ bridge/bff/src/providers/signing.rs | 44 +++++++++++++++++++ bridge/bff/src/routes/artifacts.rs | 25 +++++++++-- bridge/bff/src/routes/engineering.rs | 17 ++++--- .../bff/src/routes/engineering/remediation.rs | 6 +-- bridge/bff/src/routes/github.rs | 20 ++++++--- bridge/bff/src/routes/options.rs | 4 +- .../bff/src/routes/receipts/verification.rs | 14 +----- .../2026-09-11-bridge-application.md | 17 +++++++ 17 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 bridge/bff/src/providers.rs create mode 100644 bridge/bff/src/providers/signing.rs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index 1c0d6efd5..d05f8c6b1 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -51,7 +51,12 @@ jobs: routes::receipts::anchor::tests::pins_follow_the_controller_raw_key_fingerprint_contract \ routes::receipts::anchor::tests::copied_key_id_cannot_substitute_another_public_key \ routes::receipts::anchor::tests::malformed_empty_mismatched_or_missing_pins_fail_closed \ - kars::receipt_log::tests::receipt_endpoint_still_requires_signed_payload_binding_and_full_overflow_inclusion + kars::receipt_log::tests::receipt_endpoint_still_requires_signed_payload_binding_and_full_overflow_inclusion \ + providers::signing::tests::sha256_matches_standard_known_answers_without_normalizing_bytes \ + kars::credential_review::digest_tests::review_digest_preserves_compact_sorted_json_and_full_hex_width \ + kars::receipt_log::digest_tests::chain_hash_keeps_decimal_sequence_and_exact_pipe_framing \ + routes::artifacts::digest_tests::artifact_addresses_keep_the_existing_sixteen_byte_short_form \ + routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest do grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt done diff --git a/bridge/bff/src/kars/cluster/mission_records.rs b/bridge/bff/src/kars/cluster/mission_records.rs index 99e95905a..ee4f38f3e 100644 --- a/bridge/bff/src/kars/cluster/mission_records.rs +++ b/bridge/bff/src/kars/cluster/mission_records.rs @@ -1,7 +1,7 @@ use super::{Cluster, MissionOutputRecord}; +use crate::providers::signing::sha256_hex; use k8s_openapi::api::core::v1::ConfigMap; use kube::api::Api; -use sha2::{Digest, Sha256}; pub(super) fn mission_evidence_key(cm: &ConfigMap, legacy_label: &str) -> Option<String> { cm.metadata @@ -186,8 +186,8 @@ pub(super) fn trace_record_identity(cm: &ConfigMap) -> Option<String> { } let captured_at = data.get("capturedAt").map(String::as_str).unwrap_or(""); Some(format!( - "legacy:{captured_at}:{:x}", - Sha256::digest(trace.as_bytes()) + "legacy:{captured_at}:{}", + sha256_hex(trace.as_bytes()) )) } diff --git a/bridge/bff/src/kars/cluster/mission_runs.rs b/bridge/bff/src/kars/cluster/mission_runs.rs index 45abadf6c..c4f938c9b 100644 --- a/bridge/bff/src/kars/cluster/mission_runs.rs +++ b/bridge/bff/src/kars/cluster/mission_runs.rs @@ -1,9 +1,9 @@ use super::{AgentIdentity, Cluster, MeshRunOutcome}; +use crate::providers::signing::sha256_hex; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use k8s_openapi::api::core::v1::ConfigMap; use kube::api::Api; -use sha2::{Digest, Sha256}; impl Cluster { /// Request a **mesh-driven agent run** of a task by stamping the @@ -238,7 +238,7 @@ impl Cluster { && completed.as_deref() != Some(req.as_str()) { let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); - let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); + let digest = format!("sha256:{}", sha256_hex(revised_objective.as_bytes())); let patch = serde_json::json!({ "metadata": { "annotations": { "kars.azure.com/run-objective-nonce": req.clone(), @@ -260,7 +260,7 @@ impl Cluster { .unwrap_or(0) ); let encoded = BASE64_STANDARD.encode(revised_objective.as_bytes()); - let digest = format!("sha256:{:x}", Sha256::digest(revised_objective.as_bytes())); + let digest = format!("sha256:{}", sha256_hex(revised_objective.as_bytes())); let patch = serde_json::json!({ "metadata": { "annotations": { "kars.azure.com/run-requested": nonce.clone(), diff --git a/bridge/bff/src/kars/credential_review.rs b/bridge/bff/src/kars/credential_review.rs index 1bc4a7203..33cda1b08 100644 --- a/bridge/bff/src/kars/credential_review.rs +++ b/bridge/bff/src/kars/credential_review.rs @@ -5,6 +5,7 @@ use super::{ credential_transport::{failure, object_api, safe}, credentials::input_name, }; +use crate::providers::signing::sha256_hex; use k8s_openapi::{ api::core::v1::{Namespace, Secret}, apimachinery::pkg::apis::meta::v1::ObjectMeta, @@ -12,7 +13,6 @@ use k8s_openapi::{ use kube::{Api, ResourceExt, api::DynamicObject}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use sha2::{Digest, Sha256}; #[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -82,7 +82,35 @@ fn digest(value: &impl Serialize) -> Result<String, kube::Error> { value.sort_all_objects(); let bytes = serde_json::to_vec(&value).map_err(|_| failure("Credential review encoding failed"))?; - Ok(format!("sha256:{:x}", Sha256::digest(bytes))) + Ok(format!("sha256:{}", sha256_hex(bytes))) +} + +#[cfg(test)] +mod digest_tests { + use super::*; + + #[test] + fn review_digest_preserves_compact_sorted_json_and_full_hex_width() { + let a: Value = serde_json::from_str(r#"{"b":2,"a":1}"#).unwrap(); + let b: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).unwrap(); + assert_eq!(digest(&a).unwrap(), digest(&b).unwrap()); + assert_eq!( + digest(&a).unwrap(), + "sha256:43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777" + ); + assert_eq!( + digest(&serde_json::json!({"a":null})).unwrap(), + "sha256:d091f9c83c091f79652fe8786375b3fe4ce0861a56f5bfbafedbe431877ff0e8" + ); + assert_ne!( + digest(&serde_json::json!({"a":null})).unwrap(), + digest(&serde_json::json!({})).unwrap() + ); + assert_ne!( + digest(&serde_json::json!([1, 2])).unwrap(), + digest(&serde_json::json!([2, 1])).unwrap() + ); + } } fn intent(object: &DynamicObject) -> Result<String, kube::Error> { diff --git a/bridge/bff/src/kars/github_grants.rs b/bridge/bff/src/kars/github_grants.rs index d5e15fdbb..0b2699078 100644 --- a/bridge/bff/src/kars/github_grants.rs +++ b/bridge/bff/src/kars/github_grants.rs @@ -3,9 +3,16 @@ use super::{ credential_contract::{GitHubBinding, Identity}, credentials::failure, }; +use crate::providers::signing::sha256; use k8s_openapi::api::core::v1::ConfigMap; use kube::{Api, ResourceExt}; -use sha2::{Digest, Sha256}; + +pub(crate) fn github_connection_name(subject: &str) -> String { + format!( + "kars-github-connection-{}", + hex::encode(&sha256(subject.as_bytes())[..8]) + ) +} impl Cluster { pub async fn github_connection_grant( @@ -16,10 +23,7 @@ impl Cluster { write: bool, ) -> Result<GitHubBinding, kube::Error> { let grant = self.credential_grant(namespace).await?; - let name = format!( - "kars-github-connection-{}", - hex::encode(&Sha256::digest(subject.as_bytes())[..8]) - ); + let name = github_connection_name(subject); let connection = Api::<ConfigMap>::namespaced(self.client.clone(), namespace) .get(&name) .await?; diff --git a/bridge/bff/src/kars/mod.rs b/bridge/bff/src/kars/mod.rs index af0f40d58..12b968de4 100644 --- a/bridge/bff/src/kars/mod.rs +++ b/bridge/bff/src/kars/mod.rs @@ -10,6 +10,7 @@ mod credential_tests; mod credential_transport; pub mod credentials; mod github_grants; +pub(crate) use github_grants::github_connection_name; pub mod operator_credentials; pub mod receipt; pub(crate) mod receipt_log; diff --git a/bridge/bff/src/kars/receipt_log.rs b/bridge/bff/src/kars/receipt_log.rs index cc3f8bfdf..520b40ff4 100644 --- a/bridge/bff/src/kars/receipt_log.rs +++ b/bridge/bff/src/kars/receipt_log.rs @@ -5,11 +5,11 @@ use std::collections::BTreeMap; +use crate::providers::signing::sha256_parts; use k8s_openapi::api::core::v1::ConfigMap; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ListMeta; use kube::{Api, api::ListParams}; use serde::Deserialize; -use sha2::{Digest, Sha256}; use super::cluster::Cluster; @@ -34,15 +34,16 @@ pub(crate) fn chain_entry_hash( payload_sha256: &str, prev_hash: &str, ) -> String { - let mut hash = Sha256::new(); - hash.update(seq.to_string().as_bytes()); - hash.update(b"|"); - hash.update(receipt.as_bytes()); - hash.update(b"|"); - hash.update(payload_sha256.as_bytes()); - hash.update(b"|"); - hash.update(prev_hash.as_bytes()); - hex::encode(hash.finalize()) + let sequence = seq.to_string(); + hex::encode(sha256_parts([ + sequence.as_bytes(), + b"|", + receipt.as_bytes(), + b"|", + payload_sha256.as_bytes(), + b"|", + prev_hash.as_bytes(), + ])) } #[derive(Debug, Default)] @@ -54,6 +55,23 @@ pub(crate) struct ReceiptLog { pub public_key: Option<BTreeMap<String, String>>, } +#[cfg(test)] +mod digest_tests { + use super::*; + + #[test] + fn chain_hash_keeps_decimal_sequence_and_exact_pipe_framing() { + assert_eq!( + chain_entry_hash(0, "team/task", "abc", ""), + "aaf7650dad15d6e904ca99871f8bd152e6495e8bfd9e86a42bfd39f10a7c8499" + ); + assert_ne!( + chain_entry_hash(0, "team/task", "abc", ""), + chain_entry_hash(0, "team/task", "abc", "\n") + ); + } +} + impl ReceiptLog { pub fn anchor(&self) -> Option<(String, String, String)> { let data = self.public_key.as_ref()?; diff --git a/bridge/bff/src/lib.rs b/bridge/bff/src/lib.rs index 913b87f66..24f624cac 100644 --- a/bridge/bff/src/lib.rs +++ b/bridge/bff/src/lib.rs @@ -9,5 +9,6 @@ pub mod auth; pub mod config; pub mod error; pub mod kars; +mod providers; pub mod routes; pub mod state; diff --git a/bridge/bff/src/providers.rs b/bridge/bff/src/providers.rs new file mode 100644 index 000000000..90454b9fd --- /dev/null +++ b/bridge/bff/src/providers.rs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +pub(crate) mod signing; diff --git a/bridge/bff/src/providers/signing.rs b/bridge/bff/src/providers/signing.rs new file mode 100644 index 000000000..6f64ac38f --- /dev/null +++ b/bridge/bff/src/providers/signing.rs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Standard content/receipt digest adapters, not secret-key derivation. +//! Callers retain their existing framing, normalization and truncation contracts. + +use sha2::{Digest, Sha256}; + +pub(crate) fn sha256(bytes: impl AsRef<[u8]>) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String { + hex::encode(sha256(bytes)) +} + +pub(crate) fn sha256_parts<'a>(parts: impl IntoIterator<Item = &'a [u8]>) -> [u8; 32] { + let mut digest = Sha256::new(); + for part in parts { + digest.update(part); + } + digest.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_matches_standard_known_answers_without_normalizing_bytes() { + assert_eq!( + sha256_hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!(sha256_parts([b"a".as_slice(), b"", b"bc"]), sha256(b"abc")); + assert_ne!(sha256(b"abc"), sha256(b"abc\n")); + assert_ne!(sha256(b"abc"), sha256(b"ABC")); + assert_ne!(sha256(b"abc"), sha256(b"abc\0")); + } +} diff --git a/bridge/bff/src/routes/artifacts.rs b/bridge/bff/src/routes/artifacts.rs index 66434d918..2fe22b1e2 100644 --- a/bridge/bff/src/routes/artifacts.rs +++ b/bridge/bff/src/routes/artifacts.rs @@ -6,6 +6,7 @@ // ConfigMaps. This is a read-only projection of those real records; it never // fabricates a deliverable and is honestly empty until a mission produces one. +use crate::providers::signing::sha256; use axum::{ Json, extract::{Extension, State}, @@ -13,7 +14,6 @@ use axum::{ use kube::ResourceExt; use serde::Deserialize; use serde::Serialize; -use sha2::{Digest, Sha256}; use std::collections::HashSet; use crate::auth::Principal; @@ -21,10 +21,9 @@ use crate::error::{AppError, AppResult}; use crate::routes::ownership::output_is_owned_by; use crate::state::AppState; -/// `sha256:<hex>` content-address over arbitrary bytes (16-byte short form, -/// matching the controller's receipt-digest convention). +/// Existing 16-byte short content address used by artifact and lineage DIDs. fn content_address(bytes: &[u8]) -> String { - let full = Sha256::digest(bytes); + let full = sha256(bytes); let mut out = String::from("sha256:"); for b in &full[..16] { out.push_str(&format!("{b:02x}")); @@ -32,6 +31,24 @@ fn content_address(bytes: &[u8]) -> String { out } +#[cfg(test)] +mod digest_tests { + use super::*; + + #[test] + fn artifact_addresses_keep_the_existing_sixteen_byte_short_form() { + assert_eq!( + content_address(b"abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223" + ); + assert_eq!( + content_address(b""), + "sha256:e3b0c44298fc1c149afbf4c8996fb924" + ); + assert_ne!(content_address(b"abc"), content_address(b"abc\n")); + } +} + #[derive(Debug, Serialize)] pub struct ArtifactFileDto { pub name: String, diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs index 10daf2ef9..d4f80e9ee 100644 --- a/bridge/bff/src/routes/engineering.rs +++ b/bridge/bff/src/routes/engineering.rs @@ -7,13 +7,13 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::time::Duration; +use crate::providers::signing::sha256; use axum::Json; use axum::extract::{Extension, Path, State}; use chrono::{DateTime, Utc}; use k8s_openapi::api::core::v1::ConfigMap; use kube::ResourceExt; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::auth::Principal; use crate::error::{AppError, AppResult}; @@ -449,7 +449,7 @@ fn default_true() -> bool { } pub(crate) fn source_config_map_name(namespace: &str, team: &str) -> String { - let digest = Sha256::digest(format!("{namespace}/{team}").as_bytes()); + let digest = sha256(format!("{namespace}/{team}").as_bytes()); let stem = team.chars().take(40).collect::<String>(); format!("kars-eng-{stem}-{}", hex::encode(&digest[..6])) } @@ -632,7 +632,7 @@ fn validate_request( } fn initial_jitter_seconds(source_name: &str) -> i64 { - let digest = Sha256::digest(source_name.as_bytes()); + let digest = sha256(source_name.as_bytes()); i64::from(digest[0] % 60) } @@ -693,7 +693,7 @@ fn source_id(repo: &str, number: u64) -> String { } fn work_id(repo: &str, number: u64) -> String { - let digest = Sha256::digest(source_id(repo, number).as_bytes()); + let digest = sha256(source_id(repo, number).as_bytes()); format!("dependabot-pr-{}", hex::encode(&digest[..10])) } @@ -755,7 +755,7 @@ fn alert_source_id(signal: EngineeringSignal, repo: &str, number: u64) -> String } fn alert_work_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { - let digest = Sha256::digest(alert_source_id(signal, repo, number).as_bytes()); + let digest = sha256(alert_source_id(signal, repo, number).as_bytes()); format!("{}-{}", signal_slug(signal), hex::encode(&digest[..10])) } @@ -1598,8 +1598,7 @@ fn review_followup_task(item: &EngineeringReviewItem, created_at: &str) -> Optio ) { return None; } - let digest = - Sha256::digest(format!("github-review:{}:{}", item.repo, item.pr_number).as_bytes()); + let digest = sha256(format!("github-review:{}:{}", item.repo, item.pr_number).as_bytes()); Some(TeamTaskDto { id: format!("github-pr-fix-{}", hex::encode(&digest[..10])), title: format!( @@ -1639,7 +1638,7 @@ fn dedupe_followup_task( .map(|pull| format!("#{} {}", pull.number, pull.html_url)) .collect::<Vec<_>>() .join(", "); - let digest = Sha256::digest(format!("{repo}:{remediation_id}").as_bytes()); + let digest = sha256(format!("{repo}:{remediation_id}").as_bytes()); Some(TeamTaskDto { id: format!("github-pr-dedupe-{}", hex::encode(&digest[..10])), title: format!( @@ -2599,7 +2598,7 @@ pub async fn decide_review_item( request.pr_number, request.head_sha ); - let digest = Sha256::digest(identity.as_bytes()); + let digest = sha256(identity.as_bytes()); let task = TeamTaskDto { id: format!("github-pr-feedback-{}", hex::encode(&digest[..10])), title: format!( diff --git a/bridge/bff/src/routes/engineering/remediation.rs b/bridge/bff/src/routes/engineering/remediation.rs index a1ea3bfda..4a74d4698 100644 --- a/bridge/bff/src/routes/engineering/remediation.rs +++ b/bridge/bff/src/routes/engineering/remediation.rs @@ -1,6 +1,6 @@ // kars Bridge BFF — remediation identity and compatibility with persisted intake. -use sha2::{Digest, Sha256}; +use crate::providers::signing::sha256; use super::{GithubPull, TeamTaskDto}; @@ -24,7 +24,7 @@ impl RemediationIdentity { fn work_id(&self) -> String { let framed = serde_json::json!([2, self.repo, self.manifest_path, self.package]); - let digest = Sha256::digest(framed.to_string().as_bytes()); + let digest = sha256(framed.to_string().as_bytes()); format!("dependency-remediation-v2-{}", hex::encode(&digest[..10])) } @@ -39,7 +39,7 @@ impl RemediationIdentity { .to_ascii_lowercase(), self.package.to_ascii_lowercase() ); - let digest = Sha256::digest(identity.as_bytes()); + let digest = sha256(identity.as_bytes()); format!("dependency-remediation-{}", hex::encode(&digest[..10])) } } diff --git a/bridge/bff/src/routes/github.rs b/bridge/bff/src/routes/github.rs index 682fdb83c..f95b55353 100644 --- a/bridge/bff/src/routes/github.rs +++ b/bridge/bff/src/routes/github.rs @@ -4,10 +4,10 @@ // GitHub App; each authenticated principal gets an isolated ConfigMap containing // only its installation id, account, and repos. Tokens are minted at run time. +pub(crate) use crate::kars::github_connection_name as connection_config_map_name; use axum::Json; use axum::extract::{Extension, Path, State}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::auth::Principal; use crate::error::{AppError, AppResult}; @@ -303,11 +303,6 @@ fn upstream(e: kube::Error) -> AppError { AppError::Upstream(e.to_string()) } -pub(crate) fn connection_config_map_name(principal_sub: &str) -> String { - let digest = Sha256::digest(principal_sub.as_bytes()); - format!("kars-github-connection-{}", hex::encode(&digest[..8])) -} - pub(crate) fn authorize_repo_set( requested: &[String], granted: &[String], @@ -527,6 +522,19 @@ pub async fn disconnect( mod tests { use super::*; + #[test] + fn connection_names_keep_the_original_raw_subject_and_eight_byte_digest() { + for (subject, suffix) in [ + ("Alice", "3bc51062973c458d"), + ("alice", "2bd806c97f0e00af"), + (" Alice ", "f3a5bb89166b4962"), + ] { + let expected = format!("kars-github-connection-{suffix}"); + assert_eq!(connection_config_map_name(subject), expected); + assert_eq!(crate::kars::github_connection_name(subject), expected); + } + } + #[test] fn different_principals_have_different_non_identifying_names() { let alice = connection_config_map_name("immutable-alice-subject"); diff --git a/bridge/bff/src/routes/options.rs b/bridge/bff/src/routes/options.rs index 5e8eb25ef..8ca5d3d3b 100644 --- a/bridge/bff/src/routes/options.rs +++ b/bridge/bff/src/routes/options.rs @@ -8,11 +8,11 @@ // objects the blueprint composes by reference. Absent CRDs surface as empty // lists (the web layer renders the honesty grammar), never as errors. +use crate::providers::signing::sha256_hex; use axum::Json; use axum::extract::State; use kube::core::DynamicObject; use serde::Serialize; -use sha2::{Digest, Sha256}; use crate::error::{AppError, AppResult}; use crate::state::AppState; @@ -670,7 +670,7 @@ pub(crate) fn mcp_server_option(resource: &DynamicObject) -> RefOption { }); serde_json::to_vec(&signature) .ok() - .map(|bytes| format!("sha256:{:x}", Sha256::digest(bytes))) + .map(|bytes| format!("sha256:{}", sha256_hex(bytes))) }); let mut option = RefOption { name: name_of(resource), diff --git a/bridge/bff/src/routes/receipts/verification.rs b/bridge/bff/src/routes/receipts/verification.rs index c1ef05bb9..9681b22c0 100644 --- a/bridge/bff/src/routes/receipts/verification.rs +++ b/bridge/bff/src/routes/receipts/verification.rs @@ -85,19 +85,7 @@ pub struct CheckpointEvidence { pub witness_signature_b64: Option<String>, } -pub(super) fn sha256_hex(bytes: &[u8]) -> String { - use sha2::{Digest, Sha256}; - hex(&Sha256::digest(bytes)) -} - -fn hex(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(out, "{b:02x}"); - } - out -} +pub(super) use crate::providers::signing::sha256_hex; /// A whole-log integrity verdict — the page-level answer to "is this audit log /// actually tamper-evident?". Unlike a per-receipt proof, this recomputes the diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 42426ff07..e8c8e6e7d 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -126,6 +126,23 @@ separately versioned identity correction that preserves existing work. Standard digest/receipt adapter extraction and its exact byte-equivalence vectors remain required; no blanket crypto allowance is granted. +### Content-digest adapter qualification + +The standard SHA-256 content/receipt uses now route through a small BFF-local +`providers/signing.rs` adapter. Callers retain their original input bytes, +framing, case policy and output widths: full trace/objective/review/receipt +digests, eight-byte GitHub connection suffixes, sixteen-byte artifact addresses, +and existing engineering identifiers. The duplicated GitHub connection recipe +is shared by grant validation and the route surface. + +Independent known-answer regressions cover standard SHA-256, raw subject case +and whitespace, sorted compact review JSON with null/absent distinctions, +decimal/pipe receipt chaining, and short artifact IDs. CI requires their actual +registration before running the complete suite. Hosted execution is still +pending; syntax/metadata checks are not a substitute. This extraction does not +reclassify the V1 secret-key derivation as a content hash, alter receipt +signature verification, or grant a scanner exception. + The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, and the header gate reports missing Microsoft headers on imported files. From 580b05992e6aa4789b77793cace181efbb973ae0 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:48:12 +0200 Subject: [PATCH 023/111] Make receipt fixture digests explicit after adapter extraction Keep the signed receipt fixture's independent SHA256 dependency explicit instead of inheriting it from production imports. Remove needless borrows for the new generic digest adapter without changing pinned key bytes or warning policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/kars/receipt_log/tests.rs | 1 + bridge/bff/src/routes/receipts/anchor.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bridge/bff/src/kars/receipt_log/tests.rs b/bridge/bff/src/kars/receipt_log/tests.rs index dd7bf57f4..62e980cb4 100644 --- a/bridge/bff/src/kars/receipt_log/tests.rs +++ b/bridge/bff/src/kars/receipt_log/tests.rs @@ -10,6 +10,7 @@ use axum::{ use base64::{Engine as _, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signer, SigningKey}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use std::sync::{Arc, Mutex}; use tower::ServiceExt; diff --git a/bridge/bff/src/routes/receipts/anchor.rs b/bridge/bff/src/routes/receipts/anchor.rs index f3b018cd8..a922d3833 100644 --- a/bridge/bff/src/routes/receipts/anchor.rs +++ b/bridge/bff/src/routes/receipts/anchor.rs @@ -53,7 +53,7 @@ impl AnchorPins { if let Some(expected) = &self.key_id { // The controller defines key IDs as full SHA-256 fingerprints of // the raw public key, not an independently mutable ConfigMap label. - if expected != &super::sha256_hex(&key) || expected != &key_id { + if expected != &super::sha256_hex(key) || expected != &key_id { return Err( "Receipt anchor key does not match the configured SHA-256 fingerprint.", ); @@ -100,7 +100,7 @@ mod tests { #[test] fn pins_follow_the_controller_raw_key_fingerprint_contract() { - assert_eq!(super::super::sha256_hex(&public_key(KEY).unwrap()), ID); + assert_eq!(super::super::sha256_hex(public_key(KEY).unwrap()), ID); for (key_id, public_key) in [ (None, None), (Some(ID.into()), None), From 1289a681cc8ca99da4d02bf74657b27aa65d6f3a Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:52:07 +0200 Subject: [PATCH 024/111] Keep digest test modules after production items Move unchanged known-answer test blocks to module tails to satisfy strict items-after-test-module lint. Preserve all test registration names, assertions and digest behavior; no lint suppression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/kars/credential_review.rs | 56 ++++++++++++------------ bridge/bff/src/kars/receipt_log.rs | 34 +++++++------- bridge/bff/src/routes/artifacts.rs | 36 +++++++-------- 3 files changed, 63 insertions(+), 63 deletions(-) diff --git a/bridge/bff/src/kars/credential_review.rs b/bridge/bff/src/kars/credential_review.rs index 33cda1b08..7ff44266f 100644 --- a/bridge/bff/src/kars/credential_review.rs +++ b/bridge/bff/src/kars/credential_review.rs @@ -85,34 +85,6 @@ fn digest(value: &impl Serialize) -> Result<String, kube::Error> { Ok(format!("sha256:{}", sha256_hex(bytes))) } -#[cfg(test)] -mod digest_tests { - use super::*; - - #[test] - fn review_digest_preserves_compact_sorted_json_and_full_hex_width() { - let a: Value = serde_json::from_str(r#"{"b":2,"a":1}"#).unwrap(); - let b: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).unwrap(); - assert_eq!(digest(&a).unwrap(), digest(&b).unwrap()); - assert_eq!( - digest(&a).unwrap(), - "sha256:43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777" - ); - assert_eq!( - digest(&serde_json::json!({"a":null})).unwrap(), - "sha256:d091f9c83c091f79652fe8786375b3fe4ce0861a56f5bfbafedbe431877ff0e8" - ); - assert_ne!( - digest(&serde_json::json!({"a":null})).unwrap(), - digest(&serde_json::json!({})).unwrap() - ); - assert_ne!( - digest(&serde_json::json!([1, 2])).unwrap(), - digest(&serde_json::json!([2, 1])).unwrap() - ); - } -} - fn intent(object: &DynamicObject) -> Result<String, kube::Error> { let mut value = serde_json::to_value(object).map_err(|_| failure("Credential intent encoding failed"))?; @@ -633,3 +605,31 @@ impl Cluster { Ok(current) } } + +#[cfg(test)] +mod digest_tests { + use super::*; + + #[test] + fn review_digest_preserves_compact_sorted_json_and_full_hex_width() { + let a: Value = serde_json::from_str(r#"{"b":2,"a":1}"#).unwrap(); + let b: Value = serde_json::from_str(r#"{"a":1,"b":2}"#).unwrap(); + assert_eq!(digest(&a).unwrap(), digest(&b).unwrap()); + assert_eq!( + digest(&a).unwrap(), + "sha256:43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777" + ); + assert_eq!( + digest(&serde_json::json!({"a":null})).unwrap(), + "sha256:d091f9c83c091f79652fe8786375b3fe4ce0861a56f5bfbafedbe431877ff0e8" + ); + assert_ne!( + digest(&serde_json::json!({"a":null})).unwrap(), + digest(&serde_json::json!({})).unwrap() + ); + assert_ne!( + digest(&serde_json::json!([1, 2])).unwrap(), + digest(&serde_json::json!([2, 1])).unwrap() + ); + } +} diff --git a/bridge/bff/src/kars/receipt_log.rs b/bridge/bff/src/kars/receipt_log.rs index 520b40ff4..316bb0e69 100644 --- a/bridge/bff/src/kars/receipt_log.rs +++ b/bridge/bff/src/kars/receipt_log.rs @@ -55,23 +55,6 @@ pub(crate) struct ReceiptLog { pub public_key: Option<BTreeMap<String, String>>, } -#[cfg(test)] -mod digest_tests { - use super::*; - - #[test] - fn chain_hash_keeps_decimal_sequence_and_exact_pipe_framing() { - assert_eq!( - chain_entry_hash(0, "team/task", "abc", ""), - "aaf7650dad15d6e904ca99871f8bd152e6495e8bfd9e86a42bfd39f10a7c8499" - ); - assert_ne!( - chain_entry_hash(0, "team/task", "abc", ""), - chain_entry_hash(0, "team/task", "abc", "\n") - ); - } -} - impl ReceiptLog { pub fn anchor(&self) -> Option<(String, String, String)> { let data = self.public_key.as_ref()?; @@ -289,3 +272,20 @@ impl Cluster { #[cfg(test)] mod tests; + +#[cfg(test)] +mod digest_tests { + use super::*; + + #[test] + fn chain_hash_keeps_decimal_sequence_and_exact_pipe_framing() { + assert_eq!( + chain_entry_hash(0, "team/task", "abc", ""), + "aaf7650dad15d6e904ca99871f8bd152e6495e8bfd9e86a42bfd39f10a7c8499" + ); + assert_ne!( + chain_entry_hash(0, "team/task", "abc", ""), + chain_entry_hash(0, "team/task", "abc", "\n") + ); + } +} diff --git a/bridge/bff/src/routes/artifacts.rs b/bridge/bff/src/routes/artifacts.rs index 2fe22b1e2..e54e503f9 100644 --- a/bridge/bff/src/routes/artifacts.rs +++ b/bridge/bff/src/routes/artifacts.rs @@ -31,24 +31,6 @@ fn content_address(bytes: &[u8]) -> String { out } -#[cfg(test)] -mod digest_tests { - use super::*; - - #[test] - fn artifact_addresses_keep_the_existing_sixteen_byte_short_form() { - assert_eq!( - content_address(b"abc"), - "sha256:ba7816bf8f01cfea414140de5dae2223" - ); - assert_eq!( - content_address(b""), - "sha256:e3b0c44298fc1c149afbf4c8996fb924" - ); - assert_ne!(content_address(b"abc"), content_address(b"abc\n")); - } -} - #[derive(Debug, Serialize)] pub struct ArtifactFileDto { pub name: String, @@ -410,3 +392,21 @@ pub async fn list_artifacts( Ok(Json(ArtifactsIndexDto { missions })) } + +#[cfg(test)] +mod digest_tests { + use super::*; + + #[test] + fn artifact_addresses_keep_the_existing_sixteen_byte_short_form() { + assert_eq!( + content_address(b"abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223" + ); + assert_eq!( + content_address(b""), + "sha256:e3b0c44298fc1c149afbf4c8996fb924" + ); + assert_ne!(content_address(b"abc"), content_address(b"abc\n")); + } +} From 31cb02753c52049811f0f75c936e05b5ae1b0a53 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 21:57:23 +0200 Subject: [PATCH 025/111] Register the qualified digest adapter with exact-file boundaries Allow only the standard adapter already qualified by public Clippy/known-answer/full-suite execution. Tighten file entries against prefix lookalikes while preserving deliberate directory allowances.23source-gate regressions pass; unreviewed credential derivation and receipt test primitives still fail. No blanket Bridge exemption or audit sign-off. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci-gates.yml | 4 ++ ci/no-custom-crypto.sh | 8 ++- ci/tests/crypto_gate_test.py | 59 +++++++++++++++++++ .../2026-09-11-bridge-application.md | 15 ++++- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 ci/tests/crypto_gate_test.py diff --git a/.github/workflows/ci-gates.yml b/.github/workflows/ci-gates.yml index 229746f66..62054c3cd 100644 --- a/.github/workflows/ci-gates.yml +++ b/.github/workflows/ci-gates.yml @@ -63,6 +63,10 @@ jobs: if: matrix.gate == 'no-stubs' run: python3 -m unittest discover -s ci/tests -p '*_test.py' + - name: Verify exact crypto-adapter boundaries + if: matrix.gate == 'no-custom-crypto' + run: python3 -m unittest discover -s ci/tests -p crypto_gate_test.py + - name: Run gate ${{ matrix.gate }} shell: bash env: diff --git a/ci/no-custom-crypto.sh b/ci/no-custom-crypto.sh index c0572adb1..d7b0877fc 100755 --- a/ci/no-custom-crypto.sh +++ b/ci/no-custom-crypto.sh @@ -17,6 +17,7 @@ REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" ALLOW_PATHS=( + 'bridge/bff/src/providers/signing.rs' # Standard content/receipt SHA-256 adapter; byte/framing known answers qualified in public run34641158050. No secret-key derivation. 'controller/src/providers/signing.rs' 'controller/src/kars_receipt_log.rs' # receipt inclusion log — Sha256 Merkle-style hash chaining of receipt payload digests (transparency-log precursor); standard linkage, no bespoke crypto protocol. Tracked for the V2 external-witness upgrade. 'controller/src/kars_task.rs' # KarsTask envelope digest — Sha256 content-hash over canonical JSON (authority-binding identifier), not a crypto protocol. The Governance Receipt (kars_receipt.rs) binds its subject to this digest; signing itself stays in providers/signing.rs. @@ -95,7 +96,12 @@ for f in "${changed[@]}"; do # skip allowlisted paths skip=0 for a in "${ALLOW_PATHS[@]}"; do - case "$f" in "$a"*) skip=1; break;; esac + if [[ "$a" == */ ]]; then + [[ "$f" == "$a"* ]] && skip=1 + elif [[ "$f" == "$a" ]]; then + skip=1 + fi + [ "$skip" -eq 1 ] && break done [ "$skip" -eq 1 ] && continue # only scan prod paths diff --git a/ci/tests/crypto_gate_test.py b/ci/tests/crypto_gate_test.py new file mode 100644 index 000000000..c896c2a6d --- /dev/null +++ b/ci/tests/crypto_gate_test.py @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from pathlib import Path +import subprocess +import unittest + +from git_fixture import GitFixture + +GATE = Path(__file__).resolve().parents[1] / "no-custom-crypto.sh" + + +class CryptoGateTests(GitFixture): + def gate(self): + return subprocess.run( + ["bash", str(GATE)], cwd=self.root, text=True, capture_output=True, + env={**os.environ, "BASE_REF": self.base}, timeout=30, + ) + + def test_exact_standard_digest_adapter_is_the_only_new_allowed_file(self): + self.write("bridge/bff/src/providers/signing.rs", "use sha2::{Digest, Sha256};\n") + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + def test_filename_prefixes_cannot_impersonate_allowlisted_adapters(self): + for name in ("controller/src/providers/signing.rs-extra.rs", + "bridge/bff/src/providers/signing.rs-extra.rs", + "bridge/bff/src/providers/signing.rs/child.rs"): + self.write(name, "use sha2::{Digest, Sha256};\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1) + for name in ("controller/src/providers/signing.rs-extra.rs", + "bridge/bff/src/providers/signing.rs-extra.rs", + "bridge/bff/src/providers/signing.rs/child.rs"): + self.assertIn(f"fail: {name} introduces custom crypto", result.stderr) + + def test_application_derivation_and_unreviewed_provider_files_remain_blocked(self): + for name in ("bridge/bff/src/routes/credential_review.rs", + "bridge/bff/src/providers/another.rs", + "bridge/bff/src/routes/receipts/verification.rs"): + self.write(name, "use sha2::{Digest, Sha256};\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stderr.count("introduces custom crypto"), 3) + + def test_existing_explicit_directory_and_file_contracts_still_work(self): + self.write("controller/src/providers/signing.rs", "use sha2::{Digest, Sha256};\n") + self.write("controller/src/mesh_peer/nested/wrapper.rs", "use ed25519_dalek::Signer;\n") + self.commit() + result = self.gate() + self.assertEqual((result.returncode, result.stderr), (0, "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index e8c8e6e7d..07eb6b437 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -139,9 +139,18 @@ Independent known-answer regressions cover standard SHA-256, raw subject case and whitespace, sorted compact review JSON with null/absent distinctions, decimal/pipe receipt chaining, and short artifact IDs. CI requires their actual registration before running the complete suite. Hosted execution is still -pending; syntax/metadata checks are not a substitute. This extraction does not -reclassify the V1 secret-key derivation as a content hash, alter receipt -signature verification, or grant a scanner exception. +pending in the initial extraction; syntax/metadata checks are not a substitute. +Corrected `1289a681` then passed actual Clippy, required known-answer registration +and the complete Rust suite in public run 34641158050. + +After that qualification, only the 44-line standard digest adapter is registered +in the existing crypto-wrapper allowlist. File entries now match exactly rather +than granting accidental prefix access to lookalike paths; intentionally listed +directory prefixes retain their previous scope. Four actual-Git regressions +cover these boundaries. No directory-wide Bridge allowance is added. +The V1 secret-key derivation is not reclassified as content hashing, and +unreviewed application/provider paths remain rejected. Receipt signature +verification and outstanding source-review requirements are unchanged. The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, From a4d398dbe562ca5d7046a6d3a168e83235df9a8c Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 22:01:32 +0200 Subject: [PATCH 026/111] Keep shared-qualification diagnostics within the safe source allowlist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/tests/native-credentials/operator_diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index ce9d75bfd..ce799277b 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -19,6 +19,7 @@ MODULES = ( "commands/credential-grants", "lib/private-activation", "lib/private-activation-retirement", "lib/kube-bootstrap", "lib/kube-context", + "lib/private-activation-continuity", "lib/repo-assets", ) From 5e8f9fc1156e5d599a1c7096144b3082a706792b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 22:18:20 +0200 Subject: [PATCH 027/111] Require real single- and multi-workspace grant update continuity Exercise actual CLI preview/apply for a sole active grant and a second workspace, verifying private receipts/epochs, grant identity/spec preservation, effective writer capability and broad Secret denial. Keep exclusive enrollment and exact update UID/RV fences.106orchestration and19gateway regressions passed locally; live new-case proof remains pending the core repair. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 7 ++ .../tests/native-qualification.test.ts | 8 ++ bridge/tests/native-credentials/enrollment.py | 35 ++++++++- .../grant_continuity_case.py | 76 +++++++++++++++++++ bridge/tests/native-credentials/run.py | 3 + .../native-credentials/test_enrollment.py | 41 +++++++++- 6 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 bridge/tests/native-credentials/grant_continuity_case.py diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index fdcf14969..219d424a0 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -26,6 +26,13 @@ key scope, stores its metadata-only review privately, and waits for actual controller readiness. Local orchestration fixtures are not native authority evidence. +The native continuity case updates the sole active grant through the real +operator CLI, adds another workspace, and then updates that grant. It verifies +unchanged existing grant identities/specifications and private scope +receipts/epochs, actual writer capability reviews, and continued denial of +broad Secret listing. Ready conditions still come only from the controller; +fixture state changes are not substituted for these live checks. + Set `core.namespace` independently from the chart's `namespace`. BFF/web default workspace and provider operations use the configured core namespace; the optional Teams Secret remains in the dedicated Bridge integration namespace. diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts index eda7a7494..4dd302dce 100644 --- a/bridge/teams-gateway/tests/native-qualification.test.ts +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -85,6 +85,14 @@ describe("Monorepo native prerequisite", () => { expect(enrollment).toContain('setup.ready_grant(namespace)'); expect(enrollment).not.toContain("admin.create("); expect(enrollment).not.toMatch(/failurePolicy|patch.*conditions|break-glass/); + const continuity = read("tests/native-credentials/grant_continuity_case.py"); + expect(read("tests/native-credentials/run.py")) + .toContain('case("shared-workspace-and-active-grant-update-continuity"'); + expect(continuity).toContain("previous=reviewed"); + expect(continuity).toContain("other[\"spec\"] == original_spec"); + expect(continuity).toContain("scope_snapshot(setup, scopes) == before_update"); + expect(continuity).toContain('"verb": "use-agent-credentials"'); + expect(continuity).toContain("expected=(403,)"); }); it("qualifies actual CNI traffic and never treats API existence as enforcement", () => { diff --git a/bridge/tests/native-credentials/enrollment.py b/bridge/tests/native-credentials/enrollment.py index 0eeadabcc..bd89d0912 100644 --- a/bridge/tests/native-credentials/enrollment.py +++ b/bridge/tests/native-credentials/enrollment.py @@ -8,10 +8,31 @@ CLI = ROOT / ".native/core/cli/dist/index.js" -def enroll(setup, namespace, writer, keys): +def enroll(setup, namespace, writer, keys, *, previous=None): require(CLI.is_file(), "The exact core CLI must be built before native enrollment") path = resource(namespace, "karscredentialgrants", "workspace") - require(setup.admin.optional(path) is None, "Native enrollment refuses an existing grant") + existing = setup.admin.optional(path) + expected_metadata = {"name": "workspace", "namespace": namespace} + if previous is None: + require(existing is None, "Native enrollment refuses an existing grant") + else: + require( + existing is not None and uid(existing) == uid(previous) + and existing["metadata"]["resourceVersion"] == previous["metadata"]["resourceVersion"] + and existing["spec"] == previous["spec"], + "Native key update requires the exact previously reviewed grant", + ) + require( + previous["spec"].get("enabled") is True + and all(not previous["spec"].get(field) for field in ( + "integrationStores", "legacyImports", "observationTargets", "githubConnections", + "controller", "bridgeConsumers", + )), + "Native key-update fixture accepts only an agent-key grant", + ) + expected_metadata.update( + uid=uid(previous), resourceVersion=previous["metadata"]["resourceVersion"], + ) workspace = setup.admin.get(f"/api/v1/namespaces/{namespace}") current_writer = setup.admin.get(core(BRIDGE, "serviceaccounts", WRITER)) require(uid(current_writer) == uid(writer), "Native writer UID changed before operator review") @@ -39,11 +60,15 @@ def enroll(setup, namespace, writer, keys): isinstance(reviewed, dict) and reviewed.get("apiVersion") == "kars.azure.com/v1alpha1" and reviewed.get("kind") == "KarsCredentialGrant" - and reviewed.get("metadata") == {"name": "workspace", "namespace": namespace}, - "Operator preview did not describe one exclusive native grant", + and reviewed.get("metadata") == expected_metadata, + "Operator preview did not describe the exact native grant incarnation", ) spec = reviewed.get("spec", {}) expected_writer = {"namespace": BRIDGE, "name": WRITER, "uid": uid(writer)} + if previous is not None: + require(previous["spec"].get("writers") == [expected_writer] + and previous["spec"].get("workspaceUid") == uid(workspace), + "Native key update cannot change workspace or writer identity") require( isinstance(spec, dict) and spec.get("workspaceUid") == uid(workspace) @@ -56,6 +81,8 @@ def enroll(setup, namespace, writer, keys): review_file = private_file(f"grant-review-{namespace}.json", json.dumps(reviewed)) operator_command("apply", "node", str(CLI), "credentials", "grant", "apply", str(review_file), timeout=360) grant = setup.ready_grant(namespace) + if previous is not None: + require(uid(grant) == uid(previous), "Native key update replaced the grant") recorded = grant.get("spec", {}) activation = recorded.get("privateActivation") if isinstance(recorded, dict) else None require( diff --git a/bridge/tests/native-credentials/grant_continuity_case.py b/bridge/tests/native-credentials/grant_continuity_case.py new file mode 100644 index 000000000..c69c85dc4 --- /dev/null +++ b/bridge/tests/native-credentials/grant_continuity_case.py @@ -0,0 +1,76 @@ +"""Real operator updates must preserve other workspaces, not just render valid grants.""" + +import copy + +from enrollment import enroll +from native_api import BRIDGE, CORE, require, resource, uid + +PREFIX = "kars.azure.com/private-" + + +def scope_snapshot(setup, namespaces): + result = {} + for namespace in namespaces: + value = setup.admin.get(f"/api/v1/namespaces/{namespace}") + result[namespace] = { + "uid": uid(value), + "private": {key: value for key, value in value["metadata"].get("annotations", {}).items() + if key.startswith(PREFIX)}, + } + require(result[namespace]["private"].get(PREFIX + "state") == "Qualified", + "Continuity requires a genuinely qualified namespace") + return result + + +def writer_allowed(actor, namespace): + result = actor.create("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", { + "apiVersion": "authorization.k8s.io/v1", "kind": "SelfSubjectAccessReview", + "spec": {"resourceAttributes": { + "namespace": namespace, "group": "kars.azure.com", + "resource": "karscredentialgrants", "name": "workspace", + "verb": "use-agent-credentials", + }}, + }) + require(result.get("status", {}).get("allowed") is True + and not result.get("status", {}).get("evaluationError"), + "Existing writer lost its native grant capability") + + +def run(credentials): + setup = credentials.setup + existing_namespace = "native-grant-preflight" + original = setup.ready_grant(existing_namespace) + scopes = [CORE, BRIDGE, existing_namespace] + initial = scope_snapshot(setup, scopes) + writer_allowed(credentials.actor, existing_namespace) + original = enroll( + setup, existing_namespace, credentials.writer, + [*original["spec"]["agentKeys"], "DISCORD_BOT_TOKEN"], previous=original, + ) + require(scope_snapshot(setup, scopes) == initial, + "Updating the sole active grant changed its private qualification") + writer_allowed(credentials.actor, existing_namespace) + original_spec = copy.deepcopy(original["spec"]) + + namespace = "native-grant-update" + credentials.workspace(namespace) + require(scope_snapshot(setup, scopes) == initial, + "Adding a workspace changed existing private qualification") + require(uid(setup.ready_grant(existing_namespace)) == uid(original), + "Adding a workspace replaced the earlier grant") + reviewed = setup.ready_grant(namespace) + scopes.append(namespace) + before_update = scope_snapshot(setup, scopes) + writer_allowed(credentials.actor, namespace) + keys = [*reviewed["spec"]["agentKeys"], "DISCORD_BOT_TOKEN"] + updated = enroll(setup, namespace, credentials.writer, keys, previous=reviewed) + require(uid(updated) == uid(reviewed) and updated["spec"]["agentKeys"] == keys, + "Reviewed active-grant update did not preserve its identity and key intent") + require(scope_snapshot(setup, scopes) == before_update, + "An ordinary key update changed shared private receipts or epochs") + other = setup.ready_grant(existing_namespace) + require(uid(other) == uid(original) and other["spec"] == original_spec, + "An ordinary key update changed another workspace grant") + for name in (existing_namespace, namespace): + writer_allowed(credentials.actor, name) + credentials.actor.request("GET", resource(name, "secrets", group="/api/v1"), expected=(403,)) diff --git a/bridge/tests/native-credentials/run.py b/bridge/tests/native-credentials/run.py index 19b8b04de..448bedaae 100644 --- a/bridge/tests/native-credentials/run.py +++ b/bridge/tests/native-credentials/run.py @@ -153,6 +153,9 @@ def passed(name): "Live core did not issue native writer authority; see grant diagnostics") lifecycle = LifecycleCases(setup, bff, credentials) observations = ObservationCases(setup, bff, lifecycle) + from grant_continuity_case import run as grant_continuity + case("shared-workspace-and-active-grant-update-continuity", + lambda: grant_continuity(credentials)) workspace = case("create-only-bootstrap-and-v1-preservation", credentials.bootstrap) case("unobserved-collision-no-adoption", credentials.collision) case("new-source-late-conflict-zero-mutations", lambda: credentials.late_conflict(False)) diff --git a/bridge/tests/native-credentials/test_enrollment.py b/bridge/tests/native-credentials/test_enrollment.py index 4be355929..9afc82546 100644 --- a/bridge/tests/native-credentials/test_enrollment.py +++ b/bridge/tests/native-credentials/test_enrollment.py @@ -73,12 +73,13 @@ def private_file(self, name, data): self.review_files.append(path) return path - def enroll(self): + def enroll(self, *, previous=None, keys=None): with patch.object(enrollment, "ROOT", self.root), \ patch.object(enrollment, "CLI", self.cli), \ patch.object(operator_diagnostics, "command", side_effect=self.command), \ patch.object(enrollment, "private_file", side_effect=self.private_file): - return enrollment.enroll(self.setup, self.namespace, self.writer, self.keys) + return enrollment.enroll(self.setup, self.namespace, self.writer, + self.keys if keys is None else keys, previous=previous) def test_uses_real_public_preview_apply_and_then_controller_readiness(self): self.assertEqual(self.enroll(), self.grant) @@ -101,6 +102,42 @@ def test_existing_grant_is_not_adopted_or_updated(self): self.enroll() self.assertEqual(self.commands, []) + def test_explicit_existing_grant_key_update_uses_reviewed_uid_and_version(self): + self.grant["metadata"]["resourceVersion"] = "7" + previous = copy.deepcopy(self.grant) + self.review["metadata"].update(uid="grant", resourceVersion="7") + keys = [*self.keys, "DISCORD_BOT_TOKEN"] + self.review["spec"]["agentKeys"] = keys + self.admin.optional = lambda _path: self.grant + original = self.command + def command(*args, **kwargs): + result = original(*args, **kwargs) + if args[4] == "apply": + self.grant["spec"]["agentKeys"] = keys + self.grant["metadata"]["resourceVersion"] = "8" + return result + self.command = command + result = self.enroll(previous=previous, keys=keys) + self.assertEqual(result["metadata"]["uid"], previous["metadata"]["uid"]) + self.assertEqual(result["spec"]["agentKeys"], keys) + self.assertEqual(previous["spec"]["agentKeys"], ["SLACK_BOT_TOKEN"]) + self.assertEqual(len(self.commands), 2) + + def test_existing_update_rejects_changed_incarnations_and_non_key_authority(self): + self.grant["metadata"]["resourceVersion"] = "7" + previous = copy.deepcopy(self.grant) + self.admin.optional = lambda _path: self.grant + for field, value in (("uid", "replacement"), ("resourceVersion", "changed")): + before = copy.deepcopy(self.grant) + self.grant["metadata"][field] = value + with self.subTest(field=field), self.assertRaises(Failure): + self.enroll(previous=previous) + self.grant = before + self.grant["spec"]["integrationStores"] = [{"secret": {"name": "private"}}] + with self.assertRaisesRegex(Failure, "only an agent-key grant"): + self.enroll(previous=copy.deepcopy(self.grant)) + self.assertEqual(self.commands, []) + def test_operator_apply_failure_cannot_become_ready_or_a_direct_create_fallback(self): self.setup.ready_grant = Mock(return_value=self.grant) original = self.command From 6f0b2a1f41d3a9f39fa269a5e9dffe87351ac6d1 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 22:36:53 +0200 Subject: [PATCH 028/111] Route receipt primitives through a bounded standard verification adapter Preserve Ed25519/base64/signature-size semantics, trust pins, DSSE and checkpoint framing. Confine fixture signing to cfg(test), require RFC8032 known-answer registration, and route function-local content hashes through the existing SHA adapter. RFC vector independently verified with Node; Rust execution and new wrapper allowlisting remain pending. No custom KDF migration or scanner waiver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 3 +- bridge/bff/src/kars/receipt_log/tests.rs | 30 ++++---- bridge/bff/src/providers.rs | 1 + bridge/bff/src/providers/receipt.rs | 75 +++++++++++++++++++ .../src/routes/operator/skills_profiles.rs | 3 +- bridge/bff/src/routes/receipts/statement.rs | 16 ++-- .../bff/src/routes/receipts/verification.rs | 50 +++---------- bridge/bff/src/routes/tasks/egress.rs | 3 +- .../2026-09-11-bridge-application.md | 21 ++++++ 9 files changed, 132 insertions(+), 70 deletions(-) create mode 100644 bridge/bff/src/providers/receipt.rs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index d05f8c6b1..c3e5f2608 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -56,7 +56,8 @@ jobs: kars::credential_review::digest_tests::review_digest_preserves_compact_sorted_json_and_full_hex_width \ kars::receipt_log::digest_tests::chain_hash_keeps_decimal_sequence_and_exact_pipe_framing \ routes::artifacts::digest_tests::artifact_addresses_keep_the_existing_sixteen_byte_short_form \ - routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest + routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest \ + providers::receipt::tests::rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics do grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt done diff --git a/bridge/bff/src/kars/receipt_log/tests.rs b/bridge/bff/src/kars/receipt_log/tests.rs index 62e980cb4..14214d4ac 100644 --- a/bridge/bff/src/kars/receipt_log/tests.rs +++ b/bridge/bff/src/kars/receipt_log/tests.rs @@ -1,4 +1,6 @@ use super::*; +use crate::providers::receipt::ReceiptTestSigner as SigningKey; +use crate::providers::signing::sha256_hex; use axum::{ Json, Router, body::{Body, to_bytes}, @@ -8,9 +10,7 @@ use axum::{ routing::get, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use ed25519_dalek::{Signer, SigningKey}; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use std::sync::{Arc, Mutex}; use tower::ServiceExt; @@ -534,10 +534,10 @@ async fn receipt_detail_handler_reads_legacy_overflow_and_absence_without_hiding "spec":{"taskRef":{"name":"task-3"},"envelopeDigest":format!("sha256:{digest}"), "predicateType":predicate_type,"scheme":"DSSEv1+ed25519","keyId":"test", "dsse":{"payloadType":payload_type,"payload":STANDARD.encode(&payload), - "signatures":[{"keyid":"test","sig":STANDARD.encode(key.sign(&pae).to_bytes())}]}, + "signatures":[{"keyid":"test","sig":STANDARD.encode(key.sign(&pae))}]}, "claims":[]},"status":{"inclusionSeq":3}}); let mut chain = entries(4); - chain[3]["payloadSha256"] = hex::encode(Sha256::digest(&payload)).into(); + chain[3]["payloadSha256"] = sha256_hex(&payload).into(); chain[3]["entryHash"] = chain_entry_hash( 3, "work/task-3", @@ -599,13 +599,13 @@ fn receipt_log_integrity_still_verifies_real_signed_checkpoints_and_exact_tree_s let signature = key.sign(format!("kars-receipt-log\n{tree_size}\n{root}\n").as_bytes()); let mut maps = log_maps("work", &chain, Some(1)); maps.push(map("work", "kars-receipt-pubkey", json!({ - "keyId":"test","publicKey":STANDARD.encode(key.verifying_key().to_bytes()),"scheme":"DSSEv1+ed25519"}))); + "keyId":"test","publicKey":STANDARD.encode(key.public_key()),"scheme":"DSSEv1+ed25519"}))); maps.push(map( "work", "kars-receipt-checkpoint", json!({ "treeSize":tree_size.to_string(),"rootHash":root,"keyId":"test", - "signature":STANDARD.encode(signature.to_bytes())}), + "signature":STANDARD.encode(signature)}), )); let log = parsed(wire_snapshot(maps), "work").unwrap(); let integrity = crate::routes::receipts::verify_log_integrity(&log); @@ -621,8 +621,8 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo let namespace = std::env::var("BRIDGE_CORE_NAMESPACE").unwrap_or_else(|_| "kars-system".into()); let (_, state, api, server) = fixture(&namespace).await; let key = SigningKey::from_bytes(&[42; 32]); - let public_key = STANDARD.encode(key.verifying_key().to_bytes()); - let key_id = hex::encode(Sha256::digest(key.verifying_key().to_bytes())); + let public_key = STANDARD.encode(key.public_key()); + let key_id = sha256_hex(key.public_key()); let payload_type = "application/vnd.in-toto+json"; let predicate_type = "https://kars.azure.com/attestations/GovernanceReceipt/v0"; let digest = "0123456789abcdef0123456789abcdef"; @@ -644,10 +644,10 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo "spec":{"taskRef":{"name":"task-3"},"envelopeDigest":format!("sha256:{digest}"), "predicateType":predicate_type,"scheme":"DSSEv1+ed25519","keyId":key_id, "dsse":{"payloadType":payload_type,"payload":STANDARD.encode(&payload), - "signatures":[{"keyid":key_id,"sig":STANDARD.encode(key.sign(&pae).to_bytes())}]}, + "signatures":[{"keyid":key_id,"sig":STANDARD.encode(key.sign(&pae))}]}, "claims":[]},"status":{"inclusionSeq":3}}); let mut chain = entries(4); - chain[3]["payloadSha256"] = hex::encode(Sha256::digest(&payload)).into(); + chain[3]["payloadSha256"] = sha256_hex(&payload).into(); chain[3]["entryHash"] = chain_entry_hash( 3, "work/task-3", @@ -663,7 +663,7 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo json!({"keyId":key_id,"publicKey":public_key,"scheme":"DSSEv1+ed25519"}), )); maps.push(map(&namespace, "kars-receipt-checkpoint", json!({"treeSize":"4","rootHash":root, - "keyId":key_id,"signature":STANDARD.encode(key.sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()).to_bytes())}))); + "keyId":key_id,"signature":STANDARD.encode(key.sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()))}))); maps.push(map( &namespace, "kars-receipt-witness", @@ -711,15 +711,13 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo ( "kars-receipt-pubkey", "publicKey", - STANDARD.encode(replacement.verifying_key().to_bytes()), + STANDARD.encode(replacement.public_key()), ), ( "kars-receipt-checkpoint", "signature", STANDARD.encode( - replacement - .sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()) - .to_bytes(), + replacement.sign(format!("kars-receipt-log\n4\n{root}\n").as_bytes()), ), ), ] { @@ -732,7 +730,7 @@ async fn receipt_endpoint_still_requires_signed_payload_binding_and_full_overflo map["data"][field] = value.into(); } api.receipt_details.get_mut(receipt_path).unwrap()["spec"]["dsse"]["signatures"][0]["sig"] = - STANDARD.encode(replacement.sign(&pae).to_bytes()).into(); + STANDARD.encode(replacement.sign(&pae)).into(); } for (pin_id, pin_key, matches_original) in [ (None, None, true), diff --git a/bridge/bff/src/providers.rs b/bridge/bff/src/providers.rs index 90454b9fd..64e8ea30d 100644 --- a/bridge/bff/src/providers.rs +++ b/bridge/bff/src/providers.rs @@ -1,4 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub(crate) mod receipt; pub(crate) mod signing; diff --git a/bridge/bff/src/providers/receipt.rs b/bridge/bff/src/providers/receipt.rs new file mode 100644 index 000000000..de97c7463 --- /dev/null +++ b/bridge/bff/src/providers/receipt.rs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Standard Ed25519 verification for existing receipt and checkpoint bytes. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +pub(crate) fn verify_ed25519(public_key: &[u8; 32], message: &[u8], signature: &str) -> bool { + let Ok(key) = VerifyingKey::from_bytes(public_key) else { + return false; + }; + let Some(bytes) = STANDARD + .decode(signature.as_bytes()) + .ok() + .and_then(|bytes| <[u8; 64]>::try_from(bytes).ok()) + else { + return false; + }; + key.verify(message, &Signature::from_bytes(&bytes)).is_ok() +} + +#[cfg(test)] +pub(crate) struct ReceiptTestSigner(ed25519_dalek::SigningKey); + +#[cfg(test)] +impl ReceiptTestSigner { + pub(crate) fn from_bytes(bytes: &[u8; 32]) -> Self { + Self(ed25519_dalek::SigningKey::from_bytes(bytes)) + } + + pub(crate) fn public_key(&self) -> [u8; 32] { + self.0.verifying_key().to_bytes() + } + + pub(crate) fn sign(&self, message: &[u8]) -> [u8; 64] { + use ed25519_dalek::Signer; + self.0.sign(message).to_bytes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics() { + let key: [u8; 32] = + hex::decode("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a") + .unwrap() + .try_into() + .unwrap(); + let signature = hex::decode(concat!( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155", + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + )) + .unwrap(); + let encoded = STANDARD.encode(&signature); + assert!(verify_ed25519(&key, b"", &encoded)); + assert!(!verify_ed25519(&key, b"changed", &encoded)); + assert!(!verify_ed25519(&[0; 32], b"", &encoded)); + for invalid in [ + "".into(), + "not base64".into(), + format!("{encoded}\n"), + STANDARD.encode([0; 63]), + STANDARD.encode([0; 65]), + ] { + assert!(!verify_ed25519(&key, b"", &invalid)); + } + let mut changed = signature; + changed[0] ^= 1; + assert!(!verify_ed25519(&key, b"", &STANDARD.encode(changed))); + } +} diff --git a/bridge/bff/src/routes/operator/skills_profiles.rs b/bridge/bff/src/routes/operator/skills_profiles.rs index 9f524bc58..9ee4d531f 100644 --- a/bridge/bff/src/routes/operator/skills_profiles.rs +++ b/bridge/bff/src/routes/operator/skills_profiles.rs @@ -287,7 +287,6 @@ pub async fn submit_skill( } spec["package"] = serde_json::json!(true); spec["files"] = serde_json::json!(files.keys().cloned().collect::<Vec<_>>()); - use sha2::{Digest, Sha256}; let configmap_data: std::collections::BTreeMap<String, String> = files .iter() .map(|(path, content)| (path.replace('/', "__"), content.clone())) @@ -296,7 +295,7 @@ pub async fn submit_skill( .map_err(|e| AppError::Internal(anyhow::Error::new(e)))?; spec["packageDigest"] = serde_json::json!(format!( "sha256:{}", - hex::encode(Sha256::digest(&canonical)) + crate::providers::signing::sha256_hex(canonical) )); } let uploader = principal.name; diff --git a/bridge/bff/src/routes/receipts/statement.rs b/bridge/bff/src/routes/receipts/statement.rs index bd2638ffb..60eb4fbb8 100644 --- a/bridge/bff/src/routes/receipts/statement.rs +++ b/bridge/bff/src/routes/receipts/statement.rs @@ -97,7 +97,7 @@ mod tests { use super::*; use crate::kars::receipt::{DsseEnvelope, DsseSignature}; use crate::kars::task::LocalObjectRef; - use ed25519_dalek::{Signer, SigningKey, Verifier}; + use crate::providers::receipt::{ReceiptTestSigner as SigningKey, verify_ed25519}; use serde_json::json; fn receipt() -> (KarsReceiptSpec, SigningKey) { @@ -132,7 +132,7 @@ mod tests { payload_type: PAYLOAD_TYPE.into(), signatures: vec![DsseSignature { keyid: "test-key".into(), - sig: STANDARD.encode(signature.to_bytes()), + sig: STANDARD.encode(signature), }], }, claims, @@ -160,13 +160,11 @@ mod tests { let (mut spec, key) = receipt(); spec.claims[0].status = "PASS".into(); let payload = STANDARD.decode(&spec.dsse.payload).unwrap(); - let signature = STANDARD.decode(&spec.dsse.signatures[0].sig).unwrap(); - key.verifying_key() - .verify( - &super::super::pae(PAYLOAD_TYPE, &payload), - &ed25519_dalek::Signature::from_slice(&signature).unwrap(), - ) - .unwrap(); + assert!(verify_ed25519( + &key.public_key(), + &super::super::pae(PAYLOAD_TYPE, &payload), + &spec.dsse.signatures[0].sig, + )); assert!(decode(&spec, "tenant", "task").is_err()); } diff --git a/bridge/bff/src/routes/receipts/verification.rs b/bridge/bff/src/routes/receipts/verification.rs index 9681b22c0..c42dcd587 100644 --- a/bridge/bff/src/routes/receipts/verification.rs +++ b/bridge/bff/src/routes/receipts/verification.rs @@ -1,6 +1,7 @@ // kars Bridge BFF — receipt verification, extracted without changing wire formats. use super::*; +use crate::providers::receipt::verify_ed25519; /// The outcome of an in-browser cryptographic verification — performed /// server-side against the controller's public key and any configured @@ -126,7 +127,6 @@ pub(crate) fn verify_log_integrity_with_pins( log: &ReceiptLog, pins: Result<AnchorPins, &'static str>, ) -> LogIntegrity { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let mut out = LogIntegrity::default(); let chain = &log.entries; if chain.is_empty() { @@ -175,19 +175,7 @@ pub(crate) fn verify_log_integrity_with_pins( }; let cp_sig_ok = anchor .as_ref() - .and_then(|anchor| VerifyingKey::from_bytes(&anchor.public_key).ok()) - .map(|vk| { - BASE64 - .decode(cp_sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| { - vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) - .is_ok() - }) - .unwrap_or(false) - }) - .unwrap_or(false); + .is_some_and(|anchor| verify_ed25519(&anchor.public_key, note.as_bytes(), &cp_sig)); out.checkpoint_verified = chain_consistent && cp_sig_ok && cp_root == chain_head && cp_tree == out.tree_size; } @@ -241,7 +229,6 @@ pub(crate) async fn verify_receipt_with_pins( pins: Result<AnchorPins, &'static str>, ) -> AppResult<Json<VerifyResult>> { use base64::engine::general_purpose::STANDARD as B64; - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let cluster = require_cluster(&state)?; require_task_evidence_access(cluster, &ns, &name, &principal).await?; @@ -380,19 +367,13 @@ pub(crate) async fn verify_receipt_with_pins( // 4) Ed25519 signature verifies over the DSSE PAE of the exact payload. let mut sig_ok = false; let pub_bytes = Some(anchor.public_key); - if let (Some(pk), false) = (pub_bytes, payload_raw.is_empty()) - && let Ok(vk) = VerifyingKey::from_bytes(&pk) - { + if let (Some(pk), false) = (pub_bytes, payload_raw.is_empty()) { let message = pae(&spec.dsse.payload_type, &payload_raw); - let valid_signature = spec.dsse.signatures.iter().find(|s| { - s.keyid == anchor_key_id - && B64 - .decode(s.sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| vk.verify(&message, &Signature::from_bytes(&sb)).is_ok()) - .unwrap_or(false) - }); + let valid_signature = spec + .dsse + .signatures + .iter() + .find(|s| s.keyid == anchor_key_id && verify_ed25519(&pk, &message, &s.sig)); sig_ok = valid_signature.is_some(); if let Some(signature) = valid_signature { evidence.signature_b64 = Some(signature.sig.clone()); @@ -522,19 +503,8 @@ pub(crate) async fn verify_receipt_with_pins( let cp_root = cp.get("rootHash").cloned().unwrap_or_default(); let cp_sig = cp.get("signature").cloned().unwrap_or_default(); let note = format!("kars-receipt-log\n{cp_tree}\n{cp_root}\n"); - let cp_sig_ok = pub_bytes - .and_then(|pk| VerifyingKey::from_bytes(&pk).ok()) - .map(|vk| { - B64.decode(cp_sig.as_bytes()) - .ok() - .and_then(|sb| <[u8; 64]>::try_from(sb).ok()) - .map(|sb| { - vk.verify(note.as_bytes(), &Signature::from_bytes(&sb)) - .is_ok() - }) - .unwrap_or(false) - }) - .unwrap_or(false); + let cp_sig_ok = + pub_bytes.is_some_and(|pk| verify_ed25519(&pk, note.as_bytes(), &cp_sig)); let root_matches = cp_root == chain_head && cp_tree == tree_size as i64; let witness = log.witness.as_ref(); diff --git a/bridge/bff/src/routes/tasks/egress.rs b/bridge/bff/src/routes/tasks/egress.rs index aaa2915e3..3f99fcb07 100644 --- a/bridge/bff/src/routes/tasks/egress.rs +++ b/bridge/bff/src/routes/tasks/egress.rs @@ -74,8 +74,7 @@ pub async fn request_egress( .ok_or_else(|| AppError::BadRequest("mission has no running sandbox to widen".into()))?; let port = req.port.unwrap_or(443); let ttl = normalize_ttl(req.ttl.as_deref().unwrap_or("2h")); - use sha2::{Digest, Sha256}; - let suffix = hex::encode(Sha256::digest(format!("{host}:{port}").as_bytes())); + let suffix = crate::providers::signing::sha256_hex(format!("{host}:{port}").as_bytes()); let approval_name = format!("{name}-eg-{}", &suffix[..12]); let task_uid = task .metadata diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 07eb6b437..f751f2a59 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -152,6 +152,27 @@ The V1 secret-key derivation is not reclassified as content hashing, and unreviewed application/provider paths remain rejected. Receipt signature verification and outstanding source-review requirements are unchanged. +### Receipt primitive adapter candidate + +The existing Ed25519 verification calls now share a separate +`providers/receipt.rs` wrapper, including calls previously indented inside route +functions. It retains the same standard-base64 decoding, exact 64-byte signature +length and `ed25519-dalek` verification operation. Anchor pins, signed payload, +DSSE framing, payload-binding checks, chain/checkpoint comparisons and advisory +witness semantics remain at the existing callers. + +Signing used to construct test receipts is confined to a `cfg(test)` helper. +An RFC 8032 known-answer signature was independently verified with Node's crypto +implementation, and the Rust regression also rejects changed messages/keys, +invalid signature lengths, malformed encodings and trailing whitespace. +Existing fully re-signed attacker-anchor regressions remain required. +This new wrapper is not yet allowlisted or Rust-qualified; the V1 credential +key-derivation review remains separate and unresolved. +The remaining function-local skill-package and egress-approval content hashes +also use the qualified SHA adapter without changing their canonical input, +`host:port` framing or identifier widths. They are not left hidden from the +top-level import scanner. + The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, and the header gate reports missing Microsoft headers on imported files. From 0c2944b42d2b37e280aa56c8ebeb5f805ab034ae Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 22:52:38 +0200 Subject: [PATCH 029/111] Report only fixed credential failure categories and scope booleans Distinguish source/grant, identity, namespace and privacy readiness failures without publishing raw condition messages or private metadata. Add read-only runtime scope UID/epoch/qualification booleans and privacy regressions; never use diagnostics as authority proof. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../credential_diagnostics.py | 61 ++++++++++++++++ .../operator_diagnostics.py | 1 + bridge/tests/native-credentials/run.py | 5 +- .../test_credential_diagnostics.py | 71 +++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 bridge/tests/native-credentials/credential_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_credential_diagnostics.py diff --git a/bridge/tests/native-credentials/credential_diagnostics.py b/bridge/tests/native-credentials/credential_diagnostics.py new file mode 100644 index 000000000..55021b932 --- /dev/null +++ b/bridge/tests/native-credentials/credential_diagnostics.py @@ -0,0 +1,61 @@ +"""Secret-free failure categories and private-scope metadata booleans.""" + +from native_api import Failure, resource + +PREFIX = "kars.azure.com/private-" +CATEGORIES = { + "governed credential source or operator grant is unavailable": "source_or_grant", + "Sandbox identity/reference changed": "sandbox_rv_drift", + "runtime namespace authority changed": "namespace_authority", + "governed credential inputs are no longer authorized": "inputs_unavailable", + "Private target namespace requires reviewed grant activation before issuance or reuse": "runtime_scope_unreviewed", + "Private capability is unqualified; regenerate and apply the reviewed grant activation": "private_qualification_invalid", + "SRE privacy qualification is still pending; no credential issued or reused": "privacy_pending", +} + + +def condition_category(message): + if not isinstance(message, str): + return "unclassified" + return CATEGORIES.get(message.removeprefix("CredentialSourceUnavailable: "), "unclassified") + + +def runtime_scope(setup, sandbox): + try: + meta = sandbox["metadata"] + name = "kars-" + meta["name"] + namespace = setup.admin.optional("/api/v1/namespaces/" + name) + grant = setup.admin.optional(resource(meta["namespace"], "karscredentialgrants", "workspace")) + if namespace is None: + return {"available": True, "namespacePresent": False, "grantPresent": grant is not None} + annotations = namespace["metadata"].get("annotations") or {} + state = annotations.get(PREFIX + "state") + epoch = annotations.get(PREFIX + "epoch") + grant_meta = grant.get("metadata", {}) if grant else {} + grant_status = (grant.get("status") or {}) if grant else {} + activation = (grant["spec"].get("privateActivation") or {}) if grant else {} + scopes = activation.get("namespaces") or [] + matching = [scope for scope in scopes if scope.get("namespace", {}).get("name") == name + and scope["namespace"].get("uid") == namespace["metadata"]["uid"]] + current = grant_meta.get("generation") is not None and ( + grant_status.get("observedGeneration") == grant_meta["generation"]) + conditions = grant_status.get("conditions") or [] + return { + "available": True, "namespacePresent": True, "grantPresent": grant is not None, + "privateState": state if state in ("Pending", "Qualified") else "OtherOrAbsent", + "namespaceUidMatches": bool(namespace["metadata"].get("uid")) and ( + namespace["metadata"]["uid"] == (meta.get("annotations") or {}).get("kars.azure.com/namespace-uid")), + "namespaceOwnerMatches": annotations.get("kars.azure.com/sandbox-namespace") == meta["namespace"] + and annotations.get("kars.azure.com/sandbox-uid") == meta["uid"], + "grantScopeIncluded": len(matching) == 1, + "epochMatches": len(matching) == 1 and isinstance(epoch, str) and bool(epoch) + and matching[0].get("epoch") == epoch, + "writerReady": current and any(c.get("type") == "WriterReady" and c.get("status") == "True" + for c in conditions), + "privateConsumptionReady": current and any( + c.get("type") == "PrivateConsumptionReady" and c.get("status") == "True" for c in conditions), + } + except Failure: + return {"available": False, "category": "api-unavailable"} + except (KeyError, TypeError, AttributeError): + return {"available": False, "category": "malformed-metadata"} diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index ce799277b..c604e735f 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -20,6 +20,7 @@ "commands/credential-grants", "lib/private-activation", "lib/private-activation-retirement", "lib/kube-bootstrap", "lib/kube-context", "lib/private-activation-continuity", + "lib/private-activation-guard-retirement", "lib/repo-assets", ) diff --git a/bridge/tests/native-credentials/run.py b/bridge/tests/native-credentials/run.py index 448bedaae..717cc6d5a 100644 --- a/bridge/tests/native-credentials/run.py +++ b/bridge/tests/native-credentials/run.py @@ -10,6 +10,7 @@ from api_outcome_diagnostics import collect as api_outcome_diagnostics from boot import bridge_connection, install_bridge, install_core from credential_cases import CredentialCases +from credential_diagnostics import condition_category, runtime_scope from lifecycle_cases import LifecycleCases from native_api import CORE, STATE, Failure, Setup, command, core, require, scheduling_detail, status_detail from observation_cases import ObservationCases @@ -50,8 +51,10 @@ def diagnostics(setup): "reason": item.get("status", {}).get("reason"), "integrationError": item.get("status", {}).get("integrationError"), "serviceObservation": item.get("status", {}).get("serviceObservation"), - "conditions": [{key: condition.get(key) for key in ("type", "status", "reason")} + "conditions": [{**{key: condition.get(key) for key in ("type", "status", "reason")}, + "category": condition_category(condition.get("message"))} for condition in item.get("status", {}).get("conditions", [])], + "privateScope": runtime_scope(setup, item) if label == "sandboxes" else None, "scheduling": scheduling_detail(item) if label == "pods" else None, "containers": [ {"name": container["name"], "ready": container.get("ready"), diff --git a/bridge/tests/native-credentials/test_credential_diagnostics.py b/bridge/tests/native-credentials/test_credential_diagnostics.py new file mode 100644 index 000000000..84846ea02 --- /dev/null +++ b/bridge/tests/native-credentials/test_credential_diagnostics.py @@ -0,0 +1,71 @@ +import copy +import json +import types +import unittest + +from credential_diagnostics import CATEGORIES, PREFIX, condition_category, runtime_scope +from native_api import Failure + +PRIVATE = "DO-NOT-EMIT-PRIVATE-VALUES-OR-API-BODIES" + + +class CredentialDiagnosticsTests(unittest.TestCase): + def test_only_exact_known_messages_become_fixed_categories(self): + for message, category in CATEGORIES.items(): + self.assertEqual(condition_category(message), category) + self.assertEqual(condition_category("CredentialSourceUnavailable: " + message), category) + self.assertEqual(condition_category(message + PRIVATE), "unclassified") + for message in (None, {}, PRIVATE, "prefix" + next(iter(CATEGORIES))): + self.assertEqual(condition_category(message), "unclassified") + + def test_scope_capture_reports_only_booleans_and_fixed_states(self): + sandbox = {"metadata": {"name": "test", "namespace": "work", "uid": "sandbox", + "annotations": {"kars.azure.com/namespace-uid": "runtime"}}} + namespace = {"metadata": {"uid": "runtime", "annotations": { + PREFIX + "state": "Qualified", PREFIX + "epoch": PRIVATE, + "kars.azure.com/sandbox-namespace": "work", "kars.azure.com/sandbox-uid": "sandbox", + "unrelated-private": PRIVATE, + }}} + grant = {"metadata": {"generation": 2}, "spec": {"privateActivation": {"namespaces": [ + {"namespace": {"name": "kars-test", "uid": "runtime"}, "epoch": PRIVATE}, + ]}}, "status": {"observedGeneration": 2, "conditions": [ + {"type": "WriterReady", "status": "True", "message": PRIVATE}, + {"type": "PrivateConsumptionReady", "status": "True"}, + ]}} + responses = {"/api/v1/namespaces/kars-test": namespace, + "/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace": grant} + setup = types.SimpleNamespace(admin=types.SimpleNamespace(optional=responses.__getitem__)) + result = runtime_scope(setup, sandbox) + for key in ("available", "namespacePresent", "grantPresent", "namespaceUidMatches", + "namespaceOwnerMatches", "grantScopeIncluded", "epochMatches", + "writerReady", "privateConsumptionReady"): + self.assertIs(result[key], True, key) + self.assertNotIn(PRIVATE, json.dumps(result)) + stale = copy.deepcopy(grant) + stale["status"]["observedGeneration"] = 1 + responses[next(path for path in responses if path.startswith("/apis/"))] = stale + result = runtime_scope(setup, sandbox) + self.assertFalse(result["writerReady"]) + self.assertFalse(result["privateConsumptionReady"]) + stale["spec"]["privateActivation"]["namespaces"].clear() + result = runtime_scope(setup, sandbox) + self.assertFalse(result["grantScopeIncluded"]) + self.assertFalse(result["epochMatches"]) + self.assertNotIn(PRIVATE, json.dumps(result)) + + def test_missing_or_failed_metadata_never_claims_qualified_scope(self): + sandbox = {"metadata": {"name": "test", "namespace": "work"}} + setup = types.SimpleNamespace(admin=types.SimpleNamespace(optional=lambda _path: None)) + self.assertEqual(runtime_scope(setup, sandbox), { + "available": True, "namespacePresent": False, "grantPresent": False, + }) + def failed(_path): + raise Failure(PRIVATE) + setup.admin.optional = failed + result = runtime_scope(setup, sandbox) + self.assertEqual(result, {"available": False, "category": "api-unavailable"}) + self.assertNotIn(PRIVATE, json.dumps(result)) + + +if __name__ == "__main__": + unittest.main() From 7d50e1681dbc10519056fe28b019579804ed3d2f Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 23:00:39 +0200 Subject: [PATCH 030/111] Remove receipt decoder imports superseded by the standard adapter Fix the exact three hosted unused-import findings without changing verification or relaxing Clippy. Hold the next public push until useful native evidence completes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/receipts.rs | 2 -- bridge/bff/src/routes/receipts/verification.rs | 2 -- 2 files changed, 4 deletions(-) diff --git a/bridge/bff/src/routes/receipts.rs b/bridge/bff/src/routes/receipts.rs index 8d1c07d82..0093428f5 100644 --- a/bridge/bff/src/routes/receipts.rs +++ b/bridge/bff/src/routes/receipts.rs @@ -7,8 +7,6 @@ use axum::Json; use axum::extract::{Extension, Path, State}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; use serde::Serialize; use serde_json::Value; diff --git a/bridge/bff/src/routes/receipts/verification.rs b/bridge/bff/src/routes/receipts/verification.rs index c42dcd587..143b7d0a6 100644 --- a/bridge/bff/src/routes/receipts/verification.rs +++ b/bridge/bff/src/routes/receipts/verification.rs @@ -228,8 +228,6 @@ pub(crate) async fn verify_receipt_with_pins( Path((ns, name)): Path<(String, String)>, pins: Result<AnchorPins, &'static str>, ) -> AppResult<Json<VerifyResult>> { - use base64::engine::general_purpose::STANDARD as B64; - let cluster = require_cluster(&state)?; require_task_evidence_access(cluster, &ns, &name, &principal).await?; let receipt = cluster From a9a2d0daace0b814c78d9371f497bf01ccc99c8a Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 23:22:24 +0200 Subject: [PATCH 031/111] Isolate the legacy credential review key without changing active tickets Keep the exact v1 domain/NUL/raw-secret derivation and independent compatibility vectors separate from content hashing. Preserve all HS256, audience, expiry, operator and continuation/value-tag semantics. This is not HKDF or new cryptographic assurance; no allowance or approval granted. Hosted compatibility qualification remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 3 +- bridge/bff/src/providers.rs | 1 + bridge/bff/src/providers/credential_review.rs | 36 +++++++++++++++++++ bridge/bff/src/routes/credential_review.rs | 7 ++-- .../2026-09-11-bridge-application.md | 17 +++++++++ 5 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 bridge/bff/src/providers/credential_review.rs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index c3e5f2608..108c38c47 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -57,7 +57,8 @@ jobs: kars::receipt_log::digest_tests::chain_hash_keeps_decimal_sequence_and_exact_pipe_framing \ routes::artifacts::digest_tests::artifact_addresses_keep_the_existing_sixteen_byte_short_form \ routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest \ - providers::receipt::tests::rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics + providers::receipt::tests::rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics \ + providers::credential_review::tests::legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding do grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt done diff --git a/bridge/bff/src/providers.rs b/bridge/bff/src/providers.rs index 64e8ea30d..27a38bac1 100644 --- a/bridge/bff/src/providers.rs +++ b/bridge/bff/src/providers.rs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub(crate) mod credential_review; pub(crate) mod receipt; pub(crate) mod signing; diff --git a/bridge/bff/src/providers/credential_review.rs b/bridge/bff/src/providers/credential_review.rs new file mode 100644 index 000000000..8987291f2 --- /dev/null +++ b/bridge/bff/src/providers/credential_review.rs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Compatibility adapter for the existing version-one credential review key. +//! This is the legacy secret-key derivation, not a content digest or HKDF. +//! Changing it requires a versioned review/continuation/value-tag migration. + +use sha2::{Digest, Sha256}; + +pub(crate) fn derive_v1_key(principal_secret: &str) -> [u8; 32] { + let mut hash = Sha256::new(); + hash.update(b"kars-bridge/credential-review-signing-key/v1\0"); + hash.update(principal_secret.as_bytes()); + hash.finalize().into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding() { + for (secret, expected) in [ + ( + "public-test-principal-secret", + "8e69e654c5c17251d1573e489777e4d62ad3b8bf4d10d6f331d03a6853332f87", + ), + ( + " public-test-principal-secret ", + "f5978a2afbee1cf3936edd5d77b6ffb18547db05e343bbb41d4fba811f1fe69c", + ), + ] { + assert_eq!(hex::encode(derive_v1_key(secret)), expected); + } + } +} diff --git a/bridge/bff/src/routes/credential_review.rs b/bridge/bff/src/routes/credential_review.rs index 343ab0b44..bfc963444 100644 --- a/bridge/bff/src/routes/credential_review.rs +++ b/bridge/bff/src/routes/credential_review.rs @@ -4,13 +4,13 @@ use axum::{ }; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use super::operator::{CredentialRequest, credential_write_error, is_dns1123_label, is_env_key}; use crate::{ auth::Principal, error::{AppError, AppResult}, kars::credential_review::{CredentialReview, ReviewedWrite, StoredSource}, + providers::credential_review::derive_v1_key, state::AppState, }; @@ -101,10 +101,7 @@ fn signing_key(state: &AppState) -> AppResult<Vec<u8>> { "Signed operator sessions are required for credential review".into(), ) })?; - let mut hash = Sha256::new(); - hash.update(b"kars-bridge/credential-review-signing-key/v1\0"); - hash.update(secret.as_bytes()); - Ok(hash.finalize().to_vec()) + Ok(derive_v1_key(secret).to_vec()) } fn sign(key: &[u8], claims: &Claims) -> AppResult<String> { diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index f751f2a59..915020cbc 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -173,6 +173,23 @@ also use the qualified SHA adapter without changing their canonical input, `host:port` framing or identifier widths. They are not left hidden from the top-level import scanner. +### Explicit version-one credential-key compatibility boundary + +The existing credential review key recipe is isolated in +`providers/credential_review.rs`, separate from the allowed content-digest +adapter. It remains the exact versioned SHA-256 derivation over the domain, +literal NUL and raw principal-secret bytes; it is not described as HKDF or as a +plain content hash. Independent compatibility vectors preserve whitespace and +the existing derived-key bytes. + +This is an architectural extraction, not a claim of new cryptographic assurance. +HS256 algorithms, audiences, five-minute expiry, three-submission bounds, +operator identity and existing review/continuation/value-tag formats are +unchanged. An algorithm change would require a separately versioned migration +covering active continuation receipts and rolling upgrades, not silently +invalidating their tags. The new key adapter is not allowlisted or approved by +this record; explicit review and hosted compatibility proof remain required. + The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, and the header gate reports missing Microsoft headers on imported files. From 7e2df1eefb1a2308cc577cf99cb5500541e2f1cd Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 23:34:50 +0200 Subject: [PATCH 032/111] Bound efficiency tests and credential integration modules Extract10unchanged efficiency tests at the original module path and3unchanged controller/Teams credential methods. Preserve raw telemetry literals, public method signatures and authority behavior. Resulting files699/158/755/119lines; formatting/static parity checked, hosted compilation required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/kars/credentials.rs | 116 +------------ .../bff/src/kars/credentials/integrations.rs | 119 +++++++++++++ bridge/bff/src/routes/efficiency.rs | 161 +----------------- bridge/bff/src/routes/efficiency/tests.rs | 158 +++++++++++++++++ 4 files changed, 280 insertions(+), 274 deletions(-) create mode 100644 bridge/bff/src/kars/credentials/integrations.rs create mode 100644 bridge/bff/src/routes/efficiency/tests.rs diff --git a/bridge/bff/src/kars/credentials.rs b/bridge/bff/src/kars/credentials.rs index b64aca8c9..02d6bd10f 100644 --- a/bridge/bff/src/kars/credentials.rs +++ b/bridge/bff/src/kars/credentials.rs @@ -17,6 +17,8 @@ use kube::{ use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; +mod integrations; + const GRANT: &str = "workspace"; const PREFIX: &str = "kars-credential-input-"; const REMOVED_KEYS: &str = "kars.azure.com/credential-removed-keys"; @@ -750,118 +752,4 @@ impl Cluster { } Ok(keys.into_iter().collect()) } - - pub async fn write_controller_environment( - &self, - changes: Vec<Value>, - ) -> Result<(), kube::Error> { - let namespace = self.core_namespace(); - let grant = self.credential_grant(&namespace).await?; - if grant.document.data["spec"]["controller"]["name"] != "kars-controller" - || grant.document.data["spec"]["controller"]["uid"] - .as_str() - .is_none_or(str::is_empty) - { - return Err(failure( - "The controller Deployment UID must be enrolled before provider configuration", - )); - } - let mut incoming = Vec::new(); - for entry in changes { - let name = entry["name"] - .as_str() - .ok_or_else(|| failure("Controller setting name missing"))?; - if entry["$patch"] == "delete" { - incoming.push(json!({"name":name,"remove":true})); - } else if let Some(secret) = entry.get("valueFrom").and_then(|v| v.get("secretKeyRef")) - { - let secret_name = secret["name"] - .as_str() - .ok_or_else(|| failure("Controller Secret reference name missing"))?; - let store = grant - .stores - .iter() - .find(|store| store.secret.name == secret_name) - .ok_or_else(|| failure("Controller credential Secret is not enrolled"))?; - incoming.push(json!({"name":name,"secret":{"name":secret_name,"uid":store.secret.uid,"key":secret["key"]}})); - } else if let Some(value) = entry["value"].as_str() { - incoming.push(json!({"name":name,"value":value})); - } else { - return Err(failure("Unsupported controller environment change")); - } - } - self.mutate_integration(&namespace, "kars-credential-controller-settings", |keys| { - let mut values = keys - .get("configuration") - .and_then(|raw| serde_json::from_str::<Vec<Value>>(raw).ok()) - .unwrap_or_default(); - for change in &incoming { - values.retain(|existing| existing["name"] != change["name"]); - values.push(change.clone()); - } - keys.insert( - "configuration".into(), - serde_json::to_string(&values).expect("environment settings serialize"), - ); - }) - .await - } - - pub async fn request_teams_reconcile( - &self, - namespace: &str, - gateway: &str, - bff: &str, - ) -> Result<(), kube::Error> { - let grant = self.credential_grant(namespace).await?; - let consumers = &grant.document.data["spec"]["bridgeConsumers"]; - if consumers["gateway"]["name"] != gateway || consumers["bff"]["name"] != bff { - return Err(failure( - "Teams Deployment identities must be enrolled; Bridge cannot patch arbitrary Deployments", - )); - } - if let Some(error) = grant.document.data["status"]["integrationError"].as_str() { - return Err(failure(error)); - } - Ok(()) - } - - pub async fn teams_configured(&self) -> Result<bool, kube::Error> { - let namespace = self.integration_namespace(); - let name = std::env::var("BRIDGE_TEAMS_SECRET_NAME") - .unwrap_or_else(|_| "kars-bridge-teams".into()); - let Some(document) = object_api(self, &namespace, "KarsCredentialGrant") - .get_opt(GRANT) - .await - .map_err(|e| safe("Read optional Teams authority", e))? - else { - return Ok(false); - }; - if !document.data["spec"]["integrationStores"] - .as_array() - .is_some_and(|stores| { - stores - .iter() - .any(|store| store["secret"]["name"] == name && store["purpose"] == "teams") - }) - { - return Ok(false); - } - let (_, secret) = self.integration_store(&namespace, &name).await?; - Ok([ - "client-id", - "tenant-id", - "client-secret", - "entra-role-map", - "bff-internal-secret", - ] - .iter() - .all(|key| { - secret - .data - .as_ref() - .and_then(|values| values.get(*key)) - .is_some_and(|value| !value.0.is_empty()) - })) - } } diff --git a/bridge/bff/src/kars/credentials/integrations.rs b/bridge/bff/src/kars/credentials/integrations.rs new file mode 100644 index 000000000..f648206fa --- /dev/null +++ b/bridge/bff/src/kars/credentials/integrations.rs @@ -0,0 +1,119 @@ +// Governed credential adapter — controller and Teams integration operations. + +use super::*; + +impl Cluster { + pub async fn write_controller_environment( + &self, + changes: Vec<Value>, + ) -> Result<(), kube::Error> { + let namespace = self.core_namespace(); + let grant = self.credential_grant(&namespace).await?; + if grant.document.data["spec"]["controller"]["name"] != "kars-controller" + || grant.document.data["spec"]["controller"]["uid"] + .as_str() + .is_none_or(str::is_empty) + { + return Err(failure( + "The controller Deployment UID must be enrolled before provider configuration", + )); + } + let mut incoming = Vec::new(); + for entry in changes { + let name = entry["name"] + .as_str() + .ok_or_else(|| failure("Controller setting name missing"))?; + if entry["$patch"] == "delete" { + incoming.push(json!({"name":name,"remove":true})); + } else if let Some(secret) = entry.get("valueFrom").and_then(|v| v.get("secretKeyRef")) + { + let secret_name = secret["name"] + .as_str() + .ok_or_else(|| failure("Controller Secret reference name missing"))?; + let store = grant + .stores + .iter() + .find(|store| store.secret.name == secret_name) + .ok_or_else(|| failure("Controller credential Secret is not enrolled"))?; + incoming.push(json!({"name":name,"secret":{"name":secret_name,"uid":store.secret.uid,"key":secret["key"]}})); + } else if let Some(value) = entry["value"].as_str() { + incoming.push(json!({"name":name,"value":value})); + } else { + return Err(failure("Unsupported controller environment change")); + } + } + self.mutate_integration(&namespace, "kars-credential-controller-settings", |keys| { + let mut values = keys + .get("configuration") + .and_then(|raw| serde_json::from_str::<Vec<Value>>(raw).ok()) + .unwrap_or_default(); + for change in &incoming { + values.retain(|existing| existing["name"] != change["name"]); + values.push(change.clone()); + } + keys.insert( + "configuration".into(), + serde_json::to_string(&values).expect("environment settings serialize"), + ); + }) + .await + } + + pub async fn request_teams_reconcile( + &self, + namespace: &str, + gateway: &str, + bff: &str, + ) -> Result<(), kube::Error> { + let grant = self.credential_grant(namespace).await?; + let consumers = &grant.document.data["spec"]["bridgeConsumers"]; + if consumers["gateway"]["name"] != gateway || consumers["bff"]["name"] != bff { + return Err(failure( + "Teams Deployment identities must be enrolled; Bridge cannot patch arbitrary Deployments", + )); + } + if let Some(error) = grant.document.data["status"]["integrationError"].as_str() { + return Err(failure(error)); + } + Ok(()) + } + + pub async fn teams_configured(&self) -> Result<bool, kube::Error> { + let namespace = self.integration_namespace(); + let name = std::env::var("BRIDGE_TEAMS_SECRET_NAME") + .unwrap_or_else(|_| "kars-bridge-teams".into()); + let Some(document) = object_api(self, &namespace, "KarsCredentialGrant") + .get_opt(GRANT) + .await + .map_err(|e| safe("Read optional Teams authority", e))? + else { + return Ok(false); + }; + if !document.data["spec"]["integrationStores"] + .as_array() + .is_some_and(|stores| { + stores + .iter() + .any(|store| store["secret"]["name"] == name && store["purpose"] == "teams") + }) + { + return Ok(false); + } + let (_, secret) = self.integration_store(&namespace, &name).await?; + Ok([ + "client-id", + "tenant-id", + "client-secret", + "entra-role-map", + "bff-internal-secret", + ] + .iter() + .all(|key| { + secret + .data + .as_ref() + .and_then(|values| values.get(*key)) + .is_some_and(|value| !value.0.is_empty()) + })) + } +} diff --git a/bridge/bff/src/routes/efficiency.rs b/bridge/bff/src/routes/efficiency.rs index 489e3051a..8d6657016 100644 --- a/bridge/bff/src/routes/efficiency.rs +++ b/bridge/bff/src/routes/efficiency.rs @@ -696,163 +696,4 @@ fn percentile(sorted: &[i64], p: f64) -> i64 { } #[cfg(test)] -mod tests { - use super::*; - - fn run( - route: &str, - pkg: &str, - accepted: bool, - tokens: i64, - wall: i64, - ttfa: i64, - fail: i64, - ) -> RunMetrics { - RunMetrics { - route: route.into(), - harness: "OpenClaw".into(), - package: pkg.into(), - delivered: tokens > 0, - accepted, - total_tokens: tokens, - prompt_tokens: tokens / 2, - completion_tokens: tokens / 2, - rounds: 3, - tool_calls: 4, - tool_fail: fail, - wall_ms: wall, - ttfa_ms: ttfa, - cached_tokens: 0, - fault: String::new(), - } - } - - #[test] - fn derive_from_trace_computes_wall_ttfa_and_fails() { - let trace = r#"[ - {"kind":"round","ms":800,"ts":"2026-07-02T10:00:00Z","cached_tokens":100}, - {"kind":"tool","ms":0,"ok":true,"ts":"2026-07-02T10:00:00Z"}, - {"kind":"tool","ms":0,"ok":false,"ts":"2026-07-02T10:00:01Z"}, - {"kind":"round","ms":1200,"ts":"2026-07-02T10:00:05Z","cached_tokens":200} - ]"#; - let (wall, ttfa, fail, cached) = derive_from_trace(trace); - assert_eq!(ttfa, 800, "TTFA is the first round latency"); - assert_eq!(fail, 1, "one failed tool"); - // span 0s→5s = 5000ms + last round ms 1200 - assert_eq!(wall, 6200); - assert_eq!(cached, 300, "cached tokens summed across rounds"); - } - - #[test] - fn derive_from_trace_is_robust_to_garbage() { - assert_eq!(derive_from_trace("not json"), (0, 0, 0, 0)); - assert_eq!(derive_from_trace("{}"), (0, 0, 0, 0)); - } - - #[test] - fn cache_hit_rate_from_cached_tokens() { - let mut r = run("A", "p1", true, 2000, 5000, 500, 0); // prompt = 1000 - r.cached_tokens = 800; - let dto = aggregate(vec![r], &BTreeMap::new()); - assert!( - (dto.routes[0].cache_hit_rate - 0.8).abs() < 1e-6, - "800/1000 cached" - ); - } - - #[test] - fn fault_classification_is_deterministic() { - assert_eq!(classify_fault(true, 5, "length"), "", "accepted → no fault"); - assert_eq!(classify_fault(false, 0, "content_filter"), "policy"); - assert_eq!(classify_fault(false, 0, "length"), "capacity"); - assert_eq!( - classify_fault(false, 3, "stop"), - "environment", - "tool failures → environment" - ); - assert_eq!( - classify_fault(false, 0, "stop"), - "", - "clean stop but unaccepted → quality miss, no mechanical fault" - ); - assert_eq!(classify_fault(false, 0, ""), "", "no signal → unattributed"); - } - - #[test] - fn top_fault_is_the_dominant_one() { - let mut r1 = run("A", "p1", false, 1000, 100, 50, 2); - r1.fault = "environment".into(); - let mut r2 = run("A", "p2", false, 1000, 100, 50, 0); - r2.fault = "environment".into(); - let mut r3 = run("A", "p3", false, 1000, 100, 50, 0); - r3.fault = "capacity".into(); - let dto = aggregate(vec![r1, r2, r3], &BTreeMap::new()); - assert_eq!(dto.routes[0].top_fault, "environment"); - } - - #[test] - fn last_finish_reason_picks_final_round() { - let trace = r#"[ - {"kind":"round","finish_reason":"tool_calls"}, - {"kind":"tool","ok":true}, - {"kind":"round","finish_reason":"length"} - ]"#; - assert_eq!(last_finish_reason(trace), "length"); - assert_eq!(last_finish_reason("garbage"), ""); - } - - #[test] - fn passk_reliability_only_counts_repeated_packages() { - // route A: package p1 run twice (both accepted) → reliable; p2 once (ignored). - let runs = vec![ - run("A", "p1", true, 1000, 5000, 500, 0), - run("A", "p1", true, 1100, 5200, 400, 0), - run("A", "p2", true, 900, 4000, 300, 0), - ]; - let dto = aggregate(runs, &BTreeMap::new()); - let a = dto.routes.iter().find(|r| r.route == "A").unwrap(); - assert_eq!(a.reliability_samples, 1, "only p1 repeated"); - assert_eq!(a.reliability_rate, Some(1.0)); - assert_eq!(a.reliability_k, Some(2)); - } - - #[test] - fn passk_flags_inconsistent_package() { - // p1 accepted once, rejected once → NOT fully reliable. - let runs = vec![ - run("A", "p1", true, 1000, 5000, 500, 0), - run("A", "p1", false, 1100, 5200, 400, 2), - ]; - let dto = aggregate(runs, &BTreeMap::new()); - let a = dto.routes.iter().find(|r| r.route == "A").unwrap(); - assert_eq!( - a.reliability_rate, - Some(0.0), - "inconsistent package fails pass^k" - ); - assert_eq!(a.reliability_samples, 1); - } - - #[test] - fn usd_per_outcome_only_when_priced() { - let runs = vec![run("gpt-4o", "p1", true, 2_000_000, 5000, 500, 0)]; - // No prices → None. - let dto = aggregate(runs.clone(), &BTreeMap::new()); - assert!(dto.routes[0].usd_per_outcome.is_none()); - assert!(!dto.priced); - // Priced: 1M prompt @ $2.5 + 1M completion @ $10 = $12.5 over 1 outcome. - let mut prices = BTreeMap::new(); - prices.insert("gpt-4o".to_string(), (2.5, 10.0)); - let dto = aggregate(runs, &prices); - assert!(dto.priced); - let usd = dto.routes[0].usd_per_outcome.unwrap(); - assert!((usd - 12.5).abs() < 1e-6, "got {usd}"); - } - - #[test] - fn tool_fail_rate_computed() { - let runs = vec![run("A", "p1", true, 1000, 5000, 500, 2)]; // 2 fails of 4 calls - let dto = aggregate(runs, &BTreeMap::new()); - assert!((dto.routes[0].tool_fail_rate - 0.5).abs() < 1e-6); - } -} +mod tests; diff --git a/bridge/bff/src/routes/efficiency/tests.rs b/bridge/bff/src/routes/efficiency/tests.rs new file mode 100644 index 000000000..f6d13a904 --- /dev/null +++ b/bridge/bff/src/routes/efficiency/tests.rs @@ -0,0 +1,158 @@ +use super::*; + +fn run( + route: &str, + pkg: &str, + accepted: bool, + tokens: i64, + wall: i64, + ttfa: i64, + fail: i64, +) -> RunMetrics { + RunMetrics { + route: route.into(), + harness: "OpenClaw".into(), + package: pkg.into(), + delivered: tokens > 0, + accepted, + total_tokens: tokens, + prompt_tokens: tokens / 2, + completion_tokens: tokens / 2, + rounds: 3, + tool_calls: 4, + tool_fail: fail, + wall_ms: wall, + ttfa_ms: ttfa, + cached_tokens: 0, + fault: String::new(), + } +} + +#[test] +fn derive_from_trace_computes_wall_ttfa_and_fails() { + let trace = r#"[ + {"kind":"round","ms":800,"ts":"2026-07-02T10:00:00Z","cached_tokens":100}, + {"kind":"tool","ms":0,"ok":true,"ts":"2026-07-02T10:00:00Z"}, + {"kind":"tool","ms":0,"ok":false,"ts":"2026-07-02T10:00:01Z"}, + {"kind":"round","ms":1200,"ts":"2026-07-02T10:00:05Z","cached_tokens":200} + ]"#; + let (wall, ttfa, fail, cached) = derive_from_trace(trace); + assert_eq!(ttfa, 800, "TTFA is the first round latency"); + assert_eq!(fail, 1, "one failed tool"); + // span 0s→5s = 5000ms + last round ms 1200 + assert_eq!(wall, 6200); + assert_eq!(cached, 300, "cached tokens summed across rounds"); +} + +#[test] +fn derive_from_trace_is_robust_to_garbage() { + assert_eq!(derive_from_trace("not json"), (0, 0, 0, 0)); + assert_eq!(derive_from_trace("{}"), (0, 0, 0, 0)); +} + +#[test] +fn cache_hit_rate_from_cached_tokens() { + let mut r = run("A", "p1", true, 2000, 5000, 500, 0); // prompt = 1000 + r.cached_tokens = 800; + let dto = aggregate(vec![r], &BTreeMap::new()); + assert!( + (dto.routes[0].cache_hit_rate - 0.8).abs() < 1e-6, + "800/1000 cached" + ); +} + +#[test] +fn fault_classification_is_deterministic() { + assert_eq!(classify_fault(true, 5, "length"), "", "accepted → no fault"); + assert_eq!(classify_fault(false, 0, "content_filter"), "policy"); + assert_eq!(classify_fault(false, 0, "length"), "capacity"); + assert_eq!( + classify_fault(false, 3, "stop"), + "environment", + "tool failures → environment" + ); + assert_eq!( + classify_fault(false, 0, "stop"), + "", + "clean stop but unaccepted → quality miss, no mechanical fault" + ); + assert_eq!(classify_fault(false, 0, ""), "", "no signal → unattributed"); +} + +#[test] +fn top_fault_is_the_dominant_one() { + let mut r1 = run("A", "p1", false, 1000, 100, 50, 2); + r1.fault = "environment".into(); + let mut r2 = run("A", "p2", false, 1000, 100, 50, 0); + r2.fault = "environment".into(); + let mut r3 = run("A", "p3", false, 1000, 100, 50, 0); + r3.fault = "capacity".into(); + let dto = aggregate(vec![r1, r2, r3], &BTreeMap::new()); + assert_eq!(dto.routes[0].top_fault, "environment"); +} + +#[test] +fn last_finish_reason_picks_final_round() { + let trace = r#"[ + {"kind":"round","finish_reason":"tool_calls"}, + {"kind":"tool","ok":true}, + {"kind":"round","finish_reason":"length"} + ]"#; + assert_eq!(last_finish_reason(trace), "length"); + assert_eq!(last_finish_reason("garbage"), ""); +} + +#[test] +fn passk_reliability_only_counts_repeated_packages() { + // route A: package p1 run twice (both accepted) → reliable; p2 once (ignored). + let runs = vec![ + run("A", "p1", true, 1000, 5000, 500, 0), + run("A", "p1", true, 1100, 5200, 400, 0), + run("A", "p2", true, 900, 4000, 300, 0), + ]; + let dto = aggregate(runs, &BTreeMap::new()); + let a = dto.routes.iter().find(|r| r.route == "A").unwrap(); + assert_eq!(a.reliability_samples, 1, "only p1 repeated"); + assert_eq!(a.reliability_rate, Some(1.0)); + assert_eq!(a.reliability_k, Some(2)); +} + +#[test] +fn passk_flags_inconsistent_package() { + // p1 accepted once, rejected once → NOT fully reliable. + let runs = vec![ + run("A", "p1", true, 1000, 5000, 500, 0), + run("A", "p1", false, 1100, 5200, 400, 2), + ]; + let dto = aggregate(runs, &BTreeMap::new()); + let a = dto.routes.iter().find(|r| r.route == "A").unwrap(); + assert_eq!( + a.reliability_rate, + Some(0.0), + "inconsistent package fails pass^k" + ); + assert_eq!(a.reliability_samples, 1); +} + +#[test] +fn usd_per_outcome_only_when_priced() { + let runs = vec![run("gpt-4o", "p1", true, 2_000_000, 5000, 500, 0)]; + // No prices → None. + let dto = aggregate(runs.clone(), &BTreeMap::new()); + assert!(dto.routes[0].usd_per_outcome.is_none()); + assert!(!dto.priced); + // Priced: 1M prompt @ $2.5 + 1M completion @ $10 = $12.5 over 1 outcome. + let mut prices = BTreeMap::new(); + prices.insert("gpt-4o".to_string(), (2.5, 10.0)); + let dto = aggregate(runs, &prices); + assert!(dto.priced); + let usd = dto.routes[0].usd_per_outcome.unwrap(); + assert!((usd - 12.5).abs() < 1e-6, "got {usd}"); +} + +#[test] +fn tool_fail_rate_computed() { + let runs = vec![run("A", "p1", true, 1000, 5000, 500, 2)]; // 2 fails of 4 calls + let dto = aggregate(runs, &BTreeMap::new()); + assert!((dto.routes[0].tool_fail_rate - 0.5).abs() < 1e-6); +} From 8b05add23b303cfad83d68cb9a87173d4bf3a894 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 23:54:53 +0200 Subject: [PATCH 033/111] Require new audit records rather than reusing old scoped sign-offs Reject modified/renamed historical approvals as new capability review and fail closed on an invalid review base. Preserve two-distinct-signer enforcement and document that presence checks do not authenticate review.29source-gate regression cases pass, including exact reproduction of stale-signature reuse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- ci/security-audit-required.sh | 15 +++-- ci/tests/security_audit_gate_test.py | 85 ++++++++++++++++++++++++++++ docs/security-audits/README.md | 14 +++++ 3 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 ci/tests/security_audit_gate_test.py diff --git a/ci/security-audit-required.sh b/ci/security-audit-required.sh index 5658e5842..803c56242 100755 --- a/ci/security-audit-required.sh +++ b/ci/security-audit-required.sh @@ -19,7 +19,10 @@ cd "$REPO_ROOT" # Capability-introducing paths — mirrors §4.4 of the plan. CAP_RE='^(controller/src/(crd|reconcilers|admission)|inference-router/src/(mcp|a2a|providers|routes)|cli/src/(commands|migrate|adapters)|runtimes/openclaw/src/(core|index\.ts)|sandbox-images/[^/]+/(Dockerfile|entrypoint\.sh)|cli/profiles/|deploy/seccomp/|deploy/helm/kars/files/|shared/.*\.rs$|bridge/(bff/src/|web/src/|teams-gateway/src/|deploy/|[^/]+/Dockerfile|start-bff\.sh))' -changed=$(git diff --name-only "${BASE_REF}...HEAD" 2>/dev/null || git diff --name-only HEAD) +if ! changed=$(git diff --no-ext-diff --name-only "${BASE_REF}...HEAD"); then + echo "fail: cannot determine the reviewed capability diff." >&2 + exit 1 +fi # Exclude test files — they exercise capabilities but don't introduce # them. Catches *.test.ts / *.test.js / *_test.rs / tests/ directories. touches_cap=$(printf '%s\n' "$changed" \ @@ -30,10 +33,14 @@ if [ -z "$touches_cap" ]; then exit 0 fi -# Is at least one docs/security-audits/*.md added in this PR? -added_audit=$(printf '%s\n' "$changed" | grep -E '^docs/security-audits/[0-9]{4}-[0-9]{2}-[0-9]{2}-.+\.md$' || true) +# Modifying or renaming an older approval cannot extend its recorded scope. +if ! additions=$(git diff --no-ext-diff --name-only --find-renames=50% --diff-filter=A "${BASE_REF}...HEAD"); then + echo "fail: cannot determine newly added audit records." >&2 + exit 1 +fi +added_audit=$(printf '%s\n' "$additions" | grep -E '^docs/security-audits/[0-9]{4}-[0-9]{2}-[0-9]{2}-.+\.md$' || true) if [ -z "$added_audit" ]; then - echo "fail: capability-introducing files touched but no docs/security-audits/YYYY-MM-DD-<slug>.md added." >&2 + echo "fail: capability-introducing files touched but no new docs/security-audits/YYYY-MM-DD-<slug>.md added." >&2 echo " touched capabilities:" >&2 printf ' %s\n' $touches_cap >&2 echo " Copy docs/security-audits/_template.md and fill it in (see docs/security-audits/README.md)." >&2 diff --git a/ci/tests/security_audit_gate_test.py b/ci/tests/security_audit_gate_test.py new file mode 100644 index 000000000..3e707b60c --- /dev/null +++ b/ci/tests/security_audit_gate_test.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import os +from pathlib import Path +import subprocess +import unittest + +from git_fixture import GitFixture + +GATE = Path(__file__).resolve().parents[1] / "security-audit-required.sh" +OLD = "docs/security-audits/2026-01-01-old-scope.md" +NEW = "docs/security-audits/2026-01-02-current-scope.md" +SIGNED = ("# Approved old scope\n\n" + "Signed-off-by: Author <author@example.invalid>\n" + "Signed-off-by: Reviewer <reviewer@example.invalid>\n") + + +class SecurityAuditGateTests(GitFixture): + def old_approval(self): + self.write(OLD, SIGNED) + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + + def capability(self): + self.write("cli/src/commands/capability.ts", "export const capability = true;\n") + + def gate(self): + return subprocess.run(["bash", str(GATE)], cwd=self.root, text=True, capture_output=True, + env={**os.environ, "BASE_REF": self.base}, timeout=30) + + def test_modifying_an_old_signed_scope_does_not_approve_new_capability(self): + self.old_approval() + self.capability() + self.write(OLD, SIGNED + "\nNew unreviewed implementation notes.\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1) + self.assertIn("no new docs/security-audits/", result.stderr) + + def test_renaming_an_old_approval_is_not_a_new_review_record(self): + self.old_approval() + self.capability() + self.git("mv", OLD, NEW) + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1) + + def test_new_unsigned_record_is_not_covered_by_old_signatures(self): + self.old_approval() + self.capability() + self.write(OLD, SIGNED + "\nAdditional historical notes.\n") + self.write(NEW, "# Current source review pending\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1) + self.assertIn(NEW, result.stderr) + + def test_new_record_still_requires_two_distinct_signers(self): + self.capability() + self.write(NEW, "# Current scope\nSigned-off-by: Author <same@example.invalid>\n" + "Signed-off-by: Reviewer <same@example.invalid>\n") + self.commit() + self.assertEqual(self.gate().returncode, 1) + self.write(NEW, SIGNED.replace("Approved old scope", "Current scope")) + self.commit() + self.assertEqual(self.gate().returncode, 0) + + def test_documentation_only_changes_do_not_require_capability_approval(self): + self.old_approval() + self.write(OLD, SIGNED + "\nTypographic clarification.\n") + self.commit() + self.assertEqual(self.gate().returncode, 0) + + def test_missing_review_base_cannot_fall_back_to_an_empty_worktree_diff(self): + self.capability() + self.commit() + self.base = "missing-reviewed-base" + result = self.gate() + self.assertEqual(result.returncode, 1) + self.assertIn("cannot determine the reviewed capability diff", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/security-audits/README.md b/docs/security-audits/README.md index c7f8ead0b..fb30cf03f 100644 --- a/docs/security-audits/README.md +++ b/docs/security-audits/README.md @@ -18,5 +18,19 @@ profiles, and bundled Helm files. Test files are exempt. and a short verdict. 3. End with two `Signed-off-by:` lines using real emails (author + reviewer). +Add a **new record for the current change scope**. Modifying or renaming an +older signed audit does not extend its approval to new capabilities. Identify +the reviewed source head and base, distinguish source review from executed +qualification, and retain unresolved findings and failed-run evidence honestly. +The gate rejects an unavailable review base rather than checking an unrelated +worktree diff. + +The gate checks record presence and distinct signer emails; it does not +authenticate identities or verify that the prose covers the changed source. +Reviewers must check those facts. An explicitly maintainer-authorized delegation +must cite that authorization and disclose its actual participants and limits +in the record, never imply that AI review was a second human review or that +technical gates were waived. No delegation is inferred by default. + These docs are intentionally **tracked** (committed with the PR), unlike the private `docs/internal/` planning folder. From ac8aa0904ad69bc7816e8e128a63b33751ebdc0b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 23:57:12 +0200 Subject: [PATCH 034/111] Split engineering intake into bounded behavior-preserving modules Preserve89signature/body/attribute sets,52declarations/constants,685literals and63byte-identical production bodies, including polling claims, approval and hash semantics.37tests structurally registered; remediation paths unchanged. Sourceformat/export checks passed; hosted Rust execution remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/engineering.rs | 2968 +---------------- bridge/bff/src/routes/engineering/config.rs | 240 ++ .../bff/src/routes/engineering/endpoints.rs | 367 ++ bridge/bff/src/routes/engineering/github.rs | 296 ++ bridge/bff/src/routes/engineering/intake.rs | 325 ++ bridge/bff/src/routes/engineering/queue.rs | 215 ++ bridge/bff/src/routes/engineering/review.rs | 401 +++ .../src/routes/engineering/synchronization.rs | 612 ++++ bridge/bff/src/routes/engineering/tests.rs | 599 ++++ 9 files changed, 3086 insertions(+), 2937 deletions(-) create mode 100644 bridge/bff/src/routes/engineering/config.rs create mode 100644 bridge/bff/src/routes/engineering/endpoints.rs create mode 100644 bridge/bff/src/routes/engineering/github.rs create mode 100644 bridge/bff/src/routes/engineering/intake.rs create mode 100644 bridge/bff/src/routes/engineering/queue.rs create mode 100644 bridge/bff/src/routes/engineering/review.rs create mode 100644 bridge/bff/src/routes/engineering/synchronization.rs create mode 100644 bridge/bff/src/routes/engineering/tests.rs diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs index d4f80e9ee..63f6a4f13 100644 --- a/bridge/bff/src/routes/engineering.rs +++ b/bridge/bff/src/routes/engineering.rs @@ -4,32 +4,41 @@ // cursors, and status live in an owner-annotated ConfigMap; discovered work is // merged into the controller's existing durable team task ConfigMap. -use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::time::Duration; - -use crate::providers::signing::sha256; -use axum::Json; -use axum::extract::{Extension, Path, State}; -use chrono::{DateTime, Utc}; -use k8s_openapi::api::core::v1::ConfigMap; -use kube::ResourceExt; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; -use crate::auth::Principal; -use crate::error::{AppError, AppResult}; -use crate::kars::cluster::Cluster; -use crate::routes::github::{ - authorize_repo_set, connection_config_map_name, installation_token, mint_app_jwt, -}; -use crate::routes::tasks::require_cluster; -use crate::routes::teams::{TeamTaskDto, read_task_list, require_owned_team}; -use crate::state::AppState; +use crate::routes::teams::TeamTaskDto; +mod config; +mod endpoints; +mod github; +mod intake; +mod queue; mod remediation; -use remediation::{ - description_matches_remediation, match_remediation_task, note_candidate_pulls, - remediation_work_id, +mod review; +mod synchronization; + +pub(crate) use config::source_config_map_name; +use config::{default_poll_interval, default_true}; +pub use endpoints::{decide_review_item, delete_source, get_source, put_source, sync_now}; +pub use synchronization::spawn_poller; + +#[cfg(test)] +use config::{parse_source, source_data, validate_request}; +#[cfg(test)] +use github::{next_link, unavailable_security_product}; +#[cfg(test)] +use intake::{ + alert_backlog_task, alert_source_id, alert_work_id, backlog_task, code_scanning_task, + dependabot_alert_task, is_dependabot_pr, open_pull_may_address_dependabot_alert, + secret_scanning_task, source_id, work_id, }; +#[cfg(test)] +use queue::{append_bounded_tasks, merge_discovered_tasks}; +#[cfg(test)] +use remediation::{description_matches_remediation, remediation_work_id}; +#[cfg(test)] +use review::{classify_review_readiness, dedupe_followup_task, review_followup_task}; const CONFIG_KEY: &str = "config.json"; const CURSOR_KEY: &str = "cursor.json"; @@ -362,57 +371,6 @@ struct GithubRepositoryFeatures { security_and_analysis: Option<serde_json::Value>, } -async fn repository_features( - client: &reqwest::Client, - token: &str, - repo: &str, -) -> Option<GithubRepositoryFeatures> { - client - .get(format!("https://api.github.com/repos/{repo}")) - .bearer_auth(token) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header(reqwest::header::USER_AGENT, "kars-bridge") - .send() - .await - .ok()? - .error_for_status() - .ok()? - .json() - .await - .ok() -} - -fn unavailable_security_product( - features: Option<&GithubRepositoryFeatures>, - signal: EngineeringSignal, - error: &GithubListError, -) -> Option<GithubListError> { - let unsupported_signal = matches!( - signal, - EngineeringSignal::CodeScanningAlert | EngineeringSignal::SecretScanningAlert - ); - let private_without_security_product = - features.is_some_and(|repo| repo.private && repo.security_and_analysis.is_none()); - let unsupported_response = matches!( - error.state, - EngineeringSignalSyncState::Forbidden | EngineeringSignalSyncState::Unavailable - ); - (unsupported_signal && private_without_security_product && unsupported_response).then(|| { - GithubListError { - state: EngineeringSignalSyncState::Unavailable, - detail: format!( - "{} is unavailable because GitHub Code Security / Secret Protection is not enabled or licensed for this private repository", - match signal { - EngineeringSignal::CodeScanningAlert => "Code scanning", - EngineeringSignal::SecretScanningAlert => "Secret scanning", - _ => "Security scanning", - } - ), - } - }) -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct DependabotWorkDetails { signal: EngineeringSignal, @@ -440,673 +398,11 @@ struct SyncOutcome { signal_results: Vec<EngineeringSignalResult>, } -fn default_poll_interval() -> u64 { - DEFAULT_POLL_INTERVAL_SECONDS -} - -fn default_true() -> bool { - true -} - -pub(crate) fn source_config_map_name(namespace: &str, team: &str) -> String { - let digest = sha256(format!("{namespace}/{team}").as_bytes()); - let stem = team.chars().take(40).collect::<String>(); - format!("kars-eng-{stem}-{}", hex::encode(&digest[..6])) -} - -fn source_annotations(config: &EngineeringSourceConfig) -> BTreeMap<String, String> { - BTreeMap::from([ - (OWNER_ANNOTATION.to_string(), config.owner_sub.clone()), - ( - TEAM_NAMESPACE_ANNOTATION.to_string(), - config.team_namespace.clone(), - ), - (TEAM_NAME_ANNOTATION.to_string(), config.team_name.clone()), - ( - CONNECTION_ANNOTATION.to_string(), - config.connection_config_map_ref.clone(), - ), - ]) -} - -fn source_data( - config: &EngineeringSourceConfig, - cursor: &EngineeringCursor, - status: &EngineeringSourceStatus, -) -> AppResult<BTreeMap<String, String>> { - Ok(BTreeMap::from([ - ( - CONFIG_KEY.to_string(), - serde_json::to_string(config).map_err(|e| AppError::Internal(e.into()))?, - ), - ( - CURSOR_KEY.to_string(), - serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?, - ), - ( - STATUS_KEY.to_string(), - serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?, - ), - ])) -} - -fn parse_source( - cm: &ConfigMap, -) -> Result< - ( - EngineeringSourceConfig, - EngineeringCursor, - EngineeringSourceStatus, - ), - String, -> { - let data = cm - .data - .as_ref() - .ok_or_else(|| "engineering source has no data".to_string())?; - let config = serde_json::from_str::<EngineeringSourceConfig>( - data.get(CONFIG_KEY) - .ok_or_else(|| "engineering source is missing config.json".to_string())?, - ) - .map_err(|e| format!("invalid engineering source config: {e}"))?; - if config.version != 1 { - return Err(format!( - "unsupported engineering source config version {}", - config.version - )); - } - let cursor = data - .get(CURSOR_KEY) - .map(|raw| serde_json::from_str(raw)) - .transpose() - .map_err(|e| format!("invalid engineering source cursor: {e}"))? - .unwrap_or_default(); - let status = data - .get(STATUS_KEY) - .map(|raw| serde_json::from_str(raw)) - .transpose() - .map_err(|e| format!("invalid engineering source status: {e}"))? - .unwrap_or_default(); - Ok((config, cursor, status)) -} - -fn verify_source_owner(cm: &ConfigMap, config: &EngineeringSourceConfig, owner_sub: &str) -> bool { - config.owner_sub == owner_sub - && cm - .annotations() - .get(OWNER_ANNOTATION) - .is_some_and(|stored| stored == owner_sub) - && cm - .annotations() - .get(CONNECTION_ANNOTATION) - .is_some_and(|stored| stored == &config.connection_config_map_ref) - && cm - .annotations() - .get(TEAM_NAMESPACE_ANNOTATION) - .is_some_and(|stored| stored == &config.team_namespace) - && cm - .annotations() - .get(TEAM_NAME_ANNOTATION) - .is_some_and(|stored| stored == &config.team_name) -} - -fn to_dto( - configured: bool, - config: Option<&EngineeringSourceConfig>, - status: EngineeringSourceStatus, -) -> EngineeringSourceDto { - EngineeringSourceDto { - configured, - enabled: config.is_some_and(|c| c.enabled), - auto_run: config.is_none_or(|c| c.auto_run), - repos: config.map(|c| c.repos.clone()).unwrap_or_default(), - signals: config.map(|c| c.signals.clone()).unwrap_or_default(), - poll_interval_seconds: config - .map(|c| c.poll_interval_seconds) - .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS), - status, - } -} - -fn validate_repo_name(repo: &str) -> bool { - let mut parts = repo.split('/'); - let Some(owner) = parts.next() else { - return false; - }; - let Some(name) = parts.next() else { - return false; - }; - parts.next().is_none() - && !owner.is_empty() - && !name.is_empty() - && owner - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) - && name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) -} - -fn validate_request( - request: &PutEngineeringSourceRequest, - granted: &[String], -) -> AppResult<(Vec<String>, Vec<EngineeringSignal>)> { - if !(MIN_POLL_INTERVAL_SECONDS..=MAX_POLL_INTERVAL_SECONDS) - .contains(&request.poll_interval_seconds) - { - return Err(AppError::BadRequest(format!( - "poll_interval_seconds must be between {MIN_POLL_INTERVAL_SECONDS} and {MAX_POLL_INTERVAL_SECONDS}" - ))); - } - - let repos = authorize_repo_set(&request.repos, granted)?; - if repos.len() > MAX_REPOS { - return Err(AppError::BadRequest(format!( - "at most {MAX_REPOS} repositories can be configured per team" - ))); - } - if let Some(invalid) = repos.iter().find(|repo| !validate_repo_name(repo)) { - return Err(AppError::BadRequest(format!( - "invalid repository name `{invalid}`; expected owner/repo" - ))); - } - - let signals = request - .signals - .iter() - .copied() - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>(); - if request.enabled && repos.is_empty() { - return Err(AppError::BadRequest( - "select at least one authorized repository before enabling engineering intake".into(), - )); - } - if request.enabled && signals.is_empty() { - return Err(AppError::BadRequest( - "select at least one engineering signal before enabling intake".into(), - )); - } - Ok((repos, signals)) -} - -fn initial_jitter_seconds(source_name: &str) -> i64 { - let digest = sha256(source_name.as_bytes()); - i64::from(digest[0] % 60) -} - -fn next_poll_at(config: &EngineeringSourceConfig, now: DateTime<Utc>) -> String { - (now + chrono::Duration::seconds(config.poll_interval_seconds as i64)).to_rfc3339() -} - -fn is_due(status: &EngineeringSourceStatus, now: DateTime<Utc>) -> bool { - status - .next_poll_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .is_none_or(|value| value.with_timezone(&Utc) <= now) -} - -fn sync_claim_active(status: &EngineeringSourceStatus, now: DateTime<Utc>) -> bool { - status.state == EngineeringSyncState::Syncing - && status - .sync_claim_expires_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .is_some_and(|expires| expires.with_timezone(&Utc) > now) -} - -fn is_dependabot_pr(pr: &GithubPull) -> bool { - pr.user.as_ref().is_some_and(|user| { - user.login.eq_ignore_ascii_case("dependabot[bot]") - || user.login.eq_ignore_ascii_case("dependabot-preview[bot]") - }) || pr.head.name.to_ascii_lowercase().starts_with("dependabot/") -} - -fn open_pull_may_address_dependabot_alert(pr: &GithubPull, alert: &GithubDependabotAlert) -> bool { - let haystack = format!("{} {}", pr.title, pr.head.name).to_ascii_lowercase(); - if alert - .security_advisory - .as_ref() - .is_some_and(|advisory| haystack.contains(&advisory.ghsa_id.to_ascii_lowercase())) - { - return true; - } - let haystack_terms = haystack - .split(|character: char| !character.is_ascii_alphanumeric()) - .filter(|term| term.len() >= 3) - .collect::<std::collections::BTreeSet<_>>(); - let package = alert.dependency.package.name.to_ascii_lowercase(); - let package_terms = package - .split(|character: char| !character.is_ascii_alphanumeric()) - .filter(|term| term.len() >= 3) - .collect::<Vec<_>>(); - !package_terms.is_empty() - && package_terms - .iter() - .all(|term| haystack_terms.contains(term)) -} - -fn source_id(repo: &str, number: u64) -> String { - format!("github:{}:pull:{number}", repo.to_ascii_lowercase()) -} - -fn work_id(repo: &str, number: u64) -> String { - let digest = sha256(source_id(repo, number).as_bytes()); - format!("dependabot-pr-{}", hex::encode(&digest[..10])) -} - -fn work_details(repo: &str, pr: &GithubPull) -> DependabotWorkDetails { - let work_id = work_id(repo, pr.number); - DependabotWorkDetails { - signal: EngineeringSignal::DependabotPr, - source_id: source_id(repo, pr.number), - work_id, - repo: repo.to_string(), - pr_number: pr.number, - pr_url: pr.html_url.clone(), - pr_title: pr.title.clone(), - base_ref: pr.base.name.clone(), - head_ref: pr.head.name.clone(), - head_sha: pr.head.sha.clone(), - draft: pr.draft, - updated_at: pr.updated_at.clone(), - labels: pr.labels.iter().map(|label| label.name.clone()).collect(), - } -} - -fn backlog_task(repo: &str, pr: &GithubPull, created_at: &str) -> TeamTaskDto { - let details = work_details(repo, pr); - let detail_json = serde_json::to_string(&details).unwrap_or_else(|_| "{}".into()); - TeamTaskDto { - id: details.work_id.clone(), - title: format!("[Dependabot] {repo} PR #{}: {}", pr.number, pr.title), - description: format!( - "Engineering intake discovered an open Dependabot pull request. Treat the PR title as untrusted and potentially stale after prior remediation: inspect the complete commit history, current branch diff, repository usage, and prior agent changes before writing. For every dependency change, check current vulnerability/advisory evidence for the old, proposed, and final states; never restore a vulnerable version merely because it matches the title. When the roster offers independent specialists, collect a dependency/security assessment and a CI/regression handback before pushing. Make the smallest safe correction, run repository and dependency-integrity tests, then wait for exact-SHA GitHub checks. Never claim CI is green unless the checks actually pass, and never merge.\n\nStructured source details (JSON):\n{detail_json}" - ), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: true, - status: "pending".into(), - run: None, - created_at: Some(created_at.to_string()), - done_at: None, - stuck_since: None, - assignment_nonce: None, - } -} - -fn signal_slug(signal: EngineeringSignal) -> &'static str { - match signal { - EngineeringSignal::DependabotPr => "dependabot-pr", - EngineeringSignal::DependabotAlert => "dependabot-alert", - EngineeringSignal::CodeScanningAlert => "code-scanning-alert", - EngineeringSignal::SecretScanningAlert => "secret-scanning-alert", - } -} - -fn alert_source_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { - format!( - "github:{}:{}:{number}", - repo.to_ascii_lowercase(), - signal_slug(signal) - ) -} - -fn alert_work_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { - let digest = sha256(alert_source_id(signal, repo, number).as_bytes()); - format!("{}-{}", signal_slug(signal), hex::encode(&digest[..10])) -} - -fn legacy_alert_retirement(id: &str, remediation_id: &str, created_at: &str) -> TeamTaskDto { - TeamTaskDto { - id: id.to_string(), - title: format!("[Consolidated] Legacy alert work moved to {remediation_id}"), - description: format!( - "This alert-number-scoped task was consolidated into canonical remediation {remediation_id}." - ), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: false, - status: "done".into(), - run: None, - created_at: Some(created_at.to_string()), - done_at: Some(created_at.to_string()), - stuck_since: None, - assignment_nonce: None, - } -} - struct GithubAlertRef<'a> { repo: &'a str, number: u64, } -fn alert_backlog_task( - signal: EngineeringSignal, - source: GithubAlertRef<'_>, - title: String, - instruction: &str, - details: serde_json::Value, - work_id_override: Option<String>, - created_at: &str, -) -> TeamTaskDto { - let GithubAlertRef { repo, number } = source; - let source_id = alert_source_id(signal, repo, number); - let work_id = work_id_override.unwrap_or_else(|| alert_work_id(signal, repo, number)); - let structured = serde_json::json!({ - "signal": signal, - "source_id": source_id, - "work_id": work_id, - "repo": repo, - "alert_number": number, - "details": details.clone(), - }); - let source_facts = [ - details - .get("manifest_path") - .and_then(serde_json::Value::as_str) - .map(|value| format!("manifest={value}")), - details - .get("path") - .and_then(serde_json::Value::as_str) - .map(|value| format!("path={value}")), - details - .get("package") - .and_then(serde_json::Value::as_str) - .map(|value| format!("pkg={value}")), - details - .get("vulnerable_version_range") - .and_then(serde_json::Value::as_str) - .map(|value| format!("vuln={value}")), - details - .get("first_patched_version") - .and_then(serde_json::Value::as_str) - .map(|value| format!("fixed={value}")), - details - .get("ghsa_id") - .and_then(serde_json::Value::as_str) - .map(|value| format!("ghsa={value}")), - ] - .into_iter() - .flatten() - .collect::<Vec<_>>() - .join("; "); - TeamTaskDto { - id: work_id, - title, - description: format!( - "AUTH SOURCE: {source_facts}. RULE: exact manifest; max2 same target; search open+merged PRs for same GHSA/pkg; fix+PR handbacks; no principal substitution.\n\n{instruction} Validate the finding against the current repository state, make the smallest safe remediation, run relevant tests and security checks, and propose or update a pull request when code changes are needed. Never claim success without current evidence and never merge.\n\nStructured source details (JSON):\n{}", - serde_json::to_string(&structured).unwrap_or_else(|_| "{}".into()) - ), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: true, - status: "pending".into(), - run: None, - created_at: Some(created_at.to_string()), - done_at: None, - stuck_since: None, - assignment_nonce: None, - } -} - -fn code_scanning_task( - repo: &str, - alert: &GithubCodeScanningAlert, - created_at: &str, -) -> TeamTaskDto { - let location = alert - .most_recent_instance - .as_ref() - .and_then(|instance| instance.location.as_ref()); - let rule_name = alert.rule.name.as_deref().unwrap_or(&alert.rule.id); - let severity = alert - .rule - .security_severity_level - .as_deref() - .or(alert.rule.severity.as_deref()) - .unwrap_or("unknown"); - alert_backlog_task( - EngineeringSignal::CodeScanningAlert, - GithubAlertRef { - repo, - number: alert.number, - }, - format!( - "[Code scanning] {repo} alert #{}: {rule_name}", - alert.number - ), - "GitHub code scanning reported an open code-quality or security finding.", - serde_json::json!({ - "url": alert.html_url, - "rule_id": alert.rule.id, - "rule_name": rule_name, - "description": alert.rule.description, - "severity": severity, - "path": location.and_then(|value| value.path.clone()), - "start_line": location.and_then(|value| value.start_line), - "end_line": location.and_then(|value| value.end_line), - "updated_at": alert.updated_at, - }), - None, - created_at, - ) -} - -fn dependabot_alert_task( - repo: &str, - alert: &GithubDependabotAlert, - created_at: &str, -) -> TeamTaskDto { - let advisory = alert.security_advisory.as_ref(); - let advisory_id = advisory - .map(|value| value.ghsa_id.as_str()) - .unwrap_or("GitHub advisory"); - alert_backlog_task( - EngineeringSignal::DependabotAlert, - GithubAlertRef { - repo, - number: alert.number, - }, - format!( - "[Dependabot alert] {repo} #{}: {} ({advisory_id})", - alert.number, alert.dependency.package.name - ), - "GitHub Dependabot reported an open vulnerable-dependency alert.", - serde_json::json!({ - "url": alert.html_url, - "package": alert.dependency.package.name, - "ecosystem": alert.dependency.package.ecosystem, - "manifest_path": alert.dependency.manifest_path, - "scope": alert.dependency.scope, - "ghsa_id": advisory.map(|value| value.ghsa_id.clone()), - "cve_id": advisory.and_then(|value| value.cve_id.clone()), - "summary": advisory.map(|value| value.summary.clone()), - "severity": advisory.map(|value| value.severity.clone()), - "vulnerable_version_range": alert.security_vulnerability.vulnerable_version_range, - "first_patched_version": alert.security_vulnerability.first_patched_version.as_ref().map(|value| value.identifier.clone()), - "updated_at": alert.updated_at, - }), - Some(remediation_work_id( - repo, - alert.dependency.manifest_path.as_deref(), - &alert.dependency.package.name, - )), - created_at, - ) -} - -fn secret_scanning_task( - repo: &str, - alert: &GithubSecretScanningAlert, - created_at: &str, -) -> TeamTaskDto { - let display = alert - .secret_type_display_name - .as_deref() - .unwrap_or(&alert.secret_type); - alert_backlog_task( - EngineeringSignal::SecretScanningAlert, - GithubAlertRef { - repo, - number: alert.number, - }, - format!( - "[Secret scanning] {repo} alert #{}: {display}", - alert.number - ), - "GitHub secret scanning reported an open credential exposure. Treat the secret value as sensitive: do not print, persist, or copy it. Verify revocation or rotation, remove the exposure safely, and add prevention coverage.", - serde_json::json!({ - "url": alert.html_url, - "secret_type": alert.secret_type, - "secret_type_display_name": display, - "resolution": alert.resolution, - "created_at": alert.created_at, - "updated_at": alert.updated_at, - }), - None, - created_at, - ) -} - -fn merge_discovered_tasks( - mut existing: Vec<TeamTaskDto>, - discovered: Vec<TeamTaskDto>, -) -> (Vec<TeamTaskDto>, usize) { - for task in &mut existing { - if engineering_task_requires_review(&task.id) { - task.review_required = true; - } - } - let mut positions = existing - .iter() - .enumerate() - .map(|(index, task)| (task.id.clone(), index)) - .collect::<BTreeMap<_, _>>(); - let mut added = 0; - for mut task in discovered { - let (matching_id, _) = match_remediation_task(&mut task, |id| { - positions - .get(id) - .map(|index| existing[*index].description.as_str()) - }); - if let Some(index) = positions.get(&matching_id).copied() { - let current = &mut existing[index]; - let renewable_alert = task.id.starts_with("dependabot-alert-") - || task.id.starts_with("code-scanning-alert-") - || task.id.starts_with("secret-scanning-alert-"); - let renewable_human_decision = task.id.starts_with("github-pr-merge-") - || task.id.starts_with("github-pr-feedback-"); - let renewable_pr_control = - task.id.starts_with("github-pr-fix-") || task.id.starts_with("github-pr-dedupe-"); - if renewable_alert && current.status == "pending" && task.status == "done" { - current.title = task.title; - current.description = task.description; - current.status = "done".into(); - current.run = None; - current.done_at = task.done_at; - current.stuck_since = None; - } else if current.status == "done" - && (renewable_human_decision - || ((renewable_alert || renewable_pr_control) - && current.description != task.description)) - { - current.title = task.title; - current.description = task.description; - current.status = "pending".into(); - current.run = None; - current.done_at = None; - current.created_at = task.created_at; - added += 1; - } else if (renewable_alert || renewable_pr_control) - && matches!(current.status.as_str(), "pending" | "active") - && current.description != task.description - { - current.title = task.title; - current.description = task.description; - } - current.review_required |= task.review_required; - continue; - } - positions.insert(task.id.clone(), existing.len()); - existing.push(task); - added += 1; - } - (existing, added) -} - -fn engineering_task_requires_review(task_id: &str) -> bool { - task_id.starts_with("dependabot-pr-") - || task_id.starts_with("dependency-remediation-") - || task_id.starts_with("dependabot-alert-") - || task_id.starts_with("code-scanning-alert-") - || task_id.starts_with("secret-scanning-alert-") - || task_id.starts_with("github-pr-fix-") - || task_id.starts_with("github-pr-dedupe-") - || task_id.starts_with("github-pr-feedback-") -} - -fn append_bounded_tasks( - target: &mut Vec<TeamTaskDto>, - known_tasks: &mut BTreeMap<String, (String, String)>, - incoming: Vec<TeamTaskDto>, - queued_slots_used: &mut usize, - attempt_cap: usize, -) -> bool { - let mut queue_candidates = Vec::new(); - for mut task in incoming { - let (matching_id, _) = match_remediation_task(&mut task, |id| { - known_tasks - .get(id) - .map(|(_, description)| description.as_str()) - }); - let renewable_alert = task.id.starts_with("dependabot-alert-") - || task.id.starts_with("code-scanning-alert-") - || task.id.starts_with("secret-scanning-alert-"); - match known_tasks.get(&matching_id) { - None => { - known_tasks.insert( - task.id.clone(), - (task.status.clone(), task.description.clone()), - ); - queue_candidates.push(task); - } - Some((status, description)) => { - let reopen = - renewable_alert && status == "done" && description != &task.description; - if reopen { - known_tasks.insert( - task.id.clone(), - ("pending".into(), task.description.clone()), - ); - queue_candidates.push(task); - } else if renewable_alert - && matches!(status.as_str(), "pending" | "active") - && description != &task.description - { - known_tasks.insert(task.id.clone(), (status.clone(), task.description.clone())); - target.push(task); - } - } - } - } - let remaining = MAX_ITEMS_PER_SYNC - .saturating_sub(*queued_slots_used) - .min(attempt_cap); - let truncated = queue_candidates.len() > remaining; - queue_candidates.truncate(remaining); - *queued_slots_used += queue_candidates.len(); - target.extend(queue_candidates); - truncated -} - -fn truncate_error(value: impl Into<String>) -> String { - let value = value.into(); - value.chars().take(1000).collect() -} - #[derive(Debug)] struct GithubListError { state: EngineeringSignalSyncState, @@ -1118,353 +414,6 @@ struct GithubListResult<T> { truncated: bool, } -fn next_link(value: &str) -> Option<String> { - value.split(',').find_map(|entry| { - let mut sections = entry.trim().split(';'); - let url = sections.next()?.trim(); - if !sections.any(|section| section.trim() == r#"rel="next""#) { - return None; - } - url.strip_prefix('<')?.strip_suffix('>').map(str::to_string) - }) -} - -async fn github_get_paginated<T: serde::de::DeserializeOwned>( - client: &reqwest::Client, - token: &str, - initial_url: String, - label: &str, - max_items: usize, -) -> Result<GithubListResult<T>, GithubListError> { - let mut url = Some(initial_url); - let mut items = Vec::new(); - let mut pages = 0; - while let Some(current) = url.take() { - pages += 1; - let response = client - .get(¤t) - .bearer_auth(token) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "kars-bridge") - .header("X-GitHub-Api-Version", "2022-11-28") - .send() - .await - .map_err(|error| GithubListError { - state: EngineeringSignalSyncState::Error, - detail: format!("{label} request failed: {error}"), - })?; - let status = response.status(); - let next = response - .headers() - .get(reqwest::header::LINK) - .and_then(|value| value.to_str().ok()) - .and_then(next_link); - let body = response.text().await.map_err(|error| GithubListError { - state: EngineeringSignalSyncState::Error, - detail: format!("{label} response could not be read: {error}"), - })?; - if !status.is_success() { - return Err(GithubListError { - state: match status.as_u16() { - 403 => EngineeringSignalSyncState::Forbidden, - 404 => EngineeringSignalSyncState::Unavailable, - _ => EngineeringSignalSyncState::Error, - }, - detail: format!("{label} returned HTTP {status}"), - }); - } - let mut page = serde_json::from_str::<Vec<T>>(&body).map_err(|error| GithubListError { - state: EngineeringSignalSyncState::Error, - detail: format!("{label} returned invalid JSON: {error}"), - })?; - let remaining = max_items.saturating_sub(items.len()); - if page.len() > remaining { - page.truncate(remaining); - } - items.extend(page); - if next.is_some() && (items.len() >= max_items || pages >= MAX_GITHUB_PAGES) { - return Ok(GithubListResult { - items, - truncated: true, - }); - } - url = next; - } - Ok(GithubListResult { - items, - truncated: false, - }) -} - -#[allow(dead_code)] -async fn list_open_pulls_legacy( - client: &reqwest::Client, - token: &str, - repo: &str, -) -> Result<Vec<GithubPull>, String> { - let url = format!( - "https://api.github.com/repos/{repo}/pulls?state=open&per_page={MAX_OPEN_PRS_PER_REPO}" - ); - let response = client - .get(url) - .header("Authorization", format!("Bearer {token}")) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "kars-bridge") - .header("X-GitHub-Api-Version", "2022-11-28") - .send() - .await - .map_err(|e| format!("GitHub request for {repo} failed: {e}"))?; - let status = response.status(); - let body = response - .text() - .await - .map_err(|e| format!("GitHub response for {repo} could not be read: {e}"))?; - if !status.is_success() { - return Err(truncate_error(format!( - "GitHub returned {status} while listing open pull requests for {repo}: {body}" - ))); - } - serde_json::from_str(&body) - .map_err(|e| format!("GitHub returned invalid pull request data for {repo}: {e}")) -} - -async fn list_open_pulls( - client: &reqwest::Client, - token: &str, - repo: &str, -) -> Result<GithubListResult<GithubPull>, GithubListError> { - github_get_paginated( - client, - token, - format!("https://api.github.com/repos/{repo}/pulls?state=open&per_page=100"), - &format!("listing open pull requests for {repo}"), - MAX_OPEN_PRS_PER_REPO, - ) - .await -} - -async fn list_dependabot_alerts( - client: &reqwest::Client, - token: &str, - repo: &str, -) -> Result<GithubListResult<GithubDependabotAlert>, GithubListError> { - github_get_paginated( - client, - token, - format!("https://api.github.com/repos/{repo}/dependabot/alerts?state=open&per_page=100"), - &format!("Dependabot alerts for {repo}"), - MAX_ALERTS_PER_SIGNAL, - ) - .await -} - -async fn list_code_scanning_alerts( - client: &reqwest::Client, - token: &str, - repo: &str, -) -> Result<GithubListResult<GithubCodeScanningAlert>, GithubListError> { - github_get_paginated( - client, - token, - format!("https://api.github.com/repos/{repo}/code-scanning/alerts?state=open&per_page=100"), - &format!("code scanning alerts for {repo}"), - MAX_ALERTS_PER_SIGNAL, - ) - .await -} - -async fn list_secret_scanning_alerts( - client: &reqwest::Client, - token: &str, - repo: &str, -) -> Result<GithubListResult<GithubSecretScanningAlert>, GithubListError> { - github_get_paginated( - client, - token, - format!( - "https://api.github.com/repos/{repo}/secret-scanning/alerts?state=open&per_page=100" - ), - &format!("secret scanning alerts for {repo}"), - MAX_ALERTS_PER_SIGNAL, - ) - .await -} - -fn signal_result( - repo: &str, - signal: EngineeringSignal, - result: Result<usize, GithubListError>, - truncation_detail: Option<String>, -) -> EngineeringSignalResult { - match result { - Ok(discovered) if truncation_detail.is_some() => EngineeringSignalResult { - repo: repo.to_string(), - signal, - state: EngineeringSignalSyncState::Truncated, - discovered, - detail: truncation_detail.unwrap_or_default(), - }, - Ok(discovered) => EngineeringSignalResult { - repo: repo.to_string(), - signal, - state: EngineeringSignalSyncState::Ok, - discovered, - detail: if discovered == 0 { - "Scanned successfully; no open items.".into() - } else { - format!("Scanned successfully; found {discovered} open item(s).") - }, - }, - Err(error) => EngineeringSignalResult { - repo: repo.to_string(), - signal, - state: error.state, - discovered: 0, - detail: error.detail, - }, - } -} - -async fn github_get_json( - client: &reqwest::Client, - token: &str, - url: &str, -) -> Result<serde_json::Value, String> { - let response = client - .get(url) - .bearer_auth(token) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", "kars-bridge") - .header("X-GitHub-Api-Version", "2022-11-28") - .send() - .await - .map_err(|e| format!("GitHub request failed: {e}"))?; - let status = response.status(); - let body = response - .text() - .await - .map_err(|e| format!("GitHub response could not be read: {e}"))?; - if !status.is_success() { - return Err(truncate_error(format!("GitHub returned {status}: {body}"))); - } - serde_json::from_str(&body).map_err(|e| format!("GitHub returned invalid JSON: {e}")) -} - -fn classify_review_readiness( - pull: &serde_json::Value, - check_runs: &serde_json::Value, - status: &serde_json::Value, -) -> (EngineeringReviewState, String, usize, usize) { - let runs = check_runs - .get("check_runs") - .and_then(serde_json::Value::as_array) - .map(Vec::as_slice) - .unwrap_or_default(); - let statuses = status - .get("statuses") - .and_then(serde_json::Value::as_array) - .map(Vec::as_slice) - .unwrap_or_default(); - let total_count = check_runs - .get("total_count") - .and_then(serde_json::Value::as_u64) - .unwrap_or(runs.len() as u64) as usize; - let total = total_count + statuses.len(); - let passed_runs = runs - .iter() - .filter(|run| { - run.get("status").and_then(serde_json::Value::as_str) == Some("completed") - && matches!( - run.get("conclusion").and_then(serde_json::Value::as_str), - Some("success" | "neutral" | "skipped") - ) - }) - .count(); - let passed_statuses = statuses - .iter() - .filter(|item| item.get("state").and_then(serde_json::Value::as_str) == Some("success")) - .count(); - let passed = passed_runs + passed_statuses; - - if pull.get("draft").and_then(serde_json::Value::as_bool) == Some(true) { - return ( - EngineeringReviewState::Blocked, - "PR is still a draft.".into(), - total, - passed, - ); - } - if pull.get("mergeable").and_then(serde_json::Value::as_bool) == Some(false) { - return ( - EngineeringReviewState::Blocked, - "GitHub reports merge conflicts.".into(), - total, - passed, - ); - } - let mergeable_state = pull - .get("mergeable_state") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); - if matches!(mergeable_state, "dirty" | "blocked" | "behind") { - return ( - EngineeringReviewState::Blocked, - format!("Branch state is '{mergeable_state}', not clean and up to date."), - total, - passed, - ); - } - let combined_status = status - .get("state") - .and_then(serde_json::Value::as_str) - .unwrap_or("pending"); - if runs.iter().any(|run| { - run.get("status").and_then(serde_json::Value::as_str) == Some("completed") - && !matches!( - run.get("conclusion").and_then(serde_json::Value::as_str), - Some("success" | "neutral" | "skipped") - ) - }) || matches!(combined_status, "failure" | "error") - { - return ( - EngineeringReviewState::CiFailed, - format!("{passed}/{total} GitHub checks passed; at least one check is red."), - total, - passed, - ); - } - if total == 0 { - return ( - EngineeringReviewState::WaitingForCi, - "No GitHub CI/status evidence exists for the head commit yet.".into(), - total, - passed, - ); - } - if total_count > runs.len() - || passed < total - || runs - .iter() - .any(|run| run.get("status").and_then(serde_json::Value::as_str) != Some("completed")) - || (!statuses.is_empty() && combined_status != "success") - || pull.get("mergeable").and_then(serde_json::Value::as_bool) != Some(true) - || mergeable_state != "clean" - { - return ( - EngineeringReviewState::WaitingForCi, - format!("{passed}/{total} GitHub checks passed; waiting for a clean mergeable state."), - total, - passed, - ); - } - ( - EngineeringReviewState::ReadyForReview, - format!("GitHub reports a clean, up-to-date PR with {passed}/{total} checks green."), - total, - passed, - ) -} - struct ReviewExecution<'a> { run: &'a str, work_id: &'a str, @@ -1475,1860 +424,5 @@ struct ReviewExecution<'a> { artifact_count: Option<usize>, } -async fn inspect_review_item( - client: &reqwest::Client, - token: &str, - repo: &str, - number: u64, - execution: ReviewExecution<'_>, - observed_at: &str, -) -> Result<Option<EngineeringReviewItem>, String> { - let ReviewExecution { - run, - work_id, - task_status, - run_state, - selected_roles, - delivered_roles, - artifact_count, - } = execution; - let pull = github_get_json( - client, - token, - &format!("https://api.github.com/repos/{repo}/pulls/{number}"), - ) - .await?; - if pull.get("state").and_then(serde_json::Value::as_str) != Some("open") - || pull.get("merged").and_then(serde_json::Value::as_bool) == Some(true) - { - return Ok(None); - } - let head_sha = pull - .get("head") - .and_then(|head| head.get("sha")) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| format!("GitHub PR {repo}#{number} has no head SHA"))? - .to_string(); - let check_runs = github_get_json( - client, - token, - &format!("https://api.github.com/repos/{repo}/commits/{head_sha}/check-runs?per_page=100"), - ) - .await?; - let status = github_get_json( - client, - token, - &format!("https://api.github.com/repos/{repo}/commits/{head_sha}/status?per_page=100"), - ) - .await?; - let (state, detail, checks_total, checks_passed) = - classify_review_readiness(&pull, &check_runs, &status); - Ok(Some(EngineeringReviewItem { - repo: repo.to_string(), - pr_number: number, - pr_url: pull - .get("html_url") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(), - title: pull - .get("title") - .and_then(serde_json::Value::as_str) - .unwrap_or("Pull request") - .to_string(), - run: run.to_string(), - source_id: source_id(repo, number), - work_id: work_id.to_string(), - task_status: task_status.to_string(), - run_state, - selected_roles, - delivered_roles, - artifact_count, - head_sha, - state, - detail, - checks_total, - checks_passed, - observed_at: observed_at.to_string(), - })) -} - -async fn run_execution_summary( - cluster: &Cluster, - namespace: &str, - run: &str, -) -> (Option<String>, Vec<String>, Vec<String>) { - let task = cluster.tasks(namespace).get_opt(run).await.ok().flatten(); - let mut selected = BTreeSet::new(); - let mut delivered = BTreeSet::new(); - let run_state = task - .as_ref() - .and_then(|task| task.status.as_ref()) - .and_then(|status| status.assignment.as_ref()) - .map(|assignment| assignment.state.clone()); - if let Some(events) = task - .as_ref() - .and_then(|task| task.status.as_ref()) - .map(|status| status.assignment_events.as_slice()) - { - for event in events { - let Some(role) = event.child_role.as_ref() else { - continue; - }; - selected.insert(role.clone()); - if event.stage.as_deref() == Some("child_handback") - && event.outcome.as_deref() == Some("success") - && event.state == "Completed" - { - delivered.insert(role.clone()); - } - } - } - ( - run_state, - selected.into_iter().collect(), - delivered.into_iter().collect(), - ) -} - -fn review_followup_task(item: &EngineeringReviewItem, created_at: &str) -> Option<TeamTaskDto> { - if !matches!( - item.state, - EngineeringReviewState::CiFailed | EngineeringReviewState::Blocked - ) { - return None; - } - let digest = sha256(format!("github-review:{}:{}", item.repo, item.pr_number).as_bytes()); - Some(TeamTaskDto { - id: format!("github-pr-fix-{}", hex::encode(&digest[..10])), - title: format!( - "[PR gate] Resolve or retire {} PR #{} before review", - item.repo, item.pr_number - ), - description: format!( - "GitHub does not consider this PR ready for human review. Before modifying the branch, determine whether the PR is still needed or has been superseded by a merged PR/default-branch change. If it is superseded, do not repair or rebase it: close it when authorized, or report the exact closure recommendation. Only when its objective is still required should you resolve the observed branch/CI state, push the smallest correction, and wait for exact-SHA GitHub checks. Never claim green from local inference and never merge.\n\nPR: {}\nHead SHA: {}\nObserved state: {:?}\nDetail: {}", - item.pr_url, item.head_sha, item.state, item.detail - ), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: true, - status: "pending".into(), - run: None, - created_at: Some(created_at.to_string()), - done_at: None, - stuck_since: None, - assignment_nonce: None, - }) -} - -fn dedupe_followup_task( - repo: &str, - remediation_id: &str, - pulls: &[&GithubPull], - created_at: &str, -) -> Option<TeamTaskDto> { - if pulls.len() < 2 { - return None; - } - let mut ordered = pulls.to_vec(); - ordered.sort_by_key(|pull| pull.number); - let canonical = ordered[0]; - let duplicates = ordered[1..] - .iter() - .map(|pull| format!("#{} {}", pull.number, pull.html_url)) - .collect::<Vec<_>>() - .join(", "); - let digest = sha256(format!("{repo}:{remediation_id}").as_bytes()); - Some(TeamTaskDto { - id: format!("github-pr-dedupe-{}", hex::encode(&digest[..10])), - title: format!( - "[PR dedupe] Review {repo} PR #{} and {} possible duplicate(s)", - canonical.number, - ordered.len() - 1 - ), - description: format!( - "Multiple open pull requests mention this remediation's package or advisory. Their titles are not coverage evidence. Compare actual changed files with the exact case-sensitive manifest, package, advisory and head-SHA checks before treating any work as equivalent. Preserve distinct manifest fixes. Only after equivalence is verified, preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence and close superseded duplicates; never merge. Report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", - canonical.number, canonical.html_url, duplicates - ), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: true, - status: "pending".into(), - run: None, - created_at: Some(created_at.to_string()), - done_at: None, - stuck_since: None, - assignment_nonce: None, - }) -} - -async fn collect_review_items( - cluster: &Cluster, - client: &reqwest::Client, - token: &str, - config: &EngineeringSourceConfig, - observed_at: &str, -) -> (Vec<EngineeringReviewItem>, Vec<TeamTaskDto>, Vec<String>) { - let backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); - let configured_repos = config - .repos - .iter() - .map(|repo| repo.to_ascii_lowercase()) - .collect::<HashSet<_>>(); - let mut seen = HashSet::new(); - let mut items = Vec::new(); - let mut followups = Vec::new(); - let mut errors = Vec::new(); - for task in backlog.iter().rev() { - if seen.len() >= MAX_REVIEW_PRS_PER_SYNC { - errors.push(format!( - "review readiness reached the {MAX_REVIEW_PRS_PER_SYNC}-PR sync cap" - )); - break; - } - let Some(run) = task.run.as_deref() else { - continue; - }; - let Some(output) = cluster.read_mission_output(run).await else { - continue; - }; - let (run_state, selected_roles, delivered_roles) = - run_execution_summary(cluster, &config.team_namespace, run).await; - let artifact_count = output - .get("artifactCount") - .and_then(|value| value.parse::<usize>().ok()); - let text = output.get("output").map(String::as_str).unwrap_or_default(); - if !crate::routes::tasks::is_real_deliverable( - output.get("status").map(String::as_str), - text, - ) { - continue; - } - for pull in crate::routes::tasks::extract_pull_requests(text) { - let key = format!("{}#{}", pull.repo.to_ascii_lowercase(), pull.number); - if !configured_repos.contains(&pull.repo.to_ascii_lowercase()) || !seen.insert(key) { - continue; - } - match inspect_review_item( - client, - token, - &pull.repo, - pull.number as u64, - ReviewExecution { - run, - work_id: &task.id, - task_status: &task.status, - run_state: run_state.clone(), - selected_roles: selected_roles.clone(), - delivered_roles: delivered_roles.clone(), - artifact_count, - }, - observed_at, - ) - .await - { - Ok(Some(item)) => { - if let Some(task) = review_followup_task(&item, observed_at) { - followups.push(task); - } - items.push(item); - } - Ok(None) => {} - Err(error) => errors.push(format!( - "review readiness for {}#{} failed: {error}", - pull.repo, pull.number - )), - } - } - } - (items, followups, errors) -} - -async fn merge_into_backlog( - cluster: &Cluster, - team: &str, - discovered: Vec<TeamTaskDto>, -) -> Result<usize, String> { - let queued = std::sync::atomic::AtomicUsize::new(0); - let name = format!("kars-team-tasks-{team}"); - cluster - .update_configmap_data(&name, &[("kars.azure.com/team-tasks", team)], |data| { - let existing = data - .get("tasks.json") - .map(|raw| read_task_list(raw)) - .unwrap_or_default(); - let (merged, added) = merge_discovered_tasks(existing, discovered.clone()); - queued.store(added, std::sync::atomic::Ordering::Relaxed); - data.insert( - "tasks.json".to_string(), - serde_json::to_string(&merged).unwrap_or_else(|_| "[]".into()), - ); - }) - .await - .map_err(|e| format!("updating the team backlog failed: {e}"))?; - Ok(queued.load(std::sync::atomic::Ordering::Relaxed)) -} - -async fn request_team_run(cluster: &Cluster, namespace: &str, team: &str) -> Result<bool, String> { - let team_object = cluster - .teams(namespace) - .get_opt(team) - .await - .map_err(|error| format!("checking team run state failed: {error}"))? - .ok_or_else(|| "the standing team no longer exists".to_string())?; - if team_object.spec.paused { - return Ok(false); - } - cluster - .teams(namespace) - .patch( - team, - &kube::api::PatchParams::default(), - &kube::api::Patch::Merge(serde_json::json!({ - "metadata": { - "annotations": { - "kars.azure.com/backlog-run-now": Utc::now().to_rfc3339() - } - } - })), - ) - .await - .map(|_| true) - .map_err(|error| format!("queued work but could not request a team run: {error}")) -} - -async fn ensure_auto_run_for_backlog( - cluster: &Cluster, - config: &EngineeringSourceConfig, -) -> Result<bool, String> { - if !config.enabled || !config.auto_run { - return Ok(false); - } - let has_pending = read_task_list(&cluster.read_team_tasks(&config.team_name).await) - .iter() - .any(|task| task.status == "pending"); - if !has_pending { - return Ok(false); - } - request_team_run(cluster, &config.team_namespace, &config.team_name).await -} - -async fn perform_sync( - cluster: &Cluster, - config: &EngineeringSourceConfig, - mut cursor: EngineeringCursor, - claim_id: &str, -) -> Result<SyncOutcome, String> { - let expected_connection = connection_config_map_name(&config.owner_sub); - if config.connection_config_map_ref != expected_connection { - return Err("source connection reference does not match its owner".into()); - } - - let team = cluster - .teams(&config.team_namespace) - .get_opt(&config.team_name) - .await - .map_err(|e| format!("reading the standing team failed: {e}"))? - .ok_or_else(|| "the standing team no longer exists".to_string())?; - if team - .annotations() - .get("kars.azure.com/owner-sub") - .is_none_or(|owner| owner != &config.owner_sub) - { - return Err("the engineering source owner no longer owns this team".into()); - } - - let (installation_id, _account, granted_repos) = cluster - .read_github_connection_result(&config.team_namespace, &config.connection_config_map_ref) - .await - .map_err(|e| format!("reading the GitHub connection failed: {e}"))? - .ok_or_else(|| "the owner's GitHub connection is no longer available".to_string())?; - authorize_repo_set(&config.repos, &granted_repos) - .map_err(|e| format!("repository authorization changed: {e}"))?; - - let (app_id, private_key) = cluster - .github_app_creds() - .await - .map_err(|error| format!("GitHub credential authority unavailable: {error}"))? - .ok_or_else(|| "the shared GitHub App is not configured".to_string())?; - let app_jwt = mint_app_jwt(&app_id, &private_key).map_err(|e| e.to_string())?; - let token = installation_token(&app_jwt, &installation_id) - .await - .map_err(|e| e.to_string())?; - - let now = Utc::now().to_rfc3339(); - let mut tasks = Vec::new(); - let mut errors = Vec::new(); - let mut completed_attempts = 0; - let mut signal_results = Vec::new(); - let client = reqwest::Client::new(); - let existing_backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); - let mut known_tasks = existing_backlog - .iter() - .map(|task| { - ( - task.id.clone(), - (task.status.clone(), task.description.clone()), - ) - }) - .collect::<BTreeMap<_, _>>(); - let attempt_count = config - .repos - .len() - .saturating_mul(config.signals.len()) - .max(1); - let attempt_cap = (MAX_ITEMS_PER_SYNC / attempt_count).max(1); - let mut queued_slots_used = 0; - for repo in &config.repos { - let features = repository_features(&client, &token, repo).await; - let open_pull_coverage = if config.signals.contains(&EngineeringSignal::DependabotAlert) { - list_open_pulls(&client, &token, repo) - .await - .map(|pulls| pulls.items) - .unwrap_or_default() - } else { - Vec::new() - }; - let mut dedupe_seen = BTreeSet::new(); - for signal in config.signals.iter().copied() { - let (result, api_truncated, queue_truncated) = match signal { - EngineeringSignal::DependabotPr => { - match list_open_pulls(&client, &token, repo).await { - Ok(pulls) => { - completed_attempts += 1; - if let Some(updated_at) = - pulls.items.iter().map(|pr| pr.updated_at.as_str()).max() - { - cursor - .repository_updated_at - .insert(repo.clone(), updated_at.to_string()); - } - let signal_tasks = pulls - .items - .iter() - .filter(|pr| is_dependabot_pr(pr)) - .map(|pr| backlog_task(repo, pr, &now)) - .collect::<Vec<_>>(); - let discovered = signal_tasks.len(); - let bounded = append_bounded_tasks( - &mut tasks, - &mut known_tasks, - signal_tasks, - &mut queued_slots_used, - attempt_cap, - ); - (Ok(discovered), pulls.truncated, bounded) - } - Err(error) => (Err(error), false, false), - } - } - EngineeringSignal::DependabotAlert => { - match list_dependabot_alerts(&client, &token, repo).await { - Ok(alerts) => { - completed_attempts += 1; - let discovered = alerts.items.len(); - let mut signal_tasks = Vec::new(); - for alert in &alerts.items { - let mut task = dependabot_alert_task(repo, alert, &now); - let (matching_id, identity_warning) = - match_remediation_task(&mut task, |id| { - known_tasks - .get(id) - .map(|(_, description)| description.as_str()) - }); - if let Some(warning) = identity_warning { - errors.push(warning); - } - let legacy_ids = known_tasks - .iter() - .filter(|(id, (status, description))| { - id.starts_with("dependabot-alert-") - && status == "pending" - && description_matches_remediation( - description, - repo, - alert.dependency.manifest_path.as_deref(), - &alert.dependency.package.name, - ) - }) - .map(|(id, _)| id.clone()) - .collect::<Vec<_>>(); - for legacy_id in legacy_ids { - let retirement = - legacy_alert_retirement(&legacy_id, &matching_id, &now); - known_tasks.insert( - legacy_id, - ("done".into(), retirement.description.clone()), - ); - tasks.push(retirement); - } - let covering_pulls = open_pull_coverage - .iter() - .filter(|pull| { - open_pull_may_address_dependabot_alert(pull, alert) - }) - .collect::<Vec<_>>(); - if dedupe_seen.insert(matching_id.clone()) - && let Some(dedupe) = dedupe_followup_task( - repo, - &matching_id, - &covering_pulls, - &now, - ) - { - tasks.push(dedupe); - } - note_candidate_pulls(&mut task, &covering_pulls); - signal_tasks.push(task); - } - let bounded = append_bounded_tasks( - &mut tasks, - &mut known_tasks, - signal_tasks, - &mut queued_slots_used, - attempt_cap, - ); - (Ok(discovered), alerts.truncated, bounded) - } - Err(error) => (Err(error), false, false), - } - } - EngineeringSignal::CodeScanningAlert => { - match list_code_scanning_alerts(&client, &token, repo).await { - Ok(alerts) => { - completed_attempts += 1; - let signal_tasks = alerts - .items - .iter() - .map(|alert| code_scanning_task(repo, alert, &now)) - .collect::<Vec<_>>(); - let discovered = signal_tasks.len(); - let bounded = append_bounded_tasks( - &mut tasks, - &mut known_tasks, - signal_tasks, - &mut queued_slots_used, - attempt_cap, - ); - (Ok(discovered), alerts.truncated, bounded) - } - Err(error) => (Err(error), false, false), - } - } - EngineeringSignal::SecretScanningAlert => { - match list_secret_scanning_alerts(&client, &token, repo).await { - Ok(alerts) => { - completed_attempts += 1; - let signal_tasks = alerts - .items - .iter() - .map(|alert| secret_scanning_task(repo, alert, &now)) - .collect::<Vec<_>>(); - let discovered = signal_tasks.len(); - let bounded = append_bounded_tasks( - &mut tasks, - &mut known_tasks, - signal_tasks, - &mut queued_slots_used, - attempt_cap, - ); - (Ok(discovered), alerts.truncated, bounded) - } - Err(error) => (Err(error), false, false), - } - } - }; - let mut truncation_reasons = Vec::new(); - if api_truncated { - let item_limit = if signal == EngineeringSignal::DependabotPr { - MAX_OPEN_PRS_PER_REPO - } else { - MAX_ALERTS_PER_SIGNAL - }; - truncation_reasons.push(format!( - "GitHub returned more than the per-signal {item_limit}-item or {MAX_GITHUB_PAGES}-page scan cap." - )); - } - if queue_truncated { - truncation_reasons.push(format!( - "The sync found more new work than this source's fair {attempt_cap}-item allocation; remaining items will be retried on later polls." - )); - } - let truncation_detail = - (!truncation_reasons.is_empty()).then(|| truncation_reasons.join(" ")); - let (result, expected_unavailable) = match result { - Err(error) => match unavailable_security_product(features.as_ref(), signal, &error) - { - Some(unavailable) => (Err(unavailable), true), - None => (Err(error), false), - }, - Ok(discovered) => (Ok(discovered), false), - }; - if expected_unavailable { - completed_attempts += 1; - } - let signal_status = signal_result(repo, signal, result, truncation_detail); - if signal_status.state != EngineeringSignalSyncState::Ok && !expected_unavailable { - errors.push(format!( - "{} {:?}: {}", - repo, signal_status.signal, signal_status.detail - )); - } - signal_results.push(signal_status); - } - } - - let (review_items, review_followups, review_errors) = - collect_review_items(cluster, &client, &token, config, &now).await; - tasks.extend(review_followups); - errors.extend(review_errors); - let discovered = tasks.len(); - revalidate_claimed_source(cluster, config, claim_id).await?; - let queued = merge_into_backlog(cluster, &config.team_name, tasks).await?; - if let Err(error) = ensure_auto_run_for_backlog(cluster, config).await { - errors.push(error); - } - Ok(SyncOutcome { - cursor, - discovered, - queued, - completed_attempts, - errors, - review_items, - signal_results, - }) -} - -async fn patch_runtime_state( - cluster: &Cluster, - name: &str, - cursor: &EngineeringCursor, - status: &EngineeringSourceStatus, -) -> AppResult<()> { - let data = BTreeMap::from([ - ( - CURSOR_KEY.to_string(), - serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?, - ), - ( - STATUS_KEY.to_string(), - serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?, - ), - ]); - cluster - .patch_engineering_source_data(name, &data) - .await - .map_err(|e| AppError::Upstream(e.to_string())) -} - -async fn finalize_source_claim( - cluster: &Cluster, - name: &str, - claimed_status: &str, - cursor: &EngineeringCursor, - status: &EngineeringSourceStatus, -) -> AppResult<()> { - let cursor = serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?; - let status = serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?; - let completed = cluster - .complete_engineering_source_claim(name, claimed_status, &cursor, &status) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - if !completed { - return Err(AppError::Conflict( - "engineering sync lost its claim before completion".into(), - )); - } - Ok(()) -} - -async fn revalidate_claimed_source( - cluster: &Cluster, - config: &EngineeringSourceConfig, - claim_id: &str, -) -> Result<(), String> { - let name = source_config_map_name(&config.team_namespace, &config.team_name); - let source = cluster - .read_engineering_source(&name) - .await - .map_err(|error| format!("re-reading engineering source failed: {error}"))? - .ok_or_else(|| "engineering source was deleted during sync".to_string())?; - let (current_config, _, current_status) = parse_source(&source)?; - if ¤t_config != config - || !sync_claim_active(¤t_status, Utc::now()) - || current_status.sync_claim_id.as_deref() != Some(claim_id) - { - return Err("engineering source changed or lost its sync claim before queueing".into()); - } - Ok(()) -} - -async fn synchronize_source( - cluster: &Cluster, - config: &EngineeringSourceConfig, - _cursor: EngineeringCursor, - _status: EngineeringSourceStatus, -) -> AppResult<EngineeringSourceStatus> { - let name = source_config_map_name(&config.team_namespace, &config.team_name); - let current = cluster - .read_engineering_source(&name) - .await - .map_err(|error| AppError::Upstream(error.to_string()))? - .ok_or_else(|| AppError::Conflict("engineering source no longer exists".into()))?; - let current_data = current - .data - .as_ref() - .ok_or_else(|| AppError::Conflict("engineering source has no data".into()))?; - let expected_config = current_data - .get(CONFIG_KEY) - .cloned() - .ok_or_else(|| AppError::Conflict("engineering source config is missing".into()))?; - let expected_status = current_data - .get(STATUS_KEY) - .cloned() - .unwrap_or_else(|| "{}".into()); - let current_cursor = current_data - .get(CURSOR_KEY) - .map(|value| serde_json::from_str::<EngineeringCursor>(value)) - .transpose() - .map_err(|error| { - AppError::Conflict(format!("engineering source cursor is invalid: {error}")) - })? - .unwrap_or_default(); - let mut status = - serde_json::from_str::<EngineeringSourceStatus>(&expected_status).map_err(|error| { - AppError::Conflict(format!("engineering source status is invalid: {error}")) - })?; - let stored_config = - serde_json::from_str::<EngineeringSourceConfig>(&expected_config).map_err(|error| { - AppError::Conflict(format!("engineering source config is invalid: {error}")) - })?; - if &stored_config != config { - return Err(AppError::Conflict( - "engineering source was reconfigured before sync".into(), - )); - } - if sync_claim_active(&status, Utc::now()) { - return Err(AppError::Conflict( - "another engineering sync still owns the active claim".into(), - )); - } - let claim_id = format!( - "{}-{}", - Utc::now().timestamp_nanos_opt().unwrap_or_default(), - std::process::id() - ); - status.state = EngineeringSyncState::Syncing; - status.sync_claim_id = Some(claim_id.clone()); - status.sync_claim_expires_at = Some((Utc::now() + chrono::Duration::minutes(10)).to_rfc3339()); - status.last_error = None; - status.next_poll_at = Some(next_poll_at(config, Utc::now())); - let claimed_status = - serde_json::to_string(&status).map_err(|e| AppError::Internal(e.into()))?; - let claimed = cluster - .claim_engineering_source(&name, &expected_config, &expected_status, &claimed_status) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - if !claimed { - return Err(AppError::Conflict( - "this source was reconfigured or another sync already claimed it".into(), - )); - } - - let completed_at = Utc::now(); - match perform_sync(cluster, config, current_cursor.clone(), &claim_id).await { - Ok(outcome) => { - status.last_sync_at = Some(completed_at.to_rfc3339()); - status.items_discovered = outcome.discovered; - status.items_queued = outcome.queued; - status.total_items_queued = status - .total_items_queued - .saturating_add(outcome.queued as u64); - status.next_poll_at = Some(next_poll_at(config, completed_at)); - status.review_items = outcome.review_items; - status.signal_results = outcome.signal_results; - status.ready_for_review = status - .review_items - .iter() - .filter(|item| item.state == EngineeringReviewState::ReadyForReview) - .count(); - status.waiting_for_ci = status - .review_items - .iter() - .filter(|item| item.state == EngineeringReviewState::WaitingForCi) - .count(); - status.ci_failed = status - .review_items - .iter() - .filter(|item| { - matches!( - item.state, - EngineeringReviewState::CiFailed | EngineeringReviewState::Blocked - ) - }) - .count(); - status.last_error = - (!outcome.errors.is_empty()).then(|| truncate_error(outcome.errors.join("; "))); - status.state = if outcome.errors.is_empty() { - status.last_success_at = Some(completed_at.to_rfc3339()); - EngineeringSyncState::Ok - } else if outcome.completed_attempts > 0 { - EngineeringSyncState::Partial - } else { - EngineeringSyncState::Error - }; - status.sync_claim_id = None; - status.sync_claim_expires_at = None; - finalize_source_claim(cluster, &name, &claimed_status, &outcome.cursor, &status) - .await?; - } - Err(error) => { - status.state = EngineeringSyncState::Error; - status.last_sync_at = Some(completed_at.to_rfc3339()); - status.last_error = Some(truncate_error(error)); - status.items_discovered = 0; - status.items_queued = 0; - status.signal_results = Vec::new(); - status.next_poll_at = Some(next_poll_at(config, completed_at)); - status.sync_claim_id = None; - status.sync_claim_expires_at = None; - finalize_source_claim(cluster, &name, &claimed_status, ¤t_cursor, &status) - .await?; - } - } - Ok(status) -} - -/// `GET /api/namespaces/:ns/teams/:name/engineering-source`. -pub async fn get_source( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<EngineeringSourceDto>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let source_name = source_config_map_name(&ns, &name); - let Some(cm) = cluster - .read_engineering_source(&source_name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - else { - return Ok(Json(to_dto( - false, - None, - EngineeringSourceStatus::default(), - ))); - }; - let (config, _cursor, status) = - parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; - if config.team_namespace != ns || config.team_name != name { - return Err(AppError::NotFound); - } - if !verify_source_owner(&cm, &config, &principal.sub) { - return Ok(Json(to_dto( - false, - None, - EngineeringSourceStatus::default(), - ))); - } - Ok(Json(to_dto(true, Some(&config), status))) -} - -/// `PUT /api/namespaces/:ns/teams/:name/engineering-source`. -pub async fn put_source( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(request): Json<PutEngineeringSourceRequest>, -) -> AppResult<Json<EngineeringSourceDto>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let connection_ref = connection_config_map_name(&principal.sub); - let (_installation_id, _account, granted_repos) = cluster - .read_github_connection_result(&ns, &connection_ref) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - .ok_or_else(|| { - AppError::Rejected( - "connect GitHub for your user before configuring engineering intake".into(), - ) - })?; - let (repos, signals) = validate_request(&request, &granted_repos)?; - - let source_name = source_config_map_name(&ns, &name); - let existing = cluster - .read_engineering_source(&source_name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))?; - let (cursor, mut status, previous_config) = if let Some(cm) = existing.as_ref() { - let (config, cursor, status) = - parse_source(cm).map_err(|e| AppError::Upstream(e.to_string()))?; - if config.team_namespace != ns || config.team_name != name { - return Err(AppError::NotFound); - } - if sync_claim_active(&status, Utc::now()) { - return Err(AppError::Conflict( - "engineering intake is syncing; retry the configuration change shortly".into(), - )); - } - if verify_source_owner(cm, &config, &principal.sub) { - (cursor, status, Some(config)) - } else { - ( - EngineeringCursor::default(), - EngineeringSourceStatus::default(), - None, - ) - } - } else { - ( - EngineeringCursor::default(), - EngineeringSourceStatus::default(), - None, - ) - }; - - let config = EngineeringSourceConfig { - version: 1, - team_namespace: ns, - team_name: name, - owner_sub: principal.sub, - connection_config_map_ref: connection_ref, - enabled: request.enabled, - auto_run: request.auto_run, - repos, - signals, - poll_interval_seconds: request.poll_interval_seconds, - }; - if config.enabled { - let changed = previous_config.as_ref().is_none_or(|previous| { - !previous.enabled - || previous.repos != config.repos - || previous.signals != config.signals - || previous.auto_run != config.auto_run - || previous.poll_interval_seconds != config.poll_interval_seconds - }); - if changed || status.next_poll_at.is_none() { - status.state = EngineeringSyncState::Idle; - status.next_poll_at = Some( - (Utc::now() + chrono::Duration::seconds(initial_jitter_seconds(&source_name))) - .to_rfc3339(), - ); - } - } else { - status.state = EngineeringSyncState::Disabled; - status.next_poll_at = None; - } - let data = source_data(&config, &cursor, &status)?; - let annotations = source_annotations(&config); - if let Some(current) = existing { - cluster - .replace_engineering_source(current, &annotations, &data) - .await - .map_err(|error| { - if matches!(error, kube::Error::Api(ref response) if response.code == 409) { - AppError::Conflict( - "engineering intake changed concurrently; reload and retry".into(), - ) - } else { - AppError::Upstream(error.to_string()) - } - })?; - } else { - cluster - .create_engineering_source(&source_name, &annotations, &data) - .await - .map_err(|error| { - if matches!(error, kube::Error::Api(ref response) if response.code == 409) { - AppError::Conflict( - "engineering intake was configured concurrently; reload and retry".into(), - ) - } else { - AppError::Upstream(error.to_string()) - } - })?; - } - Ok(Json(to_dto(true, Some(&config), status))) -} - -/// `POST /api/namespaces/:ns/teams/:name/engineering-source/sync`. -pub async fn sync_now( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<EngineeringSourceDto>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let source_name = source_config_map_name(&ns, &name); - let cm = cluster - .read_engineering_source(&source_name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - .ok_or_else(|| AppError::Rejected("configure engineering intake first".into()))?; - let (config, cursor, status) = - parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; - if config.team_namespace != ns - || config.team_name != name - || !verify_source_owner(&cm, &config, &principal.sub) - { - return Err(AppError::NotFound); - } - if !config.enabled { - return Err(AppError::Rejected( - "enable engineering intake before syncing".into(), - )); - } - let status = synchronize_source(cluster, &config, cursor, status).await?; - Ok(Json(to_dto(true, Some(&config), status))) -} - -/// `DELETE /api/namespaces/:ns/teams/:name/engineering-source`. -pub async fn delete_source( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, -) -> AppResult<Json<EngineeringSourceDto>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let source_name = source_config_map_name(&ns, &name); - if let Some(cm) = cluster - .read_engineering_source(&source_name) - .await - .map_err(|e| AppError::Upstream(e.to_string()))? - { - let (config, _, status) = - parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; - if config.team_namespace != ns || config.team_name != name { - return Err(AppError::NotFound); - } - if sync_claim_active(&status, Utc::now()) { - return Err(AppError::Conflict( - "engineering intake is syncing; retry disconnect shortly".into(), - )); - } - let resource_version = cm - .metadata - .resource_version - .clone() - .ok_or_else(|| AppError::Conflict("engineering source has no version".into()))?; - - cluster - .delete_engineering_source_if_version(&source_name, resource_version) - .await - .map_err(|error| { - if matches!(error, kube::Error::Api(ref response) if response.code == 409) { - AppError::Conflict( - "engineering intake changed concurrently; reload and retry".into(), - ) - } else { - AppError::Upstream(error.to_string()) - } - })?; - } - Ok(Json(to_dto( - false, - None, - EngineeringSourceStatus::default(), - ))) -} - -/// Turn a human PR decision into durable standing-team work. This preserves the -/// same source → backlog → run chain instead of trying to mutate a retired run. -pub async fn decide_review_item( - State(state): State<AppState>, - Extension(principal): Extension<Principal>, - Path((ns, name)): Path<(String, String)>, - Json(request): Json<EngineeringReviewDecisionRequest>, -) -> AppResult<Json<serde_json::Value>> { - let cluster = require_cluster(&state)?; - require_owned_team(cluster, &ns, &name, &principal).await?; - let decision = request.decision.trim(); - if decision != "request_changes" { - return Err(AppError::BadRequest( - "only request_changes is supported; merge remains a human GitHub action until a typed single-use merge grant exists".into(), - )); - } - let comment = request - .comment - .as_deref() - .map(str::trim) - .unwrap_or("") - .chars() - .take(1500) - .collect::<String>(); - if decision == "request_changes" && comment.is_empty() { - return Err(AppError::BadRequest( - "request_changes requires concrete feedback".into(), - )); - } - - let source_name = source_config_map_name(&ns, &name); - let source = cluster - .read_engineering_source(&source_name) - .await - .map_err(|error| AppError::Upstream(error.to_string()))? - .ok_or_else(|| AppError::Rejected("configure engineering intake first".into()))?; - let (config, _, status) = - parse_source(&source).map_err(|error| AppError::Upstream(error.to_string()))?; - if !verify_source_owner(&source, &config, &principal.sub) - || !config - .repos - .iter() - .any(|repo| repo.eq_ignore_ascii_case(&request.repo)) - { - return Err(AppError::NotFound); - } - let _item = status - .review_items - .iter() - .find(|item| { - item.repo.eq_ignore_ascii_case(&request.repo) - && item.pr_number == request.pr_number - && item.head_sha == request.head_sha - && item.run == request.run - }) - .ok_or_else(|| { - AppError::Conflict( - "the PR changed since this card was rendered; sync before deciding".into(), - ) - })?; - let identity = format!( - "engineering-review:{decision}:{}:{}:{}:{comment}", - request.repo.to_ascii_lowercase(), - request.pr_number, - request.head_sha - ); - let digest = sha256(identity.as_bytes()); - let task = TeamTaskDto { - id: format!("github-pr-feedback-{}", hex::encode(&digest[..10])), - title: format!( - "[Review feedback] Revise {} PR #{}", - request.repo, request.pr_number - ), - description: format!( - "PR: {}\nREVIEWED SHA: {}\nSOURCE RUN: {}\n\nREQUESTED CHANGES:\n{}\n\nA human reviewed the team's PR and requested changes. Re-open the exact prior evidence, apply only the requested delta, run relevant tests, push a new commit, and wait for GitHub checks. Never merge and never reuse stale green evidence.", - request.pr_url, request.head_sha, request.run, comment - ), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: true, - status: "pending".into(), - run: None, - created_at: Some(Utc::now().to_rfc3339()), - done_at: None, - stuck_since: None, - assignment_nonce: None, - }; - let task_id = task.id.clone(); - let queued = merge_into_backlog(cluster, &name, vec![task]) - .await - .map_err(AppError::Upstream)?; - let pending = read_task_list(&cluster.read_team_tasks(&name).await) - .iter() - .any(|task| task.id == task_id && task.status == "pending"); - let run_requested = if pending { - request_team_run(cluster, &ns, &name) - .await - .map_err(AppError::Upstream)? - } else { - false - }; - Ok(Json(serde_json::json!({ - "queued": queued > 0, - "run_requested": run_requested, - "decision": decision, - "team": name, - }))) -} - -/// Start the bounded best-effort source poller. Durable `next_poll_at` values -/// and a stable initial jitter spread GitHub traffic across teams. -pub fn spawn_poller(state: AppState, sweep_interval: Duration) { - if state.cluster().is_none() { - return; - } - tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(5)).await; - let mut interval = tokio::time::interval(sweep_interval); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - interval.tick().await; - let Some(cluster) = state.cluster() else { - continue; - }; - let sources = match cluster - .list_engineering_sources(MAX_SOURCES_PER_SWEEP) - .await - { - Ok(sources) => sources, - Err(error) => { - tracing::error!(error = %error, "engineering intake source listing failed"); - continue; - } - }; - for source in sources { - let source_name = source.name_any(); - let (config, cursor, status) = match parse_source(&source) { - Ok(parsed) => parsed, - Err(error) => { - tracing::error!(source = %source_name, error = %error, "invalid engineering intake source"); - let failed = EngineeringSourceStatus { - state: EngineeringSyncState::Error, - last_sync_at: Some(Utc::now().to_rfc3339()), - last_error: Some(truncate_error(error)), - ..EngineeringSourceStatus::default() - }; - if let Err(patch_error) = patch_runtime_state( - cluster, - &source_name, - &EngineeringCursor::default(), - &failed, - ) - .await - { - tracing::error!(source = %source_name, error = %patch_error, "failed to record engineering intake source error"); - } - continue; - } - }; - if !verify_source_owner(&source, &config, &config.owner_sub) { - let error = "engineering source owner annotations do not match its config"; - tracing::error!(source = %source_name, error, "invalid engineering intake source"); - let failed = EngineeringSourceStatus { - state: EngineeringSyncState::Error, - last_sync_at: Some(Utc::now().to_rfc3339()), - last_error: Some(error.into()), - ..status - }; - if let Err(patch_error) = - patch_runtime_state(cluster, &source_name, &cursor, &failed).await - { - tracing::error!(source = %source_name, error = %patch_error, "failed to record engineering intake ownership error"); - } - continue; - } - if let Err(error) = ensure_auto_run_for_backlog(cluster, &config).await { - tracing::warn!(source = %source_name, team = %config.team_name, %error, "engineering intake could not rearm queued work"); - } - if !config.enabled || !is_due(&status, Utc::now()) { - continue; - } - match synchronize_source(cluster, &config, cursor, status).await { - Ok(updated) => { - if let Some(error) = updated.last_error.as_deref() { - tracing::warn!(source = %source_name, team = %config.team_name, error, "engineering intake sync completed with errors"); - } else { - tracing::info!(source = %source_name, team = %config.team_name, discovered = updated.items_discovered, queued = updated.items_queued, "engineering intake sync complete"); - } - } - Err(error) => { - tracing::error!(source = %source_name, team = %config.team_name, error = %error, "engineering intake sync failed") - } - } - } - } - }); -} - #[cfg(test)] -mod tests { - use super::*; - - pub(super) fn pull(login: &str, head: &str, number: u64) -> GithubPull { - GithubPull { - number, - html_url: format!("https://github.com/acme/api/pull/{number}"), - title: "Bump serde from 1.0.1 to 1.0.2".into(), - draft: false, - updated_at: "2026-07-20T12:00:00Z".into(), - user: Some(GithubUser { - login: login.into(), - }), - base: GithubRef { - name: "main".into(), - }, - head: GithubHead { - name: head.into(), - sha: "abc123".into(), - }, - labels: vec![GithubLabel { - name: "dependencies".into(), - }], - } - } - - fn task(id: &str, status: &str) -> TeamTaskDto { - TeamTaskDto { - id: id.into(), - title: id.into(), - description: String::new(), - depends_on: Vec::new(), - acceptance_criteria: Vec::new(), - review_required: false, - status: status.into(), - run: (status == "active").then(|| "run-1".into()), - created_at: Some("2026-07-20T00:00:00Z".into()), - done_at: (status == "done").then(|| "2026-07-20T01:00:00Z".into()), - stuck_since: (status == "active").then(|| "2026-07-20T00:30:00Z".into()), - assignment_nonce: None, - } - } - - #[test] - fn deterministic_ids_are_repo_and_pr_scoped() { - assert_eq!(work_id("Acme/API", 42), work_id("acme/api", 42)); - assert_ne!(work_id("acme/api", 42), work_id("acme/api", 43)); - assert_ne!(work_id("acme/api", 42), work_id("acme/web", 42)); - assert_eq!(source_id("Acme/API", 42), "github:acme/api:pull:42"); - assert_eq!( - source_config_map_name("kars-system", "platform"), - source_config_map_name("kars-system", "platform") - ); - assert_ne!( - source_config_map_name("tenant-a", "platform"), - source_config_map_name("tenant-b", "platform") - ); - assert_eq!( - remediation_work_id("Acme/API", Some("package-lock.json"), "@babel/core"), - remediation_work_id("acme/api", Some("package-lock.json"), "@babel/core") - ); - assert_ne!( - remediation_work_id("acme/api", Some("package-lock.json"), "@babel/core"), - remediation_work_id("acme/api", Some("other/package-lock.json"), "@babel/core") - ); - } - - #[test] - fn alert_tasks_lead_with_authoritative_source_facts() { - let task = alert_backlog_task( - EngineeringSignal::DependabotAlert, - GithubAlertRef { - repo: "pallakatos/kars", - number: 9, - }, - "vite alert".into(), - "Dependabot reported a finding.", - serde_json::json!({ - "manifest_path": "tests/compat/package-lock.json", - "package": "vite", - "vulnerable_version_range": ">= 8.0.0, <= 8.0.15", - "first_patched_version": "8.0.16", - "ghsa_id": "GHSA-v6wh-96g9-6wx3", - }), - None, - "2026-07-23T00:00:00Z", - ); - let prefix = task.description.lines().next().unwrap_or_default(); - assert!(prefix.contains("manifest=tests/compat/package-lock.json")); - assert!(prefix.contains("fixed=8.0.16")); - assert!(prefix.contains("ghsa=GHSA-v6wh-96g9-6wx3")); - assert!(prefix.contains("max2 same target")); - assert!(prefix.contains("search open+merged PRs")); - assert!(prefix.contains("no principal substitution")); - } - - #[test] - fn legacy_remediation_matching_is_exact_and_repo_scoped() { - let description = concat!( - "AUTH SOURCE: manifest=package-lock.json; pkg=react-dom; ghsa=GHSA-a. ", - "\n\nStructured source details (JSON):\n", - "{\"signal\":\"dependabot_alert\",\"work_id\":\"dependabot-alert-legacy\",", - "\"repo\":\"acme/web\",\"details\":{\"manifest_path\":\"package-lock.json\",", - "\"package\":\"react-dom\"}}" - ); - assert!(description_matches_remediation( - description, - "acme/web", - Some("package-lock.json"), - "react-dom", - )); - assert!(!description_matches_remediation( - description, - "acme/api", - Some("package-lock.json"), - "react-dom", - )); - assert!(!description_matches_remediation( - description, - "acme/web", - Some("package-lock.json"), - "react", - )); - assert!(!description_matches_remediation( - description, - "acme/web", - None, - "react-dom", - )); - } - - #[test] - fn dependabot_detection_accepts_bot_login_or_head_prefix() { - assert!(is_dependabot_pr(&pull( - "dependabot[bot]", - "renovate/foo", - 1 - ))); - assert!(is_dependabot_pr(&pull( - "someone", - "dependabot/npm/foo-1.2.3", - 2 - ))); - assert!(!is_dependabot_pr(&pull("renovate[bot]", "renovate/foo", 3))); - } - - #[test] - fn open_pull_candidates_mention_the_same_package_or_advisory() { - let alert = GithubDependabotAlert { - number: 17, - html_url: "https://github.com/acme/api/security/dependabot/17".into(), - dependency: GithubDependabotDependency { - package: GithubPackage { - ecosystem: "npm".into(), - name: "@babel/core".into(), - }, - manifest_path: Some("package-lock.json".into()), - scope: Some("development".into()), - }, - security_advisory: Some(GithubSecurityAdvisory { - ghsa_id: "GHSA-aaaa-bbbb-cccc".into(), - cve_id: None, - summary: "test".into(), - severity: "high".into(), - }), - security_vulnerability: GithubSecurityVulnerability { - vulnerable_version_range: "< 8".into(), - first_patched_version: Some(GithubPatchedVersion { - identifier: "8.0.0".into(), - }), - }, - updated_at: None, - }; - let mut package_pr = pull("agent", "fix-babel-core", 42); - package_pr.title = "chore: bump @babel/core to 8.0.0".into(); - assert!(open_pull_may_address_dependabot_alert(&package_pr, &alert)); - let mut advisory_pr = pull("agent", "security-fix", 43); - advisory_pr.title = "fix GHSA-aaaa-bbbb-cccc".into(); - assert!(open_pull_may_address_dependabot_alert(&advisory_pr, &alert)); - let unrelated = pull("agent", "fix-vite", 44); - assert!(!open_pull_may_address_dependabot_alert(&unrelated, &alert)); - } - - #[test] - fn parses_github_pull_and_builds_structured_task() { - let raw = serde_json::json!({ - "number": 7, - "html_url": "https://github.com/acme/api/pull/7", - "title": "Bump axum", - "draft": true, - "updated_at": "2026-07-20T12:00:00Z", - "user": {"login": "dependabot[bot]"}, - "base": {"ref": "main"}, - "head": {"ref": "dependabot/cargo/axum-1", "sha": "deadbeef"}, - "labels": [{"name": "dependencies"}, {"name": "rust"}] - }); - let parsed: GithubPull = serde_json::from_value(raw).unwrap(); - let task = backlog_task("acme/api", &parsed, "2026-07-20T13:00:00Z"); - assert_eq!(task.status, "pending"); - assert!(task.description.contains("\"head_sha\":\"deadbeef\"")); - assert!(task.description.contains("\"draft\":true")); - assert!(task.description.contains("Never claim CI is green")); - } - - #[test] - fn security_alert_ids_are_stable_and_signal_scoped() { - assert_eq!( - alert_work_id(EngineeringSignal::CodeScanningAlert, "Acme/API", 42), - alert_work_id(EngineeringSignal::CodeScanningAlert, "acme/api", 42) - ); - assert_ne!( - alert_work_id(EngineeringSignal::CodeScanningAlert, "acme/api", 42), - alert_work_id(EngineeringSignal::DependabotAlert, "acme/api", 42) - ); - assert_eq!( - alert_source_id(EngineeringSignal::SecretScanningAlert, "Acme/API", 7), - "github:acme/api:secret-scanning-alert:7" - ); - } - - #[test] - fn code_scanning_alert_builds_actionable_task() { - let alert: GithubCodeScanningAlert = serde_json::from_value(serde_json::json!({ - "number": 12, - "html_url": "https://github.com/acme/api/security/code-scanning/12", - "rule": { - "id": "rust/path-injection", - "name": "Path injection", - "description": "User-controlled path reaches filesystem access", - "severity": "error", - "security_severity_level": "high" - }, - "most_recent_instance": { - "location": {"path": "src/files.rs", "start_line": 44, "end_line": 47} - }, - "updated_at": "2026-07-21T00:00:00Z" - })) - .unwrap(); - let task = code_scanning_task("acme/api", &alert, "2026-07-21T01:00:00Z"); - assert!(task.title.contains("Path injection")); - assert!(task.description.contains("\"severity\":\"high\"")); - assert!(task.description.contains("\"path\":\"src/files.rs\"")); - assert!(task.description.contains("Never claim success")); - } - - #[test] - fn secret_scanning_task_never_persists_secret_value() { - let secret = "ghp_live_secret_value"; - let alert: GithubSecretScanningAlert = serde_json::from_value(serde_json::json!({ - "number": 9, - "html_url": "https://github.com/acme/api/security/secret-scanning/9", - "secret_type": "github_personal_access_token", - "secret_type_display_name": "GitHub Personal Access Token", - "secret": secret, - "resolution": null, - "created_at": "2026-07-21T00:00:00Z", - "updated_at": "2026-07-21T00:00:00Z" - })) - .unwrap(); - let task = secret_scanning_task("acme/api", &alert, "2026-07-21T01:00:00Z"); - assert!(task.description.contains("do not print, persist, or copy")); - assert!(!task.description.contains(secret)); - } - - #[test] - fn private_repo_without_security_products_is_unavailable_not_error() { - let features = GithubRepositoryFeatures { - private: true, - security_and_analysis: None, - }; - let code_error = GithubListError { - state: EngineeringSignalSyncState::Forbidden, - detail: "HTTP 403".into(), - }; - let secret_error = GithubListError { - state: EngineeringSignalSyncState::Unavailable, - detail: "HTTP 404".into(), - }; - for (signal, error) in [ - (EngineeringSignal::CodeScanningAlert, code_error), - (EngineeringSignal::SecretScanningAlert, secret_error), - ] { - let mapped = unavailable_security_product(Some(&features), signal, &error).unwrap(); - assert_eq!(mapped.state, EngineeringSignalSyncState::Unavailable); - assert!(mapped.detail.contains("not enabled or licensed")); - } - } - - #[test] - fn github_link_parser_finds_next_page() { - assert_eq!( - next_link( - r#"<https://api.github.com/repositories/1/alerts?page=2>; rel="next", <https://api.github.com/repositories/1/alerts?page=4>; rel="last""# - ) - .as_deref(), - Some("https://api.github.com/repositories/1/alerts?page=2") - ); - assert_eq!(next_link(""), None); - } - - #[test] - fn dedupe_preserves_existing_active_and_done_tasks() { - let mut active = task("dependabot-pr-active", "active"); - active.assignment_nonce = Some("run-1-assign-7".into()); - let done = task("dependabot-pr-done", "done"); - let (merged, added) = merge_discovered_tasks( - vec![active.clone(), done.clone()], - vec![ - task("dependabot-pr-active", "pending"), - task("dependabot-pr-done", "pending"), - task("dependabot-pr-new", "pending"), - ], - ); - assert_eq!(added, 1); - assert_eq!(merged.len(), 3); - assert_eq!(merged[0].status, "active"); - assert_eq!(merged[0].run, active.run); - assert_eq!(merged[0].assignment_nonce, active.assignment_nonce); - assert!(merged[0].review_required); - assert_eq!(merged[1].status, "done"); - assert_eq!(merged[1].done_at, done.done_at); - assert!(merged[1].review_required); - } - - #[test] - fn changed_open_security_alert_requeues_completed_work() { - let mut completed = task("code-scanning-alert-abc", "done"); - completed.description = "updated_at=old".into(); - let mut rediscovered = task("code-scanning-alert-abc", "pending"); - rediscovered.description = "updated_at=new".into(); - let (merged, queued) = merge_discovered_tasks(vec![completed], vec![rediscovered.clone()]); - assert_eq!(queued, 1); - assert_eq!(merged[0].status, "pending"); - assert_eq!(merged[0].description, rediscovered.description); - assert!(merged[0].run.is_none()); - assert!(merged[0].done_at.is_none()); - - let (unchanged, queued) = merge_discovered_tasks(merged, vec![rediscovered]); - assert_eq!(queued, 0); - assert_eq!(unchanged[0].status, "pending"); - - let mut refreshed = task("code-scanning-alert-abc", "pending"); - refreshed.description = "updated_at=newer".into(); - let (refreshed_tasks, queued) = merge_discovered_tasks(unchanged, vec![refreshed.clone()]); - assert_eq!(queued, 0); - assert_eq!(refreshed_tasks[0].description, refreshed.description); - } - - #[test] - fn changed_pending_alert_flows_through_without_using_queue_capacity() { - let mut existing = task("secret-scanning-alert-abc", "pending"); - existing.description = "updated_at=old".into(); - let mut refreshed = task("secret-scanning-alert-abc", "pending"); - refreshed.description = "updated_at=new".into(); - let mut candidates = Vec::new(); - let mut known = BTreeMap::from([( - existing.id.clone(), - (existing.status.clone(), existing.description.clone()), - )]); - let mut queued_slots = 0; - assert!(!append_bounded_tasks( - &mut candidates, - &mut known, - vec![refreshed.clone()], - &mut queued_slots, - 1, - )); - assert_eq!(queued_slots, 0); - assert_eq!(candidates.len(), 1); - let (merged, queued) = merge_discovered_tasks(vec![existing], candidates); - assert_eq!(queued, 0); - assert_eq!(merged[0].description, refreshed.description); - } - - #[test] - fn changed_active_alert_refreshes_source_facts_without_restarting_run() { - let mut existing = task("dependabot-alert-abc", "active"); - existing.description = "old source facts".into(); - existing.run = Some("run-in-progress".into()); - let mut refreshed = task("dependabot-alert-abc", "pending"); - refreshed.description = - "AUTHORITATIVE SOURCE FACTS: manifest_path=tests/compat/package-lock.json".into(); - let (merged, queued) = merge_discovered_tasks(vec![existing], vec![refreshed.clone()]); - assert_eq!(queued, 0); - assert_eq!(merged[0].status, "active"); - assert_eq!(merged[0].run.as_deref(), Some("run-in-progress")); - assert_eq!(merged[0].description, refreshed.description); - } - - #[test] - fn covered_pending_alert_is_retired_without_touching_active_run() { - let mut pending = task("dependabot-alert-pending", "pending"); - let mut retirement = task("dependabot-alert-pending", "done"); - retirement.description = "Covered by existing open PR #42".into(); - retirement.done_at = Some("2026-07-23T00:00:00Z".into()); - let (merged, queued) = merge_discovered_tasks(vec![pending.clone()], vec![retirement]); - assert_eq!(queued, 0); - assert_eq!(merged[0].status, "done"); - assert!(merged[0].run.is_none()); - - pending.status = "active".into(); - pending.run = Some("run-in-progress".into()); - let mut covered = task("dependabot-alert-pending", "done"); - covered.description = "Covered by existing open PR #42".into(); - let (active, queued) = merge_discovered_tasks(vec![pending], vec![covered]); - assert_eq!(queued, 0); - assert_eq!(active[0].status, "active"); - assert_eq!(active[0].run.as_deref(), Some("run-in-progress")); - } - - #[test] - fn repeated_human_review_decision_requeues_completed_task() { - let completed = task("github-pr-merge-abc", "done"); - let decision = task("github-pr-merge-abc", "pending"); - let (merged, queued) = merge_discovered_tasks(vec![completed], vec![decision]); - assert_eq!(queued, 1); - assert_eq!(merged[0].status, "pending"); - assert!(merged[0].run.is_none()); - assert!(merged[0].done_at.is_none()); - } - - #[test] - fn repo_authorization_and_limits_are_enforced() { - let granted = (0..=MAX_REPOS) - .map(|i| format!("acme/repo-{i}")) - .collect::<Vec<_>>(); - let too_many = PutEngineeringSourceRequest { - enabled: true, - auto_run: true, - repos: granted.clone(), - signals: vec![EngineeringSignal::DependabotPr], - poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, - }; - assert!(validate_request(&too_many, &granted).is_err()); - - let unauthorized = PutEngineeringSourceRequest { - enabled: true, - auto_run: true, - repos: vec!["other/private".into()], - signals: vec![EngineeringSignal::DependabotPr], - poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, - }; - assert!(validate_request(&unauthorized, &granted).is_err()); - - let invalid_interval = PutEngineeringSourceRequest { - enabled: true, - auto_run: true, - repos: vec!["acme/repo-0".into()], - signals: vec![EngineeringSignal::DependabotPr], - poll_interval_seconds: MIN_POLL_INTERVAL_SECONDS - 1, - }; - assert!(validate_request(&invalid_interval, &granted).is_err()); - } - - #[test] - fn config_cursor_and_status_serialize_round_trip() { - let config = EngineeringSourceConfig { - version: 1, - team_namespace: "kars-system".into(), - team_name: "platform".into(), - owner_sub: "subject-1".into(), - connection_config_map_ref: "kars-github-connection-deadbeef".into(), - enabled: true, - auto_run: true, - repos: vec!["acme/api".into()], - signals: vec![EngineeringSignal::DependabotPr], - poll_interval_seconds: 900, - }; - let cursor = EngineeringCursor { - repository_updated_at: BTreeMap::from([( - "acme/api".into(), - "2026-07-20T12:00:00Z".into(), - )]), - }; - let status = EngineeringSourceStatus { - state: EngineeringSyncState::Ok, - last_sync_at: Some("2026-07-20T12:00:00Z".into()), - last_success_at: Some("2026-07-20T12:00:00Z".into()), - last_error: None, - items_discovered: 2, - items_queued: 1, - total_items_queued: 4, - next_poll_at: Some("2026-07-20T12:15:00Z".into()), - ..Default::default() - }; - let data = source_data(&config, &cursor, &status).unwrap(); - let cm = ConfigMap { - data: Some(data), - ..Default::default() - }; - let round_trip = parse_source(&cm).unwrap(); - assert_eq!(round_trip, (config, cursor, status)); - } - - fn clean_pull_status() -> serde_json::Value { - serde_json::json!({ - "state": "open", - "merged": false, - "draft": false, - "mergeable": true, - "mergeable_state": "clean" - }) - } - - #[test] - fn review_readiness_only_flags_green_clean_prs() { - let (state, _, total, passed) = classify_review_readiness( - &clean_pull_status(), - &serde_json::json!({ - "check_runs": [ - {"status":"completed","conclusion":"success"}, - {"status":"completed","conclusion":"neutral"} - ] - }), - &serde_json::json!({"state":"success","statuses":[]}), - ); - assert_eq!(state, EngineeringReviewState::ReadyForReview); - assert_eq!((total, passed), (2, 2)); - - let (state, _, _, _) = classify_review_readiness( - &clean_pull_status(), - &serde_json::json!({ - "check_runs": [{"status":"in_progress","conclusion":null}] - }), - &serde_json::json!({"state":"pending","statuses":[]}), - ); - assert_eq!(state, EngineeringReviewState::WaitingForCi); - - let (state, _, _, _) = classify_review_readiness( - &clean_pull_status(), - &serde_json::json!({ - "check_runs": [{"status":"completed","conclusion":"failure"}] - }), - &serde_json::json!({"state":"failure","statuses":[]}), - ); - assert_eq!(state, EngineeringReviewState::CiFailed); - - let (state, _, _, _) = classify_review_readiness( - &clean_pull_status(), - &serde_json::json!({ - "total_count": 101, - "check_runs": (0..100).map(|_| serde_json::json!({ - "status":"completed","conclusion":"success" - })).collect::<Vec<_>>() - }), - &serde_json::json!({"state":"success","statuses":[]}), - ); - assert_eq!(state, EngineeringReviewState::WaitingForCi); - } - - #[test] - fn red_pr_creates_deterministic_followup_work() { - let item = EngineeringReviewItem { - repo: "acme/api".into(), - pr_number: 42, - pr_url: "https://github.com/acme/api/pull/42".into(), - title: "Fix dependency".into(), - run: "run-1".into(), - source_id: "github:acme/api:pull:42".into(), - work_id: "dependabot-pr-example".into(), - task_status: "done".into(), - run_state: Some("Completed".into()), - selected_roles: vec!["reviewer".into()], - delivered_roles: vec!["reviewer".into()], - artifact_count: Some(1), - head_sha: "abc123".into(), - state: EngineeringReviewState::CiFailed, - detail: "test failed".into(), - checks_total: 2, - checks_passed: 1, - observed_at: "2026-07-20T12:00:00Z".into(), - }; - let first = review_followup_task(&item, "2026-07-20T12:00:00Z").unwrap(); - let second = review_followup_task(&item, "2026-07-20T13:00:00Z").unwrap(); - assert_eq!(first.id, second.id); - let mut changed_head = item.clone(); - changed_head.head_sha = "def456".into(); - let changed = review_followup_task(&changed_head, "2026-07-20T14:00:00Z").unwrap(); - assert_eq!(first.id, changed.id); - assert_ne!(first.description, changed.description); - assert!(first.description.contains("Never claim green")); - assert!(first.description.contains("superseded")); - assert!(first.description.contains("do not repair or rebase it")); - } - - #[test] - fn duplicate_prs_create_one_canonical_retirement_task() { - let first = pull("agent", "fix-js-yaml", 18); - let second = pull("agent", "fix-js-yaml-again", 24); - let task = dedupe_followup_task( - "acme/api", - "dependency-remediation-abc", - &[&second, &first], - "2026-07-20T12:00:00Z", - ) - .unwrap(); - assert!(task.title.contains("PR #18")); - assert!(task.description.contains("#24")); - assert!(task.description.contains("never merge")); - } -} +mod tests; diff --git a/bridge/bff/src/routes/engineering/config.rs b/bridge/bff/src/routes/engineering/config.rs new file mode 100644 index 000000000..5b9e2b427 --- /dev/null +++ b/bridge/bff/src/routes/engineering/config.rs @@ -0,0 +1,240 @@ +// kars Bridge BFF — config helpers for engineering intake. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::ConfigMap; +use kube::ResourceExt; + +use crate::error::{AppError, AppResult}; +use crate::providers::signing::sha256; +use crate::routes::github::authorize_repo_set; + +use super::{ + CONFIG_KEY, CONNECTION_ANNOTATION, CURSOR_KEY, DEFAULT_POLL_INTERVAL_SECONDS, + EngineeringCursor, EngineeringSignal, EngineeringSourceConfig, EngineeringSourceDto, + EngineeringSourceStatus, EngineeringSyncState, MAX_POLL_INTERVAL_SECONDS, MAX_REPOS, + MIN_POLL_INTERVAL_SECONDS, OWNER_ANNOTATION, PutEngineeringSourceRequest, STATUS_KEY, + TEAM_NAME_ANNOTATION, TEAM_NAMESPACE_ANNOTATION, +}; + +pub(super) fn default_poll_interval() -> u64 { + DEFAULT_POLL_INTERVAL_SECONDS +} + +pub(super) fn default_true() -> bool { + true +} + +pub(crate) fn source_config_map_name(namespace: &str, team: &str) -> String { + let digest = sha256(format!("{namespace}/{team}").as_bytes()); + let stem = team.chars().take(40).collect::<String>(); + format!("kars-eng-{stem}-{}", hex::encode(&digest[..6])) +} + +pub(super) fn source_annotations(config: &EngineeringSourceConfig) -> BTreeMap<String, String> { + BTreeMap::from([ + (OWNER_ANNOTATION.to_string(), config.owner_sub.clone()), + ( + TEAM_NAMESPACE_ANNOTATION.to_string(), + config.team_namespace.clone(), + ), + (TEAM_NAME_ANNOTATION.to_string(), config.team_name.clone()), + ( + CONNECTION_ANNOTATION.to_string(), + config.connection_config_map_ref.clone(), + ), + ]) +} + +pub(super) fn source_data( + config: &EngineeringSourceConfig, + cursor: &EngineeringCursor, + status: &EngineeringSourceStatus, +) -> AppResult<BTreeMap<String, String>> { + Ok(BTreeMap::from([ + ( + CONFIG_KEY.to_string(), + serde_json::to_string(config).map_err(|e| AppError::Internal(e.into()))?, + ), + ( + CURSOR_KEY.to_string(), + serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?, + ), + ( + STATUS_KEY.to_string(), + serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?, + ), + ])) +} + +pub(super) fn parse_source( + cm: &ConfigMap, +) -> Result< + ( + EngineeringSourceConfig, + EngineeringCursor, + EngineeringSourceStatus, + ), + String, +> { + let data = cm + .data + .as_ref() + .ok_or_else(|| "engineering source has no data".to_string())?; + let config = serde_json::from_str::<EngineeringSourceConfig>( + data.get(CONFIG_KEY) + .ok_or_else(|| "engineering source is missing config.json".to_string())?, + ) + .map_err(|e| format!("invalid engineering source config: {e}"))?; + if config.version != 1 { + return Err(format!( + "unsupported engineering source config version {}", + config.version + )); + } + let cursor = data + .get(CURSOR_KEY) + .map(|raw| serde_json::from_str(raw)) + .transpose() + .map_err(|e| format!("invalid engineering source cursor: {e}"))? + .unwrap_or_default(); + let status = data + .get(STATUS_KEY) + .map(|raw| serde_json::from_str(raw)) + .transpose() + .map_err(|e| format!("invalid engineering source status: {e}"))? + .unwrap_or_default(); + Ok((config, cursor, status)) +} + +pub(super) fn verify_source_owner( + cm: &ConfigMap, + config: &EngineeringSourceConfig, + owner_sub: &str, +) -> bool { + config.owner_sub == owner_sub + && cm + .annotations() + .get(OWNER_ANNOTATION) + .is_some_and(|stored| stored == owner_sub) + && cm + .annotations() + .get(CONNECTION_ANNOTATION) + .is_some_and(|stored| stored == &config.connection_config_map_ref) + && cm + .annotations() + .get(TEAM_NAMESPACE_ANNOTATION) + .is_some_and(|stored| stored == &config.team_namespace) + && cm + .annotations() + .get(TEAM_NAME_ANNOTATION) + .is_some_and(|stored| stored == &config.team_name) +} + +pub(super) fn to_dto( + configured: bool, + config: Option<&EngineeringSourceConfig>, + status: EngineeringSourceStatus, +) -> EngineeringSourceDto { + EngineeringSourceDto { + configured, + enabled: config.is_some_and(|c| c.enabled), + auto_run: config.is_none_or(|c| c.auto_run), + repos: config.map(|c| c.repos.clone()).unwrap_or_default(), + signals: config.map(|c| c.signals.clone()).unwrap_or_default(), + poll_interval_seconds: config + .map(|c| c.poll_interval_seconds) + .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS), + status, + } +} + +fn validate_repo_name(repo: &str) -> bool { + let mut parts = repo.split('/'); + let Some(owner) = parts.next() else { + return false; + }; + let Some(name) = parts.next() else { + return false; + }; + parts.next().is_none() + && !owner.is_empty() + && !name.is_empty() + && owner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) +} + +pub(super) fn validate_request( + request: &PutEngineeringSourceRequest, + granted: &[String], +) -> AppResult<(Vec<String>, Vec<EngineeringSignal>)> { + if !(MIN_POLL_INTERVAL_SECONDS..=MAX_POLL_INTERVAL_SECONDS) + .contains(&request.poll_interval_seconds) + { + return Err(AppError::BadRequest(format!( + "poll_interval_seconds must be between {MIN_POLL_INTERVAL_SECONDS} and {MAX_POLL_INTERVAL_SECONDS}" + ))); + } + + let repos = authorize_repo_set(&request.repos, granted)?; + if repos.len() > MAX_REPOS { + return Err(AppError::BadRequest(format!( + "at most {MAX_REPOS} repositories can be configured per team" + ))); + } + if let Some(invalid) = repos.iter().find(|repo| !validate_repo_name(repo)) { + return Err(AppError::BadRequest(format!( + "invalid repository name `{invalid}`; expected owner/repo" + ))); + } + + let signals = request + .signals + .iter() + .copied() + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>(); + if request.enabled && repos.is_empty() { + return Err(AppError::BadRequest( + "select at least one authorized repository before enabling engineering intake".into(), + )); + } + if request.enabled && signals.is_empty() { + return Err(AppError::BadRequest( + "select at least one engineering signal before enabling intake".into(), + )); + } + Ok((repos, signals)) +} + +pub(super) fn initial_jitter_seconds(source_name: &str) -> i64 { + let digest = sha256(source_name.as_bytes()); + i64::from(digest[0] % 60) +} + +pub(super) fn next_poll_at(config: &EngineeringSourceConfig, now: DateTime<Utc>) -> String { + (now + chrono::Duration::seconds(config.poll_interval_seconds as i64)).to_rfc3339() +} + +pub(super) fn is_due(status: &EngineeringSourceStatus, now: DateTime<Utc>) -> bool { + status + .next_poll_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .is_none_or(|value| value.with_timezone(&Utc) <= now) +} + +pub(super) fn sync_claim_active(status: &EngineeringSourceStatus, now: DateTime<Utc>) -> bool { + status.state == EngineeringSyncState::Syncing + && status + .sync_claim_expires_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .is_some_and(|expires| expires.with_timezone(&Utc) > now) +} diff --git a/bridge/bff/src/routes/engineering/endpoints.rs b/bridge/bff/src/routes/engineering/endpoints.rs new file mode 100644 index 000000000..3ecb24f58 --- /dev/null +++ b/bridge/bff/src/routes/engineering/endpoints.rs @@ -0,0 +1,367 @@ +// kars Bridge BFF — endpoints helpers for engineering intake. + +use axum::Json; +use axum::extract::{Extension, Path, State}; +use chrono::Utc; + +use crate::auth::Principal; +use crate::error::{AppError, AppResult}; +use crate::providers::signing::sha256; +use crate::routes::github::connection_config_map_name; +use crate::routes::tasks::require_cluster; +use crate::routes::teams::{read_task_list, require_owned_team}; +use crate::state::AppState; + +use super::config::{ + initial_jitter_seconds, parse_source, source_annotations, source_data, sync_claim_active, + to_dto, validate_request, verify_source_owner, +}; +use super::queue::{merge_into_backlog, request_team_run}; +use super::synchronization::synchronize_source; +use super::{ + EngineeringCursor, EngineeringReviewDecisionRequest, EngineeringSourceConfig, + EngineeringSourceDto, EngineeringSourceStatus, EngineeringSyncState, + PutEngineeringSourceRequest, TeamTaskDto, source_config_map_name, +}; + +/// `GET /api/namespaces/:ns/teams/:name/engineering-source`. +pub async fn get_source( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<EngineeringSourceDto>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let source_name = source_config_map_name(&ns, &name); + let Some(cm) = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + else { + return Ok(Json(to_dto( + false, + None, + EngineeringSourceStatus::default(), + ))); + }; + let (config, _cursor, status) = + parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns || config.team_name != name { + return Err(AppError::NotFound); + } + if !verify_source_owner(&cm, &config, &principal.sub) { + return Ok(Json(to_dto( + false, + None, + EngineeringSourceStatus::default(), + ))); + } + Ok(Json(to_dto(true, Some(&config), status))) +} + +/// `PUT /api/namespaces/:ns/teams/:name/engineering-source`. +pub async fn put_source( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(request): Json<PutEngineeringSourceRequest>, +) -> AppResult<Json<EngineeringSourceDto>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let connection_ref = connection_config_map_name(&principal.sub); + let (_installation_id, _account, granted_repos) = cluster + .read_github_connection_result(&ns, &connection_ref) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or_else(|| { + AppError::Rejected( + "connect GitHub for your user before configuring engineering intake".into(), + ) + })?; + let (repos, signals) = validate_request(&request, &granted_repos)?; + + let source_name = source_config_map_name(&ns, &name); + let existing = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + let (cursor, mut status, previous_config) = if let Some(cm) = existing.as_ref() { + let (config, cursor, status) = + parse_source(cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns || config.team_name != name { + return Err(AppError::NotFound); + } + if sync_claim_active(&status, Utc::now()) { + return Err(AppError::Conflict( + "engineering intake is syncing; retry the configuration change shortly".into(), + )); + } + if verify_source_owner(cm, &config, &principal.sub) { + (cursor, status, Some(config)) + } else { + ( + EngineeringCursor::default(), + EngineeringSourceStatus::default(), + None, + ) + } + } else { + ( + EngineeringCursor::default(), + EngineeringSourceStatus::default(), + None, + ) + }; + + let config = EngineeringSourceConfig { + version: 1, + team_namespace: ns, + team_name: name, + owner_sub: principal.sub, + connection_config_map_ref: connection_ref, + enabled: request.enabled, + auto_run: request.auto_run, + repos, + signals, + poll_interval_seconds: request.poll_interval_seconds, + }; + if config.enabled { + let changed = previous_config.as_ref().is_none_or(|previous| { + !previous.enabled + || previous.repos != config.repos + || previous.signals != config.signals + || previous.auto_run != config.auto_run + || previous.poll_interval_seconds != config.poll_interval_seconds + }); + if changed || status.next_poll_at.is_none() { + status.state = EngineeringSyncState::Idle; + status.next_poll_at = Some( + (Utc::now() + chrono::Duration::seconds(initial_jitter_seconds(&source_name))) + .to_rfc3339(), + ); + } + } else { + status.state = EngineeringSyncState::Disabled; + status.next_poll_at = None; + } + let data = source_data(&config, &cursor, &status)?; + let annotations = source_annotations(&config); + if let Some(current) = existing { + cluster + .replace_engineering_source(current, &annotations, &data) + .await + .map_err(|error| { + if matches!(error, kube::Error::Api(ref response) if response.code == 409) { + AppError::Conflict( + "engineering intake changed concurrently; reload and retry".into(), + ) + } else { + AppError::Upstream(error.to_string()) + } + })?; + } else { + cluster + .create_engineering_source(&source_name, &annotations, &data) + .await + .map_err(|error| { + if matches!(error, kube::Error::Api(ref response) if response.code == 409) { + AppError::Conflict( + "engineering intake was configured concurrently; reload and retry".into(), + ) + } else { + AppError::Upstream(error.to_string()) + } + })?; + } + Ok(Json(to_dto(true, Some(&config), status))) +} + +/// `POST /api/namespaces/:ns/teams/:name/engineering-source/sync`. +pub async fn sync_now( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<EngineeringSourceDto>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let source_name = source_config_map_name(&ns, &name); + let cm = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + .ok_or_else(|| AppError::Rejected("configure engineering intake first".into()))?; + let (config, cursor, status) = + parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns + || config.team_name != name + || !verify_source_owner(&cm, &config, &principal.sub) + { + return Err(AppError::NotFound); + } + if !config.enabled { + return Err(AppError::Rejected( + "enable engineering intake before syncing".into(), + )); + } + let status = synchronize_source(cluster, &config, cursor, status).await?; + Ok(Json(to_dto(true, Some(&config), status))) +} + +/// `DELETE /api/namespaces/:ns/teams/:name/engineering-source`. +pub async fn delete_source( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, +) -> AppResult<Json<EngineeringSourceDto>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let source_name = source_config_map_name(&ns, &name); + if let Some(cm) = cluster + .read_engineering_source(&source_name) + .await + .map_err(|e| AppError::Upstream(e.to_string()))? + { + let (config, _, status) = + parse_source(&cm).map_err(|e| AppError::Upstream(e.to_string()))?; + if config.team_namespace != ns || config.team_name != name { + return Err(AppError::NotFound); + } + if sync_claim_active(&status, Utc::now()) { + return Err(AppError::Conflict( + "engineering intake is syncing; retry disconnect shortly".into(), + )); + } + let resource_version = cm + .metadata + .resource_version + .clone() + .ok_or_else(|| AppError::Conflict("engineering source has no version".into()))?; + + cluster + .delete_engineering_source_if_version(&source_name, resource_version) + .await + .map_err(|error| { + if matches!(error, kube::Error::Api(ref response) if response.code == 409) { + AppError::Conflict( + "engineering intake changed concurrently; reload and retry".into(), + ) + } else { + AppError::Upstream(error.to_string()) + } + })?; + } + Ok(Json(to_dto( + false, + None, + EngineeringSourceStatus::default(), + ))) +} + +/// Turn a human PR decision into durable standing-team work. This preserves the +/// same source → backlog → run chain instead of trying to mutate a retired run. +pub async fn decide_review_item( + State(state): State<AppState>, + Extension(principal): Extension<Principal>, + Path((ns, name)): Path<(String, String)>, + Json(request): Json<EngineeringReviewDecisionRequest>, +) -> AppResult<Json<serde_json::Value>> { + let cluster = require_cluster(&state)?; + require_owned_team(cluster, &ns, &name, &principal).await?; + let decision = request.decision.trim(); + if decision != "request_changes" { + return Err(AppError::BadRequest( + "only request_changes is supported; merge remains a human GitHub action until a typed single-use merge grant exists".into(), + )); + } + let comment = request + .comment + .as_deref() + .map(str::trim) + .unwrap_or("") + .chars() + .take(1500) + .collect::<String>(); + if decision == "request_changes" && comment.is_empty() { + return Err(AppError::BadRequest( + "request_changes requires concrete feedback".into(), + )); + } + + let source_name = source_config_map_name(&ns, &name); + let source = cluster + .read_engineering_source(&source_name) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .ok_or_else(|| AppError::Rejected("configure engineering intake first".into()))?; + let (config, _, status) = + parse_source(&source).map_err(|error| AppError::Upstream(error.to_string()))?; + if !verify_source_owner(&source, &config, &principal.sub) + || !config + .repos + .iter() + .any(|repo| repo.eq_ignore_ascii_case(&request.repo)) + { + return Err(AppError::NotFound); + } + let _item = status + .review_items + .iter() + .find(|item| { + item.repo.eq_ignore_ascii_case(&request.repo) + && item.pr_number == request.pr_number + && item.head_sha == request.head_sha + && item.run == request.run + }) + .ok_or_else(|| { + AppError::Conflict( + "the PR changed since this card was rendered; sync before deciding".into(), + ) + })?; + let identity = format!( + "engineering-review:{decision}:{}:{}:{}:{comment}", + request.repo.to_ascii_lowercase(), + request.pr_number, + request.head_sha + ); + let digest = sha256(identity.as_bytes()); + let task = TeamTaskDto { + id: format!("github-pr-feedback-{}", hex::encode(&digest[..10])), + title: format!( + "[Review feedback] Revise {} PR #{}", + request.repo, request.pr_number + ), + description: format!( + "PR: {}\nREVIEWED SHA: {}\nSOURCE RUN: {}\n\nREQUESTED CHANGES:\n{}\n\nA human reviewed the team's PR and requested changes. Re-open the exact prior evidence, apply only the requested delta, run relevant tests, push a new commit, and wait for GitHub checks. Never merge and never reuse stale green evidence.", + request.pr_url, request.head_sha, request.run, comment + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(Utc::now().to_rfc3339()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }; + let task_id = task.id.clone(); + let queued = merge_into_backlog(cluster, &name, vec![task]) + .await + .map_err(AppError::Upstream)?; + let pending = read_task_list(&cluster.read_team_tasks(&name).await) + .iter() + .any(|task| task.id == task_id && task.status == "pending"); + let run_requested = if pending { + request_team_run(cluster, &ns, &name) + .await + .map_err(AppError::Upstream)? + } else { + false + }; + Ok(Json(serde_json::json!({ + "queued": queued > 0, + "run_requested": run_requested, + "decision": decision, + "team": name, + }))) +} diff --git a/bridge/bff/src/routes/engineering/github.rs b/bridge/bff/src/routes/engineering/github.rs new file mode 100644 index 000000000..e6f69ea87 --- /dev/null +++ b/bridge/bff/src/routes/engineering/github.rs @@ -0,0 +1,296 @@ +// kars Bridge BFF — github helpers for engineering intake. + +use super::{ + EngineeringSignal, EngineeringSignalResult, EngineeringSignalSyncState, + GithubCodeScanningAlert, GithubDependabotAlert, GithubListError, GithubListResult, GithubPull, + GithubRepositoryFeatures, GithubSecretScanningAlert, MAX_ALERTS_PER_SIGNAL, MAX_GITHUB_PAGES, + MAX_OPEN_PRS_PER_REPO, +}; + +pub(super) async fn repository_features( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Option<GithubRepositoryFeatures> { + client + .get(format!("https://api.github.com/repos/{repo}")) + .bearer_auth(token) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header(reqwest::header::USER_AGENT, "kars-bridge") + .send() + .await + .ok()? + .error_for_status() + .ok()? + .json() + .await + .ok() +} + +pub(super) fn unavailable_security_product( + features: Option<&GithubRepositoryFeatures>, + signal: EngineeringSignal, + error: &GithubListError, +) -> Option<GithubListError> { + let unsupported_signal = matches!( + signal, + EngineeringSignal::CodeScanningAlert | EngineeringSignal::SecretScanningAlert + ); + let private_without_security_product = + features.is_some_and(|repo| repo.private && repo.security_and_analysis.is_none()); + let unsupported_response = matches!( + error.state, + EngineeringSignalSyncState::Forbidden | EngineeringSignalSyncState::Unavailable + ); + (unsupported_signal && private_without_security_product && unsupported_response).then(|| { + GithubListError { + state: EngineeringSignalSyncState::Unavailable, + detail: format!( + "{} is unavailable because GitHub Code Security / Secret Protection is not enabled or licensed for this private repository", + match signal { + EngineeringSignal::CodeScanningAlert => "Code scanning", + EngineeringSignal::SecretScanningAlert => "Secret scanning", + _ => "Security scanning", + } + ), + } + }) +} + +pub(super) fn truncate_error(value: impl Into<String>) -> String { + let value = value.into(); + value.chars().take(1000).collect() +} + +pub(super) fn next_link(value: &str) -> Option<String> { + value.split(',').find_map(|entry| { + let mut sections = entry.trim().split(';'); + let url = sections.next()?.trim(); + if !sections.any(|section| section.trim() == r#"rel="next""#) { + return None; + } + url.strip_prefix('<')?.strip_suffix('>').map(str::to_string) + }) +} + +async fn github_get_paginated<T: serde::de::DeserializeOwned>( + client: &reqwest::Client, + token: &str, + initial_url: String, + label: &str, + max_items: usize, +) -> Result<GithubListResult<T>, GithubListError> { + let mut url = Some(initial_url); + let mut items = Vec::new(); + let mut pages = 0; + while let Some(current) = url.take() { + pages += 1; + let response = client + .get(¤t) + .bearer_auth(token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|error| GithubListError { + state: EngineeringSignalSyncState::Error, + detail: format!("{label} request failed: {error}"), + })?; + let status = response.status(); + let next = response + .headers() + .get(reqwest::header::LINK) + .and_then(|value| value.to_str().ok()) + .and_then(next_link); + let body = response.text().await.map_err(|error| GithubListError { + state: EngineeringSignalSyncState::Error, + detail: format!("{label} response could not be read: {error}"), + })?; + if !status.is_success() { + return Err(GithubListError { + state: match status.as_u16() { + 403 => EngineeringSignalSyncState::Forbidden, + 404 => EngineeringSignalSyncState::Unavailable, + _ => EngineeringSignalSyncState::Error, + }, + detail: format!("{label} returned HTTP {status}"), + }); + } + let mut page = serde_json::from_str::<Vec<T>>(&body).map_err(|error| GithubListError { + state: EngineeringSignalSyncState::Error, + detail: format!("{label} returned invalid JSON: {error}"), + })?; + let remaining = max_items.saturating_sub(items.len()); + if page.len() > remaining { + page.truncate(remaining); + } + items.extend(page); + if next.is_some() && (items.len() >= max_items || pages >= MAX_GITHUB_PAGES) { + return Ok(GithubListResult { + items, + truncated: true, + }); + } + url = next; + } + Ok(GithubListResult { + items, + truncated: false, + }) +} + +#[allow(dead_code)] +async fn list_open_pulls_legacy( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result<Vec<GithubPull>, String> { + let url = format!( + "https://api.github.com/repos/{repo}/pulls?state=open&per_page={MAX_OPEN_PRS_PER_REPO}" + ); + let response = client + .get(url) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub request for {repo} failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("GitHub response for {repo} could not be read: {e}"))?; + if !status.is_success() { + return Err(truncate_error(format!( + "GitHub returned {status} while listing open pull requests for {repo}: {body}" + ))); + } + serde_json::from_str(&body) + .map_err(|e| format!("GitHub returned invalid pull request data for {repo}: {e}")) +} + +pub(super) async fn list_open_pulls( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result<GithubListResult<GithubPull>, GithubListError> { + github_get_paginated( + client, + token, + format!("https://api.github.com/repos/{repo}/pulls?state=open&per_page=100"), + &format!("listing open pull requests for {repo}"), + MAX_OPEN_PRS_PER_REPO, + ) + .await +} + +pub(super) async fn list_dependabot_alerts( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result<GithubListResult<GithubDependabotAlert>, GithubListError> { + github_get_paginated( + client, + token, + format!("https://api.github.com/repos/{repo}/dependabot/alerts?state=open&per_page=100"), + &format!("Dependabot alerts for {repo}"), + MAX_ALERTS_PER_SIGNAL, + ) + .await +} + +pub(super) async fn list_code_scanning_alerts( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result<GithubListResult<GithubCodeScanningAlert>, GithubListError> { + github_get_paginated( + client, + token, + format!("https://api.github.com/repos/{repo}/code-scanning/alerts?state=open&per_page=100"), + &format!("code scanning alerts for {repo}"), + MAX_ALERTS_PER_SIGNAL, + ) + .await +} + +pub(super) async fn list_secret_scanning_alerts( + client: &reqwest::Client, + token: &str, + repo: &str, +) -> Result<GithubListResult<GithubSecretScanningAlert>, GithubListError> { + github_get_paginated( + client, + token, + format!( + "https://api.github.com/repos/{repo}/secret-scanning/alerts?state=open&per_page=100" + ), + &format!("secret scanning alerts for {repo}"), + MAX_ALERTS_PER_SIGNAL, + ) + .await +} + +pub(super) fn signal_result( + repo: &str, + signal: EngineeringSignal, + result: Result<usize, GithubListError>, + truncation_detail: Option<String>, +) -> EngineeringSignalResult { + match result { + Ok(discovered) if truncation_detail.is_some() => EngineeringSignalResult { + repo: repo.to_string(), + signal, + state: EngineeringSignalSyncState::Truncated, + discovered, + detail: truncation_detail.unwrap_or_default(), + }, + Ok(discovered) => EngineeringSignalResult { + repo: repo.to_string(), + signal, + state: EngineeringSignalSyncState::Ok, + discovered, + detail: if discovered == 0 { + "Scanned successfully; no open items.".into() + } else { + format!("Scanned successfully; found {discovered} open item(s).") + }, + }, + Err(error) => EngineeringSignalResult { + repo: repo.to_string(), + signal, + state: error.state, + discovered: 0, + detail: error.detail, + }, + } +} + +pub(super) async fn github_get_json( + client: &reqwest::Client, + token: &str, + url: &str, +) -> Result<serde_json::Value, String> { + let response = client + .get(url) + .bearer_auth(token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "kars-bridge") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .await + .map_err(|e| format!("GitHub request failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("GitHub response could not be read: {e}"))?; + if !status.is_success() { + return Err(truncate_error(format!("GitHub returned {status}: {body}"))); + } + serde_json::from_str(&body).map_err(|e| format!("GitHub returned invalid JSON: {e}")) +} diff --git a/bridge/bff/src/routes/engineering/intake.rs b/bridge/bff/src/routes/engineering/intake.rs new file mode 100644 index 000000000..701411ccb --- /dev/null +++ b/bridge/bff/src/routes/engineering/intake.rs @@ -0,0 +1,325 @@ +// kars Bridge BFF — intake helpers for engineering intake. + +use crate::providers::signing::sha256; + +use super::remediation::remediation_work_id; +use super::{ + DependabotWorkDetails, EngineeringSignal, GithubAlertRef, GithubCodeScanningAlert, + GithubDependabotAlert, GithubPull, GithubSecretScanningAlert, TeamTaskDto, +}; + +pub(super) fn is_dependabot_pr(pr: &GithubPull) -> bool { + pr.user.as_ref().is_some_and(|user| { + user.login.eq_ignore_ascii_case("dependabot[bot]") + || user.login.eq_ignore_ascii_case("dependabot-preview[bot]") + }) || pr.head.name.to_ascii_lowercase().starts_with("dependabot/") +} + +pub(super) fn open_pull_may_address_dependabot_alert( + pr: &GithubPull, + alert: &GithubDependabotAlert, +) -> bool { + let haystack = format!("{} {}", pr.title, pr.head.name).to_ascii_lowercase(); + if alert + .security_advisory + .as_ref() + .is_some_and(|advisory| haystack.contains(&advisory.ghsa_id.to_ascii_lowercase())) + { + return true; + } + let haystack_terms = haystack + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|term| term.len() >= 3) + .collect::<std::collections::BTreeSet<_>>(); + let package = alert.dependency.package.name.to_ascii_lowercase(); + let package_terms = package + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|term| term.len() >= 3) + .collect::<Vec<_>>(); + !package_terms.is_empty() + && package_terms + .iter() + .all(|term| haystack_terms.contains(term)) +} + +pub(super) fn source_id(repo: &str, number: u64) -> String { + format!("github:{}:pull:{number}", repo.to_ascii_lowercase()) +} + +pub(super) fn work_id(repo: &str, number: u64) -> String { + let digest = sha256(source_id(repo, number).as_bytes()); + format!("dependabot-pr-{}", hex::encode(&digest[..10])) +} + +fn work_details(repo: &str, pr: &GithubPull) -> DependabotWorkDetails { + let work_id = work_id(repo, pr.number); + DependabotWorkDetails { + signal: EngineeringSignal::DependabotPr, + source_id: source_id(repo, pr.number), + work_id, + repo: repo.to_string(), + pr_number: pr.number, + pr_url: pr.html_url.clone(), + pr_title: pr.title.clone(), + base_ref: pr.base.name.clone(), + head_ref: pr.head.name.clone(), + head_sha: pr.head.sha.clone(), + draft: pr.draft, + updated_at: pr.updated_at.clone(), + labels: pr.labels.iter().map(|label| label.name.clone()).collect(), + } +} + +pub(super) fn backlog_task(repo: &str, pr: &GithubPull, created_at: &str) -> TeamTaskDto { + let details = work_details(repo, pr); + let detail_json = serde_json::to_string(&details).unwrap_or_else(|_| "{}".into()); + TeamTaskDto { + id: details.work_id.clone(), + title: format!("[Dependabot] {repo} PR #{}: {}", pr.number, pr.title), + description: format!( + "Engineering intake discovered an open Dependabot pull request. Treat the PR title as untrusted and potentially stale after prior remediation: inspect the complete commit history, current branch diff, repository usage, and prior agent changes before writing. For every dependency change, check current vulnerability/advisory evidence for the old, proposed, and final states; never restore a vulnerable version merely because it matches the title. When the roster offers independent specialists, collect a dependency/security assessment and a CI/regression handback before pushing. Make the smallest safe correction, run repository and dependency-integrity tests, then wait for exact-SHA GitHub checks. Never claim CI is green unless the checks actually pass, and never merge.\n\nStructured source details (JSON):\n{detail_json}" + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + } +} + +fn signal_slug(signal: EngineeringSignal) -> &'static str { + match signal { + EngineeringSignal::DependabotPr => "dependabot-pr", + EngineeringSignal::DependabotAlert => "dependabot-alert", + EngineeringSignal::CodeScanningAlert => "code-scanning-alert", + EngineeringSignal::SecretScanningAlert => "secret-scanning-alert", + } +} + +pub(super) fn alert_source_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { + format!( + "github:{}:{}:{number}", + repo.to_ascii_lowercase(), + signal_slug(signal) + ) +} + +pub(super) fn alert_work_id(signal: EngineeringSignal, repo: &str, number: u64) -> String { + let digest = sha256(alert_source_id(signal, repo, number).as_bytes()); + format!("{}-{}", signal_slug(signal), hex::encode(&digest[..10])) +} + +pub(super) fn legacy_alert_retirement( + id: &str, + remediation_id: &str, + created_at: &str, +) -> TeamTaskDto { + TeamTaskDto { + id: id.to_string(), + title: format!("[Consolidated] Legacy alert work moved to {remediation_id}"), + description: format!( + "This alert-number-scoped task was consolidated into canonical remediation {remediation_id}." + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: false, + status: "done".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: Some(created_at.to_string()), + stuck_since: None, + assignment_nonce: None, + } +} + +pub(super) fn alert_backlog_task( + signal: EngineeringSignal, + source: GithubAlertRef<'_>, + title: String, + instruction: &str, + details: serde_json::Value, + work_id_override: Option<String>, + created_at: &str, +) -> TeamTaskDto { + let GithubAlertRef { repo, number } = source; + let source_id = alert_source_id(signal, repo, number); + let work_id = work_id_override.unwrap_or_else(|| alert_work_id(signal, repo, number)); + let structured = serde_json::json!({ + "signal": signal, + "source_id": source_id, + "work_id": work_id, + "repo": repo, + "alert_number": number, + "details": details.clone(), + }); + let source_facts = [ + details + .get("manifest_path") + .and_then(serde_json::Value::as_str) + .map(|value| format!("manifest={value}")), + details + .get("path") + .and_then(serde_json::Value::as_str) + .map(|value| format!("path={value}")), + details + .get("package") + .and_then(serde_json::Value::as_str) + .map(|value| format!("pkg={value}")), + details + .get("vulnerable_version_range") + .and_then(serde_json::Value::as_str) + .map(|value| format!("vuln={value}")), + details + .get("first_patched_version") + .and_then(serde_json::Value::as_str) + .map(|value| format!("fixed={value}")), + details + .get("ghsa_id") + .and_then(serde_json::Value::as_str) + .map(|value| format!("ghsa={value}")), + ] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join("; "); + TeamTaskDto { + id: work_id, + title, + description: format!( + "AUTH SOURCE: {source_facts}. RULE: exact manifest; max2 same target; search open+merged PRs for same GHSA/pkg; fix+PR handbacks; no principal substitution.\n\n{instruction} Validate the finding against the current repository state, make the smallest safe remediation, run relevant tests and security checks, and propose or update a pull request when code changes are needed. Never claim success without current evidence and never merge.\n\nStructured source details (JSON):\n{}", + serde_json::to_string(&structured).unwrap_or_else(|_| "{}".into()) + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + } +} + +pub(super) fn code_scanning_task( + repo: &str, + alert: &GithubCodeScanningAlert, + created_at: &str, +) -> TeamTaskDto { + let location = alert + .most_recent_instance + .as_ref() + .and_then(|instance| instance.location.as_ref()); + let rule_name = alert.rule.name.as_deref().unwrap_or(&alert.rule.id); + let severity = alert + .rule + .security_severity_level + .as_deref() + .or(alert.rule.severity.as_deref()) + .unwrap_or("unknown"); + alert_backlog_task( + EngineeringSignal::CodeScanningAlert, + GithubAlertRef { + repo, + number: alert.number, + }, + format!( + "[Code scanning] {repo} alert #{}: {rule_name}", + alert.number + ), + "GitHub code scanning reported an open code-quality or security finding.", + serde_json::json!({ + "url": alert.html_url, + "rule_id": alert.rule.id, + "rule_name": rule_name, + "description": alert.rule.description, + "severity": severity, + "path": location.and_then(|value| value.path.clone()), + "start_line": location.and_then(|value| value.start_line), + "end_line": location.and_then(|value| value.end_line), + "updated_at": alert.updated_at, + }), + None, + created_at, + ) +} + +pub(super) fn dependabot_alert_task( + repo: &str, + alert: &GithubDependabotAlert, + created_at: &str, +) -> TeamTaskDto { + let advisory = alert.security_advisory.as_ref(); + let advisory_id = advisory + .map(|value| value.ghsa_id.as_str()) + .unwrap_or("GitHub advisory"); + alert_backlog_task( + EngineeringSignal::DependabotAlert, + GithubAlertRef { + repo, + number: alert.number, + }, + format!( + "[Dependabot alert] {repo} #{}: {} ({advisory_id})", + alert.number, alert.dependency.package.name + ), + "GitHub Dependabot reported an open vulnerable-dependency alert.", + serde_json::json!({ + "url": alert.html_url, + "package": alert.dependency.package.name, + "ecosystem": alert.dependency.package.ecosystem, + "manifest_path": alert.dependency.manifest_path, + "scope": alert.dependency.scope, + "ghsa_id": advisory.map(|value| value.ghsa_id.clone()), + "cve_id": advisory.and_then(|value| value.cve_id.clone()), + "summary": advisory.map(|value| value.summary.clone()), + "severity": advisory.map(|value| value.severity.clone()), + "vulnerable_version_range": alert.security_vulnerability.vulnerable_version_range, + "first_patched_version": alert.security_vulnerability.first_patched_version.as_ref().map(|value| value.identifier.clone()), + "updated_at": alert.updated_at, + }), + Some(remediation_work_id( + repo, + alert.dependency.manifest_path.as_deref(), + &alert.dependency.package.name, + )), + created_at, + ) +} + +pub(super) fn secret_scanning_task( + repo: &str, + alert: &GithubSecretScanningAlert, + created_at: &str, +) -> TeamTaskDto { + let display = alert + .secret_type_display_name + .as_deref() + .unwrap_or(&alert.secret_type); + alert_backlog_task( + EngineeringSignal::SecretScanningAlert, + GithubAlertRef { + repo, + number: alert.number, + }, + format!( + "[Secret scanning] {repo} alert #{}: {display}", + alert.number + ), + "GitHub secret scanning reported an open credential exposure. Treat the secret value as sensitive: do not print, persist, or copy it. Verify revocation or rotation, remove the exposure safely, and add prevention coverage.", + serde_json::json!({ + "url": alert.html_url, + "secret_type": alert.secret_type, + "secret_type_display_name": display, + "resolution": alert.resolution, + "created_at": alert.created_at, + "updated_at": alert.updated_at, + }), + None, + created_at, + ) +} diff --git a/bridge/bff/src/routes/engineering/queue.rs b/bridge/bff/src/routes/engineering/queue.rs new file mode 100644 index 000000000..5d92af5ee --- /dev/null +++ b/bridge/bff/src/routes/engineering/queue.rs @@ -0,0 +1,215 @@ +// kars Bridge BFF — queue helpers for engineering intake. + +use std::collections::BTreeMap; + +use chrono::Utc; + +use crate::kars::cluster::Cluster; +use crate::routes::teams::read_task_list; + +use super::remediation::match_remediation_task; +use super::{EngineeringSourceConfig, MAX_ITEMS_PER_SYNC, TeamTaskDto}; + +pub(super) fn merge_discovered_tasks( + mut existing: Vec<TeamTaskDto>, + discovered: Vec<TeamTaskDto>, +) -> (Vec<TeamTaskDto>, usize) { + for task in &mut existing { + if engineering_task_requires_review(&task.id) { + task.review_required = true; + } + } + let mut positions = existing + .iter() + .enumerate() + .map(|(index, task)| (task.id.clone(), index)) + .collect::<BTreeMap<_, _>>(); + let mut added = 0; + for mut task in discovered { + let (matching_id, _) = match_remediation_task(&mut task, |id| { + positions + .get(id) + .map(|index| existing[*index].description.as_str()) + }); + if let Some(index) = positions.get(&matching_id).copied() { + let current = &mut existing[index]; + let renewable_alert = task.id.starts_with("dependabot-alert-") + || task.id.starts_with("code-scanning-alert-") + || task.id.starts_with("secret-scanning-alert-"); + let renewable_human_decision = task.id.starts_with("github-pr-merge-") + || task.id.starts_with("github-pr-feedback-"); + let renewable_pr_control = + task.id.starts_with("github-pr-fix-") || task.id.starts_with("github-pr-dedupe-"); + if renewable_alert && current.status == "pending" && task.status == "done" { + current.title = task.title; + current.description = task.description; + current.status = "done".into(); + current.run = None; + current.done_at = task.done_at; + current.stuck_since = None; + } else if current.status == "done" + && (renewable_human_decision + || ((renewable_alert || renewable_pr_control) + && current.description != task.description)) + { + current.title = task.title; + current.description = task.description; + current.status = "pending".into(); + current.run = None; + current.done_at = None; + current.created_at = task.created_at; + added += 1; + } else if (renewable_alert || renewable_pr_control) + && matches!(current.status.as_str(), "pending" | "active") + && current.description != task.description + { + current.title = task.title; + current.description = task.description; + } + current.review_required |= task.review_required; + continue; + } + positions.insert(task.id.clone(), existing.len()); + existing.push(task); + added += 1; + } + (existing, added) +} + +fn engineering_task_requires_review(task_id: &str) -> bool { + task_id.starts_with("dependabot-pr-") + || task_id.starts_with("dependency-remediation-") + || task_id.starts_with("dependabot-alert-") + || task_id.starts_with("code-scanning-alert-") + || task_id.starts_with("secret-scanning-alert-") + || task_id.starts_with("github-pr-fix-") + || task_id.starts_with("github-pr-dedupe-") + || task_id.starts_with("github-pr-feedback-") +} + +pub(super) fn append_bounded_tasks( + target: &mut Vec<TeamTaskDto>, + known_tasks: &mut BTreeMap<String, (String, String)>, + incoming: Vec<TeamTaskDto>, + queued_slots_used: &mut usize, + attempt_cap: usize, +) -> bool { + let mut queue_candidates = Vec::new(); + for mut task in incoming { + let (matching_id, _) = match_remediation_task(&mut task, |id| { + known_tasks + .get(id) + .map(|(_, description)| description.as_str()) + }); + let renewable_alert = task.id.starts_with("dependabot-alert-") + || task.id.starts_with("code-scanning-alert-") + || task.id.starts_with("secret-scanning-alert-"); + match known_tasks.get(&matching_id) { + None => { + known_tasks.insert( + task.id.clone(), + (task.status.clone(), task.description.clone()), + ); + queue_candidates.push(task); + } + Some((status, description)) => { + let reopen = + renewable_alert && status == "done" && description != &task.description; + if reopen { + known_tasks.insert( + task.id.clone(), + ("pending".into(), task.description.clone()), + ); + queue_candidates.push(task); + } else if renewable_alert + && matches!(status.as_str(), "pending" | "active") + && description != &task.description + { + known_tasks.insert(task.id.clone(), (status.clone(), task.description.clone())); + target.push(task); + } + } + } + } + let remaining = MAX_ITEMS_PER_SYNC + .saturating_sub(*queued_slots_used) + .min(attempt_cap); + let truncated = queue_candidates.len() > remaining; + queue_candidates.truncate(remaining); + *queued_slots_used += queue_candidates.len(); + target.extend(queue_candidates); + truncated +} + +pub(super) async fn merge_into_backlog( + cluster: &Cluster, + team: &str, + discovered: Vec<TeamTaskDto>, +) -> Result<usize, String> { + let queued = std::sync::atomic::AtomicUsize::new(0); + let name = format!("kars-team-tasks-{team}"); + cluster + .update_configmap_data(&name, &[("kars.azure.com/team-tasks", team)], |data| { + let existing = data + .get("tasks.json") + .map(|raw| read_task_list(raw)) + .unwrap_or_default(); + let (merged, added) = merge_discovered_tasks(existing, discovered.clone()); + queued.store(added, std::sync::atomic::Ordering::Relaxed); + data.insert( + "tasks.json".to_string(), + serde_json::to_string(&merged).unwrap_or_else(|_| "[]".into()), + ); + }) + .await + .map_err(|e| format!("updating the team backlog failed: {e}"))?; + Ok(queued.load(std::sync::atomic::Ordering::Relaxed)) +} + +pub(super) async fn request_team_run( + cluster: &Cluster, + namespace: &str, + team: &str, +) -> Result<bool, String> { + let team_object = cluster + .teams(namespace) + .get_opt(team) + .await + .map_err(|error| format!("checking team run state failed: {error}"))? + .ok_or_else(|| "the standing team no longer exists".to_string())?; + if team_object.spec.paused { + return Ok(false); + } + cluster + .teams(namespace) + .patch( + team, + &kube::api::PatchParams::default(), + &kube::api::Patch::Merge(serde_json::json!({ + "metadata": { + "annotations": { + "kars.azure.com/backlog-run-now": Utc::now().to_rfc3339() + } + } + })), + ) + .await + .map(|_| true) + .map_err(|error| format!("queued work but could not request a team run: {error}")) +} + +pub(super) async fn ensure_auto_run_for_backlog( + cluster: &Cluster, + config: &EngineeringSourceConfig, +) -> Result<bool, String> { + if !config.enabled || !config.auto_run { + return Ok(false); + } + let has_pending = read_task_list(&cluster.read_team_tasks(&config.team_name).await) + .iter() + .any(|task| task.status == "pending"); + if !has_pending { + return Ok(false); + } + request_team_run(cluster, &config.team_namespace, &config.team_name).await +} diff --git a/bridge/bff/src/routes/engineering/review.rs b/bridge/bff/src/routes/engineering/review.rs new file mode 100644 index 000000000..a1c4a37bb --- /dev/null +++ b/bridge/bff/src/routes/engineering/review.rs @@ -0,0 +1,401 @@ +// kars Bridge BFF — review helpers for engineering intake. + +use std::collections::{BTreeSet, HashSet}; + +use crate::kars::cluster::Cluster; +use crate::providers::signing::sha256; +use crate::routes::teams::read_task_list; + +use super::github::github_get_json; +use super::intake::source_id; +use super::{ + EngineeringReviewItem, EngineeringReviewState, EngineeringSourceConfig, GithubPull, + MAX_REVIEW_PRS_PER_SYNC, ReviewExecution, TeamTaskDto, +}; + +pub(super) fn classify_review_readiness( + pull: &serde_json::Value, + check_runs: &serde_json::Value, + status: &serde_json::Value, +) -> (EngineeringReviewState, String, usize, usize) { + let runs = check_runs + .get("check_runs") + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let statuses = status + .get("statuses") + .and_then(serde_json::Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let total_count = check_runs + .get("total_count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(runs.len() as u64) as usize; + let total = total_count + statuses.len(); + let passed_runs = runs + .iter() + .filter(|run| { + run.get("status").and_then(serde_json::Value::as_str) == Some("completed") + && matches!( + run.get("conclusion").and_then(serde_json::Value::as_str), + Some("success" | "neutral" | "skipped") + ) + }) + .count(); + let passed_statuses = statuses + .iter() + .filter(|item| item.get("state").and_then(serde_json::Value::as_str) == Some("success")) + .count(); + let passed = passed_runs + passed_statuses; + + if pull.get("draft").and_then(serde_json::Value::as_bool) == Some(true) { + return ( + EngineeringReviewState::Blocked, + "PR is still a draft.".into(), + total, + passed, + ); + } + if pull.get("mergeable").and_then(serde_json::Value::as_bool) == Some(false) { + return ( + EngineeringReviewState::Blocked, + "GitHub reports merge conflicts.".into(), + total, + passed, + ); + } + let mergeable_state = pull + .get("mergeable_state") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + if matches!(mergeable_state, "dirty" | "blocked" | "behind") { + return ( + EngineeringReviewState::Blocked, + format!("Branch state is '{mergeable_state}', not clean and up to date."), + total, + passed, + ); + } + let combined_status = status + .get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("pending"); + if runs.iter().any(|run| { + run.get("status").and_then(serde_json::Value::as_str) == Some("completed") + && !matches!( + run.get("conclusion").and_then(serde_json::Value::as_str), + Some("success" | "neutral" | "skipped") + ) + }) || matches!(combined_status, "failure" | "error") + { + return ( + EngineeringReviewState::CiFailed, + format!("{passed}/{total} GitHub checks passed; at least one check is red."), + total, + passed, + ); + } + if total == 0 { + return ( + EngineeringReviewState::WaitingForCi, + "No GitHub CI/status evidence exists for the head commit yet.".into(), + total, + passed, + ); + } + if total_count > runs.len() + || passed < total + || runs + .iter() + .any(|run| run.get("status").and_then(serde_json::Value::as_str) != Some("completed")) + || (!statuses.is_empty() && combined_status != "success") + || pull.get("mergeable").and_then(serde_json::Value::as_bool) != Some(true) + || mergeable_state != "clean" + { + return ( + EngineeringReviewState::WaitingForCi, + format!("{passed}/{total} GitHub checks passed; waiting for a clean mergeable state."), + total, + passed, + ); + } + ( + EngineeringReviewState::ReadyForReview, + format!("GitHub reports a clean, up-to-date PR with {passed}/{total} checks green."), + total, + passed, + ) +} + +async fn inspect_review_item( + client: &reqwest::Client, + token: &str, + repo: &str, + number: u64, + execution: ReviewExecution<'_>, + observed_at: &str, +) -> Result<Option<EngineeringReviewItem>, String> { + let ReviewExecution { + run, + work_id, + task_status, + run_state, + selected_roles, + delivered_roles, + artifact_count, + } = execution; + let pull = github_get_json( + client, + token, + &format!("https://api.github.com/repos/{repo}/pulls/{number}"), + ) + .await?; + if pull.get("state").and_then(serde_json::Value::as_str) != Some("open") + || pull.get("merged").and_then(serde_json::Value::as_bool) == Some(true) + { + return Ok(None); + } + let head_sha = pull + .get("head") + .and_then(|head| head.get("sha")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("GitHub PR {repo}#{number} has no head SHA"))? + .to_string(); + let check_runs = github_get_json( + client, + token, + &format!("https://api.github.com/repos/{repo}/commits/{head_sha}/check-runs?per_page=100"), + ) + .await?; + let status = github_get_json( + client, + token, + &format!("https://api.github.com/repos/{repo}/commits/{head_sha}/status?per_page=100"), + ) + .await?; + let (state, detail, checks_total, checks_passed) = + classify_review_readiness(&pull, &check_runs, &status); + Ok(Some(EngineeringReviewItem { + repo: repo.to_string(), + pr_number: number, + pr_url: pull + .get("html_url") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + title: pull + .get("title") + .and_then(serde_json::Value::as_str) + .unwrap_or("Pull request") + .to_string(), + run: run.to_string(), + source_id: source_id(repo, number), + work_id: work_id.to_string(), + task_status: task_status.to_string(), + run_state, + selected_roles, + delivered_roles, + artifact_count, + head_sha, + state, + detail, + checks_total, + checks_passed, + observed_at: observed_at.to_string(), + })) +} + +async fn run_execution_summary( + cluster: &Cluster, + namespace: &str, + run: &str, +) -> (Option<String>, Vec<String>, Vec<String>) { + let task = cluster.tasks(namespace).get_opt(run).await.ok().flatten(); + let mut selected = BTreeSet::new(); + let mut delivered = BTreeSet::new(); + let run_state = task + .as_ref() + .and_then(|task| task.status.as_ref()) + .and_then(|status| status.assignment.as_ref()) + .map(|assignment| assignment.state.clone()); + if let Some(events) = task + .as_ref() + .and_then(|task| task.status.as_ref()) + .map(|status| status.assignment_events.as_slice()) + { + for event in events { + let Some(role) = event.child_role.as_ref() else { + continue; + }; + selected.insert(role.clone()); + if event.stage.as_deref() == Some("child_handback") + && event.outcome.as_deref() == Some("success") + && event.state == "Completed" + { + delivered.insert(role.clone()); + } + } + } + ( + run_state, + selected.into_iter().collect(), + delivered.into_iter().collect(), + ) +} + +pub(super) fn review_followup_task( + item: &EngineeringReviewItem, + created_at: &str, +) -> Option<TeamTaskDto> { + if !matches!( + item.state, + EngineeringReviewState::CiFailed | EngineeringReviewState::Blocked + ) { + return None; + } + let digest = sha256(format!("github-review:{}:{}", item.repo, item.pr_number).as_bytes()); + Some(TeamTaskDto { + id: format!("github-pr-fix-{}", hex::encode(&digest[..10])), + title: format!( + "[PR gate] Resolve or retire {} PR #{} before review", + item.repo, item.pr_number + ), + description: format!( + "GitHub does not consider this PR ready for human review. Before modifying the branch, determine whether the PR is still needed or has been superseded by a merged PR/default-branch change. If it is superseded, do not repair or rebase it: close it when authorized, or report the exact closure recommendation. Only when its objective is still required should you resolve the observed branch/CI state, push the smallest correction, and wait for exact-SHA GitHub checks. Never claim green from local inference and never merge.\n\nPR: {}\nHead SHA: {}\nObserved state: {:?}\nDetail: {}", + item.pr_url, item.head_sha, item.state, item.detail + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }) +} + +pub(super) fn dedupe_followup_task( + repo: &str, + remediation_id: &str, + pulls: &[&GithubPull], + created_at: &str, +) -> Option<TeamTaskDto> { + if pulls.len() < 2 { + return None; + } + let mut ordered = pulls.to_vec(); + ordered.sort_by_key(|pull| pull.number); + let canonical = ordered[0]; + let duplicates = ordered[1..] + .iter() + .map(|pull| format!("#{} {}", pull.number, pull.html_url)) + .collect::<Vec<_>>() + .join(", "); + let digest = sha256(format!("{repo}:{remediation_id}").as_bytes()); + Some(TeamTaskDto { + id: format!("github-pr-dedupe-{}", hex::encode(&digest[..10])), + title: format!( + "[PR dedupe] Review {repo} PR #{} and {} possible duplicate(s)", + canonical.number, + ordered.len() - 1 + ), + description: format!( + "Multiple open pull requests mention this remediation's package or advisory. Their titles are not coverage evidence. Compare actual changed files with the exact case-sensitive manifest, package, advisory and head-SHA checks before treating any work as equivalent. Preserve distinct manifest fixes. Only after equivalence is verified, preserve the oldest canonical PR unless a newer PR has strictly better, already-green evidence and close superseded duplicates; never merge. Report exact URLs/head SHAs/check states.\n\nCanonical candidate: #{} {}\nDuplicate candidates: {}", + canonical.number, canonical.html_url, duplicates + ), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: true, + status: "pending".into(), + run: None, + created_at: Some(created_at.to_string()), + done_at: None, + stuck_since: None, + assignment_nonce: None, + }) +} + +pub(super) async fn collect_review_items( + cluster: &Cluster, + client: &reqwest::Client, + token: &str, + config: &EngineeringSourceConfig, + observed_at: &str, +) -> (Vec<EngineeringReviewItem>, Vec<TeamTaskDto>, Vec<String>) { + let backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); + let configured_repos = config + .repos + .iter() + .map(|repo| repo.to_ascii_lowercase()) + .collect::<HashSet<_>>(); + let mut seen = HashSet::new(); + let mut items = Vec::new(); + let mut followups = Vec::new(); + let mut errors = Vec::new(); + for task in backlog.iter().rev() { + if seen.len() >= MAX_REVIEW_PRS_PER_SYNC { + errors.push(format!( + "review readiness reached the {MAX_REVIEW_PRS_PER_SYNC}-PR sync cap" + )); + break; + } + let Some(run) = task.run.as_deref() else { + continue; + }; + let Some(output) = cluster.read_mission_output(run).await else { + continue; + }; + let (run_state, selected_roles, delivered_roles) = + run_execution_summary(cluster, &config.team_namespace, run).await; + let artifact_count = output + .get("artifactCount") + .and_then(|value| value.parse::<usize>().ok()); + let text = output.get("output").map(String::as_str).unwrap_or_default(); + if !crate::routes::tasks::is_real_deliverable( + output.get("status").map(String::as_str), + text, + ) { + continue; + } + for pull in crate::routes::tasks::extract_pull_requests(text) { + let key = format!("{}#{}", pull.repo.to_ascii_lowercase(), pull.number); + if !configured_repos.contains(&pull.repo.to_ascii_lowercase()) || !seen.insert(key) { + continue; + } + match inspect_review_item( + client, + token, + &pull.repo, + pull.number as u64, + ReviewExecution { + run, + work_id: &task.id, + task_status: &task.status, + run_state: run_state.clone(), + selected_roles: selected_roles.clone(), + delivered_roles: delivered_roles.clone(), + artifact_count, + }, + observed_at, + ) + .await + { + Ok(Some(item)) => { + if let Some(task) = review_followup_task(&item, observed_at) { + followups.push(task); + } + items.push(item); + } + Ok(None) => {} + Err(error) => errors.push(format!( + "review readiness for {}#{} failed: {error}", + pull.repo, pull.number + )), + } + } + } + (items, followups, errors) +} diff --git a/bridge/bff/src/routes/engineering/synchronization.rs b/bridge/bff/src/routes/engineering/synchronization.rs new file mode 100644 index 000000000..820726568 --- /dev/null +++ b/bridge/bff/src/routes/engineering/synchronization.rs @@ -0,0 +1,612 @@ +// kars Bridge BFF — synchronization helpers for engineering intake. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +use chrono::Utc; +use kube::ResourceExt; + +use crate::error::{AppError, AppResult}; +use crate::kars::cluster::Cluster; +use crate::routes::github::{ + authorize_repo_set, connection_config_map_name, installation_token, mint_app_jwt, +}; +use crate::routes::teams::read_task_list; +use crate::state::AppState; + +use super::config::{is_due, next_poll_at, parse_source, sync_claim_active, verify_source_owner}; +use super::github::{ + list_code_scanning_alerts, list_dependabot_alerts, list_open_pulls, + list_secret_scanning_alerts, repository_features, signal_result, truncate_error, + unavailable_security_product, +}; +use super::intake::{ + backlog_task, code_scanning_task, dependabot_alert_task, is_dependabot_pr, + legacy_alert_retirement, open_pull_may_address_dependabot_alert, secret_scanning_task, +}; +use super::queue::{append_bounded_tasks, ensure_auto_run_for_backlog, merge_into_backlog}; +use super::remediation::{ + description_matches_remediation, match_remediation_task, note_candidate_pulls, +}; +use super::review::{collect_review_items, dedupe_followup_task}; +use super::{ + CONFIG_KEY, CURSOR_KEY, EngineeringCursor, EngineeringReviewState, EngineeringSignal, + EngineeringSignalSyncState, EngineeringSourceConfig, EngineeringSourceStatus, + EngineeringSyncState, MAX_ALERTS_PER_SIGNAL, MAX_GITHUB_PAGES, MAX_ITEMS_PER_SYNC, + MAX_OPEN_PRS_PER_REPO, MAX_SOURCES_PER_SWEEP, STATUS_KEY, SyncOutcome, source_config_map_name, +}; + +async fn perform_sync( + cluster: &Cluster, + config: &EngineeringSourceConfig, + mut cursor: EngineeringCursor, + claim_id: &str, +) -> Result<SyncOutcome, String> { + let expected_connection = connection_config_map_name(&config.owner_sub); + if config.connection_config_map_ref != expected_connection { + return Err("source connection reference does not match its owner".into()); + } + + let team = cluster + .teams(&config.team_namespace) + .get_opt(&config.team_name) + .await + .map_err(|e| format!("reading the standing team failed: {e}"))? + .ok_or_else(|| "the standing team no longer exists".to_string())?; + if team + .annotations() + .get("kars.azure.com/owner-sub") + .is_none_or(|owner| owner != &config.owner_sub) + { + return Err("the engineering source owner no longer owns this team".into()); + } + + let (installation_id, _account, granted_repos) = cluster + .read_github_connection_result(&config.team_namespace, &config.connection_config_map_ref) + .await + .map_err(|e| format!("reading the GitHub connection failed: {e}"))? + .ok_or_else(|| "the owner's GitHub connection is no longer available".to_string())?; + authorize_repo_set(&config.repos, &granted_repos) + .map_err(|e| format!("repository authorization changed: {e}"))?; + + let (app_id, private_key) = cluster + .github_app_creds() + .await + .map_err(|error| format!("GitHub credential authority unavailable: {error}"))? + .ok_or_else(|| "the shared GitHub App is not configured".to_string())?; + let app_jwt = mint_app_jwt(&app_id, &private_key).map_err(|e| e.to_string())?; + let token = installation_token(&app_jwt, &installation_id) + .await + .map_err(|e| e.to_string())?; + + let now = Utc::now().to_rfc3339(); + let mut tasks = Vec::new(); + let mut errors = Vec::new(); + let mut completed_attempts = 0; + let mut signal_results = Vec::new(); + let client = reqwest::Client::new(); + let existing_backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); + let mut known_tasks = existing_backlog + .iter() + .map(|task| { + ( + task.id.clone(), + (task.status.clone(), task.description.clone()), + ) + }) + .collect::<BTreeMap<_, _>>(); + let attempt_count = config + .repos + .len() + .saturating_mul(config.signals.len()) + .max(1); + let attempt_cap = (MAX_ITEMS_PER_SYNC / attempt_count).max(1); + let mut queued_slots_used = 0; + for repo in &config.repos { + let features = repository_features(&client, &token, repo).await; + let open_pull_coverage = if config.signals.contains(&EngineeringSignal::DependabotAlert) { + list_open_pulls(&client, &token, repo) + .await + .map(|pulls| pulls.items) + .unwrap_or_default() + } else { + Vec::new() + }; + let mut dedupe_seen = BTreeSet::new(); + for signal in config.signals.iter().copied() { + let (result, api_truncated, queue_truncated) = match signal { + EngineeringSignal::DependabotPr => { + match list_open_pulls(&client, &token, repo).await { + Ok(pulls) => { + completed_attempts += 1; + if let Some(updated_at) = + pulls.items.iter().map(|pr| pr.updated_at.as_str()).max() + { + cursor + .repository_updated_at + .insert(repo.clone(), updated_at.to_string()); + } + let signal_tasks = pulls + .items + .iter() + .filter(|pr| is_dependabot_pr(pr)) + .map(|pr| backlog_task(repo, pr, &now)) + .collect::<Vec<_>>(); + let discovered = signal_tasks.len(); + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), pulls.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + EngineeringSignal::DependabotAlert => { + match list_dependabot_alerts(&client, &token, repo).await { + Ok(alerts) => { + completed_attempts += 1; + let discovered = alerts.items.len(); + let mut signal_tasks = Vec::new(); + for alert in &alerts.items { + let mut task = dependabot_alert_task(repo, alert, &now); + let (matching_id, identity_warning) = + match_remediation_task(&mut task, |id| { + known_tasks + .get(id) + .map(|(_, description)| description.as_str()) + }); + if let Some(warning) = identity_warning { + errors.push(warning); + } + let legacy_ids = known_tasks + .iter() + .filter(|(id, (status, description))| { + id.starts_with("dependabot-alert-") + && status == "pending" + && description_matches_remediation( + description, + repo, + alert.dependency.manifest_path.as_deref(), + &alert.dependency.package.name, + ) + }) + .map(|(id, _)| id.clone()) + .collect::<Vec<_>>(); + for legacy_id in legacy_ids { + let retirement = + legacy_alert_retirement(&legacy_id, &matching_id, &now); + known_tasks.insert( + legacy_id, + ("done".into(), retirement.description.clone()), + ); + tasks.push(retirement); + } + let covering_pulls = open_pull_coverage + .iter() + .filter(|pull| { + open_pull_may_address_dependabot_alert(pull, alert) + }) + .collect::<Vec<_>>(); + if dedupe_seen.insert(matching_id.clone()) + && let Some(dedupe) = dedupe_followup_task( + repo, + &matching_id, + &covering_pulls, + &now, + ) + { + tasks.push(dedupe); + } + note_candidate_pulls(&mut task, &covering_pulls); + signal_tasks.push(task); + } + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), alerts.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + EngineeringSignal::CodeScanningAlert => { + match list_code_scanning_alerts(&client, &token, repo).await { + Ok(alerts) => { + completed_attempts += 1; + let signal_tasks = alerts + .items + .iter() + .map(|alert| code_scanning_task(repo, alert, &now)) + .collect::<Vec<_>>(); + let discovered = signal_tasks.len(); + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), alerts.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + EngineeringSignal::SecretScanningAlert => { + match list_secret_scanning_alerts(&client, &token, repo).await { + Ok(alerts) => { + completed_attempts += 1; + let signal_tasks = alerts + .items + .iter() + .map(|alert| secret_scanning_task(repo, alert, &now)) + .collect::<Vec<_>>(); + let discovered = signal_tasks.len(); + let bounded = append_bounded_tasks( + &mut tasks, + &mut known_tasks, + signal_tasks, + &mut queued_slots_used, + attempt_cap, + ); + (Ok(discovered), alerts.truncated, bounded) + } + Err(error) => (Err(error), false, false), + } + } + }; + let mut truncation_reasons = Vec::new(); + if api_truncated { + let item_limit = if signal == EngineeringSignal::DependabotPr { + MAX_OPEN_PRS_PER_REPO + } else { + MAX_ALERTS_PER_SIGNAL + }; + truncation_reasons.push(format!( + "GitHub returned more than the per-signal {item_limit}-item or {MAX_GITHUB_PAGES}-page scan cap." + )); + } + if queue_truncated { + truncation_reasons.push(format!( + "The sync found more new work than this source's fair {attempt_cap}-item allocation; remaining items will be retried on later polls." + )); + } + let truncation_detail = + (!truncation_reasons.is_empty()).then(|| truncation_reasons.join(" ")); + let (result, expected_unavailable) = match result { + Err(error) => match unavailable_security_product(features.as_ref(), signal, &error) + { + Some(unavailable) => (Err(unavailable), true), + None => (Err(error), false), + }, + Ok(discovered) => (Ok(discovered), false), + }; + if expected_unavailable { + completed_attempts += 1; + } + let signal_status = signal_result(repo, signal, result, truncation_detail); + if signal_status.state != EngineeringSignalSyncState::Ok && !expected_unavailable { + errors.push(format!( + "{} {:?}: {}", + repo, signal_status.signal, signal_status.detail + )); + } + signal_results.push(signal_status); + } + } + + let (review_items, review_followups, review_errors) = + collect_review_items(cluster, &client, &token, config, &now).await; + tasks.extend(review_followups); + errors.extend(review_errors); + let discovered = tasks.len(); + revalidate_claimed_source(cluster, config, claim_id).await?; + let queued = merge_into_backlog(cluster, &config.team_name, tasks).await?; + if let Err(error) = ensure_auto_run_for_backlog(cluster, config).await { + errors.push(error); + } + Ok(SyncOutcome { + cursor, + discovered, + queued, + completed_attempts, + errors, + review_items, + signal_results, + }) +} + +async fn patch_runtime_state( + cluster: &Cluster, + name: &str, + cursor: &EngineeringCursor, + status: &EngineeringSourceStatus, +) -> AppResult<()> { + let data = BTreeMap::from([ + ( + CURSOR_KEY.to_string(), + serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?, + ), + ( + STATUS_KEY.to_string(), + serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?, + ), + ]); + cluster + .patch_engineering_source_data(name, &data) + .await + .map_err(|e| AppError::Upstream(e.to_string())) +} + +async fn finalize_source_claim( + cluster: &Cluster, + name: &str, + claimed_status: &str, + cursor: &EngineeringCursor, + status: &EngineeringSourceStatus, +) -> AppResult<()> { + let cursor = serde_json::to_string(cursor).map_err(|e| AppError::Internal(e.into()))?; + let status = serde_json::to_string(status).map_err(|e| AppError::Internal(e.into()))?; + let completed = cluster + .complete_engineering_source_claim(name, claimed_status, &cursor, &status) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if !completed { + return Err(AppError::Conflict( + "engineering sync lost its claim before completion".into(), + )); + } + Ok(()) +} + +async fn revalidate_claimed_source( + cluster: &Cluster, + config: &EngineeringSourceConfig, + claim_id: &str, +) -> Result<(), String> { + let name = source_config_map_name(&config.team_namespace, &config.team_name); + let source = cluster + .read_engineering_source(&name) + .await + .map_err(|error| format!("re-reading engineering source failed: {error}"))? + .ok_or_else(|| "engineering source was deleted during sync".to_string())?; + let (current_config, _, current_status) = parse_source(&source)?; + if ¤t_config != config + || !sync_claim_active(¤t_status, Utc::now()) + || current_status.sync_claim_id.as_deref() != Some(claim_id) + { + return Err("engineering source changed or lost its sync claim before queueing".into()); + } + Ok(()) +} + +pub(super) async fn synchronize_source( + cluster: &Cluster, + config: &EngineeringSourceConfig, + _cursor: EngineeringCursor, + _status: EngineeringSourceStatus, +) -> AppResult<EngineeringSourceStatus> { + let name = source_config_map_name(&config.team_namespace, &config.team_name); + let current = cluster + .read_engineering_source(&name) + .await + .map_err(|error| AppError::Upstream(error.to_string()))? + .ok_or_else(|| AppError::Conflict("engineering source no longer exists".into()))?; + let current_data = current + .data + .as_ref() + .ok_or_else(|| AppError::Conflict("engineering source has no data".into()))?; + let expected_config = current_data + .get(CONFIG_KEY) + .cloned() + .ok_or_else(|| AppError::Conflict("engineering source config is missing".into()))?; + let expected_status = current_data + .get(STATUS_KEY) + .cloned() + .unwrap_or_else(|| "{}".into()); + let current_cursor = current_data + .get(CURSOR_KEY) + .map(|value| serde_json::from_str::<EngineeringCursor>(value)) + .transpose() + .map_err(|error| { + AppError::Conflict(format!("engineering source cursor is invalid: {error}")) + })? + .unwrap_or_default(); + let mut status = + serde_json::from_str::<EngineeringSourceStatus>(&expected_status).map_err(|error| { + AppError::Conflict(format!("engineering source status is invalid: {error}")) + })?; + let stored_config = + serde_json::from_str::<EngineeringSourceConfig>(&expected_config).map_err(|error| { + AppError::Conflict(format!("engineering source config is invalid: {error}")) + })?; + if &stored_config != config { + return Err(AppError::Conflict( + "engineering source was reconfigured before sync".into(), + )); + } + if sync_claim_active(&status, Utc::now()) { + return Err(AppError::Conflict( + "another engineering sync still owns the active claim".into(), + )); + } + let claim_id = format!( + "{}-{}", + Utc::now().timestamp_nanos_opt().unwrap_or_default(), + std::process::id() + ); + status.state = EngineeringSyncState::Syncing; + status.sync_claim_id = Some(claim_id.clone()); + status.sync_claim_expires_at = Some((Utc::now() + chrono::Duration::minutes(10)).to_rfc3339()); + status.last_error = None; + status.next_poll_at = Some(next_poll_at(config, Utc::now())); + let claimed_status = + serde_json::to_string(&status).map_err(|e| AppError::Internal(e.into()))?; + let claimed = cluster + .claim_engineering_source(&name, &expected_config, &expected_status, &claimed_status) + .await + .map_err(|e| AppError::Upstream(e.to_string()))?; + if !claimed { + return Err(AppError::Conflict( + "this source was reconfigured or another sync already claimed it".into(), + )); + } + + let completed_at = Utc::now(); + match perform_sync(cluster, config, current_cursor.clone(), &claim_id).await { + Ok(outcome) => { + status.last_sync_at = Some(completed_at.to_rfc3339()); + status.items_discovered = outcome.discovered; + status.items_queued = outcome.queued; + status.total_items_queued = status + .total_items_queued + .saturating_add(outcome.queued as u64); + status.next_poll_at = Some(next_poll_at(config, completed_at)); + status.review_items = outcome.review_items; + status.signal_results = outcome.signal_results; + status.ready_for_review = status + .review_items + .iter() + .filter(|item| item.state == EngineeringReviewState::ReadyForReview) + .count(); + status.waiting_for_ci = status + .review_items + .iter() + .filter(|item| item.state == EngineeringReviewState::WaitingForCi) + .count(); + status.ci_failed = status + .review_items + .iter() + .filter(|item| { + matches!( + item.state, + EngineeringReviewState::CiFailed | EngineeringReviewState::Blocked + ) + }) + .count(); + status.last_error = + (!outcome.errors.is_empty()).then(|| truncate_error(outcome.errors.join("; "))); + status.state = if outcome.errors.is_empty() { + status.last_success_at = Some(completed_at.to_rfc3339()); + EngineeringSyncState::Ok + } else if outcome.completed_attempts > 0 { + EngineeringSyncState::Partial + } else { + EngineeringSyncState::Error + }; + status.sync_claim_id = None; + status.sync_claim_expires_at = None; + finalize_source_claim(cluster, &name, &claimed_status, &outcome.cursor, &status) + .await?; + } + Err(error) => { + status.state = EngineeringSyncState::Error; + status.last_sync_at = Some(completed_at.to_rfc3339()); + status.last_error = Some(truncate_error(error)); + status.items_discovered = 0; + status.items_queued = 0; + status.signal_results = Vec::new(); + status.next_poll_at = Some(next_poll_at(config, completed_at)); + status.sync_claim_id = None; + status.sync_claim_expires_at = None; + finalize_source_claim(cluster, &name, &claimed_status, ¤t_cursor, &status) + .await?; + } + } + Ok(status) +} + +/// Start the bounded best-effort source poller. Durable `next_poll_at` values +/// and a stable initial jitter spread GitHub traffic across teams. +pub fn spawn_poller(state: AppState, sweep_interval: Duration) { + if state.cluster().is_none() { + return; + } + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5)).await; + let mut interval = tokio::time::interval(sweep_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let Some(cluster) = state.cluster() else { + continue; + }; + let sources = match cluster + .list_engineering_sources(MAX_SOURCES_PER_SWEEP) + .await + { + Ok(sources) => sources, + Err(error) => { + tracing::error!(error = %error, "engineering intake source listing failed"); + continue; + } + }; + for source in sources { + let source_name = source.name_any(); + let (config, cursor, status) = match parse_source(&source) { + Ok(parsed) => parsed, + Err(error) => { + tracing::error!(source = %source_name, error = %error, "invalid engineering intake source"); + let failed = EngineeringSourceStatus { + state: EngineeringSyncState::Error, + last_sync_at: Some(Utc::now().to_rfc3339()), + last_error: Some(truncate_error(error)), + ..EngineeringSourceStatus::default() + }; + if let Err(patch_error) = patch_runtime_state( + cluster, + &source_name, + &EngineeringCursor::default(), + &failed, + ) + .await + { + tracing::error!(source = %source_name, error = %patch_error, "failed to record engineering intake source error"); + } + continue; + } + }; + if !verify_source_owner(&source, &config, &config.owner_sub) { + let error = "engineering source owner annotations do not match its config"; + tracing::error!(source = %source_name, error, "invalid engineering intake source"); + let failed = EngineeringSourceStatus { + state: EngineeringSyncState::Error, + last_sync_at: Some(Utc::now().to_rfc3339()), + last_error: Some(error.into()), + ..status + }; + if let Err(patch_error) = + patch_runtime_state(cluster, &source_name, &cursor, &failed).await + { + tracing::error!(source = %source_name, error = %patch_error, "failed to record engineering intake ownership error"); + } + continue; + } + if let Err(error) = ensure_auto_run_for_backlog(cluster, &config).await { + tracing::warn!(source = %source_name, team = %config.team_name, %error, "engineering intake could not rearm queued work"); + } + if !config.enabled || !is_due(&status, Utc::now()) { + continue; + } + match synchronize_source(cluster, &config, cursor, status).await { + Ok(updated) => { + if let Some(error) = updated.last_error.as_deref() { + tracing::warn!(source = %source_name, team = %config.team_name, error, "engineering intake sync completed with errors"); + } else { + tracing::info!(source = %source_name, team = %config.team_name, discovered = updated.items_discovered, queued = updated.items_queued, "engineering intake sync complete"); + } + } + Err(error) => { + tracing::error!(source = %source_name, team = %config.team_name, error = %error, "engineering intake sync failed") + } + } + } + } + }); +} diff --git a/bridge/bff/src/routes/engineering/tests.rs b/bridge/bff/src/routes/engineering/tests.rs new file mode 100644 index 000000000..b16e46b4d --- /dev/null +++ b/bridge/bff/src/routes/engineering/tests.rs @@ -0,0 +1,599 @@ +// kars Bridge BFF — engineering intake regression tests. +use k8s_openapi::api::core::v1::ConfigMap; + +use super::*; + +pub(super) fn pull(login: &str, head: &str, number: u64) -> GithubPull { + GithubPull { + number, + html_url: format!("https://github.com/acme/api/pull/{number}"), + title: "Bump serde from 1.0.1 to 1.0.2".into(), + draft: false, + updated_at: "2026-07-20T12:00:00Z".into(), + user: Some(GithubUser { + login: login.into(), + }), + base: GithubRef { + name: "main".into(), + }, + head: GithubHead { + name: head.into(), + sha: "abc123".into(), + }, + labels: vec![GithubLabel { + name: "dependencies".into(), + }], + } +} + +fn task(id: &str, status: &str) -> TeamTaskDto { + TeamTaskDto { + id: id.into(), + title: id.into(), + description: String::new(), + depends_on: Vec::new(), + acceptance_criteria: Vec::new(), + review_required: false, + status: status.into(), + run: (status == "active").then(|| "run-1".into()), + created_at: Some("2026-07-20T00:00:00Z".into()), + done_at: (status == "done").then(|| "2026-07-20T01:00:00Z".into()), + stuck_since: (status == "active").then(|| "2026-07-20T00:30:00Z".into()), + assignment_nonce: None, + } +} + +#[test] +fn deterministic_ids_are_repo_and_pr_scoped() { + assert_eq!(work_id("Acme/API", 42), work_id("acme/api", 42)); + assert_ne!(work_id("acme/api", 42), work_id("acme/api", 43)); + assert_ne!(work_id("acme/api", 42), work_id("acme/web", 42)); + assert_eq!(source_id("Acme/API", 42), "github:acme/api:pull:42"); + assert_eq!( + source_config_map_name("kars-system", "platform"), + source_config_map_name("kars-system", "platform") + ); + assert_ne!( + source_config_map_name("tenant-a", "platform"), + source_config_map_name("tenant-b", "platform") + ); + assert_eq!( + remediation_work_id("Acme/API", Some("package-lock.json"), "@babel/core"), + remediation_work_id("acme/api", Some("package-lock.json"), "@babel/core") + ); + assert_ne!( + remediation_work_id("acme/api", Some("package-lock.json"), "@babel/core"), + remediation_work_id("acme/api", Some("other/package-lock.json"), "@babel/core") + ); +} + +#[test] +fn alert_tasks_lead_with_authoritative_source_facts() { + let task = alert_backlog_task( + EngineeringSignal::DependabotAlert, + GithubAlertRef { + repo: "pallakatos/kars", + number: 9, + }, + "vite alert".into(), + "Dependabot reported a finding.", + serde_json::json!({ + "manifest_path": "tests/compat/package-lock.json", + "package": "vite", + "vulnerable_version_range": ">= 8.0.0, <= 8.0.15", + "first_patched_version": "8.0.16", + "ghsa_id": "GHSA-v6wh-96g9-6wx3", + }), + None, + "2026-07-23T00:00:00Z", + ); + let prefix = task.description.lines().next().unwrap_or_default(); + assert!(prefix.contains("manifest=tests/compat/package-lock.json")); + assert!(prefix.contains("fixed=8.0.16")); + assert!(prefix.contains("ghsa=GHSA-v6wh-96g9-6wx3")); + assert!(prefix.contains("max2 same target")); + assert!(prefix.contains("search open+merged PRs")); + assert!(prefix.contains("no principal substitution")); +} + +#[test] +fn legacy_remediation_matching_is_exact_and_repo_scoped() { + let description = concat!( + "AUTH SOURCE: manifest=package-lock.json; pkg=react-dom; ghsa=GHSA-a. ", + "\n\nStructured source details (JSON):\n", + "{\"signal\":\"dependabot_alert\",\"work_id\":\"dependabot-alert-legacy\",", + "\"repo\":\"acme/web\",\"details\":{\"manifest_path\":\"package-lock.json\",", + "\"package\":\"react-dom\"}}" + ); + assert!(description_matches_remediation( + description, + "acme/web", + Some("package-lock.json"), + "react-dom", + )); + assert!(!description_matches_remediation( + description, + "acme/api", + Some("package-lock.json"), + "react-dom", + )); + assert!(!description_matches_remediation( + description, + "acme/web", + Some("package-lock.json"), + "react", + )); + assert!(!description_matches_remediation( + description, + "acme/web", + None, + "react-dom", + )); +} + +#[test] +fn dependabot_detection_accepts_bot_login_or_head_prefix() { + assert!(is_dependabot_pr(&pull( + "dependabot[bot]", + "renovate/foo", + 1 + ))); + assert!(is_dependabot_pr(&pull( + "someone", + "dependabot/npm/foo-1.2.3", + 2 + ))); + assert!(!is_dependabot_pr(&pull("renovate[bot]", "renovate/foo", 3))); +} + +#[test] +fn open_pull_candidates_mention_the_same_package_or_advisory() { + let alert = GithubDependabotAlert { + number: 17, + html_url: "https://github.com/acme/api/security/dependabot/17".into(), + dependency: GithubDependabotDependency { + package: GithubPackage { + ecosystem: "npm".into(), + name: "@babel/core".into(), + }, + manifest_path: Some("package-lock.json".into()), + scope: Some("development".into()), + }, + security_advisory: Some(GithubSecurityAdvisory { + ghsa_id: "GHSA-aaaa-bbbb-cccc".into(), + cve_id: None, + summary: "test".into(), + severity: "high".into(), + }), + security_vulnerability: GithubSecurityVulnerability { + vulnerable_version_range: "< 8".into(), + first_patched_version: Some(GithubPatchedVersion { + identifier: "8.0.0".into(), + }), + }, + updated_at: None, + }; + let mut package_pr = pull("agent", "fix-babel-core", 42); + package_pr.title = "chore: bump @babel/core to 8.0.0".into(); + assert!(open_pull_may_address_dependabot_alert(&package_pr, &alert)); + let mut advisory_pr = pull("agent", "security-fix", 43); + advisory_pr.title = "fix GHSA-aaaa-bbbb-cccc".into(); + assert!(open_pull_may_address_dependabot_alert(&advisory_pr, &alert)); + let unrelated = pull("agent", "fix-vite", 44); + assert!(!open_pull_may_address_dependabot_alert(&unrelated, &alert)); +} + +#[test] +fn parses_github_pull_and_builds_structured_task() { + let raw = serde_json::json!({ + "number": 7, + "html_url": "https://github.com/acme/api/pull/7", + "title": "Bump axum", + "draft": true, + "updated_at": "2026-07-20T12:00:00Z", + "user": {"login": "dependabot[bot]"}, + "base": {"ref": "main"}, + "head": {"ref": "dependabot/cargo/axum-1", "sha": "deadbeef"}, + "labels": [{"name": "dependencies"}, {"name": "rust"}] + }); + let parsed: GithubPull = serde_json::from_value(raw).unwrap(); + let task = backlog_task("acme/api", &parsed, "2026-07-20T13:00:00Z"); + assert_eq!(task.status, "pending"); + assert!(task.description.contains("\"head_sha\":\"deadbeef\"")); + assert!(task.description.contains("\"draft\":true")); + assert!(task.description.contains("Never claim CI is green")); +} + +#[test] +fn security_alert_ids_are_stable_and_signal_scoped() { + assert_eq!( + alert_work_id(EngineeringSignal::CodeScanningAlert, "Acme/API", 42), + alert_work_id(EngineeringSignal::CodeScanningAlert, "acme/api", 42) + ); + assert_ne!( + alert_work_id(EngineeringSignal::CodeScanningAlert, "acme/api", 42), + alert_work_id(EngineeringSignal::DependabotAlert, "acme/api", 42) + ); + assert_eq!( + alert_source_id(EngineeringSignal::SecretScanningAlert, "Acme/API", 7), + "github:acme/api:secret-scanning-alert:7" + ); +} + +#[test] +fn code_scanning_alert_builds_actionable_task() { + let alert: GithubCodeScanningAlert = serde_json::from_value(serde_json::json!({ + "number": 12, + "html_url": "https://github.com/acme/api/security/code-scanning/12", + "rule": { + "id": "rust/path-injection", + "name": "Path injection", + "description": "User-controlled path reaches filesystem access", + "severity": "error", + "security_severity_level": "high" + }, + "most_recent_instance": { + "location": {"path": "src/files.rs", "start_line": 44, "end_line": 47} + }, + "updated_at": "2026-07-21T00:00:00Z" + })) + .unwrap(); + let task = code_scanning_task("acme/api", &alert, "2026-07-21T01:00:00Z"); + assert!(task.title.contains("Path injection")); + assert!(task.description.contains("\"severity\":\"high\"")); + assert!(task.description.contains("\"path\":\"src/files.rs\"")); + assert!(task.description.contains("Never claim success")); +} + +#[test] +fn secret_scanning_task_never_persists_secret_value() { + let secret = "ghp_live_secret_value"; + let alert: GithubSecretScanningAlert = serde_json::from_value(serde_json::json!({ + "number": 9, + "html_url": "https://github.com/acme/api/security/secret-scanning/9", + "secret_type": "github_personal_access_token", + "secret_type_display_name": "GitHub Personal Access Token", + "secret": secret, + "resolution": null, + "created_at": "2026-07-21T00:00:00Z", + "updated_at": "2026-07-21T00:00:00Z" + })) + .unwrap(); + let task = secret_scanning_task("acme/api", &alert, "2026-07-21T01:00:00Z"); + assert!(task.description.contains("do not print, persist, or copy")); + assert!(!task.description.contains(secret)); +} + +#[test] +fn private_repo_without_security_products_is_unavailable_not_error() { + let features = GithubRepositoryFeatures { + private: true, + security_and_analysis: None, + }; + let code_error = GithubListError { + state: EngineeringSignalSyncState::Forbidden, + detail: "HTTP 403".into(), + }; + let secret_error = GithubListError { + state: EngineeringSignalSyncState::Unavailable, + detail: "HTTP 404".into(), + }; + for (signal, error) in [ + (EngineeringSignal::CodeScanningAlert, code_error), + (EngineeringSignal::SecretScanningAlert, secret_error), + ] { + let mapped = unavailable_security_product(Some(&features), signal, &error).unwrap(); + assert_eq!(mapped.state, EngineeringSignalSyncState::Unavailable); + assert!(mapped.detail.contains("not enabled or licensed")); + } +} + +#[test] +fn github_link_parser_finds_next_page() { + assert_eq!( + next_link( + r#"<https://api.github.com/repositories/1/alerts?page=2>; rel="next", <https://api.github.com/repositories/1/alerts?page=4>; rel="last""# + ) + .as_deref(), + Some("https://api.github.com/repositories/1/alerts?page=2") + ); + assert_eq!(next_link(""), None); +} + +#[test] +fn dedupe_preserves_existing_active_and_done_tasks() { + let mut active = task("dependabot-pr-active", "active"); + active.assignment_nonce = Some("run-1-assign-7".into()); + let done = task("dependabot-pr-done", "done"); + let (merged, added) = merge_discovered_tasks( + vec![active.clone(), done.clone()], + vec![ + task("dependabot-pr-active", "pending"), + task("dependabot-pr-done", "pending"), + task("dependabot-pr-new", "pending"), + ], + ); + assert_eq!(added, 1); + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].status, "active"); + assert_eq!(merged[0].run, active.run); + assert_eq!(merged[0].assignment_nonce, active.assignment_nonce); + assert!(merged[0].review_required); + assert_eq!(merged[1].status, "done"); + assert_eq!(merged[1].done_at, done.done_at); + assert!(merged[1].review_required); +} + +#[test] +fn changed_open_security_alert_requeues_completed_work() { + let mut completed = task("code-scanning-alert-abc", "done"); + completed.description = "updated_at=old".into(); + let mut rediscovered = task("code-scanning-alert-abc", "pending"); + rediscovered.description = "updated_at=new".into(); + let (merged, queued) = merge_discovered_tasks(vec![completed], vec![rediscovered.clone()]); + assert_eq!(queued, 1); + assert_eq!(merged[0].status, "pending"); + assert_eq!(merged[0].description, rediscovered.description); + assert!(merged[0].run.is_none()); + assert!(merged[0].done_at.is_none()); + + let (unchanged, queued) = merge_discovered_tasks(merged, vec![rediscovered]); + assert_eq!(queued, 0); + assert_eq!(unchanged[0].status, "pending"); + + let mut refreshed = task("code-scanning-alert-abc", "pending"); + refreshed.description = "updated_at=newer".into(); + let (refreshed_tasks, queued) = merge_discovered_tasks(unchanged, vec![refreshed.clone()]); + assert_eq!(queued, 0); + assert_eq!(refreshed_tasks[0].description, refreshed.description); +} + +#[test] +fn changed_pending_alert_flows_through_without_using_queue_capacity() { + let mut existing = task("secret-scanning-alert-abc", "pending"); + existing.description = "updated_at=old".into(); + let mut refreshed = task("secret-scanning-alert-abc", "pending"); + refreshed.description = "updated_at=new".into(); + let mut candidates = Vec::new(); + let mut known = BTreeMap::from([( + existing.id.clone(), + (existing.status.clone(), existing.description.clone()), + )]); + let mut queued_slots = 0; + assert!(!append_bounded_tasks( + &mut candidates, + &mut known, + vec![refreshed.clone()], + &mut queued_slots, + 1, + )); + assert_eq!(queued_slots, 0); + assert_eq!(candidates.len(), 1); + let (merged, queued) = merge_discovered_tasks(vec![existing], candidates); + assert_eq!(queued, 0); + assert_eq!(merged[0].description, refreshed.description); +} + +#[test] +fn changed_active_alert_refreshes_source_facts_without_restarting_run() { + let mut existing = task("dependabot-alert-abc", "active"); + existing.description = "old source facts".into(); + existing.run = Some("run-in-progress".into()); + let mut refreshed = task("dependabot-alert-abc", "pending"); + refreshed.description = + "AUTHORITATIVE SOURCE FACTS: manifest_path=tests/compat/package-lock.json".into(); + let (merged, queued) = merge_discovered_tasks(vec![existing], vec![refreshed.clone()]); + assert_eq!(queued, 0); + assert_eq!(merged[0].status, "active"); + assert_eq!(merged[0].run.as_deref(), Some("run-in-progress")); + assert_eq!(merged[0].description, refreshed.description); +} + +#[test] +fn covered_pending_alert_is_retired_without_touching_active_run() { + let mut pending = task("dependabot-alert-pending", "pending"); + let mut retirement = task("dependabot-alert-pending", "done"); + retirement.description = "Covered by existing open PR #42".into(); + retirement.done_at = Some("2026-07-23T00:00:00Z".into()); + let (merged, queued) = merge_discovered_tasks(vec![pending.clone()], vec![retirement]); + assert_eq!(queued, 0); + assert_eq!(merged[0].status, "done"); + assert!(merged[0].run.is_none()); + + pending.status = "active".into(); + pending.run = Some("run-in-progress".into()); + let mut covered = task("dependabot-alert-pending", "done"); + covered.description = "Covered by existing open PR #42".into(); + let (active, queued) = merge_discovered_tasks(vec![pending], vec![covered]); + assert_eq!(queued, 0); + assert_eq!(active[0].status, "active"); + assert_eq!(active[0].run.as_deref(), Some("run-in-progress")); +} + +#[test] +fn repeated_human_review_decision_requeues_completed_task() { + let completed = task("github-pr-merge-abc", "done"); + let decision = task("github-pr-merge-abc", "pending"); + let (merged, queued) = merge_discovered_tasks(vec![completed], vec![decision]); + assert_eq!(queued, 1); + assert_eq!(merged[0].status, "pending"); + assert!(merged[0].run.is_none()); + assert!(merged[0].done_at.is_none()); +} + +#[test] +fn repo_authorization_and_limits_are_enforced() { + let granted = (0..=MAX_REPOS) + .map(|i| format!("acme/repo-{i}")) + .collect::<Vec<_>>(); + let too_many = PutEngineeringSourceRequest { + enabled: true, + auto_run: true, + repos: granted.clone(), + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, + }; + assert!(validate_request(&too_many, &granted).is_err()); + + let unauthorized = PutEngineeringSourceRequest { + enabled: true, + auto_run: true, + repos: vec!["other/private".into()], + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: DEFAULT_POLL_INTERVAL_SECONDS, + }; + assert!(validate_request(&unauthorized, &granted).is_err()); + + let invalid_interval = PutEngineeringSourceRequest { + enabled: true, + auto_run: true, + repos: vec!["acme/repo-0".into()], + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: MIN_POLL_INTERVAL_SECONDS - 1, + }; + assert!(validate_request(&invalid_interval, &granted).is_err()); +} + +#[test] +fn config_cursor_and_status_serialize_round_trip() { + let config = EngineeringSourceConfig { + version: 1, + team_namespace: "kars-system".into(), + team_name: "platform".into(), + owner_sub: "subject-1".into(), + connection_config_map_ref: "kars-github-connection-deadbeef".into(), + enabled: true, + auto_run: true, + repos: vec!["acme/api".into()], + signals: vec![EngineeringSignal::DependabotPr], + poll_interval_seconds: 900, + }; + let cursor = EngineeringCursor { + repository_updated_at: BTreeMap::from([("acme/api".into(), "2026-07-20T12:00:00Z".into())]), + }; + let status = EngineeringSourceStatus { + state: EngineeringSyncState::Ok, + last_sync_at: Some("2026-07-20T12:00:00Z".into()), + last_success_at: Some("2026-07-20T12:00:00Z".into()), + last_error: None, + items_discovered: 2, + items_queued: 1, + total_items_queued: 4, + next_poll_at: Some("2026-07-20T12:15:00Z".into()), + ..Default::default() + }; + let data = source_data(&config, &cursor, &status).unwrap(); + let cm = ConfigMap { + data: Some(data), + ..Default::default() + }; + let round_trip = parse_source(&cm).unwrap(); + assert_eq!(round_trip, (config, cursor, status)); +} + +fn clean_pull_status() -> serde_json::Value { + serde_json::json!({ + "state": "open", + "merged": false, + "draft": false, + "mergeable": true, + "mergeable_state": "clean" + }) +} + +#[test] +fn review_readiness_only_flags_green_clean_prs() { + let (state, _, total, passed) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "check_runs": [ + {"status":"completed","conclusion":"success"}, + {"status":"completed","conclusion":"neutral"} + ] + }), + &serde_json::json!({"state":"success","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::ReadyForReview); + assert_eq!((total, passed), (2, 2)); + + let (state, _, _, _) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "check_runs": [{"status":"in_progress","conclusion":null}] + }), + &serde_json::json!({"state":"pending","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::WaitingForCi); + + let (state, _, _, _) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "check_runs": [{"status":"completed","conclusion":"failure"}] + }), + &serde_json::json!({"state":"failure","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::CiFailed); + + let (state, _, _, _) = classify_review_readiness( + &clean_pull_status(), + &serde_json::json!({ + "total_count": 101, + "check_runs": (0..100).map(|_| serde_json::json!({ + "status":"completed","conclusion":"success" + })).collect::<Vec<_>>() + }), + &serde_json::json!({"state":"success","statuses":[]}), + ); + assert_eq!(state, EngineeringReviewState::WaitingForCi); +} + +#[test] +fn red_pr_creates_deterministic_followup_work() { + let item = EngineeringReviewItem { + repo: "acme/api".into(), + pr_number: 42, + pr_url: "https://github.com/acme/api/pull/42".into(), + title: "Fix dependency".into(), + run: "run-1".into(), + source_id: "github:acme/api:pull:42".into(), + work_id: "dependabot-pr-example".into(), + task_status: "done".into(), + run_state: Some("Completed".into()), + selected_roles: vec!["reviewer".into()], + delivered_roles: vec!["reviewer".into()], + artifact_count: Some(1), + head_sha: "abc123".into(), + state: EngineeringReviewState::CiFailed, + detail: "test failed".into(), + checks_total: 2, + checks_passed: 1, + observed_at: "2026-07-20T12:00:00Z".into(), + }; + let first = review_followup_task(&item, "2026-07-20T12:00:00Z").unwrap(); + let second = review_followup_task(&item, "2026-07-20T13:00:00Z").unwrap(); + assert_eq!(first.id, second.id); + let mut changed_head = item.clone(); + changed_head.head_sha = "def456".into(); + let changed = review_followup_task(&changed_head, "2026-07-20T14:00:00Z").unwrap(); + assert_eq!(first.id, changed.id); + assert_ne!(first.description, changed.description); + assert!(first.description.contains("Never claim green")); + assert!(first.description.contains("superseded")); + assert!(first.description.contains("do not repair or rebase it")); +} + +#[test] +fn duplicate_prs_create_one_canonical_retirement_task() { + let first = pull("agent", "fix-js-yaml", 18); + let second = pull("agent", "fix-js-yaml-again", 24); + let task = dedupe_followup_task( + "acme/api", + "dependency-remediation-abc", + &[&second, &first], + "2026-07-20T12:00:00Z", + ) + .unwrap(); + assert!(task.title.contains("PR #18")); + assert!(task.description.contains("#24")); + assert!(task.description.contains("never merge")); +} From 751c2edbd88a81c0c2eb31e485ebdc3452793671 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Fri, 11 Sep 2026 23:57:40 +0200 Subject: [PATCH 035/111] Use vetted tag comparison and register exact reviewed crypto boundaries Replace the handwritten tag-equality fold with the already-locked subtle2.6.1 primitive without changing tag bytes, lengths or protocol. Register only the source-reviewed, hosted-qualified Ed25519 wrapper and explicit legacy v1 secret-derivation exception, never a Bridge directory exemption or HKDF/content-hash relabel. Require comparison vectors and wire fresh-audit regressions; current-head Rust qualification remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 3 ++- .github/workflows/ci-gates.yml | 4 ++++ bridge/bff/Cargo.lock | 1 + bridge/bff/Cargo.toml | 1 + bridge/bff/src/providers/credential_review.rs | 20 +++++++++++++++++++ bridge/bff/src/routes/credential_review.rs | 12 +---------- ci/no-custom-crypto.sh | 2 ++ ci/tests/crypto_gate_test.py | 13 ++++++++---- .../2026-09-11-bridge-application.md | 19 ++++++++++++++++-- 9 files changed, 57 insertions(+), 18 deletions(-) diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index 108c38c47..ed66ad945 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -58,7 +58,8 @@ jobs: routes::artifacts::digest_tests::artifact_addresses_keep_the_existing_sixteen_byte_short_form \ routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest \ providers::receipt::tests::rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics \ - providers::credential_review::tests::legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding + providers::credential_review::tests::legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding \ + providers::credential_review::tests::tag_comparison_requires_equal_length_and_every_byte_without_normalization do grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt done diff --git a/.github/workflows/ci-gates.yml b/.github/workflows/ci-gates.yml index 62054c3cd..10370a4b9 100644 --- a/.github/workflows/ci-gates.yml +++ b/.github/workflows/ci-gates.yml @@ -67,6 +67,10 @@ jobs: if: matrix.gate == 'no-custom-crypto' run: python3 -m unittest discover -s ci/tests -p crypto_gate_test.py + - name: Verify fresh audit-record boundaries + if: matrix.gate == 'security-audit-required' + run: python3 -m unittest discover -s ci/tests -p security_audit_gate_test.py + - name: Run gate ${{ matrix.gate }} shell: bash env: diff --git a/bridge/bff/Cargo.lock b/bridge/bff/Cargo.lock index be1790d23..f6737d1ec 100644 --- a/bridge/bff/Cargo.lock +++ b/bridge/bff/Cargo.lock @@ -1185,6 +1185,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subtle", "thiserror", "tokio", "tokio-stream", diff --git a/bridge/bff/Cargo.toml b/bridge/bff/Cargo.toml index cf4211749..48400d736 100644 --- a/bridge/bff/Cargo.toml +++ b/bridge/bff/Cargo.toml @@ -32,6 +32,7 @@ schemars = "0.8" rustls = { version = "0.23", features = ["aws-lc-rs"] } base64 = "0.22" sha2 = "0.10" +subtle = "2.6" ed25519-dalek = "2" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } diff --git a/bridge/bff/src/providers/credential_review.rs b/bridge/bff/src/providers/credential_review.rs index 8987291f2..bde97826f 100644 --- a/bridge/bff/src/providers/credential_review.rs +++ b/bridge/bff/src/providers/credential_review.rs @@ -6,6 +6,7 @@ //! Changing it requires a versioned review/continuation/value-tag migration. use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; pub(crate) fn derive_v1_key(principal_secret: &str) -> [u8; 32] { let mut hash = Sha256::new(); @@ -14,10 +15,29 @@ pub(crate) fn derive_v1_key(principal_secret: &str) -> [u8; 32] { hash.finalize().into() } +pub(crate) fn equal_tag(first: &str, second: &str) -> bool { + bool::from(first.as_bytes().ct_eq(second.as_bytes())) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn tag_comparison_requires_equal_length_and_every_byte_without_normalization() { + let tag = "A".repeat(43); + assert!(equal_tag(&tag, &tag)); + assert!(equal_tag("", "")); + assert!(!equal_tag(&tag, "")); + assert!(!equal_tag(&tag, &tag[..42])); + assert!(!equal_tag(&tag, &format!("{tag}A"))); + for index in 0..tag.len() { + let mut changed = tag.as_bytes().to_vec(); + changed[index] = b'B'; + assert!(!equal_tag(&tag, &String::from_utf8(changed).unwrap())); + } + } + #[test] fn legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding() { for (secret, expected) in [ diff --git a/bridge/bff/src/routes/credential_review.rs b/bridge/bff/src/routes/credential_review.rs index bfc963444..ff87cba04 100644 --- a/bridge/bff/src/routes/credential_review.rs +++ b/bridge/bff/src/routes/credential_review.rs @@ -10,7 +10,7 @@ use crate::{ auth::Principal, error::{AppError, AppResult}, kars::credential_review::{CredentialReview, ReviewedWrite, StoredSource}, - providers::credential_review::derive_v1_key, + providers::credential_review::{derive_v1_key, equal_tag}, state::AppState, }; @@ -160,16 +160,6 @@ fn value_tag( .ok_or_else(conflict) } -fn equal_tag(first: &str, second: &str) -> bool { - first.len() == second.len() - && first - .as_bytes() - .iter() - .zip(second.as_bytes()) - .fold(0u8, |diff, (a, b)| diff | (a ^ b)) - == 0 -} - fn validate_input(input: &ReviewRequest) -> AppResult<()> { if !is_dns1123_label(&input.namespace) || !is_dns1123_label(&input.target) diff --git a/ci/no-custom-crypto.sh b/ci/no-custom-crypto.sh index d7b0877fc..443c5b656 100755 --- a/ci/no-custom-crypto.sh +++ b/ci/no-custom-crypto.sh @@ -18,6 +18,8 @@ cd "$REPO_ROOT" ALLOW_PATHS=( 'bridge/bff/src/providers/signing.rs' # Standard content/receipt SHA-256 adapter; byte/framing known answers qualified in public run34641158050. No secret-key derivation. + 'bridge/bff/src/providers/receipt.rs' # Standard Ed25519 verifier and cfg(test) signer; RFC8032 and pinned/re-signed fork cases qualified in run34649106554; source-compatibility reviewed separately. + 'bridge/bff/src/providers/credential_review.rs' # Explicit legacy v1 SECRET derivation compatibility exception (not HKDF/content hashing), plus vetted tag comparison; no new protocol or active-ticket invalidation. 'controller/src/providers/signing.rs' 'controller/src/kars_receipt_log.rs' # receipt inclusion log — Sha256 Merkle-style hash chaining of receipt payload digests (transparency-log precursor); standard linkage, no bespoke crypto protocol. Tracked for the V2 external-witness upgrade. 'controller/src/kars_task.rs' # KarsTask envelope digest — Sha256 content-hash over canonical JSON (authority-binding identifier), not a crypto protocol. The Governance Receipt (kars_receipt.rs) binds its subject to this digest; signing itself stays in providers/signing.rs. diff --git a/ci/tests/crypto_gate_test.py b/ci/tests/crypto_gate_test.py index c896c2a6d..dbb2bb1c3 100644 --- a/ci/tests/crypto_gate_test.py +++ b/ci/tests/crypto_gate_test.py @@ -18,8 +18,9 @@ def gate(self): env={**os.environ, "BASE_REF": self.base}, timeout=30, ) - def test_exact_standard_digest_adapter_is_the_only_new_allowed_file(self): - self.write("bridge/bff/src/providers/signing.rs", "use sha2::{Digest, Sha256};\n") + def test_exact_reviewed_adapters_are_allowed_without_a_directory_exception(self): + for name in ("signing.rs", "receipt.rs", "credential_review.rs"): + self.write("bridge/bff/src/providers/" + name, "use sha2::{Digest, Sha256};\n") self.commit() result = self.gate() self.assertEqual((result.returncode, result.stderr), (0, "")) @@ -27,14 +28,18 @@ def test_exact_standard_digest_adapter_is_the_only_new_allowed_file(self): def test_filename_prefixes_cannot_impersonate_allowlisted_adapters(self): for name in ("controller/src/providers/signing.rs-extra.rs", "bridge/bff/src/providers/signing.rs-extra.rs", - "bridge/bff/src/providers/signing.rs/child.rs"): + "bridge/bff/src/providers/signing.rs/child.rs", + "bridge/bff/src/providers/receipt.rs-extra.rs", + "bridge/bff/src/providers/credential_review.rs-extra.rs"): self.write(name, "use sha2::{Digest, Sha256};\n") self.commit() result = self.gate() self.assertEqual(result.returncode, 1) for name in ("controller/src/providers/signing.rs-extra.rs", "bridge/bff/src/providers/signing.rs-extra.rs", - "bridge/bff/src/providers/signing.rs/child.rs"): + "bridge/bff/src/providers/signing.rs/child.rs", + "bridge/bff/src/providers/receipt.rs-extra.rs", + "bridge/bff/src/providers/credential_review.rs-extra.rs"): self.assertIn(f"fail: {name} introduces custom crypto", result.stderr) def test_application_derivation_and_unreviewed_provider_files_remain_blocked(self): diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 915020cbc..3714b8ccc 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -187,8 +187,23 @@ HS256 algorithms, audiences, five-minute expiry, three-submission bounds, operator identity and existing review/continuation/value-tag formats are unchanged. An algorithm change would require a separately versioned migration covering active continuation receipts and rolling upgrades, not silently -invalidating their tags. The new key adapter is not allowlisted or approved by -this record; explicit review and hosted compatibility proof remain required. +invalidating their tags. + +Exact `1289a681` through `a9a2d0da` source review found no new cryptographic or +v1 wire-compatibility defect in these extractions. Public run 34649106554 passed +Clippy, the required independent known-answer inventory and the complete Rust +suite. Only the two exact wrapper files are now registered: a standard Ed25519 +adapter and an **explicit legacy v1 secret-derivation compatibility exception**. +This does not relabel the legacy recipe as HKDF, extend its scope to directories, +or imply whole-application approval. + +The review also noted that the unchanged hand-written tag comparison lacked +a vetted constant-time implementation contract. It now uses `subtle::ConstantTimeEq` +through the key adapter, preserving exact byte/length equality and all tag wire +formats. `subtle` was already locked transitively at 2.6.1; only its direct +dependency edge was added, without version changes or network installation. +Every-byte, prefix and length regressions are required in hosted inventory. +That small follow-up still requires fresh Clippy and full-suite execution. The imported application predates the core repository's file-size and copyright header conventions. Several files exceed the unchanged 800-line new-file cap, From b3d39d2794d7d74507d5d821b5df362660a803e0 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 00:14:01 +0200 Subject: [PATCH 036/111] Split live capability options into bounded modules Preserve50signature/body/attribute sets,46byte-identical production bodies,269literals,11structures,16root exports and4registered tests. Retain resource/readiness/digest/prompt ordering semantics. Formatting and source parity checked; hosted Rust qualification remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/options.rs | 1340 +---------------- bridge/bff/src/routes/options/palette.rs | 413 +++++ bridge/bff/src/routes/options/projections.rs | 119 ++ .../bff/src/routes/options/qualification.rs | 559 +++++++ .../src/routes/options/qualification_tests.rs | 252 ++++ 5 files changed, 1363 insertions(+), 1320 deletions(-) create mode 100644 bridge/bff/src/routes/options/palette.rs create mode 100644 bridge/bff/src/routes/options/projections.rs create mode 100644 bridge/bff/src/routes/options/qualification.rs create mode 100644 bridge/bff/src/routes/options/qualification_tests.rs diff --git a/bridge/bff/src/routes/options.rs b/bridge/bff/src/routes/options.rs index 8ca5d3d3b..9570eb2e0 100644 --- a/bridge/bff/src/routes/options.rs +++ b/bridge/bff/src/routes/options.rs @@ -8,21 +8,29 @@ // objects the blueprint composes by reference. Absent CRDs surface as empty // lists (the web layer renders the honesty grammar), never as errors. -use crate::providers::signing::sha256_hex; -use axum::Json; -use axum::extract::State; use kube::core::DynamicObject; use serde::Serialize; -use crate::error::{AppError, AppResult}; -use crate::state::AppState; +mod palette; +mod projections; +mod qualification; + +pub(crate) use palette::provider_for; +pub use palette::{build_options, get_options}; +pub(crate) use projections::{mcp_server_option, memory_option, skill_option}; +pub(crate) use qualification::{ + channel_adapter_qualified_for_route, mcp_server_qualified_for_route, + memory_binding_qualified_for_route, qualification_constraints_summary, + resource_qualification_summary, route_label, route_minimum_tokens, route_qualification, + route_qualification_gap, skill_version_qualified_for_route, +}; + +#[cfg(test)] +use qualification::{ + channel_resource_selection, mcp_resource_selection, resource_is_qualified_in, + resource_qualification_routes_in, route_is_qualified_in, route_qualification_gap_in, +}; -fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { - state.cluster().ok_or(AppError::ClusterUnavailable) -} -fn upstream(e: kube::Error) -> AppError { - AppError::Upstream(e.to_string()) -} fn name_of(o: &DynamicObject) -> String { o.metadata.name.clone().unwrap_or_default() } @@ -287,1313 +295,5 @@ fn readiness_summary(resource: &DynamicObject) -> Option<String> { } } -fn route_matches( - route: &QualifiedRoute, - runtime: &str, - provider: &str, - deployment: &str, - max_parallel: i32, - total_tokens: Option<i64>, -) -> bool { - route.runtime.eq_ignore_ascii_case(runtime) - && route.provider == provider - && route.deployment == deployment - && max_parallel <= route.max_parallel - && route - .min_total_tokens - .is_none_or(|minimum| total_tokens.is_none_or(|tokens| tokens >= minimum)) -} - -fn evidence_complete(route: &QualifiedRoute) -> bool { - !route.evidence.task.trim().is_empty() - && !route.evidence.run_id.trim().is_empty() - && route.evidence.digest.starts_with("sha256:") -} - -fn resource_capability(kind: &str) -> Option<&'static str> { - match kind.to_ascii_lowercase().as_str() { - "mcp" => Some("mcp"), - "memory" => Some("memory"), - _ => None, - } -} - -fn resource_requires_backend(kind: &str) -> bool { - kind.eq_ignore_ascii_case("memory") -} - -fn resource_requires_schema_digest(kind: &str) -> bool { - kind.eq_ignore_ascii_case("mcp") || kind.eq_ignore_ascii_case("memory") -} - -fn resource_requires_version_digest(kind: &str) -> bool { - kind.eq_ignore_ascii_case("skill") -} - -fn resource_matches( - required: &QualifiedResourceSelection, - recorded: &QualifiedResource, - capabilities: &std::collections::BTreeSet<String>, -) -> bool { - if !recorded.kind.eq_ignore_ascii_case(&required.kind) - || !recorded.name.eq_ignore_ascii_case(&required.name) - { - return false; - } - if let Some(capability) = resource_capability(&required.kind) - && !capabilities.contains(capability) - { - return false; - } - if resource_requires_backend(&required.kind) && required.backend.is_none() { - return false; - } - if resource_requires_schema_digest(&required.kind) && required.schema_digest.is_none() { - return false; - } - if resource_requires_version_digest(&required.kind) && required.version_digest.is_none() { - return false; - } - if let Some(backend) = required.backend.as_deref() - && recorded.backend.as_deref() != Some(backend) - { - return false; - } - if let Some(schema_digest) = required.schema_digest.as_deref() - && recorded.schema_digest.as_deref() != Some(schema_digest) - { - return false; - } - if let Some(version_digest) = required.version_digest.as_deref() - && recorded.version_digest.as_deref() != Some(version_digest) - { - return false; - } - true -} - -fn qualification_records(raw: &str) -> Result<Vec<QualifiedRoute>, String> { - serde_json::from_str::<Vec<QualifiedRoute>>(raw) - .map_err(|error| format!("BRIDGE_QUALIFICATION_RECORDS_JSON is invalid: {error}")) -} - -fn qualification_records_from_env() -> Result<Vec<QualifiedRoute>, String> { - let raw = std::env::var("BRIDGE_QUALIFICATION_RECORDS_JSON") - .map_err(|_| "BRIDGE_QUALIFICATION_RECORDS_JSON is not configured".to_string())?; - let mut records = qualification_records(&raw)?; - if let Ok(additional) = std::env::var("BRIDGE_ADDITIONAL_QUALIFICATION_RECORDS_JSON") - && !additional.trim().is_empty() - { - records.extend(qualification_records(&additional).map_err(|error| { - error.replace( - "BRIDGE_QUALIFICATION_RECORDS_JSON", - "BRIDGE_ADDITIONAL_QUALIFICATION_RECORDS_JSON", - ) - })?); - } - Ok(records) -} - -fn qualification_records_raw_from_env() -> Result<String, String> { - serde_json::to_string(&qualification_records_from_env()?) - .map_err(|error| format!("qualification records could not be serialized: {error}")) -} - -pub(crate) fn route_label(runtime: &str, provider: &str, deployment: &str) -> String { - format!("{runtime} · {provider}::{deployment}") -} - -fn resource_qualification_routes_in( - raw: &str, - resource: &QualifiedResourceSelection, -) -> Result<Vec<String>, String> { - let mut labels = qualification_records(raw)? - .into_iter() - .filter(evidence_complete) - .filter_map(|route| { - let capabilities = route - .capabilities - .iter() - .cloned() - .collect::<std::collections::BTreeSet<_>>(); - route - .resource - .as_ref() - .filter(|recorded| resource_matches(resource, recorded, &capabilities)) - .map(|_| route_label(&route.runtime, &route.provider, &route.deployment)) - }) - .collect::<Vec<_>>(); - labels.sort(); - labels.dedup(); - Ok(labels) -} - -fn resource_is_qualified_in( - raw: &str, - runtime: &str, - provider: &str, - deployment: &str, - resource: &QualifiedResourceSelection, -) -> Result<bool, String> { - Ok(qualification_records(raw)?.into_iter().any(|route| { - if !evidence_complete(&route) - || !route_matches(&route, runtime, provider, deployment, 1, None) - { - return false; - } - let capabilities = route - .capabilities - .iter() - .cloned() - .collect::<std::collections::BTreeSet<_>>(); - route - .resource - .as_ref() - .is_some_and(|recorded| resource_matches(resource, recorded, &capabilities)) - })) -} - -fn resource_qualification_routes( - resource: &QualifiedResourceSelection, -) -> Result<Vec<String>, String> { - let raw = qualification_records_raw_from_env()?; - resource_qualification_routes_in(&raw, resource) -} - -fn resource_is_qualified( - runtime: &str, - provider: &str, - deployment: &str, - resource: &QualifiedResourceSelection, -) -> Result<bool, String> { - let raw = qualification_records_raw_from_env()?; - resource_is_qualified_in(&raw, runtime, provider, deployment, resource) -} - -fn mcp_resource_selection(option: &RefOption) -> QualifiedResourceSelection { - QualifiedResourceSelection { - kind: "mcp".into(), - name: option.name.clone(), - backend: None, - schema_digest: option.tool_schema_digest.clone(), - version_digest: None, - } -} - -fn memory_resource_selection(option: &RefOption) -> QualifiedResourceSelection { - QualifiedResourceSelection { - kind: "memory".into(), - name: option.name.clone(), - backend: option.backend.clone(), - schema_digest: option.compiled_digest.clone(), - version_digest: None, - } -} - -fn skill_resource_selection(option: &RefOption) -> QualifiedResourceSelection { - QualifiedResourceSelection { - kind: "skill".into(), - name: option.name.clone(), - backend: None, - schema_digest: None, - version_digest: option.version_digest.clone(), - } -} - -fn channel_resource_selection(channel: &str) -> QualifiedResourceSelection { - QualifiedResourceSelection { - kind: "channel".into(), - name: channel.to_ascii_lowercase(), - backend: None, - schema_digest: None, - version_digest: None, - } -} - -pub(crate) fn mcp_server_qualified_for_route( - runtime: &str, - provider: &str, - deployment: &str, - option: &RefOption, -) -> Result<bool, String> { - resource_is_qualified( - runtime, - provider, - deployment, - &mcp_resource_selection(option), - ) -} - -pub(crate) fn memory_binding_qualified_for_route( - runtime: &str, - provider: &str, - deployment: &str, - option: &RefOption, -) -> Result<bool, String> { - resource_is_qualified( - runtime, - provider, - deployment, - &memory_resource_selection(option), - ) -} - -pub(crate) fn skill_version_qualified_for_route( - runtime: &str, - provider: &str, - deployment: &str, - option: &RefOption, -) -> Result<bool, String> { - resource_is_qualified( - runtime, - provider, - deployment, - &skill_resource_selection(option), - ) -} - -pub(crate) fn channel_adapter_qualified_for_route( - runtime: &str, - provider: &str, - deployment: &str, - channel: &str, -) -> Result<bool, String> { - resource_is_qualified( - runtime, - provider, - deployment, - &channel_resource_selection(channel), - ) -} - -pub(crate) fn resource_qualification_summary(options: &Options) -> Result<String, String> { - let mut lines: Vec<String> = Vec::new(); - for server in &options.mcp_servers { - let routes = resource_qualification_routes(&mcp_resource_selection(server))?; - lines.push(format!( - " - MCP \"{}\"{}{}{}", - server.name, - server - .tool_schema_digest - .as_deref() - .map(|digest| format!(" schema_digest={digest}")) - .unwrap_or_else(|| " schema_digest=missing".into()), - if server.discovered_tools.is_empty() { - String::new() - } else { - format!(" tools=[{}]", server.discovered_tools.join(", ")) - }, - if routes.is_empty() { - " qualified_on=(none)".into() - } else { - format!(" qualified_on=[{}]", routes.join("; ")) - } - )); - } - for memory in &options.memories { - let routes = resource_qualification_routes(&memory_resource_selection(memory))?; - lines.push(format!( - " - MEMORY \"{}\"{}{}{}{}", - memory.name, - memory - .backend - .as_deref() - .map(|backend| format!(" backend={backend}")) - .unwrap_or_else(|| " backend=missing".into()), - memory - .compiled_digest - .as_deref() - .map(|digest| format!(" compiled_digest={digest}")) - .unwrap_or_else(|| " compiled_digest=missing".into()), - memory - .readiness - .as_deref() - .map(|readiness| format!(" readiness={readiness}")) - .unwrap_or_default(), - if routes.is_empty() { - " qualified_on=(none)".into() - } else { - format!(" qualified_on=[{}]", routes.join("; ")) - } - )); - } - for skill in &options.skills { - let routes = resource_qualification_routes(&skill_resource_selection(skill))?; - lines.push(format!( - " - SKILL \"{}\"{}{}{}{}", - skill.name, - skill - .version - .as_deref() - .map(|version| format!(" version={version}")) - .unwrap_or_default(), - skill - .version_digest - .as_deref() - .map(|digest| format!(" version_digest={digest}")) - .unwrap_or_else(|| " version_digest=missing".into()), - skill - .recipe - .as_deref() - .map(|recipe| format!(" recipe={}", recipe.chars().take(180).collect::<String>())) - .unwrap_or_default(), - if routes.is_empty() { - " qualified_on=(none)".into() - } else { - format!(" qualified_on=[{}]", routes.join("; ")) - } - )); - } - if lines.is_empty() { - Ok(" (no MCP, memory, or approved-skill resources are available)".into()) - } else { - Ok(lines.join("\n")) - } -} - -pub(crate) fn mcp_server_option(resource: &DynamicObject) -> RefOption { - let mode = string_at(&resource.data, &["/status/mode"]).or_else(|| { - bool_at(&resource.data, &["/spec/managed"]) - .map(|managed| if managed { "Managed" } else { "External" }.to_string()) - }); - let discovered_tools = string_array_at(&resource.data, &["/status/discoveredTools"]); - let tool_schema_digest = - string_at(&resource.data, &["/status/toolSchemaDigest"]).or_else(|| { - let signature = serde_json::json!({ - "mode": mode.clone(), - "endpoint": string_at( - &resource.data, - &["/status/endpoint", "/spec/url", "/spec/endpoint"], - ), - "allowed_tools": string_array_at(&resource.data, &["/spec/allowedTools"]), - "discovered_tools": discovered_tools.clone(), - }); - serde_json::to_vec(&signature) - .ok() - .map(|bytes| format!("sha256:{}", sha256_hex(bytes))) - }); - let mut option = RefOption { - name: name_of(resource), - namespace: ns_of(resource), - summary: string_at( - &resource.data, - &["/status/endpoint", "/spec/url", "/spec/endpoint"], - ), - mode, - discovered_tools, - tool_schema_digest, - compiled_digest: None, - backend: None, - readiness: readiness_summary(resource), - version: None, - recipe: None, - version_digest: None, - qualified_routes: Vec::new(), - }; - option.qualified_routes = - resource_qualification_routes(&mcp_resource_selection(&option)).unwrap_or_default(); - option -} - -pub(crate) fn memory_option(resource: &DynamicObject) -> RefOption { - let mut option = RefOption { - name: name_of(resource), - namespace: ns_of(resource), - summary: string_at( - &resource.data, - &["/spec/displayName", "/spec/storeName", "/status/storeName"], - ), - mode: None, - discovered_tools: Vec::new(), - tool_schema_digest: None, - compiled_digest: string_at( - &resource.data, - &[ - "/status/compiledDigest", - "/status/compiled/digest", - "/status/resolvedDigest", - "/status/specDigest", - ], - ), - backend: string_at( - &resource.data, - &[ - "/status/backend", - "/spec/backend", - "/status/binding/backend", - "/spec/binding/backend", - "/status/provider", - "/spec/provider", - ], - ) - .or_else(|| Some("foundry".into())), - readiness: readiness_summary(resource), - version: None, - recipe: None, - version_digest: None, - qualified_routes: Vec::new(), - }; - option.qualified_routes = - resource_qualification_routes(&memory_resource_selection(&option)).unwrap_or_default(); - option -} - -pub(crate) fn skill_option(resource: &DynamicObject) -> RefOption { - let mut option = RefOption { - name: name_of(resource), - namespace: ns_of(resource), - summary: string_at(&resource.data, &["/spec/summary"]), - mode: None, - discovered_tools: Vec::new(), - tool_schema_digest: None, - compiled_digest: None, - backend: None, - readiness: string_at(&resource.data, &["/status/phase"]), - version: string_at(&resource.data, &["/spec/version"]), - recipe: string_at(&resource.data, &["/spec/recipe"]), - version_digest: string_at(&resource.data, &["/status/versionDigest"]), - qualified_routes: Vec::new(), - }; - option.qualified_routes = - resource_qualification_routes(&skill_resource_selection(&option)).unwrap_or_default(); - option -} - -pub(crate) fn qualification_constraints_summary() -> Result<String, String> { - let routes = qualification_records_from_env()?; - if routes.is_empty() { - return Ok(" (no qualified execution routes are retained)".into()); - } - Ok(routes - .into_iter() - .map(|route| { - format!( - " - {} · capabilities=[{}] · max_parallel={} · min_total_tokens={}{}", - route_label(&route.runtime, &route.provider, &route.deployment), - route.capabilities.join(","), - route.max_parallel, - route - .min_total_tokens - .map(|tokens| tokens.to_string()) - .unwrap_or_else(|| "none".into()), - route - .resource - .map(|resource| { - format!( - " · resource={}::{}{}{}{}", - resource.kind, - resource.name, - resource - .backend - .as_deref() - .map(|backend| format!(" backend={backend}")) - .unwrap_or_default(), - resource - .schema_digest - .as_deref() - .map(|digest| format!(" schema_digest={digest}")) - .unwrap_or_default(), - resource - .version_digest - .as_deref() - .map(|digest| format!(" version_digest={digest}")) - .unwrap_or_default(), - ) - }) - .unwrap_or_default(), - ) - }) - .collect::<Vec<_>>() - .join("\n")) -} - -pub(crate) fn route_minimum_tokens( - runtime: &str, - provider: &str, - deployment: &str, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, -) -> Result<Option<i64>, String> { - let routes = qualification_records_from_env()?; - let matching = routes.into_iter().filter(|route| { - let capabilities = route - .capabilities - .iter() - .cloned() - .collect::<std::collections::BTreeSet<_>>(); - route_matches(route, runtime, provider, deployment, max_parallel, None) - && required_capabilities.is_subset(&capabilities) - }); - let mut minimum: Option<i64> = None; - for route in matching { - let Some(tokens) = route.min_total_tokens else { - return Ok(None); - }; - minimum = Some(minimum.map_or(tokens, |current| current.min(tokens))); - } - Ok(minimum) -} - -pub(crate) fn route_qualification( - runtime: &str, - provider: &str, - deployment: &str, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, - total_tokens: Option<i64>, -) -> Result<bool, String> { - let raw = qualification_records_raw_from_env()?; - route_is_qualified_in( - &raw, - runtime, - provider, - deployment, - required_capabilities, - max_parallel, - total_tokens, - ) -} - -pub(crate) fn route_qualification_gap( - runtime: &str, - provider: &str, - deployment: &str, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, - total_tokens: Option<i64>, -) -> Result<std::collections::BTreeSet<String>, String> { - let raw = qualification_records_raw_from_env()?; - route_qualification_gap_in( - &raw, - runtime, - provider, - deployment, - required_capabilities, - max_parallel, - total_tokens, - ) -} - -fn route_qualification_gap_in( - raw: &str, - runtime: &str, - provider: &str, - deployment: &str, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, - total_tokens: Option<i64>, -) -> Result<std::collections::BTreeSet<String>, String> { - let routes = qualification_records(raw)?; - let mut gaps = routes - .into_iter() - .filter_map(|route| { - if !evidence_complete(&route) - || !route_matches( - &route, - runtime, - provider, - deployment, - max_parallel, - total_tokens, - ) - { - return None; - } - let capabilities = route - .capabilities - .iter() - .cloned() - .collect::<std::collections::BTreeSet<_>>(); - Some( - required_capabilities - .difference(&capabilities) - .cloned() - .collect::<std::collections::BTreeSet<_>>(), - ) - }) - .collect::<Vec<_>>(); - gaps.sort_by(|left, right| { - left.len() - .cmp(&right.len()) - .then_with(|| left.iter().cmp(right.iter())) - }); - Ok(gaps - .into_iter() - .next() - .unwrap_or_else(|| required_capabilities.clone())) -} - -fn route_is_qualified_in( - raw: &str, - runtime: &str, - provider: &str, - deployment: &str, - required_capabilities: &std::collections::BTreeSet<String>, - max_parallel: i32, - total_tokens: Option<i64>, -) -> Result<bool, String> { - let routes = qualification_records(raw)?; - Ok(routes.into_iter().any(|route| { - let capabilities = route - .capabilities - .iter() - .cloned() - .collect::<std::collections::BTreeSet<_>>(); - route_matches( - &route, - runtime, - provider, - deployment, - max_parallel, - total_tokens, - ) && evidence_complete(&route) - && required_capabilities.is_subset(&capabilities) - })) -} - -/// Infer a provider tag from a deployment string when none is recorded — a -/// `github-models`-style `openai/<model>` carries its vendor in the prefix. A -/// bare deployment name (the Copilot/Foundry form) carries no vendor, so we tag -/// it with the cluster's ACTUAL inherited provider (`github-copilot`, -/// `github-models`, or a Foundry/Azure provider) rather than guessing -/// `azure-openai`. This is what makes a composed mission/team stamp the real -/// provider the router serves — never a misleading default. -pub(crate) fn provider_for( - deployment: &str, - recorded: Option<&str>, - cluster_default: Option<&str>, -) -> String { - if let Some(p) = recorded { - return p.to_string(); - } - match deployment.split_once('/') { - Some(("openai", _)) => "github-models".to_string(), - Some((vendor, _)) => vendor.to_string(), - None => cluster_default.unwrap_or("azure-openai").to_string(), - } -} - -/// `GET /api/options` — the composable launch-package palette, from live state. -pub async fn get_options(State(state): State<AppState>) -> AppResult<Json<Options>> { - let cluster = require_cluster(&state)?; - Ok(Json(build_options(cluster).await?)) -} - -/// Build the real composable building blocks from live cluster state. Shared by -/// the `/api/options` route and the orchestrator (`/compose`), so the LLM only -/// ever proposes models, tool policies, MCP servers, isolation levels, and -/// memory stores that genuinely exist on this cluster. -pub async fn build_options(cluster: &crate::kars::cluster::Cluster) -> AppResult<Options> { - // Models: the controller-configured default + catalog, deduped against any - // distinct models already pinned on existing InferencePolicies (real, - // in-use facts). Order: default first, then catalog, then discovered. - let (default_model, catalog) = cluster.controller_models().await; - // The cluster's inherited inference provider — the authoritative tag for any - // catalog model that doesn't carry its own vendor prefix. Fetched up front - // so every offered model is stamped with the provider the router actually - // serves (e.g. `github-copilot`), not a neutral guess. - let provider = cluster - .controller_provider() - .await - .map(|(id, label, note)| ProviderInfo { id, label, note }); - let cluster_provider_id: Option<String> = provider.as_ref().map(|p| p.id.clone()); - // The operator's declared, served set — the only models we trust enough to - // offer. Discovered InferencePolicy models are surfaced ONLY if they are - // also in this set, so a stale policy pinning an unserved model (e.g. a - // decommissioned deployment) can't leak a broken option into the picker. - let catalog_set: std::collections::BTreeSet<String> = catalog - .iter() - .cloned() - .chain(default_model.clone()) - .collect(); - let mut models: Vec<ModelOption> = Vec::new(); - let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); - // Detail blurbs (deployment → "vendor · ctx · category") for models whose - // provider exposes them; applied in a post-pass so push_model stays simple. - let mut details: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new(); - let cpid = cluster_provider_id.clone(); - let mut push_model = |deployment: String, provider: Option<&str>, is_default: bool| { - if deployment.is_empty() || !seen.insert(deployment.clone()) { - return; - } - let provider = provider_for(&deployment, provider, cpid.as_deref()); - models.push(ModelOption { - provider, - deployment, - is_default, - detail: None, - }); - }; - if let Some(def) = default_model.clone() { - push_model(def, None, true); - } - for dep in catalog { - push_model(dep, None, false); - } - // Live GitHub Copilot catalog — when Copilot is the cluster's default - // provider, surface the seat's ACTUAL served models (gpt-5.6, opus-4.8, - // gemini-3.1-pro, …) instead of only the static KARS_MODEL_CATALOG. This - // is the SAME set the wizard's Copilot discovery shows, so the Model - // catalogue, the orchestrator's menu, and the manual-override picker all - // reflect what Copilot really serves — refreshing itself as GitHub adds - // models. Cached (5-min TTL); a transient Copilot failure leaves the - // static catalog intact (best-effort, never blanks the list). - if cluster_provider_id.as_deref() == Some("github-copilot") - && let Some(token) = cluster.controller_copilot_token().await - { - for (dep, _recommended, detail) in - crate::routes::operator::copilot_catalog_cached(&token).await - { - if let Some(d) = detail { - details.entry(dep.clone()).or_insert(d); - } - push_model(dep, Some("github-copilot"), false); - } - } - for ip in cluster - .list_kind_all("InferencePolicy") - .await - .map_err(upstream)? - { - let primary = ip - .data - .get("spec") - .and_then(|s| s.get("modelPreference")) - .and_then(|m| m.get("primary")); - if let Some(p) = primary { - let dep = p.get("deployment").and_then(|d| d.as_str()).unwrap_or(""); - // Only surface a discovered model if the operator's catalog declares - // it — never an arbitrary (possibly unserved) pinned deployment. - if !catalog_set.contains(dep) { - continue; - } - let prov = p.get("provider").and_then(|d| d.as_str()); - push_model(dep.to_string(), prov, false); - } - } - - // Additional providers (§ inference-provider-wizard): every model the - // operator explicitly declared when connecting a provider beyond the - // single default — e.g. a Foundry deployment or a GitHub Models id — - // tagged with ITS OWN provider, not the cluster default. These are - // operator-declared (trusted) the same way the default catalog is, so - // they don't need the catalog_set gate above. This is what lets - // InferencePolicy's model picker offer "gpt-4.1 via Foundry" alongside - // "opus-4.8 via GitHub Copilot" with no change to that editor — it - // already keys options by `provider::deployment`. - let provider_keys = cluster - .read_secret_all("kars-system", "kars-inference-providers") - .await - .map_err(upstream)?; - let mut declared_models: std::collections::BTreeMap<String, Vec<String>> = - std::collections::BTreeMap::new(); - for (key, value) in &provider_keys { - let Some(tag_part) = key - .strip_prefix("KARS_PROVIDER_") - .and_then(|r| r.strip_suffix("_MODELS")) - else { - continue; - }; - let tag = tag_part.to_ascii_lowercase().replace('_', "-"); - declared_models.insert( - tag, - value - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(), - ); - } - for (tag, deployments) in declared_models { - for dep in deployments { - push_model(dep, Some(tag.as_str()), false); - } - } - - // Apply detail blurbs (only some providers expose them). - if !details.is_empty() { - for m in models.iter_mut() { - if m.detail.is_none() - && let Some(d) = details.get(&m.deployment) - { - m.detail = Some(d.clone()); - } - } - } - - // Runtimes: the controller wires adapters for several harnesses, but a - // harness is only RUNNABLE here when its container image is configured and - // the controller has registry credentials that cover the image. - // `wired` now means "can start a pod here" — so the UI never lets a user (or - // the orchestrator) pick a harness that would ErrImagePull. `status`: - // ready = runnable on this cluster - // needs_image = adapter exists, but no image configured here - // unavailable = no adapter at all (SemanticKernel) - let runnable = cluster.runnable_runtimes().await; - let mk = |kind: &str, label: &str, ready_note: &str| { - let is_runnable = runnable.contains(kind); - RuntimeOption { - kind: kind.into(), - label: label.into(), - wired: is_runnable, - status: if is_runnable { - "ready".into() - } else { - "needs_image".into() - }, - note: if is_runnable { - ready_note.into() - } else { - "Adapter wired, but its runtime image or registry pull credential is unavailable on this cluster.".into() - }, - } - }; - let runtimes = vec![ - mk( - "OpenClaw", - "OpenClaw", - "Autonomous — the default kars harness, exercised end-to-end (full mesh + spawn). Runs missions and standing teams.", - ), - mk( - "Hermes", - "Hermes (Nous Research)", - "Autonomous — executes a delivered objective in-process and replies (plugins, 20+ channels, native MCP). Verified end-to-end.", - ), - mk( - "Anthropic", - "Anthropic Claude Agent SDK", - "Adapter only (pins the governed router) — you supply the agent logic. Not a turnkey autonomous harness; auto-corrected to OpenClaw for missions/teams.", - ), - mk( - "OpenAIAgents", - "OpenAI Agents SDK", - "Adapter only (routes through the inference sidecar) — you supply the agent logic. Not turnkey autonomous; auto-corrected to OpenClaw for missions/teams.", - ), - mk( - "MicrosoftAgentFramework", - "Microsoft Agent Framework", - "Adapter only (MAF Python, first-party AGT integration) — you supply the agent logic. Not turnkey autonomous; auto-corrected to OpenClaw.", - ), - mk( - "LangGraph", - "LangGraph", - "Adapter only (Python + TypeScript, pins the router) — you supply the graph. Not turnkey autonomous; auto-corrected to OpenClaw.", - ), - mk( - "PydanticAi", - "Pydantic-AI", - "Adapter only (provider-agnostic, pins the router at bootstrap) — you supply the agent. Not turnkey autonomous; auto-corrected to OpenClaw.", - ), - mk( - "BYO", - "Bring-your-own runtime", - "Autonomous by contract — any image honoring the BYO contract (UID 1000, router at 127.0.0.1:8443, consumes the objective + delivers).", - ), - RuntimeOption { - kind: "SemanticKernel".into(), - label: "Semantic Kernel".into(), - wired: false, - status: "unavailable".into(), - note: "Declared on the substrate but no adapter is wired yet.".into(), - }, - ]; - - let isolation = vec![ - IsolationOption { - value: "standard".into(), - label: "Standard".into(), - note: "Namespaced sandbox, default-deny egress, seccomp.".into(), - }, - IsolationOption { - value: "enhanced".into(), - label: "Enhanced".into(), - note: "Hardened profile for sensitive work.".into(), - }, - IsolationOption { - value: "confidential".into(), - label: "Confidential".into(), - note: "Confidential compute (CVM) where the node pool supports it.".into(), - }, - ]; - - let tool_policies = cluster - .list_kind_all("ToolPolicy") - .await - .map_err(upstream)? - .iter() - .map(|o| RefOption { - name: name_of(o), - namespace: ns_of(o), - summary: o - .data - .get("spec") - .and_then(|s| s.get("appliesTo")) - .and_then(|a| a.get("tool")) - .and_then(|t| t.as_str()) - .map(|t| format!("tools {t}")), - mode: None, - discovered_tools: Vec::new(), - tool_schema_digest: None, - compiled_digest: None, - backend: None, - readiness: None, - version: None, - recipe: None, - version_digest: None, - qualified_routes: Vec::new(), - }) - .collect(); - - let mut mcp_servers: Vec<RefOption> = cluster - .list_kind_all("McpServer") - .await - .map_err(upstream)? - .iter() - .map(mcp_server_option) - .collect(); - // Collapse duplicate registrations in the SAME namespace that point at the - // same endpoint URL. Identical endpoints in different namespaces are - // distinct workspace grants and must survive namespace filtering. - // the audit saw two identical Playwright servers offered side by side, which - // is confusing and invites a redundant grant. Keep the first per URL; servers - // with no URL are always kept (nothing to compare on). - { - let mut seen_urls: std::collections::HashSet<(String, String)> = - std::collections::HashSet::new(); - mcp_servers.retain(|s| match s.summary.as_deref() { - Some(url) if !url.is_empty() => { - seen_urls.insert((s.namespace.clone(), url.to_string())) - } - _ => true, - }); - } - - let memories = cluster - .list_kind_all("KarsMemory") - .await - .map_err(upstream)? - .iter() - .map(memory_option) - .collect(); - - let skills = cluster - .list_kind_all("KarsSkill") - .await - .map_err(upstream)? - .iter() - // Operator trust gate: users may only assign skills an operator has - // approved AND locked to the skill's current version digest. A pending, - // never-approved, or changed-since-approval skill is withheld until - // (re)approved — the same rule the operator console enforces. - .filter(|o| { - let review = o - .metadata - .annotations - .as_ref() - .and_then(|a| a.get("kars.azure.com/skill-review")) - .map(String::as_str); - let locked = o - .metadata - .annotations - .as_ref() - .and_then(|a| a.get("kars.azure.com/skill-locked-digest")); - let digest = o - .data - .get("status") - .and_then(|s| s.get("versionDigest")) - .and_then(|d| d.as_str()); - review == Some("approved") && locked.is_some() && locked.map(String::as_str) == digest - }) - .map(skill_option) - .collect(); - - // Operator-curated MCP profiles (vetted bundles). Only surface servers that - // still exist on the cluster, so a deleted McpServer can't linger in a bundle. - let known_servers: std::collections::BTreeSet<String> = - mcp_servers.iter().map(|r| r.name.clone()).collect(); - let mcp_profiles: Vec<McpProfileOption> = { - let raw = cluster.read_mcp_profiles().await; - let parsed: Vec<crate::routes::operator::McpProfileDto> = - serde_json::from_str(&raw).unwrap_or_default(); - parsed - .into_iter() - .map(|p| McpProfileOption { - name: p.name, - summary: p.summary, - servers: p - .servers - .into_iter() - .filter(|s| known_servers.contains(s)) - .collect(), - }) - .collect() - }; - - Ok(Options { - models, - default_model, - provider, - runtimes, - isolation, - tool_policies, - mcp_servers, - mcp_profiles, - memories, - skills, - }) -} - #[cfg(test)] -mod qualification_tests { - use super::{ - QualifiedResourceSelection, channel_resource_selection, mcp_resource_selection, - resource_is_qualified_in, resource_qualification_routes_in, route_is_qualified_in, - route_qualification_gap_in, - }; - use std::collections::BTreeSet; - - #[test] - fn qualification_requires_route_capabilities_constraints_and_evidence() { - let routes = r#"[ - { - "runtime":"OpenClaw", - "provider":"local-inference", - "deployment":"gpt-oss-120b", - "capabilities":["delegation","filesystem-read","shell","network","artifacts","telemetry"], - "max_parallel":1, - "min_total_tokens":128144, - "evidence":{ - "task":"openclaw-proof", - "run_id":"run-1", - "digest":"sha256:abc" - } - - } - ]"#; - let required = ["delegation", "shell", "telemetry"] - .into_iter() - .map(str::to_string) - .collect::<BTreeSet<_>>(); - assert!( - route_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &required, - 1, - Some(128_144) - ) - .expect("valid routes") - ); - assert!( - route_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &required, - 1, - None - ) - .expect("an uncapped route is not below the retained minimum") - ); - let unsupported = ["delegation", "memory"] - .into_iter() - .map(str::to_string) - .collect::<BTreeSet<_>>(); - assert!( - !route_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &unsupported, - 1, - Some(128_144) - ) - .expect("valid routes") - ); - assert!( - !route_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &required, - 2, - Some(128_144) - ) - .expect("valid routes") - ); - assert!( - !route_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &required, - 1, - Some(100_000) - ) - .expect("valid routes") - ); - assert!(route_is_qualified_in("{", "OpenClaw", "x", "y", &required, 1, None).is_err()); - } - - #[test] - fn qualification_gap_reports_only_capabilities_missing_from_closest_record() { - let routes = r#"[ - { - "runtime":"OpenClaw", - "provider":"local-inference", - "deployment":"gpt-oss-120b", - "capabilities":["delegation","web-search","network","artifacts","telemetry"], - "max_parallel":1, - "min_total_tokens":300000, - "evidence":{"task":"research-proof","run_id":"run-1","digest":"sha256:abc"} - } - ]"#; - let required = [ - "artifacts", - "delegation", - "mcp", - "network", - "telemetry", - "web-search", - ] - .into_iter() - .map(str::to_string) - .collect::<BTreeSet<_>>(); - assert_eq!( - route_qualification_gap_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &required, - 1, - Some(300000), - ) - .expect("gap"), - ["mcp".to_string()].into_iter().collect() - ); - } - - #[test] - fn resource_qualification_requires_matching_current_digest_and_ignores_generic_routes() { - let routes = r#"[ - { - "runtime":"OpenClaw", - "provider":"local-inference", - "deployment":"gpt-oss-120b", - "capabilities":["mcp","network","telemetry"], - "max_parallel":1, - "evidence":{"task":"generic-proof","run_id":"run-1","digest":"sha256:generic"} - }, - { - "runtime":"OpenClaw", - "provider":"local-inference", - "deployment":"gpt-oss-120b", - "capabilities":["mcp","network","telemetry"], - "max_parallel":1, - "resource":{"kind":"mcp","name":"playwright","schema_digest":"sha256:tools-v1"}, - "evidence":{"task":"mcp-proof","run_id":"run-2","digest":"sha256:mcp"} - } - ]"#; - let selection = QualifiedResourceSelection { - kind: "mcp".into(), - name: "playwright".into(), - backend: None, - schema_digest: Some("sha256:tools-v1".into()), - version_digest: None, - }; - assert!( - resource_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &selection, - ) - .expect("resource qualification") - ); - let mismatched = QualifiedResourceSelection { - schema_digest: Some("sha256:tools-v2".into()), - ..selection.clone() - }; - assert!( - !resource_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &mismatched, - ) - .expect("resource qualification") - ); - let missing_digest = QualifiedResourceSelection { - schema_digest: None, - ..selection - }; - assert!( - !resource_is_qualified_in( - routes, - "OpenClaw", - "local-inference", - "gpt-oss-120b", - &missing_digest, - ) - .expect("resource qualification") - ); - } - - #[test] - fn resource_route_summary_lists_only_matching_resource_records() { - let routes = r#"[ - { - "runtime":"OpenClaw", - "provider":"local-inference", - "deployment":"gpt-oss-120b", - "capabilities":["skill","telemetry"], - "max_parallel":1, - "resource":{"kind":"channel","name":"telegram"}, - "evidence":{"task":"channel-proof","run_id":"run-1","digest":"sha256:chan"} - }, - { - "runtime":"Hermes", - "provider":"local-inference", - "deployment":"gpt-oss-120b", - "capabilities":["mcp","telemetry"], - "max_parallel":1, - "resource":{"kind":"mcp","name":"playwright","schema_digest":"sha256:tools-v1"}, - "evidence":{"task":"mcp-proof","run_id":"run-2","digest":"sha256:mcp"} - } - ]"#; - let selection = channel_resource_selection("telegram"); - assert_eq!( - resource_qualification_routes_in(routes, &selection).expect("summary"), - vec!["OpenClaw · local-inference::gpt-oss-120b".to_string()] - ); - let mcp = mcp_resource_selection(&super::RefOption { - name: "playwright".into(), - namespace: "demo".into(), - summary: None, - mode: None, - discovered_tools: Vec::new(), - tool_schema_digest: Some("sha256:tools-v1".into()), - compiled_digest: None, - backend: None, - readiness: None, - version: None, - recipe: None, - version_digest: None, - qualified_routes: Vec::new(), - }); - assert_eq!( - resource_qualification_routes_in(routes, &mcp).expect("summary"), - vec!["Hermes · local-inference::gpt-oss-120b".to_string()] - ); - } -} +mod qualification_tests; diff --git a/bridge/bff/src/routes/options/palette.rs b/bridge/bff/src/routes/options/palette.rs new file mode 100644 index 000000000..168812f08 --- /dev/null +++ b/bridge/bff/src/routes/options/palette.rs @@ -0,0 +1,413 @@ +// kars Bridge BFF — launch options palette. + +use axum::Json; +use axum::extract::State; + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +use super::{ + IsolationOption, McpProfileOption, ModelOption, Options, ProviderInfo, RefOption, + RuntimeOption, mcp_server_option, memory_option, name_of, ns_of, skill_option, +}; + +fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster> { + state.cluster().ok_or(AppError::ClusterUnavailable) +} + +fn upstream(e: kube::Error) -> AppError { + AppError::Upstream(e.to_string()) +} + +/// Infer a provider tag from a deployment string when none is recorded — a +/// `github-models`-style `openai/<model>` carries its vendor in the prefix. A +/// bare deployment name (the Copilot/Foundry form) carries no vendor, so we tag +/// it with the cluster's ACTUAL inherited provider (`github-copilot`, +/// `github-models`, or a Foundry/Azure provider) rather than guessing +/// `azure-openai`. This is what makes a composed mission/team stamp the real +/// provider the router serves — never a misleading default. +pub(crate) fn provider_for( + deployment: &str, + recorded: Option<&str>, + cluster_default: Option<&str>, +) -> String { + if let Some(p) = recorded { + return p.to_string(); + } + match deployment.split_once('/') { + Some(("openai", _)) => "github-models".to_string(), + Some((vendor, _)) => vendor.to_string(), + None => cluster_default.unwrap_or("azure-openai").to_string(), + } +} + +/// `GET /api/options` — the composable launch-package palette, from live state. +pub async fn get_options(State(state): State<AppState>) -> AppResult<Json<Options>> { + let cluster = require_cluster(&state)?; + Ok(Json(build_options(cluster).await?)) +} + +/// Build the real composable building blocks from live cluster state. Shared by +/// the `/api/options` route and the orchestrator (`/compose`), so the LLM only +/// ever proposes models, tool policies, MCP servers, isolation levels, and +/// memory stores that genuinely exist on this cluster. +pub async fn build_options(cluster: &crate::kars::cluster::Cluster) -> AppResult<Options> { + // Models: the controller-configured default + catalog, deduped against any + // distinct models already pinned on existing InferencePolicies (real, + // in-use facts). Order: default first, then catalog, then discovered. + let (default_model, catalog) = cluster.controller_models().await; + // The cluster's inherited inference provider — the authoritative tag for any + // catalog model that doesn't carry its own vendor prefix. Fetched up front + // so every offered model is stamped with the provider the router actually + // serves (e.g. `github-copilot`), not a neutral guess. + let provider = cluster + .controller_provider() + .await + .map(|(id, label, note)| ProviderInfo { id, label, note }); + let cluster_provider_id: Option<String> = provider.as_ref().map(|p| p.id.clone()); + // The operator's declared, served set — the only models we trust enough to + // offer. Discovered InferencePolicy models are surfaced ONLY if they are + // also in this set, so a stale policy pinning an unserved model (e.g. a + // decommissioned deployment) can't leak a broken option into the picker. + let catalog_set: std::collections::BTreeSet<String> = catalog + .iter() + .cloned() + .chain(default_model.clone()) + .collect(); + let mut models: Vec<ModelOption> = Vec::new(); + let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); + // Detail blurbs (deployment → "vendor · ctx · category") for models whose + // provider exposes them; applied in a post-pass so push_model stays simple. + let mut details: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new(); + let cpid = cluster_provider_id.clone(); + let mut push_model = |deployment: String, provider: Option<&str>, is_default: bool| { + if deployment.is_empty() || !seen.insert(deployment.clone()) { + return; + } + let provider = provider_for(&deployment, provider, cpid.as_deref()); + models.push(ModelOption { + provider, + deployment, + is_default, + detail: None, + }); + }; + if let Some(def) = default_model.clone() { + push_model(def, None, true); + } + for dep in catalog { + push_model(dep, None, false); + } + // Live GitHub Copilot catalog — when Copilot is the cluster's default + // provider, surface the seat's ACTUAL served models (gpt-5.6, opus-4.8, + // gemini-3.1-pro, …) instead of only the static KARS_MODEL_CATALOG. This + // is the SAME set the wizard's Copilot discovery shows, so the Model + // catalogue, the orchestrator's menu, and the manual-override picker all + // reflect what Copilot really serves — refreshing itself as GitHub adds + // models. Cached (5-min TTL); a transient Copilot failure leaves the + // static catalog intact (best-effort, never blanks the list). + if cluster_provider_id.as_deref() == Some("github-copilot") + && let Some(token) = cluster.controller_copilot_token().await + { + for (dep, _recommended, detail) in + crate::routes::operator::copilot_catalog_cached(&token).await + { + if let Some(d) = detail { + details.entry(dep.clone()).or_insert(d); + } + push_model(dep, Some("github-copilot"), false); + } + } + for ip in cluster + .list_kind_all("InferencePolicy") + .await + .map_err(upstream)? + { + let primary = ip + .data + .get("spec") + .and_then(|s| s.get("modelPreference")) + .and_then(|m| m.get("primary")); + if let Some(p) = primary { + let dep = p.get("deployment").and_then(|d| d.as_str()).unwrap_or(""); + // Only surface a discovered model if the operator's catalog declares + // it — never an arbitrary (possibly unserved) pinned deployment. + if !catalog_set.contains(dep) { + continue; + } + let prov = p.get("provider").and_then(|d| d.as_str()); + push_model(dep.to_string(), prov, false); + } + } + + // Additional providers (§ inference-provider-wizard): every model the + // operator explicitly declared when connecting a provider beyond the + // single default — e.g. a Foundry deployment or a GitHub Models id — + // tagged with ITS OWN provider, not the cluster default. These are + // operator-declared (trusted) the same way the default catalog is, so + // they don't need the catalog_set gate above. This is what lets + // InferencePolicy's model picker offer "gpt-4.1 via Foundry" alongside + // "opus-4.8 via GitHub Copilot" with no change to that editor — it + // already keys options by `provider::deployment`. + let provider_keys = cluster + .read_secret_all("kars-system", "kars-inference-providers") + .await + .map_err(upstream)?; + let mut declared_models: std::collections::BTreeMap<String, Vec<String>> = + std::collections::BTreeMap::new(); + for (key, value) in &provider_keys { + let Some(tag_part) = key + .strip_prefix("KARS_PROVIDER_") + .and_then(|r| r.strip_suffix("_MODELS")) + else { + continue; + }; + let tag = tag_part.to_ascii_lowercase().replace('_', "-"); + declared_models.insert( + tag, + value + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(), + ); + } + for (tag, deployments) in declared_models { + for dep in deployments { + push_model(dep, Some(tag.as_str()), false); + } + } + + // Apply detail blurbs (only some providers expose them). + if !details.is_empty() { + for m in models.iter_mut() { + if m.detail.is_none() + && let Some(d) = details.get(&m.deployment) + { + m.detail = Some(d.clone()); + } + } + } + + // Runtimes: the controller wires adapters for several harnesses, but a + // harness is only RUNNABLE here when its container image is configured and + // the controller has registry credentials that cover the image. + // `wired` now means "can start a pod here" — so the UI never lets a user (or + // the orchestrator) pick a harness that would ErrImagePull. `status`: + // ready = runnable on this cluster + // needs_image = adapter exists, but no image configured here + // unavailable = no adapter at all (SemanticKernel) + let runnable = cluster.runnable_runtimes().await; + let mk = |kind: &str, label: &str, ready_note: &str| { + let is_runnable = runnable.contains(kind); + RuntimeOption { + kind: kind.into(), + label: label.into(), + wired: is_runnable, + status: if is_runnable { + "ready".into() + } else { + "needs_image".into() + }, + note: if is_runnable { + ready_note.into() + } else { + "Adapter wired, but its runtime image or registry pull credential is unavailable on this cluster.".into() + }, + } + }; + let runtimes = vec![ + mk( + "OpenClaw", + "OpenClaw", + "Autonomous — the default kars harness, exercised end-to-end (full mesh + spawn). Runs missions and standing teams.", + ), + mk( + "Hermes", + "Hermes (Nous Research)", + "Autonomous — executes a delivered objective in-process and replies (plugins, 20+ channels, native MCP). Verified end-to-end.", + ), + mk( + "Anthropic", + "Anthropic Claude Agent SDK", + "Adapter only (pins the governed router) — you supply the agent logic. Not a turnkey autonomous harness; auto-corrected to OpenClaw for missions/teams.", + ), + mk( + "OpenAIAgents", + "OpenAI Agents SDK", + "Adapter only (routes through the inference sidecar) — you supply the agent logic. Not turnkey autonomous; auto-corrected to OpenClaw for missions/teams.", + ), + mk( + "MicrosoftAgentFramework", + "Microsoft Agent Framework", + "Adapter only (MAF Python, first-party AGT integration) — you supply the agent logic. Not turnkey autonomous; auto-corrected to OpenClaw.", + ), + mk( + "LangGraph", + "LangGraph", + "Adapter only (Python + TypeScript, pins the router) — you supply the graph. Not turnkey autonomous; auto-corrected to OpenClaw.", + ), + mk( + "PydanticAi", + "Pydantic-AI", + "Adapter only (provider-agnostic, pins the router at bootstrap) — you supply the agent. Not turnkey autonomous; auto-corrected to OpenClaw.", + ), + mk( + "BYO", + "Bring-your-own runtime", + "Autonomous by contract — any image honoring the BYO contract (UID 1000, router at 127.0.0.1:8443, consumes the objective + delivers).", + ), + RuntimeOption { + kind: "SemanticKernel".into(), + label: "Semantic Kernel".into(), + wired: false, + status: "unavailable".into(), + note: "Declared on the substrate but no adapter is wired yet.".into(), + }, + ]; + + let isolation = vec![ + IsolationOption { + value: "standard".into(), + label: "Standard".into(), + note: "Namespaced sandbox, default-deny egress, seccomp.".into(), + }, + IsolationOption { + value: "enhanced".into(), + label: "Enhanced".into(), + note: "Hardened profile for sensitive work.".into(), + }, + IsolationOption { + value: "confidential".into(), + label: "Confidential".into(), + note: "Confidential compute (CVM) where the node pool supports it.".into(), + }, + ]; + + let tool_policies = cluster + .list_kind_all("ToolPolicy") + .await + .map_err(upstream)? + .iter() + .map(|o| RefOption { + name: name_of(o), + namespace: ns_of(o), + summary: o + .data + .get("spec") + .and_then(|s| s.get("appliesTo")) + .and_then(|a| a.get("tool")) + .and_then(|t| t.as_str()) + .map(|t| format!("tools {t}")), + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: None, + compiled_digest: None, + backend: None, + readiness: None, + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }) + .collect(); + + let mut mcp_servers: Vec<RefOption> = cluster + .list_kind_all("McpServer") + .await + .map_err(upstream)? + .iter() + .map(mcp_server_option) + .collect(); + // Collapse duplicate registrations in the SAME namespace that point at the + // same endpoint URL. Identical endpoints in different namespaces are + // distinct workspace grants and must survive namespace filtering. + // the audit saw two identical Playwright servers offered side by side, which + // is confusing and invites a redundant grant. Keep the first per URL; servers + // with no URL are always kept (nothing to compare on). + { + let mut seen_urls: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); + mcp_servers.retain(|s| match s.summary.as_deref() { + Some(url) if !url.is_empty() => { + seen_urls.insert((s.namespace.clone(), url.to_string())) + } + _ => true, + }); + } + + let memories = cluster + .list_kind_all("KarsMemory") + .await + .map_err(upstream)? + .iter() + .map(memory_option) + .collect(); + + let skills = cluster + .list_kind_all("KarsSkill") + .await + .map_err(upstream)? + .iter() + // Operator trust gate: users may only assign skills an operator has + // approved AND locked to the skill's current version digest. A pending, + // never-approved, or changed-since-approval skill is withheld until + // (re)approved — the same rule the operator console enforces. + .filter(|o| { + let review = o + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/skill-review")) + .map(String::as_str); + let locked = o + .metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/skill-locked-digest")); + let digest = o + .data + .get("status") + .and_then(|s| s.get("versionDigest")) + .and_then(|d| d.as_str()); + review == Some("approved") && locked.is_some() && locked.map(String::as_str) == digest + }) + .map(skill_option) + .collect(); + + // Operator-curated MCP profiles (vetted bundles). Only surface servers that + // still exist on the cluster, so a deleted McpServer can't linger in a bundle. + let known_servers: std::collections::BTreeSet<String> = + mcp_servers.iter().map(|r| r.name.clone()).collect(); + let mcp_profiles: Vec<McpProfileOption> = { + let raw = cluster.read_mcp_profiles().await; + let parsed: Vec<crate::routes::operator::McpProfileDto> = + serde_json::from_str(&raw).unwrap_or_default(); + parsed + .into_iter() + .map(|p| McpProfileOption { + name: p.name, + summary: p.summary, + servers: p + .servers + .into_iter() + .filter(|s| known_servers.contains(s)) + .collect(), + }) + .collect() + }; + + Ok(Options { + models, + default_model, + provider, + runtimes, + isolation, + tool_policies, + mcp_servers, + mcp_profiles, + memories, + skills, + }) +} diff --git a/bridge/bff/src/routes/options/projections.rs b/bridge/bff/src/routes/options/projections.rs new file mode 100644 index 000000000..e3e7c2f6f --- /dev/null +++ b/bridge/bff/src/routes/options/projections.rs @@ -0,0 +1,119 @@ +// kars Bridge BFF — launch options projections. + +use kube::core::DynamicObject; + +use crate::providers::signing::sha256_hex; + +use super::qualification::{ + mcp_resource_selection, memory_resource_selection, resource_qualification_routes, + skill_resource_selection, +}; +use super::{RefOption, bool_at, name_of, ns_of, readiness_summary, string_array_at, string_at}; + +pub(crate) fn mcp_server_option(resource: &DynamicObject) -> RefOption { + let mode = string_at(&resource.data, &["/status/mode"]).or_else(|| { + bool_at(&resource.data, &["/spec/managed"]) + .map(|managed| if managed { "Managed" } else { "External" }.to_string()) + }); + let discovered_tools = string_array_at(&resource.data, &["/status/discoveredTools"]); + let tool_schema_digest = + string_at(&resource.data, &["/status/toolSchemaDigest"]).or_else(|| { + let signature = serde_json::json!({ + "mode": mode.clone(), + "endpoint": string_at( + &resource.data, + &["/status/endpoint", "/spec/url", "/spec/endpoint"], + ), + "allowed_tools": string_array_at(&resource.data, &["/spec/allowedTools"]), + "discovered_tools": discovered_tools.clone(), + }); + serde_json::to_vec(&signature) + .ok() + .map(|bytes| format!("sha256:{}", sha256_hex(bytes))) + }); + let mut option = RefOption { + name: name_of(resource), + namespace: ns_of(resource), + summary: string_at( + &resource.data, + &["/status/endpoint", "/spec/url", "/spec/endpoint"], + ), + mode, + discovered_tools, + tool_schema_digest, + compiled_digest: None, + backend: None, + readiness: readiness_summary(resource), + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }; + option.qualified_routes = + resource_qualification_routes(&mcp_resource_selection(&option)).unwrap_or_default(); + option +} + +pub(crate) fn memory_option(resource: &DynamicObject) -> RefOption { + let mut option = RefOption { + name: name_of(resource), + namespace: ns_of(resource), + summary: string_at( + &resource.data, + &["/spec/displayName", "/spec/storeName", "/status/storeName"], + ), + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: None, + compiled_digest: string_at( + &resource.data, + &[ + "/status/compiledDigest", + "/status/compiled/digest", + "/status/resolvedDigest", + "/status/specDigest", + ], + ), + backend: string_at( + &resource.data, + &[ + "/status/backend", + "/spec/backend", + "/status/binding/backend", + "/spec/binding/backend", + "/status/provider", + "/spec/provider", + ], + ) + .or_else(|| Some("foundry".into())), + readiness: readiness_summary(resource), + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }; + option.qualified_routes = + resource_qualification_routes(&memory_resource_selection(&option)).unwrap_or_default(); + option +} + +pub(crate) fn skill_option(resource: &DynamicObject) -> RefOption { + let mut option = RefOption { + name: name_of(resource), + namespace: ns_of(resource), + summary: string_at(&resource.data, &["/spec/summary"]), + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: None, + compiled_digest: None, + backend: None, + readiness: string_at(&resource.data, &["/status/phase"]), + version: string_at(&resource.data, &["/spec/version"]), + recipe: string_at(&resource.data, &["/spec/recipe"]), + version_digest: string_at(&resource.data, &["/status/versionDigest"]), + qualified_routes: Vec::new(), + }; + option.qualified_routes = + resource_qualification_routes(&skill_resource_selection(&option)).unwrap_or_default(); + option +} diff --git a/bridge/bff/src/routes/options/qualification.rs b/bridge/bff/src/routes/options/qualification.rs new file mode 100644 index 000000000..4e429b0e3 --- /dev/null +++ b/bridge/bff/src/routes/options/qualification.rs @@ -0,0 +1,559 @@ +// kars Bridge BFF — launch options qualification. + +use super::{Options, QualifiedResource, QualifiedResourceSelection, QualifiedRoute, RefOption}; + +fn route_matches( + route: &QualifiedRoute, + runtime: &str, + provider: &str, + deployment: &str, + max_parallel: i32, + total_tokens: Option<i64>, +) -> bool { + route.runtime.eq_ignore_ascii_case(runtime) + && route.provider == provider + && route.deployment == deployment + && max_parallel <= route.max_parallel + && route + .min_total_tokens + .is_none_or(|minimum| total_tokens.is_none_or(|tokens| tokens >= minimum)) +} + +fn evidence_complete(route: &QualifiedRoute) -> bool { + !route.evidence.task.trim().is_empty() + && !route.evidence.run_id.trim().is_empty() + && route.evidence.digest.starts_with("sha256:") +} + +fn resource_capability(kind: &str) -> Option<&'static str> { + match kind.to_ascii_lowercase().as_str() { + "mcp" => Some("mcp"), + "memory" => Some("memory"), + _ => None, + } +} + +fn resource_requires_backend(kind: &str) -> bool { + kind.eq_ignore_ascii_case("memory") +} + +fn resource_requires_schema_digest(kind: &str) -> bool { + kind.eq_ignore_ascii_case("mcp") || kind.eq_ignore_ascii_case("memory") +} + +fn resource_requires_version_digest(kind: &str) -> bool { + kind.eq_ignore_ascii_case("skill") +} + +fn resource_matches( + required: &QualifiedResourceSelection, + recorded: &QualifiedResource, + capabilities: &std::collections::BTreeSet<String>, +) -> bool { + if !recorded.kind.eq_ignore_ascii_case(&required.kind) + || !recorded.name.eq_ignore_ascii_case(&required.name) + { + return false; + } + if let Some(capability) = resource_capability(&required.kind) + && !capabilities.contains(capability) + { + return false; + } + if resource_requires_backend(&required.kind) && required.backend.is_none() { + return false; + } + if resource_requires_schema_digest(&required.kind) && required.schema_digest.is_none() { + return false; + } + if resource_requires_version_digest(&required.kind) && required.version_digest.is_none() { + return false; + } + if let Some(backend) = required.backend.as_deref() + && recorded.backend.as_deref() != Some(backend) + { + return false; + } + if let Some(schema_digest) = required.schema_digest.as_deref() + && recorded.schema_digest.as_deref() != Some(schema_digest) + { + return false; + } + if let Some(version_digest) = required.version_digest.as_deref() + && recorded.version_digest.as_deref() != Some(version_digest) + { + return false; + } + true +} + +fn qualification_records(raw: &str) -> Result<Vec<QualifiedRoute>, String> { + serde_json::from_str::<Vec<QualifiedRoute>>(raw) + .map_err(|error| format!("BRIDGE_QUALIFICATION_RECORDS_JSON is invalid: {error}")) +} + +fn qualification_records_from_env() -> Result<Vec<QualifiedRoute>, String> { + let raw = std::env::var("BRIDGE_QUALIFICATION_RECORDS_JSON") + .map_err(|_| "BRIDGE_QUALIFICATION_RECORDS_JSON is not configured".to_string())?; + let mut records = qualification_records(&raw)?; + if let Ok(additional) = std::env::var("BRIDGE_ADDITIONAL_QUALIFICATION_RECORDS_JSON") + && !additional.trim().is_empty() + { + records.extend(qualification_records(&additional).map_err(|error| { + error.replace( + "BRIDGE_QUALIFICATION_RECORDS_JSON", + "BRIDGE_ADDITIONAL_QUALIFICATION_RECORDS_JSON", + ) + })?); + } + Ok(records) +} + +fn qualification_records_raw_from_env() -> Result<String, String> { + serde_json::to_string(&qualification_records_from_env()?) + .map_err(|error| format!("qualification records could not be serialized: {error}")) +} + +pub(crate) fn route_label(runtime: &str, provider: &str, deployment: &str) -> String { + format!("{runtime} · {provider}::{deployment}") +} + +pub(super) fn resource_qualification_routes_in( + raw: &str, + resource: &QualifiedResourceSelection, +) -> Result<Vec<String>, String> { + let mut labels = qualification_records(raw)? + .into_iter() + .filter(evidence_complete) + .filter_map(|route| { + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::<std::collections::BTreeSet<_>>(); + route + .resource + .as_ref() + .filter(|recorded| resource_matches(resource, recorded, &capabilities)) + .map(|_| route_label(&route.runtime, &route.provider, &route.deployment)) + }) + .collect::<Vec<_>>(); + labels.sort(); + labels.dedup(); + Ok(labels) +} + +pub(super) fn resource_is_qualified_in( + raw: &str, + runtime: &str, + provider: &str, + deployment: &str, + resource: &QualifiedResourceSelection, +) -> Result<bool, String> { + Ok(qualification_records(raw)?.into_iter().any(|route| { + if !evidence_complete(&route) + || !route_matches(&route, runtime, provider, deployment, 1, None) + { + return false; + } + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::<std::collections::BTreeSet<_>>(); + route + .resource + .as_ref() + .is_some_and(|recorded| resource_matches(resource, recorded, &capabilities)) + })) +} + +pub(super) fn resource_qualification_routes( + resource: &QualifiedResourceSelection, +) -> Result<Vec<String>, String> { + let raw = qualification_records_raw_from_env()?; + resource_qualification_routes_in(&raw, resource) +} + +fn resource_is_qualified( + runtime: &str, + provider: &str, + deployment: &str, + resource: &QualifiedResourceSelection, +) -> Result<bool, String> { + let raw = qualification_records_raw_from_env()?; + resource_is_qualified_in(&raw, runtime, provider, deployment, resource) +} + +pub(super) fn mcp_resource_selection(option: &RefOption) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "mcp".into(), + name: option.name.clone(), + backend: None, + schema_digest: option.tool_schema_digest.clone(), + version_digest: None, + } +} + +pub(super) fn memory_resource_selection(option: &RefOption) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "memory".into(), + name: option.name.clone(), + backend: option.backend.clone(), + schema_digest: option.compiled_digest.clone(), + version_digest: None, + } +} + +pub(super) fn skill_resource_selection(option: &RefOption) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "skill".into(), + name: option.name.clone(), + backend: None, + schema_digest: None, + version_digest: option.version_digest.clone(), + } +} + +pub(super) fn channel_resource_selection(channel: &str) -> QualifiedResourceSelection { + QualifiedResourceSelection { + kind: "channel".into(), + name: channel.to_ascii_lowercase(), + backend: None, + schema_digest: None, + version_digest: None, + } +} + +pub(crate) fn mcp_server_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + option: &RefOption, +) -> Result<bool, String> { + resource_is_qualified( + runtime, + provider, + deployment, + &mcp_resource_selection(option), + ) +} + +pub(crate) fn memory_binding_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + option: &RefOption, +) -> Result<bool, String> { + resource_is_qualified( + runtime, + provider, + deployment, + &memory_resource_selection(option), + ) +} + +pub(crate) fn skill_version_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + option: &RefOption, +) -> Result<bool, String> { + resource_is_qualified( + runtime, + provider, + deployment, + &skill_resource_selection(option), + ) +} + +pub(crate) fn channel_adapter_qualified_for_route( + runtime: &str, + provider: &str, + deployment: &str, + channel: &str, +) -> Result<bool, String> { + resource_is_qualified( + runtime, + provider, + deployment, + &channel_resource_selection(channel), + ) +} + +pub(crate) fn resource_qualification_summary(options: &Options) -> Result<String, String> { + let mut lines: Vec<String> = Vec::new(); + for server in &options.mcp_servers { + let routes = resource_qualification_routes(&mcp_resource_selection(server))?; + lines.push(format!( + " - MCP \"{}\"{}{}{}", + server.name, + server + .tool_schema_digest + .as_deref() + .map(|digest| format!(" schema_digest={digest}")) + .unwrap_or_else(|| " schema_digest=missing".into()), + if server.discovered_tools.is_empty() { + String::new() + } else { + format!(" tools=[{}]", server.discovered_tools.join(", ")) + }, + if routes.is_empty() { + " qualified_on=(none)".into() + } else { + format!(" qualified_on=[{}]", routes.join("; ")) + } + )); + } + for memory in &options.memories { + let routes = resource_qualification_routes(&memory_resource_selection(memory))?; + lines.push(format!( + " - MEMORY \"{}\"{}{}{}{}", + memory.name, + memory + .backend + .as_deref() + .map(|backend| format!(" backend={backend}")) + .unwrap_or_else(|| " backend=missing".into()), + memory + .compiled_digest + .as_deref() + .map(|digest| format!(" compiled_digest={digest}")) + .unwrap_or_else(|| " compiled_digest=missing".into()), + memory + .readiness + .as_deref() + .map(|readiness| format!(" readiness={readiness}")) + .unwrap_or_default(), + if routes.is_empty() { + " qualified_on=(none)".into() + } else { + format!(" qualified_on=[{}]", routes.join("; ")) + } + )); + } + for skill in &options.skills { + let routes = resource_qualification_routes(&skill_resource_selection(skill))?; + lines.push(format!( + " - SKILL \"{}\"{}{}{}{}", + skill.name, + skill + .version + .as_deref() + .map(|version| format!(" version={version}")) + .unwrap_or_default(), + skill + .version_digest + .as_deref() + .map(|digest| format!(" version_digest={digest}")) + .unwrap_or_else(|| " version_digest=missing".into()), + skill + .recipe + .as_deref() + .map(|recipe| format!(" recipe={}", recipe.chars().take(180).collect::<String>())) + .unwrap_or_default(), + if routes.is_empty() { + " qualified_on=(none)".into() + } else { + format!(" qualified_on=[{}]", routes.join("; ")) + } + )); + } + if lines.is_empty() { + Ok(" (no MCP, memory, or approved-skill resources are available)".into()) + } else { + Ok(lines.join("\n")) + } +} + +pub(crate) fn qualification_constraints_summary() -> Result<String, String> { + let routes = qualification_records_from_env()?; + if routes.is_empty() { + return Ok(" (no qualified execution routes are retained)".into()); + } + Ok(routes + .into_iter() + .map(|route| { + format!( + " - {} · capabilities=[{}] · max_parallel={} · min_total_tokens={}{}", + route_label(&route.runtime, &route.provider, &route.deployment), + route.capabilities.join(","), + route.max_parallel, + route + .min_total_tokens + .map(|tokens| tokens.to_string()) + .unwrap_or_else(|| "none".into()), + route + .resource + .map(|resource| { + format!( + " · resource={}::{}{}{}{}", + resource.kind, + resource.name, + resource + .backend + .as_deref() + .map(|backend| format!(" backend={backend}")) + .unwrap_or_default(), + resource + .schema_digest + .as_deref() + .map(|digest| format!(" schema_digest={digest}")) + .unwrap_or_default(), + resource + .version_digest + .as_deref() + .map(|digest| format!(" version_digest={digest}")) + .unwrap_or_default(), + ) + }) + .unwrap_or_default(), + ) + }) + .collect::<Vec<_>>() + .join("\n")) +} + +pub(crate) fn route_minimum_tokens( + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, +) -> Result<Option<i64>, String> { + let routes = qualification_records_from_env()?; + let matching = routes.into_iter().filter(|route| { + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::<std::collections::BTreeSet<_>>(); + route_matches(route, runtime, provider, deployment, max_parallel, None) + && required_capabilities.is_subset(&capabilities) + }); + let mut minimum: Option<i64> = None; + for route in matching { + let Some(tokens) = route.min_total_tokens else { + return Ok(None); + }; + minimum = Some(minimum.map_or(tokens, |current| current.min(tokens))); + } + Ok(minimum) +} + +pub(crate) fn route_qualification( + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, + total_tokens: Option<i64>, +) -> Result<bool, String> { + let raw = qualification_records_raw_from_env()?; + route_is_qualified_in( + &raw, + runtime, + provider, + deployment, + required_capabilities, + max_parallel, + total_tokens, + ) +} + +pub(crate) fn route_qualification_gap( + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, + total_tokens: Option<i64>, +) -> Result<std::collections::BTreeSet<String>, String> { + let raw = qualification_records_raw_from_env()?; + route_qualification_gap_in( + &raw, + runtime, + provider, + deployment, + required_capabilities, + max_parallel, + total_tokens, + ) +} + +pub(super) fn route_qualification_gap_in( + raw: &str, + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, + total_tokens: Option<i64>, +) -> Result<std::collections::BTreeSet<String>, String> { + let routes = qualification_records(raw)?; + let mut gaps = routes + .into_iter() + .filter_map(|route| { + if !evidence_complete(&route) + || !route_matches( + &route, + runtime, + provider, + deployment, + max_parallel, + total_tokens, + ) + { + return None; + } + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::<std::collections::BTreeSet<_>>(); + Some( + required_capabilities + .difference(&capabilities) + .cloned() + .collect::<std::collections::BTreeSet<_>>(), + ) + }) + .collect::<Vec<_>>(); + gaps.sort_by(|left, right| { + left.len() + .cmp(&right.len()) + .then_with(|| left.iter().cmp(right.iter())) + }); + Ok(gaps + .into_iter() + .next() + .unwrap_or_else(|| required_capabilities.clone())) +} + +pub(super) fn route_is_qualified_in( + raw: &str, + runtime: &str, + provider: &str, + deployment: &str, + required_capabilities: &std::collections::BTreeSet<String>, + max_parallel: i32, + total_tokens: Option<i64>, +) -> Result<bool, String> { + let routes = qualification_records(raw)?; + Ok(routes.into_iter().any(|route| { + let capabilities = route + .capabilities + .iter() + .cloned() + .collect::<std::collections::BTreeSet<_>>(); + route_matches( + &route, + runtime, + provider, + deployment, + max_parallel, + total_tokens, + ) && evidence_complete(&route) + && required_capabilities.is_subset(&capabilities) + })) +} diff --git a/bridge/bff/src/routes/options/qualification_tests.rs b/bridge/bff/src/routes/options/qualification_tests.rs new file mode 100644 index 000000000..a71e31285 --- /dev/null +++ b/bridge/bff/src/routes/options/qualification_tests.rs @@ -0,0 +1,252 @@ +// kars Bridge BFF — launch qualification regression tests. + +use super::{ + QualifiedResourceSelection, channel_resource_selection, mcp_resource_selection, + resource_is_qualified_in, resource_qualification_routes_in, route_is_qualified_in, + route_qualification_gap_in, +}; +use std::collections::BTreeSet; + +#[test] +fn qualification_requires_route_capabilities_constraints_and_evidence() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["delegation","filesystem-read","shell","network","artifacts","telemetry"], + "max_parallel":1, + "min_total_tokens":128144, + "evidence":{ + "task":"openclaw-proof", + "run_id":"run-1", + "digest":"sha256:abc" + } + + } + ]"#; + let required = ["delegation", "shell", "telemetry"] + .into_iter() + .map(str::to_string) + .collect::<BTreeSet<_>>(); + assert!( + route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + Some(128_144) + ) + .expect("valid routes") + ); + assert!( + route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + None + ) + .expect("an uncapped route is not below the retained minimum") + ); + let unsupported = ["delegation", "memory"] + .into_iter() + .map(str::to_string) + .collect::<BTreeSet<_>>(); + assert!( + !route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &unsupported, + 1, + Some(128_144) + ) + .expect("valid routes") + ); + assert!( + !route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 2, + Some(128_144) + ) + .expect("valid routes") + ); + assert!( + !route_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + Some(100_000) + ) + .expect("valid routes") + ); + assert!(route_is_qualified_in("{", "OpenClaw", "x", "y", &required, 1, None).is_err()); +} + +#[test] +fn qualification_gap_reports_only_capabilities_missing_from_closest_record() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["delegation","web-search","network","artifacts","telemetry"], + "max_parallel":1, + "min_total_tokens":300000, + "evidence":{"task":"research-proof","run_id":"run-1","digest":"sha256:abc"} + } + ]"#; + let required = [ + "artifacts", + "delegation", + "mcp", + "network", + "telemetry", + "web-search", + ] + .into_iter() + .map(str::to_string) + .collect::<BTreeSet<_>>(); + assert_eq!( + route_qualification_gap_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &required, + 1, + Some(300000), + ) + .expect("gap"), + ["mcp".to_string()].into_iter().collect() + ); +} + +#[test] +fn resource_qualification_requires_matching_current_digest_and_ignores_generic_routes() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["mcp","network","telemetry"], + "max_parallel":1, + "evidence":{"task":"generic-proof","run_id":"run-1","digest":"sha256:generic"} + }, + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["mcp","network","telemetry"], + "max_parallel":1, + "resource":{"kind":"mcp","name":"playwright","schema_digest":"sha256:tools-v1"}, + "evidence":{"task":"mcp-proof","run_id":"run-2","digest":"sha256:mcp"} + } + ]"#; + let selection = QualifiedResourceSelection { + kind: "mcp".into(), + name: "playwright".into(), + backend: None, + schema_digest: Some("sha256:tools-v1".into()), + version_digest: None, + }; + assert!( + resource_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &selection, + ) + .expect("resource qualification") + ); + let mismatched = QualifiedResourceSelection { + schema_digest: Some("sha256:tools-v2".into()), + ..selection.clone() + }; + assert!( + !resource_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &mismatched, + ) + .expect("resource qualification") + ); + let missing_digest = QualifiedResourceSelection { + schema_digest: None, + ..selection + }; + assert!( + !resource_is_qualified_in( + routes, + "OpenClaw", + "local-inference", + "gpt-oss-120b", + &missing_digest, + ) + .expect("resource qualification") + ); +} + +#[test] +fn resource_route_summary_lists_only_matching_resource_records() { + let routes = r#"[ + { + "runtime":"OpenClaw", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["skill","telemetry"], + "max_parallel":1, + "resource":{"kind":"channel","name":"telegram"}, + "evidence":{"task":"channel-proof","run_id":"run-1","digest":"sha256:chan"} + }, + { + "runtime":"Hermes", + "provider":"local-inference", + "deployment":"gpt-oss-120b", + "capabilities":["mcp","telemetry"], + "max_parallel":1, + "resource":{"kind":"mcp","name":"playwright","schema_digest":"sha256:tools-v1"}, + "evidence":{"task":"mcp-proof","run_id":"run-2","digest":"sha256:mcp"} + } + ]"#; + let selection = channel_resource_selection("telegram"); + assert_eq!( + resource_qualification_routes_in(routes, &selection).expect("summary"), + vec!["OpenClaw · local-inference::gpt-oss-120b".to_string()] + ); + let mcp = mcp_resource_selection(&super::RefOption { + name: "playwright".into(), + namespace: "demo".into(), + summary: None, + mode: None, + discovered_tools: Vec::new(), + tool_schema_digest: Some("sha256:tools-v1".into()), + compiled_digest: None, + backend: None, + readiness: None, + version: None, + recipe: None, + version_digest: None, + qualified_routes: Vec::new(), + }); + assert_eq!( + resource_qualification_routes_in(routes, &mcp).expect("summary"), + vec!["Hermes · local-inference::gpt-oss-120b".to_string()] + ); +} From 8f0232e1984b8dbf1e4f6075f2aa4f9b0776e732 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 00:24:40 +0200 Subject: [PATCH 037/111] Split web DTOs by domain while preserving the public type surface Move138unchanged declarations/exports into bounded type-only modules, keeping both runtime label objects and the original barrel. Exact locked TypeScript5.9.3 checks source graph/export parity;22pure DTO/form contracts and19gateway contracts passed locally. Full framework cache is unavailable at its lock version, so hosted web lint/typecheck/build remains required; no dependency installs or cache changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 4 +- bridge/docs/contributing.md | 7 + bridge/web/src/lib/types.ts | 1619 +-------------------- bridge/web/src/lib/types/governance.ts | 161 ++ bridge/web/src/lib/types/missions.ts | 360 +++++ bridge/web/src/lib/types/operations.ts | 300 ++++ bridge/web/src/lib/types/operator.ts | 139 ++ bridge/web/src/lib/types/orchestration.ts | 175 +++ bridge/web/src/lib/types/system.ts | 92 ++ bridge/web/src/lib/types/teams.ts | 299 ++++ bridge/web/src/lib/types/workspace.ts | 98 ++ bridge/web/tests/type-contract.test.mjs | 81 ++ 12 files changed, 1724 insertions(+), 1611 deletions(-) create mode 100644 bridge/web/src/lib/types/governance.ts create mode 100644 bridge/web/src/lib/types/missions.ts create mode 100644 bridge/web/src/lib/types/operations.ts create mode 100644 bridge/web/src/lib/types/operator.ts create mode 100644 bridge/web/src/lib/types/orchestration.ts create mode 100644 bridge/web/src/lib/types/system.ts create mode 100644 bridge/web/src/lib/types/teams.ts create mode 100644 bridge/web/src/lib/types/workspace.ts create mode 100644 bridge/web/tests/type-contract.test.mjs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index ed66ad945..c8a78f0f0 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -83,8 +83,8 @@ jobs: - run: npm ci - run: npm run lint - run: npx --no-install tsc --noEmit - - name: Check credential form review and resubmission - run: node --experimental-strip-types --test tests/credential-review.test.mjs + - name: Check credential form and shared DTO contracts + run: node --experimental-strip-types --test tests/credential-review.test.mjs tests/type-contract.test.mjs - name: Build the production web image without publishing run: docker build --tag kars-bridge-web-qualification:latest . - name: Start web with an immutable root filesystem diff --git a/bridge/docs/contributing.md b/bridge/docs/contributing.md index 0050f5562..f5b3f9087 100644 --- a/bridge/docs/contributing.md +++ b/bridge/docs/contributing.md @@ -39,6 +39,13 @@ kubeconfig. ## Permanent core/Bridge CI boundary +The web DTO surface remains available from `@/lib/types`. Domain files under +`web/src/lib/types/` use type-only cross-imports; the public barrel re-exports +their complete API. They mirror BFF DTOs rather than owning server contracts. +The only runtime values are the existing `TIER_LABELS` and `WIRING_LABELS` +literal objects. The Node contract test checks the locked compiler, complete +barrel exports, erased imports, inert runtime values and per-file bounds. + The integration candidate's core Rust, CLI and Kind jobs check out the repository with `bridge/` physically absent. Core builds and runtime acceptance must not acquire a mandatory dependency on the add-on. diff --git a/bridge/web/src/lib/types.ts b/bridge/web/src/lib/types.ts index 1f7baedf5..70f485c75 100644 --- a/bridge/web/src/lib/types.ts +++ b/bridge/web/src/lib/types.ts @@ -1,1611 +1,12 @@ // kars Bridge web — shared types mirroring the BFF API DTOs. // The BFF (Rust) owns these shapes; keep field names in sync with -// bff/src/routes/tasks.rs. - -export interface Budget { - scope?: "GovernedInference"; - tokens: number | null; - usd_micros: number | null; -} - -export interface Envelope { - tier: number; - authority_ceiling: number; - delegation_depth: number; - budget: Budget | null; - tool_policy: string | null; - egress_allowlist: string | null; -} - -export interface TaskSummary { - name: string; - namespace: string; - objective: string; - display_name: string | null; - created_at: string | null; - tier: number; - phase: string; - envelope_digest: string | null; - team: string | null; - delivered: boolean; - failed: boolean; - launched: boolean; - execution_phase: string | null; -} - -export interface TaskDetail { - name: string; - namespace: string; - objective: string; - display_name: string | null; - created_at: string | null; - envelope: Envelope; - phase: string; - envelope_digest: string | null; - observed_generation: number | null; - lineage: string[]; - parent: string | null; - /** The standing team that owns this task (from kars.azure.com/team). */ - team: string | null; - status_message: string | null; - children: TaskSummary[]; - launched: boolean; - execution_phase: string | null; - sandbox: string | null; - egress_mode: string | null; - execution_detail: string | null; - assignment: TaskAssignmentStatus | null; - assignment_events: TaskAssignmentEvent[]; - assignment_sequence: number | null; - composition: Composition | null; - sub_agents: SubAgent[]; - result: MissionResult | null; - artifacts: MissionArtifact[]; - role_plan: TeamRolePlan; - collaboration_events: TeamCollaborationEvent[]; - /** Pull requests the mission opened — first-class deliverables shown on the - * Artifacts tab (a PR is a delivery type). Empty when none. */ - pull_requests?: PullRequestRef[]; - activity: ActivityEvent[]; - telemetry: MissionTelemetry | null; - checkpoint: TaskCheckpoint | null; - agent_identity: AgentIdentity | null; - /** A governed capability-routing correction recorded at creation (e.g. a - * chat-gateway harness swapped to an autonomous one for a one-shot mission). - * Null when no correction was needed. */ - harness_corrected: string | null; - /** A governed emergency-stop decision (operator/reason/at) when the mission - * was halted. Null when never halted. */ - halted: string | null; - /** Whether a run has ever been requested (the run-requested annotation is set). - * Gates the client auto-kickoff so the first run fires exactly once. */ - run_requested: boolean; - /** Exact latest requested run nonce, available before assignment acknowledgement. */ - current_run_nonce: string | null; -} - -export interface TeamRolePlan { - selected_roles: string[]; - skipped_roles: string[]; -} - -export interface TeamCollaborationEvent { - at: string | null; - event: string; - agent: string | null; - member: string | null; - outcome: string | null; - message_id: string | null; - reply_preview: string | null; - content_preview: string | null; -} - -export interface TaskCheckpoint { - schema: string; - milestone_id: string; - status: "pending" | "in_progress" | "completed" | "blocked"; - summary: string; - acceptance_criteria?: string[]; - artifacts?: string[]; - next_steps?: string[]; - updated_at?: string; - agent?: string; -} - -export interface TaskAssignmentStatus { - task_id: string; - state: string; - worker_did: string | null; - stage: string | null; - child_task_id: string | null; - child_role: string | null; - last_progress_at: string | null; - completed_at: string | null; - error: string | null; -} - -export interface TaskAssignmentEvent { - sequence: number; - event_id: string; - task_id: string; - event_type: string; - state: string; - at: string; - worker_did: string | null; - stage: string | null; - child_task_id: string | null; - child_role: string | null; - outcome: string | null; - message: string | null; -} - -/** Loop-shape telemetry for a mission run (token totals are on MissionResult). */ -export interface MissionTelemetry { - rounds: number | null; - tool_calls: number | null; -} - -/** One event in the agent's live execution trace. A `round` event records the - * model call (real token usage); a `tool` event records one tool invocation - * with a sanitized args/result preview. */ -export type ActivityEvent = - | { - kind: "round"; - round: number; - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - finish_reason: string; - tool_calls: number; - ms: number; - ts: string; - /** The agent (sandbox) that emitted this event, and its role in the tree. - * Present when the stream aggregates the whole agent tree; absent for a - * single-agent trace read from the persisted ConfigMap. */ - agent?: string; - agentInstance?: string; - agentRole?: "principal" | "subagent"; - seq?: number; - } - | { - kind: "tool"; - round: number; - name: string; - args_preview: string; - result_preview: string; - ms: number; - ok: boolean; - ts: string; - agent?: string; - agentInstance?: string; - agentRole?: "principal" | "subagent"; - seq?: number; - /** Present on tools authoritatively executed and recorded by the router. */ - source?: "router" | "harness" | "governance"; - }; - -/** One artifact file in a mission's deliverable set. `content` is present for - * text artifacts (markdown/json/csv/…) and null for binary ones. */ -export interface MissionArtifact { - name: string; - size_bytes: number | null; - content: string | null; - content_bytes: number | null; - content_truncated: boolean; - source_agent: string | null; - source_path: string | null; - digest: string | null; -} - -/** A running agent's real mesh identity, discovered from the AGT registry. */ -export interface AgentIdentity { - did: string; - capabilities: string[]; - last_seen: string | null; - reputation_score: number | null; -} - -/** A captured mission run result — a real deliverable + real token cost. */ -export interface MissionResult { - output: string; - status: string | null; - model: string | null; - total_tokens: number | null; - prompt_tokens: number | null; - completion_tokens: number | null; - finished_at: string | null; - assignment_nonce: string | null; - /** How the deliverable was produced. "single_turn" = one model turn (no - * tools/sub-agents) because the mesh agent loop was unavailable; absent for - * a full agent-loop run. */ - source: string | null; - /** Set when this run's ok-output is actually a capability/limit STOP (today the - * daily token budget), not a deliverable — rendered as an actionable state. */ - blocked: RunBlocked | null; - artifact_persistence: "complete" | "partial" | null; - artifact_count: number | null; - declared_artifact_count: number | null; -} - -export interface RunBlocked { - /** Machine reason. Today: "budget". */ - reason: string; - detail: string; - spent: number | null; - limit: number | null; -} - -/** A sub-agent the mission's agent spawned at run time (a labelled sandbox). */ -export interface SubAgent { - name: string; - namespace: string; - phase: string | null; - runtime: string | null; - role: string | null; - parent: string | null; - logical_agent_id: string | null; - model: string | null; -} - -/** The composed run — what a mission actually runs with (from the blueprint). */ -export interface Composition { - runtime: string | null; - model: string | null; - instructions: string | null; - tool_policy: string | null; - mcp_servers: string[]; - egress: string[]; - isolation: string | null; - memory: string | null; -} - -export interface CreateTaskRequest { - name: string; - objective: string; - display_name: string | null; - envelope: Envelope; - parent?: string | null; - blueprint?: Blueprint | null; - delegation?: MissionDelegation | null; - launch?: boolean; - /** Repos (owner/name) from this principal's GitHub connection. The BFF - * validates the complete set and derives the connection reference. */ - git_write_repos?: string[] | null; - /** The creating principal, for per-user budget attribution. */ - created_by?: string | null; -} - -/** A model route — provider tag + deployment, lands on InferencePolicy. */ -export interface BlueprintModel { - provider: string; - deployment: string; -} - -/** A network destination the mission may reach. */ -export interface BlueprintEgress { - host: string; - port?: number | null; -} - -/** - * The editable run composition reviewed on the launch package. Every field maps - * to a real field on the materialized InferencePolicy / KarsSandbox; the - * controller compiles it. Mirrors bff/src/kars/task.rs::TaskBlueprint. - */ -export interface Blueprint { - runtime?: string | null; - model?: BlueprintModel | null; - model_fallbacks?: BlueprintModel[]; - instructions?: string | null; - tool_policy?: string | null; - mcp_servers?: string[]; - egress?: BlueprintEgress[]; - egress_mode?: "strict" | "learning"; - isolation?: string | null; - memory?: string | null; - skills?: string[]; - execution_plan?: ExecutionPlan | null; -} - -export interface ExecutionPlan { - schema: "kars.execution-plan/v1"; - roles: ExecutionRole[]; - max_parallel: number; - synthesis: ExecutionSynthesis; - deliverables: ExecutionDeliverable[]; -} - -export interface ExecutionRole { - name: string; - objective: string; - depends_on: string[]; - phases: ExecutionPhase[]; - budget_tokens?: number | null; -} - -export interface ExecutionPhase { - name: string; - objective: string; - capabilities: ExecutionCapability[]; - required_tool_calls?: ExecutionRequiredToolCall[]; - min_tool_calls?: number; - max_tool_calls: number; - fresh_context: boolean; -} - -export interface ExecutionRequiredToolCall { - name: "github_actions_job_logs"; - arguments: Record<string, string>; -} - -export type ExecutionCapability = - | "filesystem-read" - | "filesystem-write" - | "shell" - | "network" - | "mcp" - | "memory"; - -export interface ExecutionSynthesis { - objective: string; - capabilities: ExecutionCapability[]; - max_tool_calls: number; -} - -export interface ExecutionDeliverable { - name: string; - media_type?: string | null; -} - -// ─── Launch-package options (from /api/options) ────────────────────────────── - -export interface ModelOption { - provider: string; - deployment: string; - is_default: boolean; - /** Short human detail (e.g. "Anthropic · 1.0M ctx · powerful"), when known. */ - detail?: string | null; -} -export interface RuntimeOption { - kind: string; - label: string; - wired: boolean; - status: "ready" | "needs_image" | "unavailable" | "validated" | "available"; - note: string; -} -export interface ProviderInfo { - id: string; - label: string; - note: string; -} -/** An additional inference provider configured alongside the single - * default — e.g. GitHub Copilot as the default plus Azure AI Foundry also - * connected. `has_key` only reports whether a dev-mode key is stored, never - * the value. `models` are the deployment ids this provider serves, feeding - * the shared model catalog tagged with this provider's own tag. */ -export interface AdditionalProvider { - tag: string; - endpoint: string | null; - has_key: boolean; - models: string[]; -} -export interface RefOption { - name: string; - namespace: string; - summary: string | null; - mode?: string | null; - discovered_tools?: string[]; - tool_schema_digest?: string | null; - compiled_digest?: string | null; - backend?: string | null; - readiness?: string | null; - version?: string | null; - recipe?: string | null; - version_digest?: string | null; - qualified_routes?: string[]; -} -export interface IsolationOption { - value: string; - label: string; - note: string; -} -export interface Options { - models: ModelOption[]; - default_model: string | null; - provider: ProviderInfo | null; - runtimes: RuntimeOption[]; - isolation: IsolationOption[]; - tool_policies: RefOption[]; - mcp_servers: RefOption[]; - mcp_profiles: McpProfileOption[]; - memories: RefOption[]; - skills: RefOption[]; -} - -/** Operator-curated MCP bundle (a vetted set of McpServers). */ -export interface McpProfileOption { - name: string; - summary: string | null; - servers: string[]; -} - -// ─── Orchestrator: intent → composed launch package (§20) ──────────────────── - -export interface ComposeProposal { - tier: number; - model: BlueprintModel | null; - model_fallbacks: BlueprintModel[]; - model_basis: string | null; - runtime: string; - instructions: string; - tool_policy: string | null; - mcp_servers: string[]; - skills: string[]; - egress: BlueprintEgress[]; - isolation: string; - memory: string | null; - budget_tokens: number | null; - execution_plan: ExecutionPlan | null; - delegation: MissionDelegation; -} - -export interface MissionDelegationRole { - name: string; - objective: string; -} - -export interface MissionDelegation { - mode: "single-agent" | "principal-specialists"; - roles: MissionDelegationRole[]; - max_parallel: number; -} -export interface ComposeResponse { - available: boolean; - reason: string | null; - proposal: ComposeProposal | null; - rationale: string | null; - source: string | null; -} - -// ─── Team orchestrator: charter → org chart ────────────────────────────────── - -export interface ComposeTeamRole { - name: string; - system_prompt: string; - runtime: string; - model: string; - skills: string[]; -} - -export interface ComposeTeamProposal { - tier: number; - cadence_minutes: number; - instructions: string; - model: string; - model_fallbacks: string[]; - model_basis: string | null; - expected_tokens_per_outcome: number | null; - efficiency_sample_runs: number; - mcp_servers: string[]; - memory: string | null; - egress: BlueprintEgress[]; - egress_mode: "learning" | "strict"; - engineering_enabled: boolean; - engineering_signals: EngineeringSignal[]; - engineering_poll_interval_seconds: number; - engineering_auto_run: boolean; - roles: ComposeTeamRole[]; - execution_plan: ExecutionPlan | null; - milestones: ComposeTeamMilestone[]; -} - -export interface TeamChannelStatus { - channel: string; - enabled: boolean; - qualified?: boolean | null; - detail?: string | null; -} - -export interface TeamChannelsState { - enabled: string[]; - statuses: TeamChannelStatus[]; -} - -export interface ComposeTeamMilestone { - id: string; - title: string; - description: string; - owner_role: string | null; - depends_on: string[]; - acceptance_criteria: string[]; - review_required: boolean; -} - -export interface ComposeTeamResponse { - available: boolean; - reason: string | null; - proposal: ComposeTeamProposal | null; - rationale: string | null; - source: string | null; -} - -// ─── Artifacts index (cross-mission deliverables, §16) ─────────────────────── - -export interface ArtifactFile { - name: string; - size_bytes: number | null; - has_content: boolean; - content_address: string | null; - did: string | null; -} -export interface MissionArtifacts { - task: string; - evidence_key: string | null; - team: string | null; - archived: boolean; - display_name: string | null; - objective: string | null; - model: string | null; - finished_at: string | null; - status: string | null; - review_status: string; - review_revision: number; - files: ArtifactFile[]; - summary: string | null; - excerpt: string | null; - pull_requests: PullRequestRef[]; - deliverable_did: string | null; -} -export interface PullRequestRef { - repo: string; - number: number; - url: string; -} -export interface ArtifactsIndex { - missions: MissionArtifacts[]; -} - -/** One level of the hierarchical inference token budget (cluster / workspace), - * with the live measured daily usage and computed enforcement status. Mirrors - * bff/src/routes/budgets.rs::BudgetLevelDto. */ -export interface BudgetLevel { - scope: string; - label: string; - daily_tokens: number; - mode: "passive" | "buffer" | "strict"; - buffer_percent: number; - used_today: number; - status: "ok" | "alert" | "over_buffer_headroom" | "blocking"; - percent: number; - hard_cap: number; -} -export interface InferenceBudgets { - cluster: BudgetLevel | null; - cluster_used_today: number; - workspaces: BudgetLevel[]; - users: BudgetLevel[]; - default_namespace: string; - unbudgeted_namespaces: string[]; - unbudgeted_users: string[]; - alerts?: BudgetAlert[]; -} -export interface BudgetAlert { - scope: string; - label: string; - severity: "alert" | "over_buffer" | "blocking"; - message: string; -} - -// ─── Retention policy (mission/team-run auto-cleanup) ─────────────────────── - -export interface RetentionPolicy { - default_ttl_seconds: number; - summary: string; -} - -// ─── Pre-flight validation (§20) ───────────────────────────────────────────── - -export type CheckStatus = "pass" | "fail" | "warn"; -export interface ValidationCheck { - id: string; - label: string; - status: CheckStatus; - detail: string; -} -export interface ValidationResult { - ok: boolean; - checks: ValidationCheck[]; -} - -/** Autonomy tier labels (1..5), aligned with the kars taxonomy. */ -export const TIER_LABELS: Record<number, string> = { - 1: "Manual", - 2: "Shared", - 3: "Conditional", - 4: "Supervised", - 5: "Full", -}; - -// ─── Teams (standing orgs) ─────────────────────────────────────────────────── -// A Team is the durability-axis primitive: a standing org with a charter and a -// cadence loop that mints task-force work autonomously. Distinct from a Mission -// (a finite task force). Mirrors the BFF Teams DTOs. - -export interface TeamSummary { - name: string; - display_name: string | null; - charter: string; - phase: string; - reporting_to: string | null; - tier: number; - member_count: number; - generated_task_count: number; - every_minutes: number | null; - lifecycle_mode: TeamLifecycleMode; - warm_idle_seconds: number | null; - runtime_state: TeamRuntimeState | null; - current_assignment_task: string | null; - idle_deadline_at: string | null; - paused: boolean; - created_at: string | null; - last_run_at: string | null; - last_success_at: string | null; - last_activity_at: string | null; - next_run_at: string | null; - health: string | null; - detail: string | null; - runs_succeeded: number; - retained_delivered: number; - retained_no_action: number; - retained_failed: number; -} - -export interface TeamRole { - name: string; - system_prompt: string | null; - tier: number | null; - member_task: string | null; - skills: string[]; - runtime: string | null; - model: string | null; -} - -export interface LedgerEvent { - at: string; - kind: string; - summary: string; - task: string | null; - tokens: number | null; -} - -export interface TeamDetail { - name: string; - display_name: string | null; - charter: string; - phase: string; - reporting_to: string | null; - knowledge_commons: string | null; - tier: number; - authority_ceiling: number; - delegation_depth: number; - paused: boolean; - every_minutes: number | null; - lifecycle_mode: TeamLifecycleMode; - warm_idle_seconds: number | null; - runtime_state: TeamRuntimeState | null; - current_assignment_nonce: string | null; - current_assignment_task: string | null; - idle_deadline_at: string | null; - envelope_digest: string | null; - principal_task: string | null; - roster: TeamRole[]; - member_count: number; - generated_task_count: number; - last_generated_task: string | null; - last_run_at: string | null; - next_run_at: string | null; - detail: string | null; - health: string | null; - runs_succeeded: number; - tokens_spent_total: number; - commons_entry_count: number; - last_success_at: string | null; - created_at: string | null; - last_activity_at: string | null; - generated_tasks: string[]; - recent_outcomes: TeamOutcome[]; - recent_outcome_summary: TeamOutcomeSummary; - tool_policy: string | null; - tool_policy_default: boolean; - mcp_servers: string[]; - git_write_repos: string[]; - egress: string[]; - egress_mode: string | null; - /** Domains the team's agents have actually reached (live, from running runs). */ - learned_egress: string[]; - network_posture: string; - model: string | null; - model_fallbacks: string[]; - model_default: boolean; - memory: string | null; - runtime: string | null; - runtime_default: boolean; - isolation: string | null; - execution_plan: ExecutionPlan | null; - tasks: TeamTask[]; - channels: string[]; -} - -export type TeamLifecycleMode = "ephemeral" | "resourceOptimized" | "persistent"; -export type TeamRuntimeState = "Working" | "Warm" | "Hibernating" | "Idle"; - -export type TeamOutcomeDisposition = - | "change_proposed" - | "no_action_needed" - | "completed" - | "failed"; - -export interface TeamOutcome { - run: string; - disposition: TeamOutcomeDisposition; - headline: string; - detail: string; - objective: string; - finished_at: string | null; - duration_seconds: number | null; - tokens: number | null; - model: string | null; - pull_requests: PullRequestRef[]; - artifact_count: number; -} - -export interface TeamOutcomeSummary { - change_proposed: number; - no_action_needed: number; - completed: number; - failed: number; -} - -/** A backlog task assigned to a standing team. */ -export interface TeamTask { - id: string; - title: string; - description: string; - depends_on: string[]; - acceptance_criteria: string[]; - review_required: boolean; - status: string; // pending | active | done - run: string | null; - created_at: string | null; - done_at: string | null; - stuck_since?: string | null; - assignment_nonce?: string | null; -} - -export type EngineeringSignal = - | "dependabot_pr" - | "dependabot_alert" - | "code_scanning_alert" - | "secret_scanning_alert"; -export type EngineeringSignalSyncState = - | "ok" - | "unavailable" - | "forbidden" - | "truncated" - | "error"; -export interface EngineeringSignalResult { - repo: string; - signal: EngineeringSignal; - state: EngineeringSignalSyncState; - discovered: number; - detail: string; -} -export type EngineeringSyncState = - | "disabled" - | "idle" - | "syncing" - | "ok" - | "partial" - | "error"; -export type EngineeringReviewState = - | "ready_for_review" - | "waiting_for_ci" - | "ci_failed" - | "blocked" - | "unknown"; - -export interface EngineeringReviewItem { - repo: string; - pr_number: number; - pr_url: string; - title: string; - run: string; - source_id: string; - work_id: string; - task_status: string; - run_state: string | null; - selected_roles: string[]; - delivered_roles: string[]; - artifact_count: number | null; - head_sha: string; - state: EngineeringReviewState; - detail: string; - checks_total: number; - checks_passed: number; - observed_at: string; -} - -export interface EngineeringSourceStatus { - state: EngineeringSyncState; - last_sync_at: string | null; - last_success_at: string | null; - last_error: string | null; - items_discovered: number; - items_queued: number; - total_items_queued: number; - next_poll_at: string | null; - review_items: EngineeringReviewItem[]; - ready_for_review: number; - waiting_for_ci: number; - ci_failed: number; - signal_results: EngineeringSignalResult[]; -} - -export interface EngineeringSource { - configured: boolean; - enabled: boolean; - auto_run: boolean; - repos: string[]; - signals: EngineeringSignal[]; - poll_interval_seconds: number; - status: EngineeringSourceStatus; -} - -export interface GithubConnection { - connected: boolean; - account: string | null; - repos: string[]; -} - -export interface CommonsEntry { - id: string; - title: string; - author: string; - source_task: string; - created_at: string; - digest: string; - size_bytes: number; - content: string | null; -} - -export interface CommonsResponse { - commons: string; - count: number; - entries: CommonsEntry[]; -} - -// ─── Artifact review (§16) ─────────────────────────────────────────────────── - -export interface ReviewEntry { - decision: string; - comment: string | null; - reviewer: string; - decided_at: string; - revision: number; - /** Whether the reviewer identity is server-attested. A self-reported - * (client-supplied) name in V0 is unverified → shown as such. */ - attested?: boolean; - assignment_nonce?: string | null; -} - -export interface ReviewState { - status: string; - revision: number; - history: ReviewEntry[]; - redrive_pending: boolean; - assignment_nonce: string | null; -} - -// ─── Team digests (§20) ────────────────────────────────────────────────────── - -export interface Digest { - team: string; - at: string; - reporting_to: string | null; - health: string; - summary: string; - runs_generated: number; - runs_delivered: number; - tokens_spent: number; - knowledge_entries: number; - channel: string | null; - gated: boolean; -} - - -// ─── Governance Receipt ────────────────────────────────────────────────────── - -export type ClaimStatus = "PASS" | "PARTIAL" | "FAIL" | "OMITTED"; -export interface ReceiptClaim { - class: string; - status: string; - detail: string; -} - -export interface ReceiptSignature { - keyid: string; - sig: string; -} - -export interface Receipt { - name: string; - namespace: string; - task: string; - envelope_digest: string; - predicate_type: string; - scheme: string; - key_id: string; - payload_type: string; - signatures: ReceiptSignature[]; - claims: ReceiptClaim[]; - /** The decoded in-toto Statement — the exact bytes the signature covers. */ - statement: unknown; - issued_at: string | null; - /** Inclusion-log sequence (cross-receipt tamper-evidence chain). */ - inclusion_seq: number | null; - /** Inclusion-log entry hash. */ - inclusion_entry_hash: string | null; - inclusion_state: "Included" | "Failed" | null; - inclusion_error: string | null; - log_segment: string | null; - checkpoint_tree_size: number | null; - witnessed: boolean | null; - /** The log's signed checkpoint (signed tree head), when published. */ - checkpoint: ReceiptCheckpoint | null; - /** The exact command an auditor runs to verify independently. */ - verify_command: string; -} - -/** A KarsEval safety/conformance eval and its latest verdict. */ -export interface EvalResult { - total: number; - passed: number; - failed: number; - errored: number; - corpus_name: string | null; - corpus_digest: string | null; - completed_at: string | null; -} - -export interface Eval { - name: string; - namespace: string; - display_name: string | null; - target_sandbox: string | null; - corpus: string | null; - phase: string | null; - schedule: string | null; - last_run_at: string | null; - last_result: EvalResult | null; - created: string | null; -} - -/** A single eval case: what it probes + its latest verdict. */ -export interface EvalCase { - id: string; - tags: string[]; - probe: string | null; - expected: string | null; - actual: string | null; - actual_reason: string | null; - pass: boolean | null; - /** True when the case couldn't be evaluated (target unreachable) — inconclusive, not a policy fail. */ - errored: boolean; -} - -/** The detailed eval report — corpus cases merged with per-case verdicts. */ -export interface EvalReport { - name: string; - corpus: string | null; - total: number; - passed: number; - failed: number; - /** Cases the runner couldn't evaluate (target unreachable) — inconclusive, shown separately. */ - errored: number; - completed_at: string | null; - per_case_available: boolean; - cases: EvalCase[]; -} - -export interface ReceiptCheckpoint { - tree_size: number; - root_hash: string; - key_id: string; - published_at: string | null; -} - -/** A signed receipt claim mapped to an external regulatory obligation. */ -export interface ComplianceControl { - control_id: string; - framework: string; - reference: string; - receipt_class: string; - status: string; - evidence: string; - /** True for the `regulatory` claim class — a named V0 limitation (external - * transparency anchor lands in V1), so it reads PARTIAL on every receipt - * this product issues today, not a gap specific to this task. */ - advisory?: boolean; -} - -/** A compliance evidence pack derived from a mission's signed receipt. */ -export interface CompliancePack { - task: string; - namespace: string; - generated_at: string; - predicate_type: string; - envelope_digest: string; - signature_scheme: string; - key_id: string; - inclusion_seq: number | null; - issued_at: string | null; - verify_command: string; - controls: ComplianceControl[]; - satisfied: number; - partial: number; - /** Count of `advisory` controls — excluded from `partial`. */ - advisory?: number; -} - -// ─── Steering / HITL approvals ─────────────────────────────────────────────── - -export interface Approval { - name: string; - namespace: string; - task: string; - team: string | null; - milestone: string | null; - action_kind: string; - summary: string; - detail: string | null; - requested_tier: number | null; - phase: string; - decider: string | null; - requested_at: string | null; - decided_at: string | null; - expires_at: string | null; - bound_envelope_digest: string | null; - run_nonce: string | null; - resource_version: string; - generation: number; - /** Whether a human can still act on this (only a Pending approval). */ - actionable: boolean; -} - -// ─── System / wiring ───────────────────────────────────────────────────────── - -export type WiringStatus = "live" | "partial" | "not_wired"; - -export interface PipelineStage { - id: string; - name: string; - description: string; - status: WiringStatus; - detail: string; -} - -export interface CrdStatus { - name: string; - installed: boolean; -} - -export interface SystemCounts { - tasks: number; - ready_tasks: number; - degraded_tasks: number; - digested_tasks: number; - sandboxes: number | null; -} - -export interface SystemStatus { - namespace: string; - controller_reachable: boolean; - crds: CrdStatus[]; - counts: SystemCounts; - pipeline: PipelineStage[]; -} - -/** One concrete, act-on-it problem the live diagnostics scan found. */ -export interface DiagnosticIssue { - severity: "critical" | "warning"; - kind: string; - subject: string; - reason: string; - detail: string | null; - remedy: string; -} - -export interface Diagnostics { - issues: DiagnosticIssue[]; - scanned_pods: number; - scanned_sandboxes: number; - healthy: boolean; -} - -/** The orchestrator's proposed loop for an intent, shown in the Loop Designer. */ -export interface LoopProposal { - pattern: string; - goal: string; - criteria: string; - rationale: string; - source: "orchestrator" | "heuristic"; -} - -/** kars-SRE agent + Headlamp plugin integration status. */ -export interface Integrations { - sre_present: boolean; - sre_phase: string | null; - sre_ready: string | null; - sre_activate_cmd: string; - headlamp_deployed: boolean; - headlamp_url: string | null; - headlamp_paths: { label: string; path: string }[]; - headlamp_install_hint: string; -} - -/** Orchestrator (compose engine) health + the active inference path. */ -export interface Orchestrator { - mode: "direct" | "sandbox" | "none"; - direct_configured: boolean; - sandbox_present: boolean; - sandbox_phase: string | null; - sandbox_ready: string | null; - sandbox_restarts: number | null; - sandbox_waiting_reason: string | null; - router_candidates: number; - recommend_direct: boolean; - note: string; -} - -export const WIRING_LABELS: Record<WiringStatus, string> = { - live: "Live", - partial: "Partial", - not_wired: "Not wired", -}; - -// ─── Operator Console projections (real CRD reads) ────────────────────────── - -export interface Sandbox { - name: string; - namespace: string; - runtime_namespace: string | null; - phase: string | null; - runtime: string | null; - isolation: string | null; - tool_policy: string | null; - inference_policy: string | null; - governed: boolean; - team: string | null; - parent: string | null; - message: string | null; - created: string | null; - working: boolean | null; - /** Currently executing a task (Running AND not yet delivered) — distinct - * from `working` (has ever produced activity). See operator.rs SandboxDto. */ - executing: boolean | null; - cpu_millicores: number | null; - memory_bytes: number | null; - conditions: Array<{ - type_: string; - status: string; - reason: string | null; - message: string | null; - }>; -} - -export interface NodeCapacity { - name: string; - cpu_usage_millicores: number | null; - cpu_allocatable_millicores: number | null; - memory_usage_bytes: number | null; - memory_allocatable_bytes: number | null; - cpu_percent: number | null; - memory_percent: number | null; -} - -export interface ClusterCapacity { - metrics_available: boolean; - metrics_error: string | null; - team_max_concurrent_runs: number; - global_active_runs_limit: number; - active_team_runs: number; - pod_metrics_available: boolean; - pod_metrics_error: string | null; - nodes: NodeCapacity[]; -} - -export interface McpServer { - name: string; - namespace: string; - url: string | null; - phase: string | null; - mode: "Managed" | "External" | null; - endpoint: string | null; - workload_ref: string | null; - discovered_tools: string[]; - tool_schema_digest: string | null; - production: boolean | null; - allowed_tools: string[]; - created: string | null; - spec: Record<string, unknown>; -} - -export interface ToolPolicy { - name: string; - namespace: string; - phase: string | null; - version_hash: string | null; - applies_to: string | null; - has_governance_profile: boolean; - allowed: string[]; - created: string | null; - spec: Record<string, unknown>; -} - -export interface InferencePolicy { - name: string; - namespace: string; - phase: string | null; - version_hash: string | null; - sandbox: string | null; - daily_token_budget: number | null; - content_safety: boolean; - created: string | null; - spec: Record<string, unknown>; -} - -export interface EgressApproval { - name: string; - namespace: string; - sandbox: string | null; - phase: string | null; - reason: string | null; - hosts: string[]; - expires_at: string | null; - created: string | null; -} - -// ─── GitHub App (platform identity) ───────────────────────────────────────── - -export interface GithubApp { - configured: boolean; - slug: string | null; - install_url: string | null; -} - -export interface DiscoveredModel { - id: string; - label: string | null; - /** True for the one starred/pre-selected pick — currently only populated - * for GitHub Copilot's curated catalog (mirrors `kars dev`'s picker). */ - recommended?: boolean; -} - -// ─── kars-SRE self-remediation proposals ──────────────────────────────────── - -export interface SreAction { - name: string; - namespace: string; - action_type: string; - target_namespace: string | null; - target_name: string | null; - params: Record<string, unknown>; - rationale: string | null; - diagnosis: string | null; - approval_state: string; - approval_note: string | null; - phase: string; - applied_at: string | null; - ttl_minutes: number | null; - created_at: string | null; - actionable: boolean; -} - -// ─── Insights / scorecard (real + honest) ─────────────────────────────────── - -export interface CountPair { - label: string; - count: number; -} - -export interface Insights { - missions_by_phase: CountPair[]; - missions_by_tier: CountPair[]; - decisions: CountPair[]; - launched: number; - receipts_issued: number; - inclusion_log_size: number; - amplification_rejections: number; - runtime_metrics_available: boolean; - runtime_metrics_note: string | null; -} - -export interface Scorecard { - task: string; - namespace: string; - tier: number | null; - launched: boolean; - execution_phase: string | null; - token_budget: number | null; - decisions_recorded: number; - approvals_granted: number; - approvals_denied: number; - receipt_issued: boolean; - run_total_tokens: number | null; - run_prompt_tokens: number | null; - run_completion_tokens: number | null; - run_model: string | null; - runtime_metrics_available: boolean; - runtime_metrics_note: string | null; -} - -export interface ReceiptSummary { - name: string; - namespace: string; - task: string | null; - envelope_digest: string | null; - key_id: string | null; - inclusion_seq: number | null; - created: string | null; - verdict: "verified" | "failed" | "partial" | "none"; -} - -export interface Audit { - receipts: ReceiptSummary[]; - inclusion_log_size: number; - checkpoint: { - tree_size: number; - root_hash: string; - key_id: string; - published_at: string | null; - } | null; - /** Real cryptographic integrity verdict computed server-side: the whole hash - * chain recomputed + the signed checkpoint verified against the anchor. */ - integrity: { - chain_consistent: boolean; - tree_size: number; - checkpoint_verified: boolean; - witness_present: boolean; - anchor_pinned: boolean; - }; -} - -/** One independent verification check performed server-side by the BFF. */ -export interface VerifyCheck { - name: string; - passed: boolean; - detail: string; - /** Displayed but not cryptographically re-verified here (e.g. the V0 witness - * whose public key isn't published) — rendered as "shown, not verified", - * never a green ✓. */ - advisory?: boolean; - /** The recorded value the proof expected (e.g. a logged hash), when shown. */ - expected?: string | null; - /** The value the BFF independently recomputed — visibly matches `expected`. */ - computed?: string | null; -} - -export interface InclusionEvidence { - seq: number; - receipt: string; - payload_sha256: string; - prev_hash: string; - entry_hash: string; - recomputed_entry_hash: string; - chain_head: string; - chain_consistent: boolean; - tree_size: number; -} - -export interface CheckpointEvidence { - tree_size: number; - root_hash: string; - signed_note: string; - signature_b64: string; - signature_valid: boolean; - witness_key_id?: string | null; - witness_signature_b64?: string | null; -} - -export interface Evidence { - signed_statement?: unknown; - signature_b64?: string | null; - scheme?: string | null; - anchor_key_id?: string | null; - anchor_public_key_b64?: string | null; - inclusion?: InclusionEvidence | null; - checkpoint?: CheckpointEvidence | null; -} - -/** The result of in-browser (BFF-side) cryptographic receipt verification. */ -export interface VerifyResult { - verified: boolean; - checks: VerifyCheck[]; - evidence: Evidence; -} - -// ─── Cross-harness efficiency frontier (§3B, Pillar B) ─────────────────────── - -export interface RouteEfficiency { - route: string; - harness: string; - runs: number; - delivered: number; - success_rate: number; - accepted: number; - acceptance_rate: number; - avg_tokens: number; - tokens_per_outcome: number; - avg_rounds: number; - avg_tool_calls: number; - // 2026 enrichments. - avg_prompt_tokens: number; - avg_completion_tokens: number; - tool_fail_rate: number; - avg_wall_ms: number; - p95_wall_ms: number; - avg_ttfa_ms: number; - reliability_rate: number | null; - reliability_k: number | null; - reliability_samples: number; - usd_per_outcome: number | null; - cache_hit_rate: number; - top_fault: string; -} - -export interface Efficiency { - routes: RouteEfficiency[]; - recommended: string | null; - recommended_harness: string | null; - recommended_basis: string | null; - recommended_low_confidence: boolean; - total_runs: number; - priced: boolean; -} - -export interface SkillSummary { - name: string; - namespace: string; - version: string | null; - summary: string | null; - bounding_policy: string | null; - phase: string | null; - version_digest: string | null; - attestation_verified: boolean | null; - // Operator trust gate. - review: string; - locked_digest: string | null; - approved_by: string | null; - approved_at: string | null; - usable: boolean; - spec: Record<string, unknown>; -} - -export interface ProfileRole { - name: string; - system_prompt: string | null; - skills: string[]; -} - -export interface ProfileSummary { - name: string; - namespace: string; - domain: string | null; - phase: string | null; - template_digest: string | null; - display_name: string | null; - charter_template: string | null; - tier: number | null; - tool_policy: string | null; - knowledge_commons: string | null; - roles: ProfileRole[]; - spec: Record<string, unknown>; -} - -export interface AgentLifecycle { - sandbox: string; - namespace: string; - phase: string | null; - parent: string | null; - task: string | null; - objective: string | null; - tier: number | null; - rounds: number; - tool_calls: number; - last_action: string | null; - live: boolean; - tokens: number | null; - /** The run's token budget ceiling (envelope), when set — for spend-vs-limit. */ - budget_tokens: number | null; - status: string | null; - finished_at: string | null; - team: string | null; - display_name: string | null; - health: PodHealth | null; -} - -/** Honest pod-level health of a live agent (no CPU/mem — status-derived). */ -export interface PodHealth { - ready_containers: number; - total_containers: number; - restarts: number; - uptime_seconds: number | null; - node: string | null; - waiting_reason: string | null; -} - -/** Datapath-completeness witness — the optional eBPF (Inspektor Gadget) witness - * cross-checks kernel-observed egress against each sandbox's declared allowlist. - * `enabled: false` => the witness isn't installed (show enable instructions). */ -export interface DatapathWitnessSandbox { - namespace: string; - sandbox: string; - declared_hosts: string[]; - observed_dns: string[]; - observed_connects: number; - beyond_declared: string[]; - unused_declared: string[]; - verdict: "COMPLIANT" | "BEYOND-DECLARED" | "LEARN" | string; -} -export interface DatapathWitness { - enabled: boolean; - generated_at: string | null; - window_seconds: number | null; - sandboxes: DatapathWitnessSandbox[]; - install_hint: string; -} - -/** A single cross-agent activity event in the fleet live feed. */ -export interface FleetActivityItem { - agent: string; - display_name: string | null; - team: string | null; - kind: "tool" | "round" | string; - label: string; - detail: string | null; - failed: boolean; - round: number; - seq: number; - ms: number | null; -} -/** Fleet-wide live telemetry — aggregate metrics + merged activity feed. */ -export interface FleetTelemetry { - working: number; - teams_active: number; - sub_agents: number; - tokens_in_flight: number; - tool_calls: number; - rounds: number; - feed: FleetActivityItem[]; -} - -/** Live troubleshooting evidence for a failed run (from GET …/troubleshoot). */ -export interface TroubleshootContainer { - name: string; - ready: boolean; - restarts: number; - state: string; - reason: string | null; -} -export interface Troubleshoot { - pod_found: boolean; - pod_summary: string | null; - containers: TroubleshootContainer[]; - agent_log_tail: string[]; - evidence: string[]; - cause: string; - remedy: string; - harness_issue: boolean; - result_status: string | null; - result_reason: string | null; -} +// bff/src/routes/ and bff/src/kars/. Domain modules keep this public barrel stable. + +export * from "./types/missions"; +export * from "./types/orchestration"; +export * from "./types/workspace"; +export * from "./types/teams"; +export * from "./types/governance"; +export * from "./types/system"; +export * from "./types/operator"; +export * from "./types/operations"; diff --git a/bridge/web/src/lib/types/governance.ts b/bridge/web/src/lib/types/governance.ts new file mode 100644 index 000000000..73eefa808 --- /dev/null +++ b/bridge/web/src/lib/types/governance.ts @@ -0,0 +1,161 @@ + + + +// ─── Governance Receipt ────────────────────────────────────────────────────── + +export type ClaimStatus = "PASS" | "PARTIAL" | "FAIL" | "OMITTED"; +export interface ReceiptClaim { + class: string; + status: string; + detail: string; +} + +export interface ReceiptSignature { + keyid: string; + sig: string; +} + +export interface Receipt { + name: string; + namespace: string; + task: string; + envelope_digest: string; + predicate_type: string; + scheme: string; + key_id: string; + payload_type: string; + signatures: ReceiptSignature[]; + claims: ReceiptClaim[]; + /** The decoded in-toto Statement — the exact bytes the signature covers. */ + statement: unknown; + issued_at: string | null; + /** Inclusion-log sequence (cross-receipt tamper-evidence chain). */ + inclusion_seq: number | null; + /** Inclusion-log entry hash. */ + inclusion_entry_hash: string | null; + inclusion_state: "Included" | "Failed" | null; + inclusion_error: string | null; + log_segment: string | null; + checkpoint_tree_size: number | null; + witnessed: boolean | null; + /** The log's signed checkpoint (signed tree head), when published. */ + checkpoint: ReceiptCheckpoint | null; + /** The exact command an auditor runs to verify independently. */ + verify_command: string; +} + +/** A KarsEval safety/conformance eval and its latest verdict. */ +export interface EvalResult { + total: number; + passed: number; + failed: number; + errored: number; + corpus_name: string | null; + corpus_digest: string | null; + completed_at: string | null; +} + +export interface Eval { + name: string; + namespace: string; + display_name: string | null; + target_sandbox: string | null; + corpus: string | null; + phase: string | null; + schedule: string | null; + last_run_at: string | null; + last_result: EvalResult | null; + created: string | null; +} + +/** A single eval case: what it probes + its latest verdict. */ +export interface EvalCase { + id: string; + tags: string[]; + probe: string | null; + expected: string | null; + actual: string | null; + actual_reason: string | null; + pass: boolean | null; + /** True when the case couldn't be evaluated (target unreachable) — inconclusive, not a policy fail. */ + errored: boolean; +} + +/** The detailed eval report — corpus cases merged with per-case verdicts. */ +export interface EvalReport { + name: string; + corpus: string | null; + total: number; + passed: number; + failed: number; + /** Cases the runner couldn't evaluate (target unreachable) — inconclusive, shown separately. */ + errored: number; + completed_at: string | null; + per_case_available: boolean; + cases: EvalCase[]; +} + +export interface ReceiptCheckpoint { + tree_size: number; + root_hash: string; + key_id: string; + published_at: string | null; +} + +/** A signed receipt claim mapped to an external regulatory obligation. */ +export interface ComplianceControl { + control_id: string; + framework: string; + reference: string; + receipt_class: string; + status: string; + evidence: string; + /** True for the `regulatory` claim class — a named V0 limitation (external + * transparency anchor lands in V1), so it reads PARTIAL on every receipt + * this product issues today, not a gap specific to this task. */ + advisory?: boolean; +} + +/** A compliance evidence pack derived from a mission's signed receipt. */ +export interface CompliancePack { + task: string; + namespace: string; + generated_at: string; + predicate_type: string; + envelope_digest: string; + signature_scheme: string; + key_id: string; + inclusion_seq: number | null; + issued_at: string | null; + verify_command: string; + controls: ComplianceControl[]; + satisfied: number; + partial: number; + /** Count of `advisory` controls — excluded from `partial`. */ + advisory?: number; +} + +// ─── Steering / HITL approvals ─────────────────────────────────────────────── + +export interface Approval { + name: string; + namespace: string; + task: string; + team: string | null; + milestone: string | null; + action_kind: string; + summary: string; + detail: string | null; + requested_tier: number | null; + phase: string; + decider: string | null; + requested_at: string | null; + decided_at: string | null; + expires_at: string | null; + bound_envelope_digest: string | null; + run_nonce: string | null; + resource_version: string; + generation: number; + /** Whether a human can still act on this (only a Pending approval). */ + actionable: boolean; +} diff --git a/bridge/web/src/lib/types/missions.ts b/bridge/web/src/lib/types/missions.ts new file mode 100644 index 000000000..2ef2dec75 --- /dev/null +++ b/bridge/web/src/lib/types/missions.ts @@ -0,0 +1,360 @@ +import type { MissionDelegation } from "./orchestration"; +import type { PullRequestRef } from "./workspace"; +// kars Bridge web — shared types mirroring the BFF API DTOs. +// The BFF (Rust) owns these shapes; keep field names in sync with +// bff/src/routes/tasks.rs. + +export interface Budget { + scope?: "GovernedInference"; + tokens: number | null; + usd_micros: number | null; +} + +export interface Envelope { + tier: number; + authority_ceiling: number; + delegation_depth: number; + budget: Budget | null; + tool_policy: string | null; + egress_allowlist: string | null; +} + +export interface TaskSummary { + name: string; + namespace: string; + objective: string; + display_name: string | null; + created_at: string | null; + tier: number; + phase: string; + envelope_digest: string | null; + team: string | null; + delivered: boolean; + failed: boolean; + launched: boolean; + execution_phase: string | null; +} + +export interface TaskDetail { + name: string; + namespace: string; + objective: string; + display_name: string | null; + created_at: string | null; + envelope: Envelope; + phase: string; + envelope_digest: string | null; + observed_generation: number | null; + lineage: string[]; + parent: string | null; + /** The standing team that owns this task (from kars.azure.com/team). */ + team: string | null; + status_message: string | null; + children: TaskSummary[]; + launched: boolean; + execution_phase: string | null; + sandbox: string | null; + egress_mode: string | null; + execution_detail: string | null; + assignment: TaskAssignmentStatus | null; + assignment_events: TaskAssignmentEvent[]; + assignment_sequence: number | null; + composition: Composition | null; + sub_agents: SubAgent[]; + result: MissionResult | null; + artifacts: MissionArtifact[]; + role_plan: TeamRolePlan; + collaboration_events: TeamCollaborationEvent[]; + /** Pull requests the mission opened — first-class deliverables shown on the + * Artifacts tab (a PR is a delivery type). Empty when none. */ + pull_requests?: PullRequestRef[]; + activity: ActivityEvent[]; + telemetry: MissionTelemetry | null; + checkpoint: TaskCheckpoint | null; + agent_identity: AgentIdentity | null; + /** A governed capability-routing correction recorded at creation (e.g. a + * chat-gateway harness swapped to an autonomous one for a one-shot mission). + * Null when no correction was needed. */ + harness_corrected: string | null; + /** A governed emergency-stop decision (operator/reason/at) when the mission + * was halted. Null when never halted. */ + halted: string | null; + /** Whether a run has ever been requested (the run-requested annotation is set). + * Gates the client auto-kickoff so the first run fires exactly once. */ + run_requested: boolean; + /** Exact latest requested run nonce, available before assignment acknowledgement. */ + current_run_nonce: string | null; +} + +export interface TeamRolePlan { + selected_roles: string[]; + skipped_roles: string[]; +} + +export interface TeamCollaborationEvent { + at: string | null; + event: string; + agent: string | null; + member: string | null; + outcome: string | null; + message_id: string | null; + reply_preview: string | null; + content_preview: string | null; +} + +export interface TaskCheckpoint { + schema: string; + milestone_id: string; + status: "pending" | "in_progress" | "completed" | "blocked"; + summary: string; + acceptance_criteria?: string[]; + artifacts?: string[]; + next_steps?: string[]; + updated_at?: string; + agent?: string; +} + +export interface TaskAssignmentStatus { + task_id: string; + state: string; + worker_did: string | null; + stage: string | null; + child_task_id: string | null; + child_role: string | null; + last_progress_at: string | null; + completed_at: string | null; + error: string | null; +} + +export interface TaskAssignmentEvent { + sequence: number; + event_id: string; + task_id: string; + event_type: string; + state: string; + at: string; + worker_did: string | null; + stage: string | null; + child_task_id: string | null; + child_role: string | null; + outcome: string | null; + message: string | null; +} + +/** Loop-shape telemetry for a mission run (token totals are on MissionResult). */ +export interface MissionTelemetry { + rounds: number | null; + tool_calls: number | null; +} + +/** One event in the agent's live execution trace. A `round` event records the + * model call (real token usage); a `tool` event records one tool invocation + * with a sanitized args/result preview. */ +export type ActivityEvent = + | { + kind: "round"; + round: number; + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + finish_reason: string; + tool_calls: number; + ms: number; + ts: string; + /** The agent (sandbox) that emitted this event, and its role in the tree. + * Present when the stream aggregates the whole agent tree; absent for a + * single-agent trace read from the persisted ConfigMap. */ + agent?: string; + agentInstance?: string; + agentRole?: "principal" | "subagent"; + seq?: number; + } + | { + kind: "tool"; + round: number; + name: string; + args_preview: string; + result_preview: string; + ms: number; + ok: boolean; + ts: string; + agent?: string; + agentInstance?: string; + agentRole?: "principal" | "subagent"; + seq?: number; + /** Present on tools authoritatively executed and recorded by the router. */ + source?: "router" | "harness" | "governance"; + }; + +/** One artifact file in a mission's deliverable set. `content` is present for + * text artifacts (markdown/json/csv/…) and null for binary ones. */ +export interface MissionArtifact { + name: string; + size_bytes: number | null; + content: string | null; + content_bytes: number | null; + content_truncated: boolean; + source_agent: string | null; + source_path: string | null; + digest: string | null; +} + +/** A running agent's real mesh identity, discovered from the AGT registry. */ +export interface AgentIdentity { + did: string; + capabilities: string[]; + last_seen: string | null; + reputation_score: number | null; +} + +/** A captured mission run result — a real deliverable + real token cost. */ +export interface MissionResult { + output: string; + status: string | null; + model: string | null; + total_tokens: number | null; + prompt_tokens: number | null; + completion_tokens: number | null; + finished_at: string | null; + assignment_nonce: string | null; + /** How the deliverable was produced. "single_turn" = one model turn (no + * tools/sub-agents) because the mesh agent loop was unavailable; absent for + * a full agent-loop run. */ + source: string | null; + /** Set when this run's ok-output is actually a capability/limit STOP (today the + * daily token budget), not a deliverable — rendered as an actionable state. */ + blocked: RunBlocked | null; + artifact_persistence: "complete" | "partial" | null; + artifact_count: number | null; + declared_artifact_count: number | null; +} + +export interface RunBlocked { + /** Machine reason. Today: "budget". */ + reason: string; + detail: string; + spent: number | null; + limit: number | null; +} + +/** A sub-agent the mission's agent spawned at run time (a labelled sandbox). */ +export interface SubAgent { + name: string; + namespace: string; + phase: string | null; + runtime: string | null; + role: string | null; + parent: string | null; + logical_agent_id: string | null; + model: string | null; +} + +/** The composed run — what a mission actually runs with (from the blueprint). */ +export interface Composition { + runtime: string | null; + model: string | null; + instructions: string | null; + tool_policy: string | null; + mcp_servers: string[]; + egress: string[]; + isolation: string | null; + memory: string | null; +} + +export interface CreateTaskRequest { + name: string; + objective: string; + display_name: string | null; + envelope: Envelope; + parent?: string | null; + blueprint?: Blueprint | null; + delegation?: MissionDelegation | null; + launch?: boolean; + /** Repos (owner/name) from this principal's GitHub connection. The BFF + * validates the complete set and derives the connection reference. */ + git_write_repos?: string[] | null; + /** The creating principal, for per-user budget attribution. */ + created_by?: string | null; +} + +/** A model route — provider tag + deployment, lands on InferencePolicy. */ +export interface BlueprintModel { + provider: string; + deployment: string; +} + +/** A network destination the mission may reach. */ +export interface BlueprintEgress { + host: string; + port?: number | null; +} + +/** + * The editable run composition reviewed on the launch package. Every field maps + * to a real field on the materialized InferencePolicy / KarsSandbox; the + * controller compiles it. Mirrors bff/src/kars/task.rs::TaskBlueprint. + */ +export interface Blueprint { + runtime?: string | null; + model?: BlueprintModel | null; + model_fallbacks?: BlueprintModel[]; + instructions?: string | null; + tool_policy?: string | null; + mcp_servers?: string[]; + egress?: BlueprintEgress[]; + egress_mode?: "strict" | "learning"; + isolation?: string | null; + memory?: string | null; + skills?: string[]; + execution_plan?: ExecutionPlan | null; +} + +export interface ExecutionPlan { + schema: "kars.execution-plan/v1"; + roles: ExecutionRole[]; + max_parallel: number; + synthesis: ExecutionSynthesis; + deliverables: ExecutionDeliverable[]; +} + +export interface ExecutionRole { + name: string; + objective: string; + depends_on: string[]; + phases: ExecutionPhase[]; + budget_tokens?: number | null; +} + +export interface ExecutionPhase { + name: string; + objective: string; + capabilities: ExecutionCapability[]; + required_tool_calls?: ExecutionRequiredToolCall[]; + min_tool_calls?: number; + max_tool_calls: number; + fresh_context: boolean; +} + +export interface ExecutionRequiredToolCall { + name: "github_actions_job_logs"; + arguments: Record<string, string>; +} + +export type ExecutionCapability = + | "filesystem-read" + | "filesystem-write" + | "shell" + | "network" + | "mcp" + | "memory"; + +export interface ExecutionSynthesis { + objective: string; + capabilities: ExecutionCapability[]; + max_tool_calls: number; +} + +export interface ExecutionDeliverable { + name: string; + media_type?: string | null; +} diff --git a/bridge/web/src/lib/types/operations.ts b/bridge/web/src/lib/types/operations.ts new file mode 100644 index 000000000..91bbe2659 --- /dev/null +++ b/bridge/web/src/lib/types/operations.ts @@ -0,0 +1,300 @@ + + +// ─── Insights / scorecard (real + honest) ─────────────────────────────────── + +export interface CountPair { + label: string; + count: number; +} + +export interface Insights { + missions_by_phase: CountPair[]; + missions_by_tier: CountPair[]; + decisions: CountPair[]; + launched: number; + receipts_issued: number; + inclusion_log_size: number; + amplification_rejections: number; + runtime_metrics_available: boolean; + runtime_metrics_note: string | null; +} + +export interface Scorecard { + task: string; + namespace: string; + tier: number | null; + launched: boolean; + execution_phase: string | null; + token_budget: number | null; + decisions_recorded: number; + approvals_granted: number; + approvals_denied: number; + receipt_issued: boolean; + run_total_tokens: number | null; + run_prompt_tokens: number | null; + run_completion_tokens: number | null; + run_model: string | null; + runtime_metrics_available: boolean; + runtime_metrics_note: string | null; +} + +export interface ReceiptSummary { + name: string; + namespace: string; + task: string | null; + envelope_digest: string | null; + key_id: string | null; + inclusion_seq: number | null; + created: string | null; + verdict: "verified" | "failed" | "partial" | "none"; +} + +export interface Audit { + receipts: ReceiptSummary[]; + inclusion_log_size: number; + checkpoint: { + tree_size: number; + root_hash: string; + key_id: string; + published_at: string | null; + } | null; + /** Real cryptographic integrity verdict computed server-side: the whole hash + * chain recomputed + the signed checkpoint verified against the anchor. */ + integrity: { + chain_consistent: boolean; + tree_size: number; + checkpoint_verified: boolean; + witness_present: boolean; + anchor_pinned: boolean; + }; +} + +/** One independent verification check performed server-side by the BFF. */ +export interface VerifyCheck { + name: string; + passed: boolean; + detail: string; + /** Displayed but not cryptographically re-verified here (e.g. the V0 witness + * whose public key isn't published) — rendered as "shown, not verified", + * never a green ✓. */ + advisory?: boolean; + /** The recorded value the proof expected (e.g. a logged hash), when shown. */ + expected?: string | null; + /** The value the BFF independently recomputed — visibly matches `expected`. */ + computed?: string | null; +} + +export interface InclusionEvidence { + seq: number; + receipt: string; + payload_sha256: string; + prev_hash: string; + entry_hash: string; + recomputed_entry_hash: string; + chain_head: string; + chain_consistent: boolean; + tree_size: number; +} + +export interface CheckpointEvidence { + tree_size: number; + root_hash: string; + signed_note: string; + signature_b64: string; + signature_valid: boolean; + witness_key_id?: string | null; + witness_signature_b64?: string | null; +} + +export interface Evidence { + signed_statement?: unknown; + signature_b64?: string | null; + scheme?: string | null; + anchor_key_id?: string | null; + anchor_public_key_b64?: string | null; + inclusion?: InclusionEvidence | null; + checkpoint?: CheckpointEvidence | null; +} + +/** The result of in-browser (BFF-side) cryptographic receipt verification. */ +export interface VerifyResult { + verified: boolean; + checks: VerifyCheck[]; + evidence: Evidence; +} + +// ─── Cross-harness efficiency frontier (§3B, Pillar B) ─────────────────────── + +export interface RouteEfficiency { + route: string; + harness: string; + runs: number; + delivered: number; + success_rate: number; + accepted: number; + acceptance_rate: number; + avg_tokens: number; + tokens_per_outcome: number; + avg_rounds: number; + avg_tool_calls: number; + // 2026 enrichments. + avg_prompt_tokens: number; + avg_completion_tokens: number; + tool_fail_rate: number; + avg_wall_ms: number; + p95_wall_ms: number; + avg_ttfa_ms: number; + reliability_rate: number | null; + reliability_k: number | null; + reliability_samples: number; + usd_per_outcome: number | null; + cache_hit_rate: number; + top_fault: string; +} + +export interface Efficiency { + routes: RouteEfficiency[]; + recommended: string | null; + recommended_harness: string | null; + recommended_basis: string | null; + recommended_low_confidence: boolean; + total_runs: number; + priced: boolean; +} + +export interface SkillSummary { + name: string; + namespace: string; + version: string | null; + summary: string | null; + bounding_policy: string | null; + phase: string | null; + version_digest: string | null; + attestation_verified: boolean | null; + // Operator trust gate. + review: string; + locked_digest: string | null; + approved_by: string | null; + approved_at: string | null; + usable: boolean; + spec: Record<string, unknown>; +} + +export interface ProfileRole { + name: string; + system_prompt: string | null; + skills: string[]; +} + +export interface ProfileSummary { + name: string; + namespace: string; + domain: string | null; + phase: string | null; + template_digest: string | null; + display_name: string | null; + charter_template: string | null; + tier: number | null; + tool_policy: string | null; + knowledge_commons: string | null; + roles: ProfileRole[]; + spec: Record<string, unknown>; +} + +export interface AgentLifecycle { + sandbox: string; + namespace: string; + phase: string | null; + parent: string | null; + task: string | null; + objective: string | null; + tier: number | null; + rounds: number; + tool_calls: number; + last_action: string | null; + live: boolean; + tokens: number | null; + /** The run's token budget ceiling (envelope), when set — for spend-vs-limit. */ + budget_tokens: number | null; + status: string | null; + finished_at: string | null; + team: string | null; + display_name: string | null; + health: PodHealth | null; +} + +/** Honest pod-level health of a live agent (no CPU/mem — status-derived). */ +export interface PodHealth { + ready_containers: number; + total_containers: number; + restarts: number; + uptime_seconds: number | null; + node: string | null; + waiting_reason: string | null; +} + +/** Datapath-completeness witness — the optional eBPF (Inspektor Gadget) witness + * cross-checks kernel-observed egress against each sandbox's declared allowlist. + * `enabled: false` => the witness isn't installed (show enable instructions). */ +export interface DatapathWitnessSandbox { + namespace: string; + sandbox: string; + declared_hosts: string[]; + observed_dns: string[]; + observed_connects: number; + beyond_declared: string[]; + unused_declared: string[]; + verdict: "COMPLIANT" | "BEYOND-DECLARED" | "LEARN" | string; +} +export interface DatapathWitness { + enabled: boolean; + generated_at: string | null; + window_seconds: number | null; + sandboxes: DatapathWitnessSandbox[]; + install_hint: string; +} + +/** A single cross-agent activity event in the fleet live feed. */ +export interface FleetActivityItem { + agent: string; + display_name: string | null; + team: string | null; + kind: "tool" | "round" | string; + label: string; + detail: string | null; + failed: boolean; + round: number; + seq: number; + ms: number | null; +} +/** Fleet-wide live telemetry — aggregate metrics + merged activity feed. */ +export interface FleetTelemetry { + working: number; + teams_active: number; + sub_agents: number; + tokens_in_flight: number; + tool_calls: number; + rounds: number; + feed: FleetActivityItem[]; +} + +/** Live troubleshooting evidence for a failed run (from GET …/troubleshoot). */ +export interface TroubleshootContainer { + name: string; + ready: boolean; + restarts: number; + state: string; + reason: string | null; +} +export interface Troubleshoot { + pod_found: boolean; + pod_summary: string | null; + containers: TroubleshootContainer[]; + agent_log_tail: string[]; + evidence: string[]; + cause: string; + remedy: string; + harness_issue: boolean; + result_status: string | null; + result_reason: string | null; +} diff --git a/bridge/web/src/lib/types/operator.ts b/bridge/web/src/lib/types/operator.ts new file mode 100644 index 000000000..6135e0b89 --- /dev/null +++ b/bridge/web/src/lib/types/operator.ts @@ -0,0 +1,139 @@ + + +// ─── Operator Console projections (real CRD reads) ────────────────────────── + +export interface Sandbox { + name: string; + namespace: string; + runtime_namespace: string | null; + phase: string | null; + runtime: string | null; + isolation: string | null; + tool_policy: string | null; + inference_policy: string | null; + governed: boolean; + team: string | null; + parent: string | null; + message: string | null; + created: string | null; + working: boolean | null; + /** Currently executing a task (Running AND not yet delivered) — distinct + * from `working` (has ever produced activity). See operator.rs SandboxDto. */ + executing: boolean | null; + cpu_millicores: number | null; + memory_bytes: number | null; + conditions: Array<{ + type_: string; + status: string; + reason: string | null; + message: string | null; + }>; +} + +export interface NodeCapacity { + name: string; + cpu_usage_millicores: number | null; + cpu_allocatable_millicores: number | null; + memory_usage_bytes: number | null; + memory_allocatable_bytes: number | null; + cpu_percent: number | null; + memory_percent: number | null; +} + +export interface ClusterCapacity { + metrics_available: boolean; + metrics_error: string | null; + team_max_concurrent_runs: number; + global_active_runs_limit: number; + active_team_runs: number; + pod_metrics_available: boolean; + pod_metrics_error: string | null; + nodes: NodeCapacity[]; +} + +export interface McpServer { + name: string; + namespace: string; + url: string | null; + phase: string | null; + mode: "Managed" | "External" | null; + endpoint: string | null; + workload_ref: string | null; + discovered_tools: string[]; + tool_schema_digest: string | null; + production: boolean | null; + allowed_tools: string[]; + created: string | null; + spec: Record<string, unknown>; +} + +export interface ToolPolicy { + name: string; + namespace: string; + phase: string | null; + version_hash: string | null; + applies_to: string | null; + has_governance_profile: boolean; + allowed: string[]; + created: string | null; + spec: Record<string, unknown>; +} + +export interface InferencePolicy { + name: string; + namespace: string; + phase: string | null; + version_hash: string | null; + sandbox: string | null; + daily_token_budget: number | null; + content_safety: boolean; + created: string | null; + spec: Record<string, unknown>; +} + +export interface EgressApproval { + name: string; + namespace: string; + sandbox: string | null; + phase: string | null; + reason: string | null; + hosts: string[]; + expires_at: string | null; + created: string | null; +} + +// ─── GitHub App (platform identity) ───────────────────────────────────────── + +export interface GithubApp { + configured: boolean; + slug: string | null; + install_url: string | null; +} + +export interface DiscoveredModel { + id: string; + label: string | null; + /** True for the one starred/pre-selected pick — currently only populated + * for GitHub Copilot's curated catalog (mirrors `kars dev`'s picker). */ + recommended?: boolean; +} + +// ─── kars-SRE self-remediation proposals ──────────────────────────────────── + +export interface SreAction { + name: string; + namespace: string; + action_type: string; + target_namespace: string | null; + target_name: string | null; + params: Record<string, unknown>; + rationale: string | null; + diagnosis: string | null; + approval_state: string; + approval_note: string | null; + phase: string; + applied_at: string | null; + ttl_minutes: number | null; + created_at: string | null; + actionable: boolean; +} diff --git a/bridge/web/src/lib/types/orchestration.ts b/bridge/web/src/lib/types/orchestration.ts new file mode 100644 index 000000000..242c51350 --- /dev/null +++ b/bridge/web/src/lib/types/orchestration.ts @@ -0,0 +1,175 @@ +import type { BlueprintEgress, BlueprintModel, ExecutionPlan } from "./missions"; +import type { EngineeringSignal } from "./teams"; + + +// ─── Launch-package options (from /api/options) ────────────────────────────── + +export interface ModelOption { + provider: string; + deployment: string; + is_default: boolean; + /** Short human detail (e.g. "Anthropic · 1.0M ctx · powerful"), when known. */ + detail?: string | null; +} +export interface RuntimeOption { + kind: string; + label: string; + wired: boolean; + status: "ready" | "needs_image" | "unavailable" | "validated" | "available"; + note: string; +} +export interface ProviderInfo { + id: string; + label: string; + note: string; +} +/** An additional inference provider configured alongside the single + * default — e.g. GitHub Copilot as the default plus Azure AI Foundry also + * connected. `has_key` only reports whether a dev-mode key is stored, never + * the value. `models` are the deployment ids this provider serves, feeding + * the shared model catalog tagged with this provider's own tag. */ +export interface AdditionalProvider { + tag: string; + endpoint: string | null; + has_key: boolean; + models: string[]; +} +export interface RefOption { + name: string; + namespace: string; + summary: string | null; + mode?: string | null; + discovered_tools?: string[]; + tool_schema_digest?: string | null; + compiled_digest?: string | null; + backend?: string | null; + readiness?: string | null; + version?: string | null; + recipe?: string | null; + version_digest?: string | null; + qualified_routes?: string[]; +} +export interface IsolationOption { + value: string; + label: string; + note: string; +} +export interface Options { + models: ModelOption[]; + default_model: string | null; + provider: ProviderInfo | null; + runtimes: RuntimeOption[]; + isolation: IsolationOption[]; + tool_policies: RefOption[]; + mcp_servers: RefOption[]; + mcp_profiles: McpProfileOption[]; + memories: RefOption[]; + skills: RefOption[]; +} + +/** Operator-curated MCP bundle (a vetted set of McpServers). */ +export interface McpProfileOption { + name: string; + summary: string | null; + servers: string[]; +} + +// ─── Orchestrator: intent → composed launch package (§20) ──────────────────── + +export interface ComposeProposal { + tier: number; + model: BlueprintModel | null; + model_fallbacks: BlueprintModel[]; + model_basis: string | null; + runtime: string; + instructions: string; + tool_policy: string | null; + mcp_servers: string[]; + skills: string[]; + egress: BlueprintEgress[]; + isolation: string; + memory: string | null; + budget_tokens: number | null; + execution_plan: ExecutionPlan | null; + delegation: MissionDelegation; +} + +export interface MissionDelegationRole { + name: string; + objective: string; +} + +export interface MissionDelegation { + mode: "single-agent" | "principal-specialists"; + roles: MissionDelegationRole[]; + max_parallel: number; +} +export interface ComposeResponse { + available: boolean; + reason: string | null; + proposal: ComposeProposal | null; + rationale: string | null; + source: string | null; +} + +// ─── Team orchestrator: charter → org chart ────────────────────────────────── + +export interface ComposeTeamRole { + name: string; + system_prompt: string; + runtime: string; + model: string; + skills: string[]; +} + +export interface ComposeTeamProposal { + tier: number; + cadence_minutes: number; + instructions: string; + model: string; + model_fallbacks: string[]; + model_basis: string | null; + expected_tokens_per_outcome: number | null; + efficiency_sample_runs: number; + mcp_servers: string[]; + memory: string | null; + egress: BlueprintEgress[]; + egress_mode: "learning" | "strict"; + engineering_enabled: boolean; + engineering_signals: EngineeringSignal[]; + engineering_poll_interval_seconds: number; + engineering_auto_run: boolean; + roles: ComposeTeamRole[]; + execution_plan: ExecutionPlan | null; + milestones: ComposeTeamMilestone[]; +} + +export interface TeamChannelStatus { + channel: string; + enabled: boolean; + qualified?: boolean | null; + detail?: string | null; +} + +export interface TeamChannelsState { + enabled: string[]; + statuses: TeamChannelStatus[]; +} + +export interface ComposeTeamMilestone { + id: string; + title: string; + description: string; + owner_role: string | null; + depends_on: string[]; + acceptance_criteria: string[]; + review_required: boolean; +} + +export interface ComposeTeamResponse { + available: boolean; + reason: string | null; + proposal: ComposeTeamProposal | null; + rationale: string | null; + source: string | null; +} diff --git a/bridge/web/src/lib/types/system.ts b/bridge/web/src/lib/types/system.ts new file mode 100644 index 000000000..712be7dae --- /dev/null +++ b/bridge/web/src/lib/types/system.ts @@ -0,0 +1,92 @@ + + +// ─── System / wiring ───────────────────────────────────────────────────────── + +export type WiringStatus = "live" | "partial" | "not_wired"; + +export interface PipelineStage { + id: string; + name: string; + description: string; + status: WiringStatus; + detail: string; +} + +export interface CrdStatus { + name: string; + installed: boolean; +} + +export interface SystemCounts { + tasks: number; + ready_tasks: number; + degraded_tasks: number; + digested_tasks: number; + sandboxes: number | null; +} + +export interface SystemStatus { + namespace: string; + controller_reachable: boolean; + crds: CrdStatus[]; + counts: SystemCounts; + pipeline: PipelineStage[]; +} + +/** One concrete, act-on-it problem the live diagnostics scan found. */ +export interface DiagnosticIssue { + severity: "critical" | "warning"; + kind: string; + subject: string; + reason: string; + detail: string | null; + remedy: string; +} + +export interface Diagnostics { + issues: DiagnosticIssue[]; + scanned_pods: number; + scanned_sandboxes: number; + healthy: boolean; +} + +/** The orchestrator's proposed loop for an intent, shown in the Loop Designer. */ +export interface LoopProposal { + pattern: string; + goal: string; + criteria: string; + rationale: string; + source: "orchestrator" | "heuristic"; +} + +/** kars-SRE agent + Headlamp plugin integration status. */ +export interface Integrations { + sre_present: boolean; + sre_phase: string | null; + sre_ready: string | null; + sre_activate_cmd: string; + headlamp_deployed: boolean; + headlamp_url: string | null; + headlamp_paths: { label: string; path: string }[]; + headlamp_install_hint: string; +} + +/** Orchestrator (compose engine) health + the active inference path. */ +export interface Orchestrator { + mode: "direct" | "sandbox" | "none"; + direct_configured: boolean; + sandbox_present: boolean; + sandbox_phase: string | null; + sandbox_ready: string | null; + sandbox_restarts: number | null; + sandbox_waiting_reason: string | null; + router_candidates: number; + recommend_direct: boolean; + note: string; +} + +export const WIRING_LABELS: Record<WiringStatus, string> = { + live: "Live", + partial: "Partial", + not_wired: "Not wired", +}; diff --git a/bridge/web/src/lib/types/teams.ts b/bridge/web/src/lib/types/teams.ts new file mode 100644 index 000000000..e4ba73430 --- /dev/null +++ b/bridge/web/src/lib/types/teams.ts @@ -0,0 +1,299 @@ +import type { ExecutionPlan } from "./missions"; +import type { PullRequestRef } from "./workspace"; + + +// ─── Teams (standing orgs) ─────────────────────────────────────────────────── +// A Team is the durability-axis primitive: a standing org with a charter and a +// cadence loop that mints task-force work autonomously. Distinct from a Mission +// (a finite task force). Mirrors the BFF Teams DTOs. + +export interface TeamSummary { + name: string; + display_name: string | null; + charter: string; + phase: string; + reporting_to: string | null; + tier: number; + member_count: number; + generated_task_count: number; + every_minutes: number | null; + lifecycle_mode: TeamLifecycleMode; + warm_idle_seconds: number | null; + runtime_state: TeamRuntimeState | null; + current_assignment_task: string | null; + idle_deadline_at: string | null; + paused: boolean; + created_at: string | null; + last_run_at: string | null; + last_success_at: string | null; + last_activity_at: string | null; + next_run_at: string | null; + health: string | null; + detail: string | null; + runs_succeeded: number; + retained_delivered: number; + retained_no_action: number; + retained_failed: number; +} + +export interface TeamRole { + name: string; + system_prompt: string | null; + tier: number | null; + member_task: string | null; + skills: string[]; + runtime: string | null; + model: string | null; +} + +export interface LedgerEvent { + at: string; + kind: string; + summary: string; + task: string | null; + tokens: number | null; +} + +export interface TeamDetail { + name: string; + display_name: string | null; + charter: string; + phase: string; + reporting_to: string | null; + knowledge_commons: string | null; + tier: number; + authority_ceiling: number; + delegation_depth: number; + paused: boolean; + every_minutes: number | null; + lifecycle_mode: TeamLifecycleMode; + warm_idle_seconds: number | null; + runtime_state: TeamRuntimeState | null; + current_assignment_nonce: string | null; + current_assignment_task: string | null; + idle_deadline_at: string | null; + envelope_digest: string | null; + principal_task: string | null; + roster: TeamRole[]; + member_count: number; + generated_task_count: number; + last_generated_task: string | null; + last_run_at: string | null; + next_run_at: string | null; + detail: string | null; + health: string | null; + runs_succeeded: number; + tokens_spent_total: number; + commons_entry_count: number; + last_success_at: string | null; + created_at: string | null; + last_activity_at: string | null; + generated_tasks: string[]; + recent_outcomes: TeamOutcome[]; + recent_outcome_summary: TeamOutcomeSummary; + tool_policy: string | null; + tool_policy_default: boolean; + mcp_servers: string[]; + git_write_repos: string[]; + egress: string[]; + egress_mode: string | null; + /** Domains the team's agents have actually reached (live, from running runs). */ + learned_egress: string[]; + network_posture: string; + model: string | null; + model_fallbacks: string[]; + model_default: boolean; + memory: string | null; + runtime: string | null; + runtime_default: boolean; + isolation: string | null; + execution_plan: ExecutionPlan | null; + tasks: TeamTask[]; + channels: string[]; +} + +export type TeamLifecycleMode = "ephemeral" | "resourceOptimized" | "persistent"; +export type TeamRuntimeState = "Working" | "Warm" | "Hibernating" | "Idle"; + +export type TeamOutcomeDisposition = + | "change_proposed" + | "no_action_needed" + | "completed" + | "failed"; + +export interface TeamOutcome { + run: string; + disposition: TeamOutcomeDisposition; + headline: string; + detail: string; + objective: string; + finished_at: string | null; + duration_seconds: number | null; + tokens: number | null; + model: string | null; + pull_requests: PullRequestRef[]; + artifact_count: number; +} + +export interface TeamOutcomeSummary { + change_proposed: number; + no_action_needed: number; + completed: number; + failed: number; +} + +/** A backlog task assigned to a standing team. */ +export interface TeamTask { + id: string; + title: string; + description: string; + depends_on: string[]; + acceptance_criteria: string[]; + review_required: boolean; + status: string; // pending | active | done + run: string | null; + created_at: string | null; + done_at: string | null; + stuck_since?: string | null; + assignment_nonce?: string | null; +} + +export type EngineeringSignal = + | "dependabot_pr" + | "dependabot_alert" + | "code_scanning_alert" + | "secret_scanning_alert"; +export type EngineeringSignalSyncState = + | "ok" + | "unavailable" + | "forbidden" + | "truncated" + | "error"; +export interface EngineeringSignalResult { + repo: string; + signal: EngineeringSignal; + state: EngineeringSignalSyncState; + discovered: number; + detail: string; +} +export type EngineeringSyncState = + | "disabled" + | "idle" + | "syncing" + | "ok" + | "partial" + | "error"; +export type EngineeringReviewState = + | "ready_for_review" + | "waiting_for_ci" + | "ci_failed" + | "blocked" + | "unknown"; + +export interface EngineeringReviewItem { + repo: string; + pr_number: number; + pr_url: string; + title: string; + run: string; + source_id: string; + work_id: string; + task_status: string; + run_state: string | null; + selected_roles: string[]; + delivered_roles: string[]; + artifact_count: number | null; + head_sha: string; + state: EngineeringReviewState; + detail: string; + checks_total: number; + checks_passed: number; + observed_at: string; +} + +export interface EngineeringSourceStatus { + state: EngineeringSyncState; + last_sync_at: string | null; + last_success_at: string | null; + last_error: string | null; + items_discovered: number; + items_queued: number; + total_items_queued: number; + next_poll_at: string | null; + review_items: EngineeringReviewItem[]; + ready_for_review: number; + waiting_for_ci: number; + ci_failed: number; + signal_results: EngineeringSignalResult[]; +} + +export interface EngineeringSource { + configured: boolean; + enabled: boolean; + auto_run: boolean; + repos: string[]; + signals: EngineeringSignal[]; + poll_interval_seconds: number; + status: EngineeringSourceStatus; +} + +export interface GithubConnection { + connected: boolean; + account: string | null; + repos: string[]; +} + +export interface CommonsEntry { + id: string; + title: string; + author: string; + source_task: string; + created_at: string; + digest: string; + size_bytes: number; + content: string | null; +} + +export interface CommonsResponse { + commons: string; + count: number; + entries: CommonsEntry[]; +} + +// ─── Artifact review (§16) ─────────────────────────────────────────────────── + +export interface ReviewEntry { + decision: string; + comment: string | null; + reviewer: string; + decided_at: string; + revision: number; + /** Whether the reviewer identity is server-attested. A self-reported + * (client-supplied) name in V0 is unverified → shown as such. */ + attested?: boolean; + assignment_nonce?: string | null; +} + +export interface ReviewState { + status: string; + revision: number; + history: ReviewEntry[]; + redrive_pending: boolean; + assignment_nonce: string | null; +} + +// ─── Team digests (§20) ────────────────────────────────────────────────────── + +export interface Digest { + team: string; + at: string; + reporting_to: string | null; + health: string; + summary: string; + runs_generated: number; + runs_delivered: number; + tokens_spent: number; + knowledge_entries: number; + channel: string | null; + gated: boolean; +} diff --git a/bridge/web/src/lib/types/workspace.ts b/bridge/web/src/lib/types/workspace.ts new file mode 100644 index 000000000..a548a5892 --- /dev/null +++ b/bridge/web/src/lib/types/workspace.ts @@ -0,0 +1,98 @@ + + +// ─── Artifacts index (cross-mission deliverables, §16) ─────────────────────── + +export interface ArtifactFile { + name: string; + size_bytes: number | null; + has_content: boolean; + content_address: string | null; + did: string | null; +} +export interface MissionArtifacts { + task: string; + evidence_key: string | null; + team: string | null; + archived: boolean; + display_name: string | null; + objective: string | null; + model: string | null; + finished_at: string | null; + status: string | null; + review_status: string; + review_revision: number; + files: ArtifactFile[]; + summary: string | null; + excerpt: string | null; + pull_requests: PullRequestRef[]; + deliverable_did: string | null; +} +export interface PullRequestRef { + repo: string; + number: number; + url: string; +} +export interface ArtifactsIndex { + missions: MissionArtifacts[]; +} + +/** One level of the hierarchical inference token budget (cluster / workspace), + * with the live measured daily usage and computed enforcement status. Mirrors + * bff/src/routes/budgets.rs::BudgetLevelDto. */ +export interface BudgetLevel { + scope: string; + label: string; + daily_tokens: number; + mode: "passive" | "buffer" | "strict"; + buffer_percent: number; + used_today: number; + status: "ok" | "alert" | "over_buffer_headroom" | "blocking"; + percent: number; + hard_cap: number; +} +export interface InferenceBudgets { + cluster: BudgetLevel | null; + cluster_used_today: number; + workspaces: BudgetLevel[]; + users: BudgetLevel[]; + default_namespace: string; + unbudgeted_namespaces: string[]; + unbudgeted_users: string[]; + alerts?: BudgetAlert[]; +} +export interface BudgetAlert { + scope: string; + label: string; + severity: "alert" | "over_buffer" | "blocking"; + message: string; +} + +// ─── Retention policy (mission/team-run auto-cleanup) ─────────────────────── + +export interface RetentionPolicy { + default_ttl_seconds: number; + summary: string; +} + +// ─── Pre-flight validation (§20) ───────────────────────────────────────────── + +export type CheckStatus = "pass" | "fail" | "warn"; +export interface ValidationCheck { + id: string; + label: string; + status: CheckStatus; + detail: string; +} +export interface ValidationResult { + ok: boolean; + checks: ValidationCheck[]; +} + +/** Autonomy tier labels (1..5), aligned with the kars taxonomy. */ +export const TIER_LABELS: Record<number, string> = { + 1: "Manual", + 2: "Shared", + 3: "Conditional", + 4: "Supervised", + 5: "Full", +}; diff --git a/bridge/web/tests/type-contract.test.mjs b/bridge/web/tests/type-contract.test.mjs new file mode 100644 index 000000000..0fbae8bea --- /dev/null +++ b/bridge/web/tests/type-contract.test.mjs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const entry = fileURLToPath(new URL("../src/lib/types.ts", import.meta.url)); +const directory = new URL("../src/lib/types/", import.meta.url); +const modules = readdirSync(directory).filter(name => name.endsWith(".ts")) + .map(name => fileURLToPath(new URL(name, directory))); +const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8")); + +test("the shared DTO surface type-checks with its locked compiler", () => { + assert.equal(ts.version, lock.packages["node_modules/typescript"].version); + const program = ts.createProgram([entry, ...modules], { + strict: true, noEmit: true, types: [], skipLibCheck: true, + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + lib: ["lib.esnext.d.ts", "lib.dom.d.ts"], + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + assert.deepEqual(diagnostics.map(diagnostic => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")), []); + const checker = program.getTypeChecker(); + const specifiers = new Set(modules.map(path => `./types/${basename(path, ".ts")}`)); + for (const path of [entry, ...modules]) { + for (const statement of program.getSourceFile(path).statements) { + if (path === entry) { + assert.ok(ts.isExportDeclaration(statement) && statement.moduleSpecifier + && ts.isStringLiteral(statement.moduleSpecifier) + && specifiers.has(statement.moduleSpecifier.text), + "the DTO barrel may only forward the known domain modules"); + } else if (ts.isImportDeclaration(statement)) { + assert.ok(statement.importClause?.isTypeOnly, "domain dependencies must be erased type imports"); + } else if (ts.isVariableStatement(statement)) { + assert.ok(statement.declarationList.flags & ts.NodeFlags.Const); + for (const declaration of statement.declarationList.declarations) { + assert.ok(ts.isIdentifier(declaration.name) + && ["TIER_LABELS", "WIRING_LABELS"].includes(declaration.name.text)); + assert.ok(declaration.initializer && ts.isObjectLiteralExpression(declaration.initializer) + && declaration.initializer.properties.every(property => + ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) + && ts.isStringLiteral(property.initializer)), + "label values must remain inert string-literal objects"); + } + } else { + assert.ok(ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement), + "domain modules must not introduce runtime side effects"); + } + } + } + const exports = path => checker.getExportsOfModule( + checker.getSymbolAtLocation(program.getSourceFile(path)), + ); + const domainNames = modules.flatMap(path => exports(path).map(symbol => symbol.name)); + assert.equal(new Set(domainNames).size, domainNames.length, "public DTO names must not collide"); + assert.deepEqual(exports(entry).map(symbol => symbol.name).sort(), domainNames.sort(), + "the original barrel must retain every public domain export"); + const runtimeNames = exports(entry).filter(symbol => { + const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + return target.flags & ts.SymbolFlags.Value; + }).map(symbol => symbol.name).sort(); + assert.deepEqual(runtimeNames, ["TIER_LABELS", "WIRING_LABELS"], + "DTO modules must not add runtime initialization or lose existing label exports"); +}); + +test("the public DTO barrel and domain modules remain bounded", () => { + assert.ok(modules.length > 0); + for (const path of [entry, ...modules]) { + const source = readFileSync(path, "utf8"); + const lines = source.split("\n").length - (source.endsWith("\n") ? 1 : 0); + assert.ok(lines <= 800, `${path} has ${lines} physical lines`); + } +}); From b82a9767a2ee379d52650be0e54d101b2597115a Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 00:32:50 +0200 Subject: [PATCH 038/111] Keep gateway watcher wire types in a bounded type-only module Move10unchanged interface bodies without changing watcher runtime, exportedoptions or watcher aliases.16gateway behavioral cases, typecheck and lint passed locally; source files730/131lines. No dependency or transport changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/teams-gateway/src/watcher-types.ts | 131 ++++++++++++++++++++ bridge/teams-gateway/src/watcher.ts | 141 ++-------------------- 2 files changed, 142 insertions(+), 130 deletions(-) create mode 100644 bridge/teams-gateway/src/watcher-types.ts diff --git a/bridge/teams-gateway/src/watcher-types.ts b/bridge/teams-gateway/src/watcher-types.ts new file mode 100644 index 000000000..6ea402913 --- /dev/null +++ b/bridge/teams-gateway/src/watcher-types.ts @@ -0,0 +1,131 @@ +// GatewayWatcher wire and collaborator types; no runtime initialization. + +export interface Metadata { + readonly name?: string | undefined; + readonly namespace?: string | undefined; + readonly resourceVersion?: string | undefined; + readonly labels?: Readonly<Record<string, string>> | undefined; + readonly annotations?: Readonly<Record<string, string>> | undefined; +} + +export interface ApprovalDecisionResource { + readonly verdict?: string | undefined; + readonly decider?: string | undefined; + readonly reason?: string | undefined; +} + +export interface ApprovalResource { + readonly metadata?: Metadata | undefined; + readonly spec?: { + readonly taskRef?: { readonly name?: string | undefined } | undefined; + readonly action?: { + readonly kind?: string | undefined; + readonly summary?: string | undefined; + readonly detail?: string | undefined; + readonly requestedTier?: number | undefined; + } | undefined; + readonly decision?: ApprovalDecisionResource | undefined; + } | undefined; + readonly status?: { + readonly phase?: string | undefined; + readonly boundEnvelopeDigest?: string | undefined; + readonly decider?: string | undefined; + readonly decidedAt?: string | undefined; + } | undefined; +} + +export interface TaskResource { + readonly metadata?: Metadata | undefined; + readonly spec?: { + readonly objective?: string | undefined; + readonly displayName?: string | undefined; + } | undefined; + readonly status?: { + readonly phase?: string | undefined; + readonly executionPhase?: string | undefined; + readonly executionDetail?: string | undefined; + readonly assignmentSequence?: number | undefined; + readonly assignment?: { + readonly state?: string | undefined; + readonly stage?: string | undefined; + readonly error?: string | undefined; + } | undefined; + } | undefined; +} + +export interface TeamResource { + readonly metadata?: Metadata | undefined; + readonly spec?: { + readonly displayName?: string | undefined; + readonly paused?: boolean | undefined; + readonly charter?: string | undefined; + } | undefined; + readonly status?: { + readonly phase?: string | undefined; + readonly health?: string | undefined; + readonly detail?: string | undefined; + readonly runtimeState?: string | undefined; + readonly generatedTaskCount?: number | undefined; + readonly lastGeneratedTask?: string | undefined; + readonly currentAssignmentTask?: string | undefined; + } | undefined; +} + +export interface KubernetesList<T> { + readonly items?: readonly T[] | undefined; + readonly metadata?: { + readonly resourceVersion?: string | undefined; + } | undefined; +} + +export interface CustomObjectsApiLike { + listNamespacedCustomObject( + group: string, + version: string, + namespace: string, + plural: string, + pretty?: string, + allowWatchBookmarks?: boolean, + _continue?: string, + fieldSelector?: string, + labelSelector?: string, + limit?: number, + resourceVersion?: string, + resourceVersionMatch?: string, + timeoutSeconds?: number, + watch?: boolean + ): Promise<unknown>; +} + +export interface WatchLike { + watch( + path: string, + queryParams: Record<string, string | number | boolean | undefined>, + callback: (phase: string, apiObj: unknown, watchObj?: unknown) => void, + done: (err: unknown) => void + ): Promise<AbortController>; +} + +export interface TeamsMessenger { + readonly api?: { + readonly conversations?: { + updateActivity( + conversationId: string, + activityId: string, + activity: TeamsActivity + ): Promise<unknown>; + } | undefined; + } | undefined; + send( + conversationId: string, + activity: TeamsActivity + ): Promise<{ id?: string | undefined } | null>; +} + +export interface TeamsActivity { + readonly type: "message"; + readonly attachments: readonly { + readonly contentType: "application/vnd.microsoft.card.adaptive"; + readonly content: object; + }[]; +} diff --git a/bridge/teams-gateway/src/watcher.ts b/bridge/teams-gateway/src/watcher.ts index 4e9cb77cb..6875aa2bd 100644 --- a/bridge/teams-gateway/src/watcher.ts +++ b/bridge/teams-gateway/src/watcher.ts @@ -14,6 +14,17 @@ import type { ConversationStore, } from "./conversation-store.js"; import { log } from "./log.js"; +import type { + Metadata, + ApprovalResource, + TaskResource, + TeamResource, + KubernetesList, + CustomObjectsApiLike, + WatchLike, + TeamsMessenger, + TeamsActivity, +} from "./watcher-types.js"; const GROUP = "kars.azure.com"; const VERSION = "v1alpha1"; @@ -26,136 +37,6 @@ const TEAMS_STREAM = "watch.karsteams"; const WATCH_TIMEOUT_SECONDS = 300; const TEAM_METADATA_KEY = "kars.azure.com/team"; -interface Metadata { - readonly name?: string | undefined; - readonly namespace?: string | undefined; - readonly resourceVersion?: string | undefined; - readonly labels?: Readonly<Record<string, string>> | undefined; - readonly annotations?: Readonly<Record<string, string>> | undefined; -} - -interface ApprovalDecisionResource { - readonly verdict?: string | undefined; - readonly decider?: string | undefined; - readonly reason?: string | undefined; -} - -interface ApprovalResource { - readonly metadata?: Metadata | undefined; - readonly spec?: { - readonly taskRef?: { readonly name?: string | undefined } | undefined; - readonly action?: { - readonly kind?: string | undefined; - readonly summary?: string | undefined; - readonly detail?: string | undefined; - readonly requestedTier?: number | undefined; - } | undefined; - readonly decision?: ApprovalDecisionResource | undefined; - } | undefined; - readonly status?: { - readonly phase?: string | undefined; - readonly boundEnvelopeDigest?: string | undefined; - readonly decider?: string | undefined; - readonly decidedAt?: string | undefined; - } | undefined; -} - -interface TaskResource { - readonly metadata?: Metadata | undefined; - readonly spec?: { - readonly objective?: string | undefined; - readonly displayName?: string | undefined; - } | undefined; - readonly status?: { - readonly phase?: string | undefined; - readonly executionPhase?: string | undefined; - readonly executionDetail?: string | undefined; - readonly assignmentSequence?: number | undefined; - readonly assignment?: { - readonly state?: string | undefined; - readonly stage?: string | undefined; - readonly error?: string | undefined; - } | undefined; - } | undefined; -} - -interface TeamResource { - readonly metadata?: Metadata | undefined; - readonly spec?: { - readonly displayName?: string | undefined; - readonly paused?: boolean | undefined; - readonly charter?: string | undefined; - } | undefined; - readonly status?: { - readonly phase?: string | undefined; - readonly health?: string | undefined; - readonly detail?: string | undefined; - readonly runtimeState?: string | undefined; - readonly generatedTaskCount?: number | undefined; - readonly lastGeneratedTask?: string | undefined; - readonly currentAssignmentTask?: string | undefined; - } | undefined; -} - -interface KubernetesList<T> { - readonly items?: readonly T[] | undefined; - readonly metadata?: { - readonly resourceVersion?: string | undefined; - } | undefined; -} - -interface CustomObjectsApiLike { - listNamespacedCustomObject( - group: string, - version: string, - namespace: string, - plural: string, - pretty?: string, - allowWatchBookmarks?: boolean, - _continue?: string, - fieldSelector?: string, - labelSelector?: string, - limit?: number, - resourceVersion?: string, - resourceVersionMatch?: string, - timeoutSeconds?: number, - watch?: boolean - ): Promise<unknown>; -} - -interface WatchLike { - watch( - path: string, - queryParams: Record<string, string | number | boolean | undefined>, - callback: (phase: string, apiObj: unknown, watchObj?: unknown) => void, - done: (err: unknown) => void - ): Promise<AbortController>; -} - -interface TeamsMessenger { - readonly api?: { - readonly conversations?: { - updateActivity( - conversationId: string, - activityId: string, - activity: TeamsActivity - ): Promise<unknown>; - } | undefined; - } | undefined; - send( - conversationId: string, - activity: TeamsActivity - ): Promise<{ id?: string | undefined } | null>; -} - -interface TeamsActivity { - readonly type: "message"; - readonly attachments: readonly { - readonly contentType: "application/vnd.microsoft.card.adaptive"; - readonly content: object; - }[]; -} - export interface GatewayWatcherOptions { readonly customObjectsApi?: CustomObjectsApiLike | undefined; readonly watch?: WatchLike | undefined; From 47716baa57e74b0c53b6d82b158bf28d0dd980c4 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 00:47:37 +0200 Subject: [PATCH 039/111] Bound server BFF client types without moving authentication or transport Extract19byte-identical public type declarations and preserve98runtime function/class declarations verbatim. Original type exports remain forwarded from the server client; sourcefiles791/112lines. Locked compiler contract checks and23purewebtests pass; fullframework qualification remains hosted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/web/src/lib/bff-contracts.ts | 112 +++++++++++++++++++++++ bridge/web/src/lib/bff.ts | 117 ++---------------------- bridge/web/tests/type-contract.test.mjs | 21 ++++- 3 files changed, 138 insertions(+), 112 deletions(-) create mode 100644 bridge/web/src/lib/bff-contracts.ts diff --git a/bridge/web/src/lib/bff-contracts.ts b/bridge/web/src/lib/bff-contracts.ts new file mode 100644 index 000000000..c1522e11f --- /dev/null +++ b/bridge/web/src/lib/bff-contracts.ts @@ -0,0 +1,112 @@ +// Server-side BFF client contracts; transport and authentication remain in ./bff. + + +/** Liveness shape returned by the BFF `/healthz`. */ +export interface BffHealth { + status: string; + service: string; + version: string; +} + +/** Readiness shape returned by the BFF `/readyz`. */ +export interface BffReadiness { + status: string; + cluster_configured: boolean; +} + +/** Result of probing the BFF — either reachable with a payload, or not. */ +export type BffProbe<T> = + | { reachable: true; data: T } + | { reachable: false; error: string }; +export interface SubmitSkillInput { + name: string; + display_name?: string; + version: string; + summary: string; + bounding_policy: string; + recipe?: string; + mcp_servers?: string[]; + uploaded_by?: string; + /** Package files — flat filenames (SKILL.md + scripts) the agent installs. */ + files?: { name: string; content: string }[]; +} + +/** Operator: Foundry connection status/onboarding. */ +export interface FoundryStatus { + connected: boolean; + project_endpoint: string | null; + inference_endpoint: string | null; + memory_store_id: string | null; + auth: string | null; + has_api_key: boolean; +} +export interface FoundryCheck { label: string; status: string; detail: string } +export interface FoundryConnection { name: string; category: string | null } +export interface FoundryDiscovered { + models: string[]; + connections: FoundryConnection[]; + memory_store_found: boolean | null; +} +export interface FoundryVerifyResult { checks: FoundryCheck[]; discovered: FoundryDiscovered } + +export interface CreateRole { name: string; system_prompt?: string; runtime?: string; model?: string; skills?: string[] } + +// ─── GitHub Copilot device-flow sign-in ───────────────────────────────────── +// Mints a Copilot-authorized token via GitHub's device flow (a stock `gh` +// token 404s on the Copilot exchange). The token is stored server-side; the +// browser only ever sees the user code + the discovered models. +export interface CopilotLoginStart { device_code: string; user_code: string; verification_uri: string; interval: number; expires_in: number } +export interface CopilotLoginPoll { status: "pending" | "authorized"; models?: import("./types").DiscoveredModel[] } + + +// ─── Local (in-cluster) inference (§ local-inference) ──────────────────────── +// A model running entirely inside the cluster — no external API, no egress +// dependency. Built on AI Runway's ModelDeployment CRD, which kars does not +// install itself (see docs/local-inference.md in the kars core repo) — an +// operator installs AI Runway + KAITO once, the same tier as the GitHub App. + +export interface LocalInferenceStatus { + available: boolean; + gpu_node_count: number; + gpu_products: string[]; +} + +export interface CuratedLocalModel { + id: string; + label: string; + tier: "cpu" | "gpu"; + params: string; +} + +export interface LocalModelDeployment { + name: string; + namespace: string; + managed: boolean; + model_id: string | null; + engine: string | null; + provider: string | null; + phase: string | null; + message: string | null; + endpoint: string | null; + created_at: string | null; +} + +export interface DeployCondition { type: string; status: string; reason: string; message: string } +export interface DeployPodState { name: string; phase: string; ready: boolean; running: boolean; waiting_reason: string | null; waiting_message: string | null } +export interface DeployActivity { time: string | null; reason: string; message: string; type: string; count: number } +export interface LocalDeployLiveStatus { + name: string; + found: boolean; + phase: string | null; + message: string | null; + percent: number; + ready: boolean; + failed: boolean; + failure_reason: string | null; + failure_message: string | null; + replicas_desired: number; + replicas_ready: number; + conditions: DeployCondition[]; + pods: DeployPodState[]; + activities: DeployActivity[]; +} diff --git a/bridge/web/src/lib/bff.ts b/bridge/web/src/lib/bff.ts index a9596475e..bd1cafbf1 100644 --- a/bridge/web/src/lib/bff.ts +++ b/bridge/web/src/lib/bff.ts @@ -5,6 +5,13 @@ // live here once. Importing this from a Client Component is a build error by // design (it has no "use client"). +import type { + BffHealth, BffReadiness, BffProbe, SubmitSkillInput, FoundryStatus, FoundryVerifyResult, + CreateRole, CopilotLoginStart, CopilotLoginPoll, LocalInferenceStatus, CuratedLocalModel, + LocalModelDeployment, LocalDeployLiveStatus, +} from "./bff-contracts"; +export type * from "./bff-contracts"; + import { bffBaseUrl } from "./config"; import { cookies } from "next/headers"; import { SESSION_COOKIE, verifySession } from "./session-token"; @@ -21,24 +28,6 @@ import type { TaskSummary, } from "./types"; -/** Liveness shape returned by the BFF `/healthz`. */ -export interface BffHealth { - status: string; - service: string; - version: string; -} - -/** Readiness shape returned by the BFF `/readyz`. */ -export interface BffReadiness { - status: string; - cluster_configured: boolean; -} - -/** Result of probing the BFF — either reachable with a payload, or not. */ -export type BffProbe<T> = - | { reachable: true; data: T } - | { reachable: false; error: string }; - async function getJson<T>(path: string): Promise<BffProbe<T>> { const url = `${bffBaseUrl()}${path}`; try { @@ -616,18 +605,6 @@ export function listUserSkills(): Promise<import("./types").SkillSummary[]> { export function getFleetTelemetry(): Promise<import("./types").FleetTelemetry> { return requestJson("/api/agents/fleet"); } -export interface SubmitSkillInput { - name: string; - display_name?: string; - version: string; - summary: string; - bounding_policy: string; - recipe?: string; - mcp_servers?: string[]; - uploaded_by?: string; - /** Package files — flat filenames (SKILL.md + scripts) the agent installs. */ - files?: { name: string; content: string }[]; -} /** User-side: submit a skill package. It lands PENDING operator review. */ export function submitSkill( input: SubmitSkillInput, @@ -728,24 +705,6 @@ export function deleteMcpProfile(name: string): Promise<import("./types").McpPro return requestJson(`/api/operator/mcp-profiles/${encodeURIComponent(name)}`, { method: "DELETE" }); } -/** Operator: Foundry connection status/onboarding. */ -export interface FoundryStatus { - connected: boolean; - project_endpoint: string | null; - inference_endpoint: string | null; - memory_store_id: string | null; - auth: string | null; - has_api_key: boolean; -} -export interface FoundryCheck { label: string; status: string; detail: string } -export interface FoundryConnection { name: string; category: string | null } -export interface FoundryDiscovered { - models: string[]; - connections: FoundryConnection[]; - memory_store_found: boolean | null; -} -export interface FoundryVerifyResult { checks: FoundryCheck[]; discovered: FoundryDiscovered } - export function getFoundry(): Promise<FoundryStatus> { return requestJson("/api/operator/foundry"); } @@ -768,8 +727,6 @@ export function verifyFoundry(): Promise<FoundryVerifyResult> { export function listAgents(): Promise<import("./types").AgentLifecycle[]> { return requestJson("/api/agents"); } - -export interface CreateRole { name: string; system_prompt?: string; runtime?: string; model?: string; skills?: string[] } export function createTeam(namespace: string, body: { name: string; display_name?: string; charter: string; tier?: number; authority_ceiling?: number; delegation_depth?: number; reporting_to?: string; knowledge_commons?: string; memory?: string; tool_policy?: string; runtime?: string; model?: string; model_fallbacks?: string[]; mcp_servers?: string[]; egress?: { host: string; port?: number }[]; egress_mode?: "learning" | "strict"; cadence_minutes?: number; lifecycle_mode?: import("./types").TeamLifecycleMode; warm_idle_seconds?: number; launch?: boolean; roles?: CreateRole[]; execution_plan?: import("./types").ExecutionPlan; git_write_repos?: string[]; created_by?: string }): Promise<{ created: boolean; name: string }> { return requestJson(`/api/namespaces/${encodeURIComponent(namespace)}/teams`, { method: "POST", body: JSON.stringify(body) }); } @@ -787,16 +744,9 @@ export function putProvider(body: { kind: string; auth: string; endpoint?: strin export function discoverModels(body: { kind: string; endpoint?: string; key?: string }): Promise<import("./types").DiscoveredModel[]> { return requestJson("/api/operator/providers/discover", { method: "POST", body: JSON.stringify(body) }); } - -// ─── GitHub Copilot device-flow sign-in ───────────────────────────────────── -// Mints a Copilot-authorized token via GitHub's device flow (a stock `gh` -// token 404s on the Copilot exchange). The token is stored server-side; the -// browser only ever sees the user code + the discovered models. -export interface CopilotLoginStart { device_code: string; user_code: string; verification_uri: string; interval: number; expires_in: number } export function copilotLoginStart(): Promise<CopilotLoginStart> { return requestJson("/api/operator/providers/copilot/login/start", { method: "POST" }); } -export interface CopilotLoginPoll { status: "pending" | "authorized"; models?: import("./types").DiscoveredModel[] } export function copilotLoginPoll(device_code: string): Promise<CopilotLoginPoll> { return requestJson("/api/operator/providers/copilot/login/poll", { method: "POST", body: JSON.stringify({ device_code }) }); } @@ -821,45 +771,12 @@ export function promoteAdditionalProvider(tag: string): Promise<{ promoted: bool export function setDefaultModel(deployment: string, provider: string): Promise<{ ok: boolean; default: string; provider: string }> { return requestJson("/api/operator/models/default", { method: "POST", body: JSON.stringify({ deployment, provider }) }); } - - -// ─── Local (in-cluster) inference (§ local-inference) ──────────────────────── -// A model running entirely inside the cluster — no external API, no egress -// dependency. Built on AI Runway's ModelDeployment CRD, which kars does not -// install itself (see docs/local-inference.md in the kars core repo) — an -// operator installs AI Runway + KAITO once, the same tier as the GitHub App. - -export interface LocalInferenceStatus { - available: boolean; - gpu_node_count: number; - gpu_products: string[]; -} export function getLocalInferenceStatus(): Promise<LocalInferenceStatus> { return requestJson("/api/operator/local-inference/status"); } - -export interface CuratedLocalModel { - id: string; - label: string; - tier: "cpu" | "gpu"; - params: string; -} export function getLocalInferenceCatalog(): Promise<CuratedLocalModel[]> { return requestJson("/api/operator/local-inference/catalog"); } - -export interface LocalModelDeployment { - name: string; - namespace: string; - managed: boolean; - model_id: string | null; - engine: string | null; - provider: string | null; - phase: string | null; - message: string | null; - endpoint: string | null; - created_at: string | null; -} export function listLocalModelDeployments(): Promise<LocalModelDeployment[]> { return requestJson("/api/operator/local-inference/deployments"); } @@ -869,26 +786,6 @@ export function createLocalModelDeployment(body: { name: string; model_id: strin export function deleteLocalModelDeployment(name: string): Promise<{ deleted: boolean; name: string }> { return requestJson(`/api/operator/local-inference/deployments/${encodeURIComponent(name)}`, { method: "DELETE" }); } - -export interface DeployCondition { type: string; status: string; reason: string; message: string } -export interface DeployPodState { name: string; phase: string; ready: boolean; running: boolean; waiting_reason: string | null; waiting_message: string | null } -export interface DeployActivity { time: string | null; reason: string; message: string; type: string; count: number } -export interface LocalDeployLiveStatus { - name: string; - found: boolean; - phase: string | null; - message: string | null; - percent: number; - ready: boolean; - failed: boolean; - failure_reason: string | null; - failure_message: string | null; - replicas_desired: number; - replicas_ready: number; - conditions: DeployCondition[]; - pods: DeployPodState[]; - activities: DeployActivity[]; -} export function getLocalDeploymentLiveStatus(name: string): Promise<LocalDeployLiveStatus> { return requestJson(`/api/operator/local-inference/deployments/${encodeURIComponent(name)}/status`); } diff --git a/bridge/web/tests/type-contract.test.mjs b/bridge/web/tests/type-contract.test.mjs index 0fbae8bea..236b5ffae 100644 --- a/bridge/web/tests/type-contract.test.mjs +++ b/bridge/web/tests/type-contract.test.mjs @@ -11,6 +11,7 @@ import test from "node:test"; const require = createRequire(import.meta.url); const ts = require("typescript"); const entry = fileURLToPath(new URL("../src/lib/types.ts", import.meta.url)); +const clientContracts = fileURLToPath(new URL("../src/lib/bff-contracts.ts", import.meta.url)); const directory = new URL("../src/lib/types/", import.meta.url); const modules = readdirSync(directory).filter(name => name.endsWith(".ts")) .map(name => fileURLToPath(new URL(name, directory))); @@ -18,7 +19,7 @@ const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta test("the shared DTO surface type-checks with its locked compiler", () => { assert.equal(ts.version, lock.packages["node_modules/typescript"].version); - const program = ts.createProgram([entry, ...modules], { + const program = ts.createProgram([entry, ...modules, clientContracts], { strict: true, noEmit: true, types: [], skipLibCheck: true, target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.ESNext, @@ -69,11 +70,27 @@ test("the shared DTO surface type-checks with its locked compiler", () => { }).map(symbol => symbol.name).sort(); assert.deepEqual(runtimeNames, ["TIER_LABELS", "WIRING_LABELS"], "DTO modules must not add runtime initialization or lose existing label exports"); + assert.ok(program.getSourceFile(clientContracts).statements.every(statement => + ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)), + "server client contracts must remain entirely erased types"); +}); + +test("server client types keep their original barrel without moving transport", () => { + const path = fileURLToPath(new URL("../src/lib/bff.ts", import.meta.url)); + const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true); + const forwarding = source.statements.filter(statement => + ts.isExportDeclaration(statement) && statement.isTypeOnly && !statement.exportClause + && statement.moduleSpecifier?.text === "./bff-contracts"); + assert.equal(forwarding.length, 1); + assert.ok(source.statements.some(statement => + ts.isFunctionDeclaration(statement) && statement.name?.text === "authenticatedBffFetch")); + assert.ok(source.statements.some(statement => + ts.isClassDeclaration(statement) && statement.name?.text === "BffError")); }); test("the public DTO barrel and domain modules remain bounded", () => { assert.ok(modules.length > 0); - for (const path of [entry, ...modules]) { + for (const path of [entry, ...modules, clientContracts]) { const source = readFileSync(path, "utf8"); const lines = source.split("\n").length - (source.endsWith("\n") ? 1 : 0); assert.ok(lines <= 800, `${path} has ${lines} physical lines`); From 4ea344883948505dad696f7b29814b5f5542cfd8 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 00:47:37 +0200 Subject: [PATCH 040/111] Qualify schema-first installation through the public core operator Use exact chart/values/context schema preparation before both API and runtime Helm installs. Require three independent cold-install API lanes and unique artifacts, retaining all warning, ownership and native authority gates.113Python fixture and35gateway tests passed locally; actual cold-install qualification is pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-native.yml | 15 ++- bridge/docs/contributing.md | 10 ++ .../tests/native-qualification.test.ts | 7 ++ bridge/tests/native-credentials/api_gate.py | 6 +- bridge/tests/native-credentials/boot.py | 6 +- .../operator_diagnostics.py | 4 +- .../native-credentials/schema_preparation.py | 31 ++++++ .../test_schema_preparation.py | 95 +++++++++++++++++++ cli/src/lib/bridge-contract-ci.test.ts | 8 ++ 9 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 bridge/tests/native-credentials/schema_preparation.py create mode 100644 bridge/tests/native-credentials/test_schema_preparation.py diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml index 780cd1460..4039c14ef 100644 --- a/.github/workflows/bridge-native.yml +++ b/.github/workflows/bridge-native.yml @@ -48,11 +48,15 @@ jobs: --head "${{ github.event.pull_request.head.sha || github.sha }}" >> "$GITHUB_OUTPUT" api-admission: - name: Native API and admission (no active SRE) + name: Native API and admission (cold install ${{ matrix.cold_install }}, no active SRE) needs: contract-scope if: needs.contract-scope.outputs.required == 'true' runs-on: ubuntu-22.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + cold_install: [1, 2, 3] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -69,6 +73,13 @@ jobs: - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: v3.17.3 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + cache-dependency-path: bridge/.native/core/cli/package-lock.json + - name: Build the locked public schema operator + run: npm ci --prefix .native/core/cli && npm run build --prefix .native/core/cli - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 with: version: v0.24.0 @@ -82,7 +93,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 with: - name: native-api-evidence + name: native-api-evidence-${{ matrix.cold_install }} path: bridge/.native/evidence/*.json if-no-files-found: warn retention-days: 7 diff --git a/bridge/docs/contributing.md b/bridge/docs/contributing.md index f5b3f9087..e34047ba6 100644 --- a/bridge/docs/contributing.md +++ b/bridge/docs/contributing.md @@ -45,6 +45,9 @@ their complete API. They mirror BFF DTOs rather than owning server contracts. The only runtime values are the existing `TIER_LABELS` and `WIRING_LABELS` literal objects. The Node contract test checks the locked compiler, complete barrel exports, erased imports, inert runtime values and per-file bounds. +Server-side client types are similarly re-exported from `@/lib/bff` through +`bff-contracts.ts`; authentication, cookie forwarding, fetch and error handling +remain in the original server module. The integration candidate's core Rust, CLI and Kind jobs check out the repository with `bridge/` physically absent. Core builds and runtime acceptance must not @@ -57,6 +60,13 @@ unknown source changes require it. Core-only Kind scope excludes Bridge-only changes, which still require paired Bridge qualification. Pushes, manual runs and reusable CI callers retain full qualification. +API/admission acceptance runs three isolated cold installs. Each builds the +locked, same-source core CLI and calls `kars schemas prepare` with the exact +chart, values, release, namespace and context used by the following Helm +installation. Runtime acceptance uses that same core preparation entrypoint. +Schema and admission warnings remain failures; no policy status or generation +is changed merely to refresh a diagnostic. + `Bridge component acceptance` aggregates every BFF, web, audit and add-on job and runs even for core-only PRs. Failed, cancelled or skipped component jobs cannot satisfy it. Together with `Require both native API and runtime acceptance`, diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts index 4dd302dce..e5407d13a 100644 --- a/bridge/teams-gateway/tests/native-qualification.test.ts +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -32,6 +32,13 @@ describe("Monorepo native prerequisite", () => { expect(gate).not.toMatch(/--validate=false|--disable-openapi-validation|failurePolicy.*Ignore/); expect(gate).not.toContain("--create-namespace"); expect(read("tests/native-credentials/api-values.yaml")).toContain("sre:\n enabled: false"); + expect(gate.indexOf('evidence["schemaPreparation"] = prepare_schemas(')) + .toBeLessThan(gate.indexOf('"helm", "install", "kars"')); + const boot = read("tests/native-credentials/boot.py"); + expect(boot.indexOf("prepared = prepare_schemas(")) + .toBeLessThan(boot.indexOf('command("helm", "install", "kars"')); + expect(workflow).toContain("cold_install: [1, 2, 3]"); + expect(workflow).toContain("name: native-api-evidence-${{ matrix.cold_install }}"); }); it("parses the runner without executing local Kubernetes or producing cache files", () => { diff --git a/bridge/tests/native-credentials/api_gate.py b/bridge/tests/native-credentials/api_gate.py index 1377513ed..3a56f8aec 100644 --- a/bridge/tests/native-credentials/api_gate.py +++ b/bridge/tests/native-credentials/api_gate.py @@ -12,6 +12,7 @@ import time from source_revision import CORE_REVISION +from schema_preparation import prepare_schemas ROOT = Path(__file__).resolve().parents[2] EVIDENCE = ROOT / ".native/evidence/api.json" @@ -52,11 +53,14 @@ def main(): run("kubectl", "label", "namespace", "kars-system", "app.kubernetes.io/managed-by=Helm") run("kubectl", "annotate", "namespace", "kars-system", "meta.helm.sh/release-name=kars", "meta.helm.sh/release-namespace=kars-system") + evidence["schemaPreparation"] = prepare_schemas( + "tests/native-credentials/api-values.yaml", "kind-bridge-native-api") + evidence["markers"].append("native-schemas-published-before-admission") run( "helm", "install", "kars", ".native/core/deploy/helm/kars", "--namespace", "kars-system", "--values", "tests/native-credentials/api-values.yaml", - "--timeout", "120s", + "--timeout", "120s", "--kube-context", "kind-bridge-native-api", ) evidence["markers"].append("native-chart-install") crds = kubernetes("get", "customresourcedefinitions")["items"] diff --git a/bridge/tests/native-credentials/boot.py b/bridge/tests/native-credentials/boot.py index e4aff5d83..43d496f94 100644 --- a/bridge/tests/native-credentials/boot.py +++ b/bridge/tests/native-credentials/boot.py @@ -12,6 +12,7 @@ from native_api import BRIDGE, CORE, ROOT, STATE, command, core, private_file, require, until from loaded_images import loaded_image +from schema_preparation import prepare_schemas def install_core(setup): @@ -43,8 +44,11 @@ def install_core(setup): "monitoring": {"enabled": False, "prometheus": {"enabled": False}}, } private_file("core-values.json", json.dumps(values)) + prepared = prepare_schemas(".native/core-values.json", "kind-bridge-native") + print(json.dumps({"nativeSetup": "core-schema-preparation", **prepared}), flush=True) command("helm", "install", "kars", ".native/core/deploy/helm/kars", - "--namespace", CORE, "--values", ".native/core-values.json", "--timeout", "180s") + "--namespace", CORE, "--values", ".native/core-values.json", "--timeout", "180s", + "--kube-context", "kind-bridge-native") command("kubectl", "rollout", "status", "deployment/kars-controller", "-n", CORE, "--timeout=180s", timeout=195) require(not setup.admin.get("/apis/kars.azure.com/v1alpha1/karssreregistrations")["items"], diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index c604e735f..afbd881b7 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -21,6 +21,8 @@ "lib/private-activation-retirement", "lib/kube-bootstrap", "lib/kube-context", "lib/private-activation-continuity", "lib/private-activation-guard-retirement", + "commands/schemas", "lib/core-helm-schemas", "lib/schema-stage", + "lib/schema-documents", "lib/schema-discovery", "lib/repo-assets", ) @@ -43,7 +45,7 @@ def source_location(stderr): def operator_command(stage, *args, timeout): - if stage not in ("preview", "apply"): + if stage not in ("preview", "apply", "schemas"): raise Failure("Unknown native operator enrollment stage") try: return command(*args, timeout=timeout) diff --git a/bridge/tests/native-credentials/schema_preparation.py b/bridge/tests/native-credentials/schema_preparation.py new file mode 100644 index 000000000..182a2a788 --- /dev/null +++ b/bridge/tests/native-credentials/schema_preparation.py @@ -0,0 +1,31 @@ +"""Use the exact core operator's schema lifecycle before installing its policies.""" + +import json + +from native_api import CORE, ROOT, Failure, require +from operator_diagnostics import operator_command + +CLI = ROOT / ".native/core/cli/dist/index.js" +CHART = ".native/core/deploy/helm/kars" + + +def prepare_schemas(values, context): + require(CLI.is_file(), "The exact core CLI must be built before schema preparation") + raw = operator_command( + "schemas", "node", str(CLI), "schemas", "prepare", + "--release", "kars", "--namespace", CORE, "--chart", CHART, + "--context", context, "--ownership", "helm", "--timeout", "120", + "--values", str(values), timeout=180, + ) + try: + result = json.loads(raw) + except json.JSONDecodeError: + raise Failure("Core schema preparation did not return a JSON result") from None + require( + isinstance(result, dict) and result.get("published") is True + and type(result.get("schemas")) is int and result["schemas"] > 0 + and result.get("release") == "kars" and result.get("namespace") == CORE + and result.get("ownership") == "helm", + "Core schema preparation did not acknowledge the exact owned schema operation", + ) + return {"schemas": result["schemas"], "published": True} diff --git a/bridge/tests/native-credentials/test_schema_preparation.py b/bridge/tests/native-credentials/test_schema_preparation.py new file mode 100644 index 000000000..4b70119dd --- /dev/null +++ b/bridge/tests/native-credentials/test_schema_preparation.py @@ -0,0 +1,95 @@ +"""Schema-preparation orchestration tests, not live discovery evidence.""" + +import io +import json +from contextlib import redirect_stdout +from pathlib import Path +import tempfile +import types +import unittest +from unittest.mock import patch + +import boot +import schema_preparation +from native_api import CORE, Failure + + +class SchemaPreparationTests(unittest.TestCase): + def test_invokes_the_exact_public_operator_and_validates_its_result(self): + result = {"schemas": 12, "published": True, "release": "kars", + "namespace": CORE, "ownership": "helm"} + with tempfile.TemporaryDirectory(prefix="native-schema-cli-") as directory: + cli = Path(directory) / "index.js" + cli.touch() + with patch.object(schema_preparation, "CLI", cli), \ + patch.object(schema_preparation, "operator_command", + return_value=json.dumps(result)) as command: + self.assertEqual(schema_preparation.prepare_schemas("values.json", "kind-native"), + {"schemas": 12, "published": True}) + self.assertEqual(command.call_args.args, ( + "schemas", "node", str(cli), "schemas", "prepare", + "--release", "kars", "--namespace", CORE, + "--chart", ".native/core/deploy/helm/kars", + "--context", "kind-native", "--ownership", "helm", + "--timeout", "120", "--values", "values.json", + )) + self.assertEqual(command.call_args.kwargs, {"timeout": 180}) + + def test_failed_or_mismatched_preparation_never_becomes_success(self): + baseline = {"schemas": 12, "published": True, "release": "kars", + "namespace": CORE, "ownership": "helm"} + with tempfile.TemporaryDirectory(prefix="native-schema-result-") as directory: + cli = Path(directory) / "index.js" + cli.touch() + with patch.object(schema_preparation, "CLI", cli): + for change in ({"schemas": 0}, {"schemas": True}, {"published": False}, + {"release": "other"}, {"namespace": "other"}, + {"ownership": "template"}): + with self.subTest(change=change), \ + patch.object(schema_preparation, "operator_command", + return_value=json.dumps({**baseline, **change})), \ + self.assertRaises(Failure): + schema_preparation.prepare_schemas("values.json", "kind-native") + with patch.object(schema_preparation, "operator_command", + side_effect=Failure("Operator preparation failed")), \ + self.assertRaisesRegex(Failure, "Operator preparation failed"): + schema_preparation.prepare_schemas("values.json", "kind-native") + + def test_runtime_install_prepares_schemas_before_the_matching_helm_operation(self): + calls = [] + setup = types.SimpleNamespace( + namespace=lambda name: {"metadata": {"name": name, "uid": "namespace"}}, + admin=types.SimpleNamespace(patch=lambda *_: None, get=lambda *_: {"items": []}), + ) + def prepared(values, context): + calls.append(("prepare", values, context)) + return {"schemas": 12, "published": True} + def command(*args, **_kwargs): + calls.append(args) + return "" + with patch.object(boot, "prepare_schemas", side_effect=prepared), \ + patch.object(boot, "command", side_effect=command), \ + patch.object(boot, "private_file"), \ + patch.object(boot, "loaded_image", side_effect=lambda name: name + ":latest"), \ + redirect_stdout(io.StringIO()): + boot.install_core(setup) + self.assertEqual(calls[0], ("prepare", ".native/core-values.json", "kind-bridge-native")) + self.assertEqual(calls[1][:4], ("helm", "install", "kars", ".native/core/deploy/helm/kars")) + self.assertIn(".native/core-values.json", calls[1]) + self.assertEqual(calls[1][-2:], ("--kube-context", "kind-bridge-native")) + + def test_preparation_failure_prevents_helm_install(self): + setup = types.SimpleNamespace( + namespace=lambda name: {"metadata": {"name": name, "uid": "namespace"}}, + admin=types.SimpleNamespace(patch=lambda *_: None), + ) + with patch.object(boot, "prepare_schemas", side_effect=Failure("Preparation refused")), \ + patch.object(boot, "command") as command, patch.object(boot, "private_file"), \ + patch.object(boot, "loaded_image", side_effect=lambda name: name + ":latest"), \ + self.assertRaisesRegex(Failure, "Preparation refused"): + boot.install_core(setup) + command.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/cli/src/lib/bridge-contract-ci.test.ts b/cli/src/lib/bridge-contract-ci.test.ts index 8d4f8c289..719c6fdb4 100644 --- a/cli/src/lib/bridge-contract-ci.test.ts +++ b/cli/src/lib/bridge-contract-ci.test.ts @@ -80,6 +80,14 @@ describe("permanent core and Bridge CI boundary", () => { expect(steps.some(step => String(step.run).includes("ci/bridge_contracts.py"))).toBe(true); const checkout = steps.find(step => String(step.uses).startsWith("actions/checkout@")); expect(mapping(checkout?.with)["fetch-depth"]).toBe(0); + const api = mapping(mapping(native.jobs)["api-admission"]); + const strategy = mapping(api.strategy); + expect(strategy["fail-fast"]).toBe(false); + expect(mapping(strategy.matrix).cold_install).toEqual([1, 2, 3]); + const apiSteps = jobSteps(native, "api-admission"); + expect(apiSteps.some(step => String(step.run).includes("npm ci --prefix .native/core/cli"))).toBe(true); + const artifact = apiSteps.find(step => String(step.uses).startsWith("actions/upload-artifact@")); + expect(mapping(artifact?.with).name).toBe("native-api-evidence-${{ matrix.cold_install }}"); }); it("cannot turn skipped or failed required native jobs into a passing aggregate", () => { From 1b1f98c09a2ebfb806ce1411b22e6dfa5278c9d8 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 00:56:39 +0200 Subject: [PATCH 041/111] Bound validation phases while retaining exact check ordering and outcomes Independent review confirmed four serial awaited phases reconstruct the original run_checks tokens, arguments and shared checks vector.16original functions,5types,305literals,ownership/qualification exports and4tests preserved. No failure/warning reduction or control-order change. Source-format checks passed; hosted Rust execution remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/routes/validate.rs | 1104 +---------------- bridge/bff/src/routes/validate/envelope.rs | 110 ++ bridge/bff/src/routes/validate/models.rs | 476 +++++++ bridge/bff/src/routes/validate/network.rs | 59 + .../qualification_requirement_tests.rs | 142 +++ bridge/bff/src/routes/validate/resources.rs | 347 ++++++ 6 files changed, 1146 insertions(+), 1092 deletions(-) create mode 100644 bridge/bff/src/routes/validate/envelope.rs create mode 100644 bridge/bff/src/routes/validate/models.rs create mode 100644 bridge/bff/src/routes/validate/network.rs create mode 100644 bridge/bff/src/routes/validate/qualification_requirement_tests.rs create mode 100644 bridge/bff/src/routes/validate/resources.rs diff --git a/bridge/bff/src/routes/validate.rs b/bridge/bff/src/routes/validate.rs index 52d3cee25..1c85e1972 100644 --- a/bridge/bff/src/routes/validate.rs +++ b/bridge/bff/src/routes/validate.rs @@ -10,8 +10,6 @@ // tool-invocation probe, RBAC-delegation) are a named next step and are // reported as such rather than faked. -use std::time::Duration; - use axum::Json; use axum::extract::{Extension, Path, State}; use serde::{Deserialize, Serialize}; @@ -21,6 +19,13 @@ use crate::error::{AppError, AppResult}; use crate::routes::ownership::require_owned_task; use crate::state::AppState; +mod envelope; +mod models; +mod network; +mod resources; + +pub(crate) use models::qualification_requirements; + #[derive(Debug, Deserialize)] pub struct ValidateRequest { #[serde(default)] @@ -66,101 +71,6 @@ fn require_cluster(state: &AppState) -> AppResult<&crate::kars::cluster::Cluster state.cluster().ok_or(AppError::ClusterUnavailable) } -fn names(items: &[kube::core::DynamicObject]) -> Vec<String> { - items - .iter() - .filter_map(|o| o.metadata.name.clone()) - .collect() -} - -/// Readiness facts read from a CRD object's status: the `phase`, whether a -/// `Ready` condition is `True`, and that condition's message (the controller's -/// own honest reason). This is the §19 "provisioned ≠ usable" signal — the -/// controller reconciles + (for MCP) probes these, so we report its real -/// verdict rather than a fabricated one. -struct Readiness { - phase: Option<String>, - ready: Option<bool>, - message: Option<String>, -} - -fn readiness_of(items: &[kube::core::DynamicObject], name: &str) -> Option<Readiness> { - let obj = items - .iter() - .find(|o| o.metadata.name.as_deref() == Some(name))?; - let status = obj.data.get("status"); - let phase = status - .and_then(|s| s.get("phase")) - .and_then(|p| p.as_str()) - .map(|s| s.to_string()); - let ready_cond = status - .and_then(|s| s.get("conditions")) - .and_then(|c| c.as_array()) - .and_then(|arr| { - arr.iter() - .find(|c| c.get("type").and_then(|t| t.as_str()) == Some("Ready")) - }); - let ready = ready_cond - .and_then(|c| c.get("status")) - .and_then(|s| s.as_str()) - .map(|s| s == "True"); - let message = ready_cond - .and_then(|c| c.get("message")) - .and_then(|m| m.as_str()) - .map(|s| s.to_string()); - Some(Readiness { - phase, - ready, - message, - }) -} - -fn status_observes_current_generation(resource: &kube::core::DynamicObject) -> bool { - resource - .data - .get("status") - .and_then(|status| status.get("observedGeneration")) - .and_then(|generation| generation.as_i64()) - == resource.metadata.generation -} - -pub(crate) fn qualification_requirements( - bp: &crate::routes::tasks::BlueprintDto, - workload: Option<&str>, -) -> (std::collections::BTreeSet<String>, i32) { - let mut capabilities = std::collections::BTreeSet::new(); - if !bp.egress.is_empty() { - capabilities.insert("network".into()); - } - if !bp.mcp_servers.is_empty() { - capabilities.insert("mcp".into()); - } - if bp.memory.is_some() { - capabilities.insert("memory".into()); - } - let max_parallel = if let Some(plan) = bp.execution_plan.as_ref() { - capabilities.insert("delegation".into()); - if !plan.deliverables.is_empty() { - capabilities.insert("artifacts".into()); - } - for role in &plan.roles { - for phase in &role.phases { - capabilities.extend(phase.capabilities.iter().cloned()); - } - } - capabilities.extend(plan.synthesis.capabilities.iter().cloned()); - plan.max_parallel - } else { - capabilities.insert("single-agent".into()); - 1 - }; - if workload.is_some_and(|value| value.eq_ignore_ascii_case("team")) { - capabilities.insert("team".into()); - } - capabilities.insert("telemetry".into()); - (capabilities, max_parallel) -} - /// `POST /api/namespaces/:ns/validate` — validate a launch package against live /// cluster state. Pure read + DNS; never mutates anything. pub async fn validate_package( @@ -258,831 +168,13 @@ async fn run_checks( ) -> ValidateResponse { let mut checks: Vec<Check> = Vec::new(); - // 0. Envelope sanity — the autonomy tier and budget the operator will launch - // with. The UI presents validation as covering the whole package, so the - // gate must actually check the envelope, not only the blueprint. - if let Some(t) = tier { - checks.push(if (1..=5).contains(&t) { - Check { - id: "tier".into(), - label: format!("Autonomy tier {t}"), - status: CheckStatus::Pass, - detail: "Within the valid range (1–5). Sub-roles are capped one tier below.".into(), - } - } else { - Check { - id: "tier".into(), - label: "Autonomy tier".into(), - status: CheckStatus::Fail, - detail: format!("Tier {t} is out of range — must be 1–5."), - } - }); - } - let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); - match budget_tokens { - Some(b) if b <= 0 => checks.push(Check { - id: "budget".into(), - label: "Token budget".into(), - status: CheckStatus::Fail, - detail: "The budget cap must be a positive number of tokens.".into(), - }), - Some(b) if b < 500 => checks.push(Check { - id: "budget".into(), - label: format!("Token budget {b}"), - status: CheckStatus::Warn, - detail: "This cap is very low — a real run may stop before producing a deliverable.".into(), - }), - Some(b) => checks.push(Check { - id: "budget".into(), - label: format!("Token budget {b}"), - status: CheckStatus::Pass, - detail: "A per-run token cap is set — the router stops the run at this ceiling.".into(), - }), - None => checks.push(Check { - id: "budget".into(), - label: "Token budget".into(), - status: CheckStatus::Warn, - detail: "No token cap set — the run is bounded only by the mission's autonomy and the cluster defaults.".into(), - }), - } - if let Some(plan) = bp.execution_plan.as_ref() { - checks.push(match crate::routes::compose::validate_execution_plan(plan) { - Ok(()) => Check { - id: "execution_plan".into(), - label: format!( - "Execution plan · {} role{} · up to {} in parallel", - plan.roles.len(), - if plan.roles.len() == 1 { "" } else { "s" }, - plan.max_parallel - ), - status: CheckStatus::Pass, - detail: - "Role dependencies, phases, capabilities, tool-call bounds, synthesis, and deliverables are valid." - .into(), - }, - Err(error) => Check { - id: "execution_plan".into(), - label: "Execution plan is invalid".into(), - status: CheckStatus::Fail, - detail: error, - }, - }); - } + envelope::check_envelope(cluster, bp, tier, budget_tokens, &mut checks).await; - // 0b. Runtime image and registry credentials. This is a real launch blocker: - // allowing a configured private image without a matching pull secret produces - // an immediate ImagePullBackOff before the agent can execute anything. - let runnable_runtimes = cluster.runnable_runtimes().await; - checks.push(if runnable_runtimes.contains(runtime) { - Check { - id: "runtime".into(), - label: format!("Runtime “{runtime}” can start on this cluster"), - status: CheckStatus::Pass, - detail: "The runtime image is configured and its registry is covered by the controller's pull credentials.".into(), - } - } else { - Check { - id: "runtime".into(), - label: format!("Runtime “{runtime}” cannot start on this cluster"), - status: CheckStatus::Fail, - detail: "The runtime image is missing or its private registry is not covered by the controller's pull credentials. Launch is blocked to prevent ImagePullBackOff.".into(), - } - }); - if runtime != "OpenClaw" && !bp.skills.is_empty() { - checks.push(Check { - id: "runtime_skills".into(), - label: format!("Runtime “{runtime}” cannot mount file skills"), - status: CheckStatus::Fail, - detail: "Controller-mounted file skills are currently supported only by OpenClaw; remove them or use OpenClaw instead.".into(), - }); - } + resources::check_resources(cluster, namespace, bp, &mut checks).await; - // 1. Tool policy — provisioned AND usable (compiled). A ToolPolicy that - // exists but hasn't compiled its AGT profile can't actually govern tools. - if let Some(tp) = bp.tool_policy.as_deref().filter(|s| !s.is_empty()) { - let found = cluster - .get_kind(namespace, "ToolPolicy", tp) - .await - .ok() - .flatten(); - checks.push(match found.as_ref() { - None => Check { - id: "tool_policy".into(), - label: format!("Tool policy “{tp}” not found"), - status: CheckStatus::Fail, - detail: "No ToolPolicy by that name exists. Pick an existing policy or create it in the Operator Console.".into(), - }, - Some(resource) => { - let r = readiness_of(std::slice::from_ref(resource), tp) - .expect("resource was supplied"); - // The policy compiles its AGT profile to a `Compiled` phase; a - // `Ready=True` condition only appears once a sandbox references - // it, so `Compiled` (or Ready) is the usable signal here. - let compiled = r - .phase - .as_deref() - .map(|p| p == "Compiled" || p == "Ready") - .unwrap_or(false); - if compiled && status_observes_current_generation(resource) { - Check { - id: "tool_policy".into(), - label: format!("Tool policy “{tp}” is compiled and usable"), - status: CheckStatus::Pass, - detail: "The ToolPolicy exists and its governance profile compiled — it can bound tool calls.".into(), - } - } else { - Check { - id: "tool_policy".into(), - label: format!("Tool policy “{tp}” isn't compiled yet"), - status: CheckStatus::Warn, - detail: format!( - "The ToolPolicy exists but its current generation is not compiled (phase {}).", - r.phase.as_deref().unwrap_or("unknown") - ), - } - } - } - }); - } else { - // No tool policy pinned in the blueprint. The controller applies a - // cluster-default ToolPolicy at launch, but this package doesn't specify - // one — surface it as a Warn rather than silently passing, so "launch- - // ready" never hides an unpinned governance boundary. - checks.push(Check { - id: "tool_policy".into(), - label: "No tool policy pinned in this package".into(), - status: CheckStatus::Warn, - detail: "The cluster's default ToolPolicy will be applied at launch. Pin an explicit policy here if you need this package's tool-governance boundary to be reviewable and reproducible.".into(), - }); - } - - // 2. MCP servers — managed Ready means the controller completed a real MCP - // initialize/tools-list probe and recorded a schema digest. External - // registrations remain explicitly registration-only until a sandbox call. - if !bp.mcp_servers.is_empty() { - for m in &bp.mcp_servers { - let found = cluster - .get_kind(namespace, "McpServer", m) - .await - .ok() - .flatten(); - let url = found - .as_ref() - .and_then(|resource| { - resource - .data - .get("status") - .and_then(|status| status.get("endpoint")) - .or_else(|| resource.data.get("spec").and_then(|spec| spec.get("url"))) - }) - .and_then(|url| url.as_str()); - checks.push(match found.as_ref() { - None => Check { - id: format!("mcp:{m}"), - label: format!("Connected service “{m}” not found"), - status: CheckStatus::Fail, - detail: format!( - "No McpServer by that name exists in namespace `{namespace}`." - ), - }, - Some(resource) => { - let r = readiness_of(std::slice::from_ref(resource), m) - .expect("resource was supplied"); - let ready = (r.ready.unwrap_or(false) - || r.phase.as_deref() == Some("Ready")) - && status_observes_current_generation(resource); - if ready { - let verified_tools = resource - .data - .get("status") - .and_then(|s| s.get("discoveredTools")) - .and_then(|v| v.as_array()) - .map_or(0, Vec::len); - Check { - id: format!("mcp:{m}"), - label: format!("Connected service “{m}” is registered and reconciled"), - status: CheckStatus::Pass, - detail: if verified_tools > 0 { - format!( - "The managed MCP workload is Ready and its live initialize/tools-list probe verified {verified_tools} tools. Mission launch still proves the sandbox-router call path." - ) - } else { - "The external endpoint is registered and reconciled. Its credentials and real tool call are verified from the launched sandbox, not assumed here.".into() - }, - } - } else { - Check { - id: format!("mcp:{m}"), - label: format!("Connected service “{m}” isn't reconciled"), - status: CheckStatus::Fail, - detail: format!( - "The McpServer exists but the controller hasn't reconciled it to Ready ({}). Tool calls to it would likely be denied.", - r.message.or(r.phase).unwrap_or_else(|| "no status".into()) - ), - } - } - } - }); - - // Real endpoint-reachability signal: resolve the MCP server's URL - // host. Catches a misconfigured/typo'd endpoint honestly. - if let Some(host) = url.and_then(url_host) { - let resolved = resolves(&host, 443).await; - checks.push(Check { - id: format!("mcp_endpoint:{m}"), - label: if resolved { - format!("“{m}” endpoint {host} resolves") - } else { - format!("“{m}” endpoint {host} does not resolve") - }, - status: if resolved { CheckStatus::Pass } else { CheckStatus::Warn }, - detail: if resolved { - "The MCP server's URL host resolves in DNS. A full handshake is a deeper probe.".into() - } else { - "The MCP server's URL host did not resolve from the gateway. Check the endpoint URL; it may still be reachable from inside the cluster.".into() - }, - }); - } - } - if bp - .tool_policy - .as_deref() - .filter(|s| !s.is_empty()) - .is_none() - { - checks.push(Check { - id: "mcp_needs_policy".into(), - label: "Connected services need a tool policy".into(), - status: CheckStatus::Fail, - detail: "Governed MCP access must be bounded by a tool policy (admission enforces this).".into(), - }); - } - } - - // 3. Shared memory exists. - if let Some(mem) = bp.memory.as_deref().filter(|s| !s.is_empty()) { - let existing = cluster - .list_kind_all("KarsMemory") - .await - .map(|v| names(&v)) - .unwrap_or_default(); - checks.push(if existing.iter().any(|n| n == mem) { - Check { - id: "memory".into(), - label: format!("Shared memory “{mem}” exists"), - status: CheckStatus::Pass, - detail: "The KarsMemory store is present.".into(), - } - } else { - Check { - id: "memory".into(), - label: format!("Shared memory “{mem}” not found"), - status: CheckStatus::Fail, - detail: "No KarsMemory by that name exists.".into(), - } - }); - } + models::check_models(cluster, bp, budget_tokens, workload, &mut checks).await; - // 3b. (Removed) Harness-suitability check that flagged Hermes as a - // chat-gateway which would sit idle on a one-shot autonomous mission. - // Hermes now runs the agent in-process like OpenClaw and executes - // autonomous missions + genuine mesh delegation (verified E2E), so the - // warning was a stale false-positive. Both wired harnesses are valid - // mission runners; there is no longer a harness-suitability failure to - // surface here. - - // 3c. Skills — each required capability bundle (KarsSkill) must exist AND be - // APPROVED, because the controller's trust gate refuses to mount an - // unapproved skill into the sandbox. Pre-flighting this turns a silent - // "the agent never got the skill it needed" runtime gap into an explicit, - // fixable launch check (get the operator to approve it first). - if !bp.skills.is_empty() { - for s in &bp.skills { - let found = cluster - .get_kind(namespace, "KarsSkill", s) - .await - .ok() - .flatten(); - let approved = found.as_ref().is_some_and(|o| { - o.metadata - .annotations - .as_ref() - .and_then(|a| a.get("kars.azure.com/skill-review")) - .is_some_and(|review| review == "approved") - }); - if approved { - let required_mcp = found - .as_ref() - .and_then(|o| o.data.get("spec")) - .and_then(|spec| spec.get("mcpServers")) - .and_then(|servers| servers.as_array()) - .cloned() - .unwrap_or_default(); - for server in required_mcp.iter().filter_map(|value| value.as_str()) { - let dependency = cluster - .get_kind(namespace, "McpServer", server) - .await - .ok() - .flatten(); - let ready = dependency.as_ref().is_some_and(|resource| { - readiness_of(std::slice::from_ref(resource), server).is_some_and(|status| { - (status.ready.unwrap_or(false) - || status.phase.as_deref() == Some("Ready")) - && status_observes_current_generation(resource) - }) - }); - checks.push(Check { - id: format!("skill_mcp:{s}:{server}"), - label: format!("Skill “{s}” dependency “{server}”"), - status: if ready { - CheckStatus::Pass - } else { - CheckStatus::Fail - }, - detail: if ready { - "The skill's required MCP server is Ready in this namespace.".into() - } else { - "The skill requires an MCP server that is missing, stale, or not Ready in this namespace.".into() - }, - }); - } - } - checks.push(match found.as_ref() { - None => Check { - id: format!("skill:{s}"), - label: format!("Skill “{s}” not found"), - status: CheckStatus::Fail, - detail: "No KarsSkill by that name exists — upload it in Skills, then have an operator approve it.".into(), - }, - Some(_) => { - if approved { - Check { - id: format!("skill:{s}"), - label: format!("Skill “{s}” is approved and mountable"), - status: CheckStatus::Pass, - detail: "The skill package is approved; the controller will mount it into the sandbox.".into(), - } - } else { - Check { - id: format!("skill:{s}"), - label: format!("Skill “{s}” is not approved"), - status: CheckStatus::Fail, - detail: "The skill exists but hasn't been approved — the sandbox trust gate will refuse to mount it. An operator must approve it before the agent can use it.".into(), - } - } - } - }); - } - } - - // 4. Model is one the cluster serves. Use the exact same provider-aware - // catalogue as the composer/picker; checking only the controller default - // catalogue incorrectly rejects additional providers such as Foundry. - let effective_default_model = if bp.model.is_none() { - crate::routes::options::build_options(cluster) - .await - .ok() - .and_then(|options| { - options - .models - .iter() - .find(|model| model.is_default) - .or_else(|| options.models.first()) - .map(|model| crate::routes::tasks::ModelDto { - provider: model.provider.clone(), - deployment: model.deployment.clone(), - }) - }) - } else { - None - }; - if let Some(model) = bp.model.as_ref().or(effective_default_model.as_ref()) { - match crate::routes::options::build_options(cluster).await { - Ok(options) => { - let known = options.models.iter().any(|option| { - option.provider == model.provider && option.deployment == model.deployment - }); - checks.push(Check { - id: "model".into(), - label: if known { - format!( - "Model “{}” is served through {}", - model.deployment, model.provider - ) - } else { - format!( - "Model route “{}::{}” is not served by this cluster", - model.provider, model.deployment - ) - }, - status: if known { - CheckStatus::Pass - } else { - CheckStatus::Fail - }, - detail: if known { - "This exact provider and deployment pair is present in the live model catalogue used by the picker.".into() - } else { - "This exact provider and deployment pair is absent from the live model catalogue. Select a listed route before launch.".into() - }, - }); - if known { - let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); - let (required_capabilities, max_parallel) = - qualification_requirements(bp, workload); - let qualification = crate::routes::options::route_qualification( - runtime, - &model.provider, - &model.deployment, - &required_capabilities, - max_parallel, - budget_tokens, - ); - let qualified = qualification.as_ref().copied().unwrap_or(false); - checks.push(Check { - id: "route_qualification".into(), - label: if qualified { - format!( - "{runtime} · {}::{} is qualified", - model.provider, model.deployment - ) - } else { - format!( - "{runtime} · {}::{} is not qualified", - model.provider, model.deployment - ) - }, - status: if qualified { - CheckStatus::Pass - } else { - CheckStatus::Fail - }, - detail: if let Err(error) = qualification { - format!("Route qualification configuration error: {error}") - } else if qualified { - format!( - "This route has verified evidence for capabilities: {}.", - required_capabilities.iter().cloned().collect::<Vec<_>>().join(", ") - ) - } else { - let missing = crate::routes::options::route_qualification_gap( - runtime, - &model.provider, - &model.deployment, - &required_capabilities, - max_parallel, - budget_tokens, - ) - .unwrap_or_else(|_| required_capabilities.clone()); - format!( - "This route lacks verified evidence for: {}. Existing retained evidence covers the other required capabilities, but qualification records are atomic and cannot be combined.", - missing.iter().cloned().collect::<Vec<_>>().join(", ") - ) - }, - }); - fn find_resource<'a>( - items: &'a [crate::routes::options::RefOption], - name: &str, - ) -> Option<&'a crate::routes::options::RefOption> { - items.iter().find(|option| option.name == name) - } - for server in &bp.mcp_servers { - let qualification = find_resource(&options.mcp_servers, server) - .map(|option| { - crate::routes::options::mcp_server_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) - .map(|qualified| (qualified, option)) - }) - .transpose(); - checks.push(match qualification { - Ok(Some((true, option))) => Check { - id: format!("mcp_qualification:{server}"), - label: format!( - "Connected service “{server}” has retained resource qualification" - ), - status: CheckStatus::Pass, - detail: format!( - "Retained evidence matches the current tool schema digest {} on {}.", - option.tool_schema_digest.as_deref().unwrap_or("missing"), - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ) - ), - }, - Ok(Some((false, option))) => Check { - id: format!("mcp_qualification:{server}"), - label: format!( - "Connected service “{server}” lacks retained resource qualification" - ), - status: CheckStatus::Fail, - detail: format!( - "No retained resource-scoped qualification record matches the current tool schema digest {} on {}. Generic route records do not prove this MCP server.", - option.tool_schema_digest.as_deref().unwrap_or("missing"), - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ) - ), - }, - Ok(None) => Check { - id: format!("mcp_qualification:{server}"), - label: format!( - "Connected service “{server}” could not be matched to live metadata" - ), - status: CheckStatus::Fail, - detail: - "The live MCP catalogue has no current schema digest for this server, so resource-scoped qualification cannot be proven." - .into(), - }, - Err(error) => Check { - id: format!("mcp_qualification:{server}"), - label: format!( - "Connected service “{server}” qualification could not be evaluated" - ), - status: CheckStatus::Fail, - detail: format!( - "Resource qualification configuration error: {error}" - ), - }, - }); - } - if let Some(memory) = bp.memory.as_deref().filter(|memory| !memory.is_empty()) { - let qualification = find_resource(&options.memories, memory) - .map(|option| { - crate::routes::options::memory_binding_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) - .map(|qualified| (qualified, option)) - }) - .transpose(); - checks.push(match qualification { - Ok(Some((true, option))) => Check { - id: "memory_qualification".into(), - label: format!( - "Shared memory “{memory}” has retained resource qualification" - ), - status: CheckStatus::Pass, - detail: format!( - "Retained evidence matches backend {} and compiled digest {} on {}.", - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing"), - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ) - ), - }, - Ok(Some((false, option))) => Check { - id: "memory_qualification".into(), - label: format!( - "Shared memory “{memory}” lacks retained resource qualification" - ), - status: CheckStatus::Fail, - detail: format!( - "No retained resource-scoped qualification record matches backend {} and compiled digest {} on {}. Generic route records do not prove this memory binding.", - option.backend.as_deref().unwrap_or("missing"), - option.compiled_digest.as_deref().unwrap_or("missing"), - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ) - ), - }, - Ok(None) => Check { - id: "memory_qualification".into(), - label: format!( - "Shared memory “{memory}” could not be matched to live metadata" - ), - status: CheckStatus::Fail, - detail: - "The live memory catalogue has no current backend and compiled digest for this binding, so resource-scoped qualification cannot be proven." - .into(), - }, - Err(error) => Check { - id: "memory_qualification".into(), - label: format!( - "Shared memory “{memory}” qualification could not be evaluated" - ), - status: CheckStatus::Fail, - detail: format!( - "Resource qualification configuration error: {error}" - ), - }, - }); - } - for skill in &bp.skills { - let qualification = find_resource(&options.skills, skill) - .map(|option| { - crate::routes::options::skill_version_qualified_for_route( - runtime, - &model.provider, - &model.deployment, - option, - ) - .map(|qualified| (qualified, option)) - }) - .transpose(); - checks.push(match qualification { - Ok(Some((true, option))) => Check { - id: format!("skill_qualification:{skill}"), - label: format!( - "Skill “{skill}” has retained resource qualification" - ), - status: CheckStatus::Pass, - detail: format!( - "Retained evidence matches the current approved version digest {} on {}.", - option.version_digest.as_deref().unwrap_or("missing"), - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ) - ), - }, - Ok(Some((false, option))) => Check { - id: format!("skill_qualification:{skill}"), - label: format!( - "Skill “{skill}” lacks retained resource qualification" - ), - status: CheckStatus::Fail, - detail: format!( - "No retained resource-scoped qualification record matches the current approved version digest {} on {}. Generic route records do not prove this skill version.", - option.version_digest.as_deref().unwrap_or("missing"), - crate::routes::options::route_label( - runtime, - &model.provider, - &model.deployment - ) - ), - }, - Ok(None) => Check { - id: format!("skill_qualification:{skill}"), - label: format!( - "Skill “{skill}” could not be matched to live metadata" - ), - status: CheckStatus::Fail, - detail: - "The live skill catalogue has no current approved version digest for this skill, so resource-scoped qualification cannot be proven." - .into(), - }, - Err(error) => Check { - id: format!("skill_qualification:{skill}"), - label: format!( - "Skill “{skill}” qualification could not be evaluated" - ), - status: CheckStatus::Fail, - detail: format!( - "Resource qualification configuration error: {error}" - ), - }, - }); - } - } - let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); - let (required_capabilities, max_parallel) = - qualification_requirements(bp, workload); - if bp.model_fallbacks.len() > 8 { - checks.push(Check { - id: "model_fallback_count".into(), - label: "Too many fallback model routes".into(), - status: CheckStatus::Fail, - detail: "A blueprint may declare at most 8 ordered fallback routes.".into(), - }); - } - for (index, fallback) in bp.model_fallbacks.iter().enumerate() { - let route = crate::routes::options::route_label( - runtime, - &fallback.provider, - &fallback.deployment, - ); - let known = options.models.iter().any(|option| { - option.provider == fallback.provider - && option.deployment == fallback.deployment - }); - let route_qualified = known - && crate::routes::options::route_qualification( - runtime, - &fallback.provider, - &fallback.deployment, - &required_capabilities, - max_parallel, - budget_tokens, - ) - .unwrap_or(false); - let mcp_qualified = bp.mcp_servers.iter().all(|server| { - options - .mcp_servers - .iter() - .find(|option| option.name == *server) - .is_some_and(|option| { - crate::routes::options::mcp_server_qualified_for_route( - runtime, - &fallback.provider, - &fallback.deployment, - option, - ) - .unwrap_or(false) - }) - }); - let memory_qualified = bp.memory.as_ref().is_none_or(|memory| { - options - .memories - .iter() - .find(|option| option.name == *memory) - .is_some_and(|option| { - crate::routes::options::memory_binding_qualified_for_route( - runtime, - &fallback.provider, - &fallback.deployment, - option, - ) - .unwrap_or(false) - }) - }); - let skills_qualified = bp.skills.iter().all(|skill| { - options - .skills - .iter() - .find(|option| option.name == *skill) - .is_some_and(|option| { - crate::routes::options::skill_version_qualified_for_route( - runtime, - &fallback.provider, - &fallback.deployment, - option, - ) - .unwrap_or(false) - }) - }); - let qualified = - route_qualified && mcp_qualified && memory_qualified && skills_qualified; - checks.push(Check { - id: format!("model_fallback:{index}"), - label: if qualified { - format!("Fallback {route} is qualified") - } else { - format!("Fallback {route} is not qualified") - }, - status: if qualified { - CheckStatus::Pass - } else { - CheckStatus::Fail - }, - detail: if qualified { - "Retained evidence proves the complete capability and selected-resource contract for this fallback route.".into() - } else if !known { - "This fallback is absent from the live model catalogue.".into() - } else if !route_qualified { - "No atomic qualification record proves the complete capability contract for this fallback.".into() - } else { - "The route is generally qualified, but at least one selected MCP server, memory binding, or skill version lacks current resource-scoped evidence on it.".into() - }, - }); - } - } - Err(error) => checks.push(Check { - id: "model".into(), - label: "Live model catalogue could not be verified".into(), - status: CheckStatus::Fail, - detail: format!( - "Pre-flight could not confirm the requested provider/model route: {error}" - ), - }), - } - } - - // 5. Egress hosts resolve (DNS) — a real, honest reachability signal from - // the BFF (not the full in-sandbox egress path, which is a deeper probe). - for e in &bp.egress { - let port = e.port.unwrap_or(443) as u16; - let resolved = resolves(&e.host, port).await; - checks.push(Check { - id: format!("egress:{}", e.host), - label: if resolved { - format!("Egress host {} resolves", e.host) - } else { - format!("Egress host {} does not resolve", e.host) - }, - status: if resolved { CheckStatus::Pass } else { CheckStatus::Warn }, - detail: if resolved { - "The host resolves in DNS. Full reachability from the sandbox egress path is a deeper probe (named next step).".into() - } else { - "The host did not resolve from the gateway. Check the spelling; it may still be reachable from inside the cluster.".into() - }, - }); - } + network::check_egress(bp, &mut checks).await; if checks.is_empty() { checks.push(Check { @@ -1097,177 +189,5 @@ async fn run_checks( ValidateResponse { ok, checks } } -/// Extract the host from a URL string for a reachability check. Best-effort: -/// strips a scheme and any path/port. Returns `None` for an empty host. -fn url_host(url: &str) -> Option<String> { - let after_scheme = url.split("://").nth(1).unwrap_or(url); - let host = after_scheme - .split('/') - .next() - .unwrap_or("") - .split(':') - .next() - .unwrap_or("") - .trim(); - if host.is_empty() { - None - } else { - Some(host.to_string()) - } -} - -/// True when `host:port` resolves in DNS within a short timeout. A real, -/// honest reachability signal from the gateway — not a full connection. -async fn resolves(host: &str, port: u16) -> bool { - let addr = format!("{host}:{port}"); - tokio::time::timeout(Duration::from_secs(3), tokio::net::lookup_host(&addr)) - .await - .ok() - .and_then(|r| r.ok()) - .map(|mut it| it.next().is_some()) - .unwrap_or(false) -} - #[cfg(test)] -mod qualification_requirement_tests { - use super::{blueprint_to_dto, qualification_requirements}; - - #[test] - fn persisted_draft_plan_preserves_create_preflight_requirements_and_all_plan_fields() { - use crate::kars::task::TaskBlueprint; - use crate::routes::tasks::{BlueprintDto, ExecutionPlanDto}; - let plan: ExecutionPlanDto = serde_json::from_value(serde_json::json!({ - "schema": "kars.execution-plan/v1", - "roles": [ - { - "name": "evidence", - "objective": "Read public evidence.", - "depends_on": [], - "phases": [{ - "name": "inspect", - "objective": "Read current check logs.", - "capabilities": ["network", "mcp"], - "required_tool_calls": [{ - "name": "github_actions_job_logs", - "arguments": {"owner": "owner", "repo": "repo", "job_id": "42"} - }], - "min_tool_calls": 1, - "max_tool_calls": 4, - "fresh_context": true - }], - "budget_tokens": 1500 - }, - { - "name": "review", - "objective": "Review the evidence.", - "depends_on": ["evidence"], - "phases": [{ - "name": "assess", - "objective": "Assess the handback.", - "capabilities": [], - "required_tool_calls": [], - "min_tool_calls": 0, - "max_tool_calls": 0, - "fresh_context": false - }], - "budget_tokens": 500 - } - ], - "max_parallel": 2, - "synthesis": { - "objective": "Produce the review.", - "capabilities": ["network"], - "max_tool_calls": 1 - }, - "deliverables": [ - {"name": "review.md", "media_type": "text/markdown"}, - {"name": "evidence.json", "media_type": null} - ] - })) - .unwrap(); - let create = BlueprintDto { - execution_plan: Some(plan.clone()), - ..Default::default() - }; - let stored = serde_json::to_value(TaskBlueprint { - execution_plan: Some(plan.into_crd()), - ..Default::default() - }) - .unwrap(); - assert_eq!(stored["executionPlan"]["maxParallel"], 2); - let persisted: TaskBlueprint = serde_json::from_value(stored).unwrap(); - let launch = blueprint_to_dto(&persisted); - assert_eq!( - serde_json::to_value(&launch.execution_plan).unwrap(), - serde_json::to_value(&create.execution_plan).unwrap() - ); - let required = qualification_requirements(&launch, Some("mission")); - assert_eq!( - required, - qualification_requirements(&create, Some("mission")) - ); - assert_eq!(required.1, 2); - for capability in ["artifacts", "delegation", "mcp", "network", "telemetry"] { - assert!(required.0.contains(capability)); - } - assert!(!required.0.contains("single-agent")); - } - - #[test] - fn legacy_draft_without_a_plan_still_requires_single_agent_qualification() { - let launch = blueprint_to_dto(&crate::kars::task::TaskBlueprint::default()); - assert!(launch.execution_plan.is_none()); - let (required, parallel) = qualification_requirements(&launch, Some("mission")); - assert_eq!(parallel, 1); - assert!(required.contains("single-agent")); - assert!(!required.contains("delegation")); - } - - #[test] - fn team_preflight_requires_retained_team_evidence() { - let blueprint = crate::routes::tasks::BlueprintDto::default(); - let (mission, _) = qualification_requirements(&blueprint, Some("mission")); - let (team, _) = qualification_requirements(&blueprint, Some("team")); - - assert!(!mission.contains("team")); - assert!(team.contains("team")); - } - - #[test] - fn execution_plan_capabilities_flow_into_atomic_qualification_requirements() { - let blueprint = crate::routes::tasks::BlueprintDto { - execution_plan: Some(crate::routes::tasks::ExecutionPlanDto { - schema: "kars.execution-plan/v1".into(), - roles: vec![crate::routes::tasks::ExecutionRoleDto { - name: "source-scout".into(), - objective: "Discover exact URLs and fetch the source evidence.".into(), - depends_on: Vec::new(), - phases: vec![crate::routes::tasks::ExecutionPhaseDto { - name: "discover".into(), - objective: "Search and fetch authoritative sources.".into(), - capabilities: vec!["web-search".into(), "network".into()], - required_tool_calls: Vec::new(), - min_tool_calls: 1, - max_tool_calls: 4, - fresh_context: true, - }], - budget_tokens: None, - }], - max_parallel: 1, - synthesis: crate::routes::tasks::ExecutionSynthesisDto { - objective: "Return the verified answer.".into(), - capabilities: Vec::new(), - max_tool_calls: 0, - }, - deliverables: Vec::new(), - }), - ..Default::default() - }; - - let (required, max_parallel) = qualification_requirements(&blueprint, Some("mission")); - assert_eq!(max_parallel, 1); - assert!(required.contains("delegation")); - assert!(required.contains("web-search")); - assert!(required.contains("network")); - } -} +mod qualification_requirement_tests; diff --git a/bridge/bff/src/routes/validate/envelope.rs b/bridge/bff/src/routes/validate/envelope.rs new file mode 100644 index 000000000..700225599 --- /dev/null +++ b/bridge/bff/src/routes/validate/envelope.rs @@ -0,0 +1,110 @@ +// kars Bridge BFF — envelope pre-flight checks. + +use super::{Check, CheckStatus}; + +pub(super) async fn check_envelope( + cluster: &crate::kars::cluster::Cluster, + bp: &crate::routes::tasks::BlueprintDto, + tier: Option<i32>, + budget_tokens: Option<i64>, + checks: &mut Vec<Check>, +) { + // 0. Envelope sanity — the autonomy tier and budget the operator will launch + // with. The UI presents validation as covering the whole package, so the + // gate must actually check the envelope, not only the blueprint. + if let Some(t) = tier { + checks.push(if (1..=5).contains(&t) { + Check { + id: "tier".into(), + label: format!("Autonomy tier {t}"), + status: CheckStatus::Pass, + detail: "Within the valid range (1–5). Sub-roles are capped one tier below.".into(), + } + } else { + Check { + id: "tier".into(), + label: "Autonomy tier".into(), + status: CheckStatus::Fail, + detail: format!("Tier {t} is out of range — must be 1–5."), + } + }); + } + let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); + match budget_tokens { + Some(b) if b <= 0 => checks.push(Check { + id: "budget".into(), + label: "Token budget".into(), + status: CheckStatus::Fail, + detail: "The budget cap must be a positive number of tokens.".into(), + }), + Some(b) if b < 500 => checks.push(Check { + id: "budget".into(), + label: format!("Token budget {b}"), + status: CheckStatus::Warn, + detail: "This cap is very low — a real run may stop before producing a deliverable.".into(), + }), + Some(b) => checks.push(Check { + id: "budget".into(), + label: format!("Token budget {b}"), + status: CheckStatus::Pass, + detail: "A per-run token cap is set — the router stops the run at this ceiling.".into(), + }), + None => checks.push(Check { + id: "budget".into(), + label: "Token budget".into(), + status: CheckStatus::Warn, + detail: "No token cap set — the run is bounded only by the mission's autonomy and the cluster defaults.".into(), + }), + } + if let Some(plan) = bp.execution_plan.as_ref() { + checks.push(match crate::routes::compose::validate_execution_plan(plan) { + Ok(()) => Check { + id: "execution_plan".into(), + label: format!( + "Execution plan · {} role{} · up to {} in parallel", + plan.roles.len(), + if plan.roles.len() == 1 { "" } else { "s" }, + plan.max_parallel + ), + status: CheckStatus::Pass, + detail: + "Role dependencies, phases, capabilities, tool-call bounds, synthesis, and deliverables are valid." + .into(), + }, + Err(error) => Check { + id: "execution_plan".into(), + label: "Execution plan is invalid".into(), + status: CheckStatus::Fail, + detail: error, + }, + }); + } + + // 0b. Runtime image and registry credentials. This is a real launch blocker: + // allowing a configured private image without a matching pull secret produces + // an immediate ImagePullBackOff before the agent can execute anything. + let runnable_runtimes = cluster.runnable_runtimes().await; + checks.push(if runnable_runtimes.contains(runtime) { + Check { + id: "runtime".into(), + label: format!("Runtime “{runtime}” can start on this cluster"), + status: CheckStatus::Pass, + detail: "The runtime image is configured and its registry is covered by the controller's pull credentials.".into(), + } + } else { + Check { + id: "runtime".into(), + label: format!("Runtime “{runtime}” cannot start on this cluster"), + status: CheckStatus::Fail, + detail: "The runtime image is missing or its private registry is not covered by the controller's pull credentials. Launch is blocked to prevent ImagePullBackOff.".into(), + } + }); + if runtime != "OpenClaw" && !bp.skills.is_empty() { + checks.push(Check { + id: "runtime_skills".into(), + label: format!("Runtime “{runtime}” cannot mount file skills"), + status: CheckStatus::Fail, + detail: "Controller-mounted file skills are currently supported only by OpenClaw; remove them or use OpenClaw instead.".into(), + }); + } +} diff --git a/bridge/bff/src/routes/validate/models.rs b/bridge/bff/src/routes/validate/models.rs new file mode 100644 index 000000000..4924051a1 --- /dev/null +++ b/bridge/bff/src/routes/validate/models.rs @@ -0,0 +1,476 @@ +// kars Bridge BFF — models pre-flight checks. + +use super::{Check, CheckStatus}; + +pub(crate) fn qualification_requirements( + bp: &crate::routes::tasks::BlueprintDto, + workload: Option<&str>, +) -> (std::collections::BTreeSet<String>, i32) { + let mut capabilities = std::collections::BTreeSet::new(); + if !bp.egress.is_empty() { + capabilities.insert("network".into()); + } + if !bp.mcp_servers.is_empty() { + capabilities.insert("mcp".into()); + } + if bp.memory.is_some() { + capabilities.insert("memory".into()); + } + let max_parallel = if let Some(plan) = bp.execution_plan.as_ref() { + capabilities.insert("delegation".into()); + if !plan.deliverables.is_empty() { + capabilities.insert("artifacts".into()); + } + for role in &plan.roles { + for phase in &role.phases { + capabilities.extend(phase.capabilities.iter().cloned()); + } + } + capabilities.extend(plan.synthesis.capabilities.iter().cloned()); + plan.max_parallel + } else { + capabilities.insert("single-agent".into()); + 1 + }; + if workload.is_some_and(|value| value.eq_ignore_ascii_case("team")) { + capabilities.insert("team".into()); + } + capabilities.insert("telemetry".into()); + (capabilities, max_parallel) +} + +pub(super) async fn check_models( + cluster: &crate::kars::cluster::Cluster, + bp: &crate::routes::tasks::BlueprintDto, + budget_tokens: Option<i64>, + workload: Option<&str>, + checks: &mut Vec<Check>, +) { + // 4. Model is one the cluster serves. Use the exact same provider-aware + // catalogue as the composer/picker; checking only the controller default + // catalogue incorrectly rejects additional providers such as Foundry. + let effective_default_model = if bp.model.is_none() { + crate::routes::options::build_options(cluster) + .await + .ok() + .and_then(|options| { + options + .models + .iter() + .find(|model| model.is_default) + .or_else(|| options.models.first()) + .map(|model| crate::routes::tasks::ModelDto { + provider: model.provider.clone(), + deployment: model.deployment.clone(), + }) + }) + } else { + None + }; + if let Some(model) = bp.model.as_ref().or(effective_default_model.as_ref()) { + match crate::routes::options::build_options(cluster).await { + Ok(options) => { + let known = options.models.iter().any(|option| { + option.provider == model.provider && option.deployment == model.deployment + }); + checks.push(Check { + id: "model".into(), + label: if known { + format!( + "Model “{}” is served through {}", + model.deployment, model.provider + ) + } else { + format!( + "Model route “{}::{}” is not served by this cluster", + model.provider, model.deployment + ) + }, + status: if known { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if known { + "This exact provider and deployment pair is present in the live model catalogue used by the picker.".into() + } else { + "This exact provider and deployment pair is absent from the live model catalogue. Select a listed route before launch.".into() + }, + }); + if known { + let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); + let (required_capabilities, max_parallel) = + qualification_requirements(bp, workload); + let qualification = crate::routes::options::route_qualification( + runtime, + &model.provider, + &model.deployment, + &required_capabilities, + max_parallel, + budget_tokens, + ); + let qualified = qualification.as_ref().copied().unwrap_or(false); + checks.push(Check { + id: "route_qualification".into(), + label: if qualified { + format!( + "{runtime} · {}::{} is qualified", + model.provider, model.deployment + ) + } else { + format!( + "{runtime} · {}::{} is not qualified", + model.provider, model.deployment + ) + }, + status: if qualified { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if let Err(error) = qualification { + format!("Route qualification configuration error: {error}") + } else if qualified { + format!( + "This route has verified evidence for capabilities: {}.", + required_capabilities.iter().cloned().collect::<Vec<_>>().join(", ") + ) + } else { + let missing = crate::routes::options::route_qualification_gap( + runtime, + &model.provider, + &model.deployment, + &required_capabilities, + max_parallel, + budget_tokens, + ) + .unwrap_or_else(|_| required_capabilities.clone()); + format!( + "This route lacks verified evidence for: {}. Existing retained evidence covers the other required capabilities, but qualification records are atomic and cannot be combined.", + missing.iter().cloned().collect::<Vec<_>>().join(", ") + ) + }, + }); + fn find_resource<'a>( + items: &'a [crate::routes::options::RefOption], + name: &str, + ) -> Option<&'a crate::routes::options::RefOption> { + items.iter().find(|option| option.name == name) + } + for server in &bp.mcp_servers { + let qualification = find_resource(&options.mcp_servers, server) + .map(|option| { + crate::routes::options::mcp_server_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map(|qualified| (qualified, option)) + }) + .transpose(); + checks.push(match qualification { + Ok(Some((true, option))) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” has retained resource qualification" + ), + status: CheckStatus::Pass, + detail: format!( + "Retained evidence matches the current tool schema digest {} on {}.", + option.tool_schema_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(Some((false, option))) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” lacks retained resource qualification" + ), + status: CheckStatus::Fail, + detail: format!( + "No retained resource-scoped qualification record matches the current tool schema digest {} on {}. Generic route records do not prove this MCP server.", + option.tool_schema_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(None) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” could not be matched to live metadata" + ), + status: CheckStatus::Fail, + detail: + "The live MCP catalogue has no current schema digest for this server, so resource-scoped qualification cannot be proven." + .into(), + }, + Err(error) => Check { + id: format!("mcp_qualification:{server}"), + label: format!( + "Connected service “{server}” qualification could not be evaluated" + ), + status: CheckStatus::Fail, + detail: format!( + "Resource qualification configuration error: {error}" + ), + }, + }); + } + if let Some(memory) = bp.memory.as_deref().filter(|memory| !memory.is_empty()) { + let qualification = find_resource(&options.memories, memory) + .map(|option| { + crate::routes::options::memory_binding_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map(|qualified| (qualified, option)) + }) + .transpose(); + checks.push(match qualification { + Ok(Some((true, option))) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” has retained resource qualification" + ), + status: CheckStatus::Pass, + detail: format!( + "Retained evidence matches backend {} and compiled digest {} on {}.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(Some((false, option))) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” lacks retained resource qualification" + ), + status: CheckStatus::Fail, + detail: format!( + "No retained resource-scoped qualification record matches backend {} and compiled digest {} on {}. Generic route records do not prove this memory binding.", + option.backend.as_deref().unwrap_or("missing"), + option.compiled_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(None) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” could not be matched to live metadata" + ), + status: CheckStatus::Fail, + detail: + "The live memory catalogue has no current backend and compiled digest for this binding, so resource-scoped qualification cannot be proven." + .into(), + }, + Err(error) => Check { + id: "memory_qualification".into(), + label: format!( + "Shared memory “{memory}” qualification could not be evaluated" + ), + status: CheckStatus::Fail, + detail: format!( + "Resource qualification configuration error: {error}" + ), + }, + }); + } + for skill in &bp.skills { + let qualification = find_resource(&options.skills, skill) + .map(|option| { + crate::routes::options::skill_version_qualified_for_route( + runtime, + &model.provider, + &model.deployment, + option, + ) + .map(|qualified| (qualified, option)) + }) + .transpose(); + checks.push(match qualification { + Ok(Some((true, option))) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” has retained resource qualification" + ), + status: CheckStatus::Pass, + detail: format!( + "Retained evidence matches the current approved version digest {} on {}.", + option.version_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(Some((false, option))) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” lacks retained resource qualification" + ), + status: CheckStatus::Fail, + detail: format!( + "No retained resource-scoped qualification record matches the current approved version digest {} on {}. Generic route records do not prove this skill version.", + option.version_digest.as_deref().unwrap_or("missing"), + crate::routes::options::route_label( + runtime, + &model.provider, + &model.deployment + ) + ), + }, + Ok(None) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” could not be matched to live metadata" + ), + status: CheckStatus::Fail, + detail: + "The live skill catalogue has no current approved version digest for this skill, so resource-scoped qualification cannot be proven." + .into(), + }, + Err(error) => Check { + id: format!("skill_qualification:{skill}"), + label: format!( + "Skill “{skill}” qualification could not be evaluated" + ), + status: CheckStatus::Fail, + detail: format!( + "Resource qualification configuration error: {error}" + ), + }, + }); + } + } + let runtime = bp.runtime.as_deref().unwrap_or("OpenClaw"); + let (required_capabilities, max_parallel) = + qualification_requirements(bp, workload); + if bp.model_fallbacks.len() > 8 { + checks.push(Check { + id: "model_fallback_count".into(), + label: "Too many fallback model routes".into(), + status: CheckStatus::Fail, + detail: "A blueprint may declare at most 8 ordered fallback routes.".into(), + }); + } + for (index, fallback) in bp.model_fallbacks.iter().enumerate() { + let route = crate::routes::options::route_label( + runtime, + &fallback.provider, + &fallback.deployment, + ); + let known = options.models.iter().any(|option| { + option.provider == fallback.provider + && option.deployment == fallback.deployment + }); + let route_qualified = known + && crate::routes::options::route_qualification( + runtime, + &fallback.provider, + &fallback.deployment, + &required_capabilities, + max_parallel, + budget_tokens, + ) + .unwrap_or(false); + let mcp_qualified = bp.mcp_servers.iter().all(|server| { + options + .mcp_servers + .iter() + .find(|option| option.name == *server) + .is_some_and(|option| { + crate::routes::options::mcp_server_qualified_for_route( + runtime, + &fallback.provider, + &fallback.deployment, + option, + ) + .unwrap_or(false) + }) + }); + let memory_qualified = bp.memory.as_ref().is_none_or(|memory| { + options + .memories + .iter() + .find(|option| option.name == *memory) + .is_some_and(|option| { + crate::routes::options::memory_binding_qualified_for_route( + runtime, + &fallback.provider, + &fallback.deployment, + option, + ) + .unwrap_or(false) + }) + }); + let skills_qualified = bp.skills.iter().all(|skill| { + options + .skills + .iter() + .find(|option| option.name == *skill) + .is_some_and(|option| { + crate::routes::options::skill_version_qualified_for_route( + runtime, + &fallback.provider, + &fallback.deployment, + option, + ) + .unwrap_or(false) + }) + }); + let qualified = + route_qualified && mcp_qualified && memory_qualified && skills_qualified; + checks.push(Check { + id: format!("model_fallback:{index}"), + label: if qualified { + format!("Fallback {route} is qualified") + } else { + format!("Fallback {route} is not qualified") + }, + status: if qualified { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if qualified { + "Retained evidence proves the complete capability and selected-resource contract for this fallback route.".into() + } else if !known { + "This fallback is absent from the live model catalogue.".into() + } else if !route_qualified { + "No atomic qualification record proves the complete capability contract for this fallback.".into() + } else { + "The route is generally qualified, but at least one selected MCP server, memory binding, or skill version lacks current resource-scoped evidence on it.".into() + }, + }); + } + } + Err(error) => checks.push(Check { + id: "model".into(), + label: "Live model catalogue could not be verified".into(), + status: CheckStatus::Fail, + detail: format!( + "Pre-flight could not confirm the requested provider/model route: {error}" + ), + }), + } + } +} diff --git a/bridge/bff/src/routes/validate/network.rs b/bridge/bff/src/routes/validate/network.rs new file mode 100644 index 000000000..904e9bc55 --- /dev/null +++ b/bridge/bff/src/routes/validate/network.rs @@ -0,0 +1,59 @@ +// kars Bridge BFF — network pre-flight checks. + +use std::time::Duration; + +use super::{Check, CheckStatus}; + +/// Extract the host from a URL string for a reachability check. Best-effort: +/// strips a scheme and any path/port. Returns `None` for an empty host. +pub(super) fn url_host(url: &str) -> Option<String> { + let after_scheme = url.split("://").nth(1).unwrap_or(url); + let host = after_scheme + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or("") + .trim(); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// True when `host:port` resolves in DNS within a short timeout. A real, +/// honest reachability signal from the gateway — not a full connection. +pub(super) async fn resolves(host: &str, port: u16) -> bool { + let addr = format!("{host}:{port}"); + tokio::time::timeout(Duration::from_secs(3), tokio::net::lookup_host(&addr)) + .await + .ok() + .and_then(|r| r.ok()) + .map(|mut it| it.next().is_some()) + .unwrap_or(false) +} + +pub(super) async fn check_egress(bp: &crate::routes::tasks::BlueprintDto, checks: &mut Vec<Check>) { + // 5. Egress hosts resolve (DNS) — a real, honest reachability signal from + // the BFF (not the full in-sandbox egress path, which is a deeper probe). + for e in &bp.egress { + let port = e.port.unwrap_or(443) as u16; + let resolved = resolves(&e.host, port).await; + checks.push(Check { + id: format!("egress:{}", e.host), + label: if resolved { + format!("Egress host {} resolves", e.host) + } else { + format!("Egress host {} does not resolve", e.host) + }, + status: if resolved { CheckStatus::Pass } else { CheckStatus::Warn }, + detail: if resolved { + "The host resolves in DNS. Full reachability from the sandbox egress path is a deeper probe (named next step).".into() + } else { + "The host did not resolve from the gateway. Check the spelling; it may still be reachable from inside the cluster.".into() + }, + }); + } +} diff --git a/bridge/bff/src/routes/validate/qualification_requirement_tests.rs b/bridge/bff/src/routes/validate/qualification_requirement_tests.rs new file mode 100644 index 000000000..c7e638f98 --- /dev/null +++ b/bridge/bff/src/routes/validate/qualification_requirement_tests.rs @@ -0,0 +1,142 @@ +// kars Bridge BFF — qualification requirement regression tests. + +use super::{blueprint_to_dto, qualification_requirements}; + +#[test] +fn persisted_draft_plan_preserves_create_preflight_requirements_and_all_plan_fields() { + use crate::kars::task::TaskBlueprint; + use crate::routes::tasks::{BlueprintDto, ExecutionPlanDto}; + let plan: ExecutionPlanDto = serde_json::from_value(serde_json::json!({ + "schema": "kars.execution-plan/v1", + "roles": [ + { + "name": "evidence", + "objective": "Read public evidence.", + "depends_on": [], + "phases": [{ + "name": "inspect", + "objective": "Read current check logs.", + "capabilities": ["network", "mcp"], + "required_tool_calls": [{ + "name": "github_actions_job_logs", + "arguments": {"owner": "owner", "repo": "repo", "job_id": "42"} + }], + "min_tool_calls": 1, + "max_tool_calls": 4, + "fresh_context": true + }], + "budget_tokens": 1500 + }, + { + "name": "review", + "objective": "Review the evidence.", + "depends_on": ["evidence"], + "phases": [{ + "name": "assess", + "objective": "Assess the handback.", + "capabilities": [], + "required_tool_calls": [], + "min_tool_calls": 0, + "max_tool_calls": 0, + "fresh_context": false + }], + "budget_tokens": 500 + } + ], + "max_parallel": 2, + "synthesis": { + "objective": "Produce the review.", + "capabilities": ["network"], + "max_tool_calls": 1 + }, + "deliverables": [ + {"name": "review.md", "media_type": "text/markdown"}, + {"name": "evidence.json", "media_type": null} + ] + })) + .unwrap(); + let create = BlueprintDto { + execution_plan: Some(plan.clone()), + ..Default::default() + }; + let stored = serde_json::to_value(TaskBlueprint { + execution_plan: Some(plan.into_crd()), + ..Default::default() + }) + .unwrap(); + assert_eq!(stored["executionPlan"]["maxParallel"], 2); + let persisted: TaskBlueprint = serde_json::from_value(stored).unwrap(); + let launch = blueprint_to_dto(&persisted); + assert_eq!( + serde_json::to_value(&launch.execution_plan).unwrap(), + serde_json::to_value(&create.execution_plan).unwrap() + ); + let required = qualification_requirements(&launch, Some("mission")); + assert_eq!( + required, + qualification_requirements(&create, Some("mission")) + ); + assert_eq!(required.1, 2); + for capability in ["artifacts", "delegation", "mcp", "network", "telemetry"] { + assert!(required.0.contains(capability)); + } + assert!(!required.0.contains("single-agent")); +} + +#[test] +fn legacy_draft_without_a_plan_still_requires_single_agent_qualification() { + let launch = blueprint_to_dto(&crate::kars::task::TaskBlueprint::default()); + assert!(launch.execution_plan.is_none()); + let (required, parallel) = qualification_requirements(&launch, Some("mission")); + assert_eq!(parallel, 1); + assert!(required.contains("single-agent")); + assert!(!required.contains("delegation")); +} + +#[test] +fn team_preflight_requires_retained_team_evidence() { + let blueprint = crate::routes::tasks::BlueprintDto::default(); + let (mission, _) = qualification_requirements(&blueprint, Some("mission")); + let (team, _) = qualification_requirements(&blueprint, Some("team")); + + assert!(!mission.contains("team")); + assert!(team.contains("team")); +} + +#[test] +fn execution_plan_capabilities_flow_into_atomic_qualification_requirements() { + let blueprint = crate::routes::tasks::BlueprintDto { + execution_plan: Some(crate::routes::tasks::ExecutionPlanDto { + schema: "kars.execution-plan/v1".into(), + roles: vec![crate::routes::tasks::ExecutionRoleDto { + name: "source-scout".into(), + objective: "Discover exact URLs and fetch the source evidence.".into(), + depends_on: Vec::new(), + phases: vec![crate::routes::tasks::ExecutionPhaseDto { + name: "discover".into(), + objective: "Search and fetch authoritative sources.".into(), + capabilities: vec!["web-search".into(), "network".into()], + required_tool_calls: Vec::new(), + min_tool_calls: 1, + max_tool_calls: 4, + fresh_context: true, + }], + budget_tokens: None, + }], + max_parallel: 1, + synthesis: crate::routes::tasks::ExecutionSynthesisDto { + objective: "Return the verified answer.".into(), + capabilities: Vec::new(), + max_tool_calls: 0, + }, + deliverables: Vec::new(), + }), + ..Default::default() + }; + + let (required, max_parallel) = qualification_requirements(&blueprint, Some("mission")); + assert_eq!(max_parallel, 1); + assert!(required.contains("delegation")); + assert!(required.contains("web-search")); + assert!(required.contains("network")); +} diff --git a/bridge/bff/src/routes/validate/resources.rs b/bridge/bff/src/routes/validate/resources.rs new file mode 100644 index 000000000..e2089c1a8 --- /dev/null +++ b/bridge/bff/src/routes/validate/resources.rs @@ -0,0 +1,347 @@ +// kars Bridge BFF — resources pre-flight checks. + +use super::network::{resolves, url_host}; +use super::{Check, CheckStatus}; + +fn names(items: &[kube::core::DynamicObject]) -> Vec<String> { + items + .iter() + .filter_map(|o| o.metadata.name.clone()) + .collect() +} + +/// Readiness facts read from a CRD object's status: the `phase`, whether a +/// `Ready` condition is `True`, and that condition's message (the controller's +/// own honest reason). This is the §19 "provisioned ≠ usable" signal — the +/// controller reconciles + (for MCP) probes these, so we report its real +/// verdict rather than a fabricated one. +struct Readiness { + phase: Option<String>, + ready: Option<bool>, + message: Option<String>, +} + +fn readiness_of(items: &[kube::core::DynamicObject], name: &str) -> Option<Readiness> { + let obj = items + .iter() + .find(|o| o.metadata.name.as_deref() == Some(name))?; + let status = obj.data.get("status"); + let phase = status + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .map(|s| s.to_string()); + let ready_cond = status + .and_then(|s| s.get("conditions")) + .and_then(|c| c.as_array()) + .and_then(|arr| { + arr.iter() + .find(|c| c.get("type").and_then(|t| t.as_str()) == Some("Ready")) + }); + let ready = ready_cond + .and_then(|c| c.get("status")) + .and_then(|s| s.as_str()) + .map(|s| s == "True"); + let message = ready_cond + .and_then(|c| c.get("message")) + .and_then(|m| m.as_str()) + .map(|s| s.to_string()); + Some(Readiness { + phase, + ready, + message, + }) +} + +fn status_observes_current_generation(resource: &kube::core::DynamicObject) -> bool { + resource + .data + .get("status") + .and_then(|status| status.get("observedGeneration")) + .and_then(|generation| generation.as_i64()) + == resource.metadata.generation +} + +pub(super) async fn check_resources( + cluster: &crate::kars::cluster::Cluster, + namespace: &str, + bp: &crate::routes::tasks::BlueprintDto, + checks: &mut Vec<Check>, +) { + // 1. Tool policy — provisioned AND usable (compiled). A ToolPolicy that + // exists but hasn't compiled its AGT profile can't actually govern tools. + if let Some(tp) = bp.tool_policy.as_deref().filter(|s| !s.is_empty()) { + let found = cluster + .get_kind(namespace, "ToolPolicy", tp) + .await + .ok() + .flatten(); + checks.push(match found.as_ref() { + None => Check { + id: "tool_policy".into(), + label: format!("Tool policy “{tp}” not found"), + status: CheckStatus::Fail, + detail: "No ToolPolicy by that name exists. Pick an existing policy or create it in the Operator Console.".into(), + }, + Some(resource) => { + let r = readiness_of(std::slice::from_ref(resource), tp) + .expect("resource was supplied"); + // The policy compiles its AGT profile to a `Compiled` phase; a + // `Ready=True` condition only appears once a sandbox references + // it, so `Compiled` (or Ready) is the usable signal here. + let compiled = r + .phase + .as_deref() + .map(|p| p == "Compiled" || p == "Ready") + .unwrap_or(false); + if compiled && status_observes_current_generation(resource) { + Check { + id: "tool_policy".into(), + label: format!("Tool policy “{tp}” is compiled and usable"), + status: CheckStatus::Pass, + detail: "The ToolPolicy exists and its governance profile compiled — it can bound tool calls.".into(), + } + } else { + Check { + id: "tool_policy".into(), + label: format!("Tool policy “{tp}” isn't compiled yet"), + status: CheckStatus::Warn, + detail: format!( + "The ToolPolicy exists but its current generation is not compiled (phase {}).", + r.phase.as_deref().unwrap_or("unknown") + ), + } + } + } + }); + } else { + // No tool policy pinned in the blueprint. The controller applies a + // cluster-default ToolPolicy at launch, but this package doesn't specify + // one — surface it as a Warn rather than silently passing, so "launch- + // ready" never hides an unpinned governance boundary. + checks.push(Check { + id: "tool_policy".into(), + label: "No tool policy pinned in this package".into(), + status: CheckStatus::Warn, + detail: "The cluster's default ToolPolicy will be applied at launch. Pin an explicit policy here if you need this package's tool-governance boundary to be reviewable and reproducible.".into(), + }); + } + + // 2. MCP servers — managed Ready means the controller completed a real MCP + // initialize/tools-list probe and recorded a schema digest. External + // registrations remain explicitly registration-only until a sandbox call. + if !bp.mcp_servers.is_empty() { + for m in &bp.mcp_servers { + let found = cluster + .get_kind(namespace, "McpServer", m) + .await + .ok() + .flatten(); + let url = found + .as_ref() + .and_then(|resource| { + resource + .data + .get("status") + .and_then(|status| status.get("endpoint")) + .or_else(|| resource.data.get("spec").and_then(|spec| spec.get("url"))) + }) + .and_then(|url| url.as_str()); + checks.push(match found.as_ref() { + None => Check { + id: format!("mcp:{m}"), + label: format!("Connected service “{m}” not found"), + status: CheckStatus::Fail, + detail: format!( + "No McpServer by that name exists in namespace `{namespace}`." + ), + }, + Some(resource) => { + let r = readiness_of(std::slice::from_ref(resource), m) + .expect("resource was supplied"); + let ready = (r.ready.unwrap_or(false) + || r.phase.as_deref() == Some("Ready")) + && status_observes_current_generation(resource); + if ready { + let verified_tools = resource + .data + .get("status") + .and_then(|s| s.get("discoveredTools")) + .and_then(|v| v.as_array()) + .map_or(0, Vec::len); + Check { + id: format!("mcp:{m}"), + label: format!("Connected service “{m}” is registered and reconciled"), + status: CheckStatus::Pass, + detail: if verified_tools > 0 { + format!( + "The managed MCP workload is Ready and its live initialize/tools-list probe verified {verified_tools} tools. Mission launch still proves the sandbox-router call path." + ) + } else { + "The external endpoint is registered and reconciled. Its credentials and real tool call are verified from the launched sandbox, not assumed here.".into() + }, + } + } else { + Check { + id: format!("mcp:{m}"), + label: format!("Connected service “{m}” isn't reconciled"), + status: CheckStatus::Fail, + detail: format!( + "The McpServer exists but the controller hasn't reconciled it to Ready ({}). Tool calls to it would likely be denied.", + r.message.or(r.phase).unwrap_or_else(|| "no status".into()) + ), + } + } + } + }); + + // Real endpoint-reachability signal: resolve the MCP server's URL + // host. Catches a misconfigured/typo'd endpoint honestly. + if let Some(host) = url.and_then(url_host) { + let resolved = resolves(&host, 443).await; + checks.push(Check { + id: format!("mcp_endpoint:{m}"), + label: if resolved { + format!("“{m}” endpoint {host} resolves") + } else { + format!("“{m}” endpoint {host} does not resolve") + }, + status: if resolved { CheckStatus::Pass } else { CheckStatus::Warn }, + detail: if resolved { + "The MCP server's URL host resolves in DNS. A full handshake is a deeper probe.".into() + } else { + "The MCP server's URL host did not resolve from the gateway. Check the endpoint URL; it may still be reachable from inside the cluster.".into() + }, + }); + } + } + if bp + .tool_policy + .as_deref() + .filter(|s| !s.is_empty()) + .is_none() + { + checks.push(Check { + id: "mcp_needs_policy".into(), + label: "Connected services need a tool policy".into(), + status: CheckStatus::Fail, + detail: "Governed MCP access must be bounded by a tool policy (admission enforces this).".into(), + }); + } + } + + // 3. Shared memory exists. + if let Some(mem) = bp.memory.as_deref().filter(|s| !s.is_empty()) { + let existing = cluster + .list_kind_all("KarsMemory") + .await + .map(|v| names(&v)) + .unwrap_or_default(); + checks.push(if existing.iter().any(|n| n == mem) { + Check { + id: "memory".into(), + label: format!("Shared memory “{mem}” exists"), + status: CheckStatus::Pass, + detail: "The KarsMemory store is present.".into(), + } + } else { + Check { + id: "memory".into(), + label: format!("Shared memory “{mem}” not found"), + status: CheckStatus::Fail, + detail: "No KarsMemory by that name exists.".into(), + } + }); + } + + // 3b. (Removed) Harness-suitability check that flagged Hermes as a + // chat-gateway which would sit idle on a one-shot autonomous mission. + // Hermes now runs the agent in-process like OpenClaw and executes + // autonomous missions + genuine mesh delegation (verified E2E), so the + // warning was a stale false-positive. Both wired harnesses are valid + // mission runners; there is no longer a harness-suitability failure to + // surface here. + + // 3c. Skills — each required capability bundle (KarsSkill) must exist AND be + // APPROVED, because the controller's trust gate refuses to mount an + // unapproved skill into the sandbox. Pre-flighting this turns a silent + // "the agent never got the skill it needed" runtime gap into an explicit, + // fixable launch check (get the operator to approve it first). + if !bp.skills.is_empty() { + for s in &bp.skills { + let found = cluster + .get_kind(namespace, "KarsSkill", s) + .await + .ok() + .flatten(); + let approved = found.as_ref().is_some_and(|o| { + o.metadata + .annotations + .as_ref() + .and_then(|a| a.get("kars.azure.com/skill-review")) + .is_some_and(|review| review == "approved") + }); + if approved { + let required_mcp = found + .as_ref() + .and_then(|o| o.data.get("spec")) + .and_then(|spec| spec.get("mcpServers")) + .and_then(|servers| servers.as_array()) + .cloned() + .unwrap_or_default(); + for server in required_mcp.iter().filter_map(|value| value.as_str()) { + let dependency = cluster + .get_kind(namespace, "McpServer", server) + .await + .ok() + .flatten(); + let ready = dependency.as_ref().is_some_and(|resource| { + readiness_of(std::slice::from_ref(resource), server).is_some_and(|status| { + (status.ready.unwrap_or(false) + || status.phase.as_deref() == Some("Ready")) + && status_observes_current_generation(resource) + }) + }); + checks.push(Check { + id: format!("skill_mcp:{s}:{server}"), + label: format!("Skill “{s}” dependency “{server}”"), + status: if ready { + CheckStatus::Pass + } else { + CheckStatus::Fail + }, + detail: if ready { + "The skill's required MCP server is Ready in this namespace.".into() + } else { + "The skill requires an MCP server that is missing, stale, or not Ready in this namespace.".into() + }, + }); + } + } + checks.push(match found.as_ref() { + None => Check { + id: format!("skill:{s}"), + label: format!("Skill “{s}” not found"), + status: CheckStatus::Fail, + detail: "No KarsSkill by that name exists — upload it in Skills, then have an operator approve it.".into(), + }, + Some(_) => { + if approved { + Check { + id: format!("skill:{s}"), + label: format!("Skill “{s}” is approved and mountable"), + status: CheckStatus::Pass, + detail: "The skill package is approved; the controller will mount it into the sandbox.".into(), + } + } else { + Check { + id: format!("skill:{s}"), + label: format!("Skill “{s}” is not approved"), + status: CheckStatus::Fail, + detail: "The skill exists but hasn't been approved — the sandbox trust gate will refuse to mount it. An operator must approve it before the agent can use it.".into(), + } + } + } + }); + } + } +} From f6b7694dcad0aff81adcd432e405e61ae4513784 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 01:04:33 +0200 Subject: [PATCH 042/111] Extract the existing Copilot sign-in component from the provider wizard Keep every wizard/helper body, hook/effect cleanup, JSX, classes and both client boundaries unchanged. Module-scoped component identity and server actions remain intact; files777/114lines. AST parity and syntax checked; fullframework hosted qualification remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../console/configuration/copilot-sign-in.tsx | 114 +++++++++++++++++ .../console/configuration/provider-wizard.tsx | 116 +----------------- 2 files changed, 116 insertions(+), 114 deletions(-) create mode 100644 bridge/web/src/app/console/configuration/copilot-sign-in.tsx diff --git a/bridge/web/src/app/console/configuration/copilot-sign-in.tsx b/bridge/web/src/app/console/configuration/copilot-sign-in.tsx new file mode 100644 index 000000000..8e0a2d5cc --- /dev/null +++ b/bridge/web/src/app/console/configuration/copilot-sign-in.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { copilotLoginStartAction, copilotLoginPollAction } from "./copilot-login-actions"; +import { Icon } from "@/components/icon"; +import type { DiscoveredModel } from "@/lib/types"; +import type { CopilotLoginStart } from "@/lib/bff"; + +export function CopilotSignIn({ + signedIn, + onAuthorized, +}: { + signedIn: boolean; + onAuthorized: (models: DiscoveredModel[]) => void; +}) { + const [starting, startStarting] = useTransition(); + const [flow, setFlow] = useState<CopilotLoginStart | null>(null); + const [error, setError] = useState<string | null>(null); + const [copied, setCopied] = useState(false); + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); + + // Poll once a flow is active. + useEffect(() => { + if (!flow) return; + let cancelled = false; + const started = Date.now(); + const tick = async () => { + if (cancelled) return; + if (Date.now() - started > flow.expires_in * 1000) { + setError("The sign-in code expired. Start again."); + setFlow(null); + return; + } + const r = await copilotLoginPollAction(flow.device_code); + if (cancelled) return; + if (r.status === "authorized") { + if (pollRef.current) clearInterval(pollRef.current); + setFlow(null); + onAuthorized(r.models); + } else if (r.status === "error") { + if (pollRef.current) clearInterval(pollRef.current); + setError(r.error); + setFlow(null); + } + }; + pollRef.current = setInterval(() => void tick(), Math.max(flow.interval, 3) * 1000); + return () => { + cancelled = true; + if (pollRef.current) clearInterval(pollRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flow]); + + if (signedIn) { + return ( + <p className="flex items-center gap-1.5 text-xs text-signal"> + <Icon name="check" size={13} /> Signed in to GitHub Copilot — your seat’s models are listed in the next step. + </p> + ); + } + + function begin() { + setError(null); + startStarting(async () => { + const r = await copilotLoginStartAction(); + if (r.ok) setFlow(r.data); + else setError(r.error); + }); + } + + if (!flow) { + return ( + <div className="space-y-2"> + <p className="text-xs text-foreground-muted"> + Sign in with your GitHub account to verify your Copilot seat and load the exact models it serves. No token to paste — it’s minted and stored securely on the cluster. + </p> + <button type="button" onClick={begin} disabled={starting} className="inline-flex items-center gap-1.5 rounded-lg bg-signal px-3 py-2 text-xs font-semibold text-signal-fg disabled:opacity-50"> + <Icon name="link" size={13} /> {starting ? "Starting…" : "Sign in with GitHub"} + </button> + {error && <p className="text-[11px] text-danger">{error}</p>} + </div> + ); + } + + return ( + <div className="space-y-2.5"> + <p className="text-xs text-foreground-muted">Finish signing in on GitHub:</p> + <ol className="space-y-2 text-xs"> + <li className="flex items-center gap-2"> + <span className="grid h-5 w-5 place-items-center rounded-full bg-signal/15 text-[10px] text-signal">1</span> + <span>Open <a href={flow.verification_uri} target="_blank" rel="noreferrer" className="font-medium text-signal hover:underline">{flow.verification_uri} ↗</a></span> + </li> + <li className="flex items-center gap-2"> + <span className="grid h-5 w-5 place-items-center rounded-full bg-signal/15 text-[10px] text-signal">2</span> + <span className="flex items-center gap-2"> + Enter code + <code className="rounded border border-border bg-surface-muted px-2 py-0.5 font-mono text-sm tracking-widest">{flow.user_code}</code> + <button + type="button" + onClick={() => { navigator.clipboard?.writeText(flow.user_code); setCopied(true); setTimeout(() => setCopied(false), 1500); }} + className="rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-foreground-muted hover:text-signal" + > + {copied ? "copied" : "copy"} + </button> + </span> + </li> + </ol> + <p className="flex items-center gap-1.5 text-[11px] text-foreground-muted"> + <span className="h-2 w-2 animate-pulse rounded-full bg-signal" /> Waiting for approval… + </p> + {error && <p className="text-[11px] text-danger">{error}</p>} + </div> + ); +} diff --git a/bridge/web/src/app/console/configuration/provider-wizard.tsx b/bridge/web/src/app/console/configuration/provider-wizard.tsx index 895bbf589..1de5ff608 100644 --- a/bridge/web/src/app/console/configuration/provider-wizard.tsx +++ b/bridge/web/src/app/console/configuration/provider-wizard.tsx @@ -17,12 +17,12 @@ // when an InferencePolicy names its tag) — but the OPERATOR shouldn't have // to know two different forms to use either one. -import { useActionState, useEffect, useRef, useState, useTransition, type ElementType } from "react"; +import { useActionState, useState, useTransition, type ElementType } from "react"; import { onboardProviderAction, type ProviderState } from "./provider-actions"; import { addAdditionalProviderAction, removeAdditionalProviderAction, type AdditionalProviderState } from "./additional-provider-actions"; import { discoverModelsAction } from "./provider-discover-actions"; import { undeployLocalModelAction, type LocalInferenceState } from "./local-inference-actions"; -import { copilotLoginStartAction, copilotLoginPollAction } from "./copilot-login-actions"; +import { CopilotSignIn } from "./copilot-sign-in"; import { disconnectFoundryAction, type FoundryState } from "../foundry-actions"; import { FoundryOnboard } from "../foundry-onboard"; import { Icon, type IconName } from "@/components/icon"; @@ -33,7 +33,6 @@ import type { LocalInferenceStatus, LocalModelDeployment, FoundryStatus, - CopilotLoginStart, } from "@/lib/bff"; const init: ProviderState = { error: null, ok: null }; @@ -288,117 +287,6 @@ function isAiRunwayDefault( ); } -/** GitHub Copilot device-flow sign-in, inline in the wizard. Starts the flow, - * shows the user code + verification link, polls until approved, then hands - * the seat's live models to the parent. The token is minted + stored - * server-side — the browser never handles it. */ -function CopilotSignIn({ - signedIn, - onAuthorized, -}: { - signedIn: boolean; - onAuthorized: (models: DiscoveredModel[]) => void; -}) { - const [starting, startStarting] = useTransition(); - const [flow, setFlow] = useState<CopilotLoginStart | null>(null); - const [error, setError] = useState<string | null>(null); - const [copied, setCopied] = useState(false); - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); - - // Poll once a flow is active. - useEffect(() => { - if (!flow) return; - let cancelled = false; - const started = Date.now(); - const tick = async () => { - if (cancelled) return; - if (Date.now() - started > flow.expires_in * 1000) { - setError("The sign-in code expired. Start again."); - setFlow(null); - return; - } - const r = await copilotLoginPollAction(flow.device_code); - if (cancelled) return; - if (r.status === "authorized") { - if (pollRef.current) clearInterval(pollRef.current); - setFlow(null); - onAuthorized(r.models); - } else if (r.status === "error") { - if (pollRef.current) clearInterval(pollRef.current); - setError(r.error); - setFlow(null); - } - }; - pollRef.current = setInterval(() => void tick(), Math.max(flow.interval, 3) * 1000); - return () => { - cancelled = true; - if (pollRef.current) clearInterval(pollRef.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [flow]); - - if (signedIn) { - return ( - <p className="flex items-center gap-1.5 text-xs text-signal"> - <Icon name="check" size={13} /> Signed in to GitHub Copilot — your seat’s models are listed in the next step. - </p> - ); - } - - function begin() { - setError(null); - startStarting(async () => { - const r = await copilotLoginStartAction(); - if (r.ok) setFlow(r.data); - else setError(r.error); - }); - } - - if (!flow) { - return ( - <div className="space-y-2"> - <p className="text-xs text-foreground-muted"> - Sign in with your GitHub account to verify your Copilot seat and load the exact models it serves. No token to paste — it’s minted and stored securely on the cluster. - </p> - <button type="button" onClick={begin} disabled={starting} className="inline-flex items-center gap-1.5 rounded-lg bg-signal px-3 py-2 text-xs font-semibold text-signal-fg disabled:opacity-50"> - <Icon name="link" size={13} /> {starting ? "Starting…" : "Sign in with GitHub"} - </button> - {error && <p className="text-[11px] text-danger">{error}</p>} - </div> - ); - } - - return ( - <div className="space-y-2.5"> - <p className="text-xs text-foreground-muted">Finish signing in on GitHub:</p> - <ol className="space-y-2 text-xs"> - <li className="flex items-center gap-2"> - <span className="grid h-5 w-5 place-items-center rounded-full bg-signal/15 text-[10px] text-signal">1</span> - <span>Open <a href={flow.verification_uri} target="_blank" rel="noreferrer" className="font-medium text-signal hover:underline">{flow.verification_uri} ↗</a></span> - </li> - <li className="flex items-center gap-2"> - <span className="grid h-5 w-5 place-items-center rounded-full bg-signal/15 text-[10px] text-signal">2</span> - <span className="flex items-center gap-2"> - Enter code - <code className="rounded border border-border bg-surface-muted px-2 py-0.5 font-mono text-sm tracking-widest">{flow.user_code}</code> - <button - type="button" - onClick={() => { navigator.clipboard?.writeText(flow.user_code); setCopied(true); setTimeout(() => setCopied(false), 1500); }} - className="rounded border border-border px-1.5 py-0.5 text-[10px] font-medium text-foreground-muted hover:text-signal" - > - {copied ? "copied" : "copy"} - </button> - </span> - </li> - </ol> - <p className="flex items-center gap-1.5 text-[11px] text-foreground-muted"> - <span className="h-2 w-2 animate-pulse rounded-full bg-signal" /> Waiting for approval… - </p> - {error && <p className="text-[11px] text-danger">{error}</p>} - </div> - ); -} - export function ProviderWizard({ hasDefaultProvider, From 56799741f4461d13ce4eda430ad84c36f69003b9 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 01:34:44 +0200 Subject: [PATCH 043/111] Keep governed credential failures actionable without exposing inner data Preserve the fail-closed source path while projecting only enumerated static failure categories and bounded HTTP status from previously collapsed errors. Retain no raw cause. Add Rust/native parser privacy regressions;114Python fixtures and19gateway contracts passed locally, Rust execution pending. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../credential_diagnostics.py | 21 ++- .../test_credential_diagnostics.py | 10 ++ .../credential_source_diagnostics.rs | 121 ++++++++++++++++++ .../src/reconciler/credential_sources.rs | 14 +- 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 controller/src/reconciler/credential_source_diagnostics.rs diff --git a/bridge/tests/native-credentials/credential_diagnostics.py b/bridge/tests/native-credentials/credential_diagnostics.py index 55021b932..784a80f4d 100644 --- a/bridge/tests/native-credentials/credential_diagnostics.py +++ b/bridge/tests/native-credentials/credential_diagnostics.py @@ -1,5 +1,7 @@ """Secret-free failure categories and private-scope metadata booleans.""" +import re + from native_api import Failure, resource PREFIX = "kars.azure.com/private-" @@ -12,12 +14,29 @@ "Private capability is unqualified; regenerate and apply the reviewed grant activation": "private_qualification_invalid", "SRE privacy qualification is still pending; no credential issued or reused": "privacy_pending", } +GOVERNED_CATEGORIES = { + "binding_shape", "grant_identity", "target_identity", "owner_authority", + "source_identity", "source_metadata", "source_owner", "legacy_review", + "bundle_owner", "bundle_missing", "target_changed", "grant_changed", "source_changed", + "grant_api", "namespace_api", "target_api", "source_metadata_api", "source_value_api", + "bundle_read_api", "bundle_create_api", "bundle_bind_api", "bundle_write_api", + "source_recheck_api", "source_bind_api", "legacy_import_api", "legacy_identity_api", + "legacy_namespace_api", "legacy_metadata_api", "legacy_target_api", "legacy_value_api", + "unclassified", +} def condition_category(message): if not isinstance(message, str): return "unclassified" - return CATEGORIES.get(message.removeprefix("CredentialSourceUnavailable: "), "unclassified") + message = message.removeprefix("CredentialSourceUnavailable: ") + detail = re.fullmatch( + r"governed credential source or operator grant is unavailable " + r"\[([a-z_]+); code=(?:None|Some\(([1-5][0-9]{2})\))\]", message, + ) + if detail and detail[1] in GOVERNED_CATEGORIES: + return "source_or_grant:" + detail[1] + (":" + detail[2] if detail[2] else "") + return CATEGORIES.get(message, "unclassified") def runtime_scope(setup, sandbox): diff --git a/bridge/tests/native-credentials/test_credential_diagnostics.py b/bridge/tests/native-credentials/test_credential_diagnostics.py index 84846ea02..78e68ab5a 100644 --- a/bridge/tests/native-credentials/test_credential_diagnostics.py +++ b/bridge/tests/native-credentials/test_credential_diagnostics.py @@ -18,6 +18,16 @@ def test_only_exact_known_messages_become_fixed_categories(self): for message in (None, {}, PRIVATE, "prefix" + next(iter(CATEGORIES))): self.assertEqual(condition_category(message), "unclassified") + def test_governed_failure_details_only_retain_known_categories_and_http_codes(self): + prefix = "CredentialSourceUnavailable: governed credential source or operator grant is unavailable " + self.assertEqual(condition_category(prefix + "[target_api; code=Some(404)]"), + "source_or_grant:target_api:404") + self.assertEqual(condition_category(prefix + "[bundle_owner; code=None]"), + "source_or_grant:bundle_owner") + for suffix in ("[target_api; code=Some(999)]", "[private_value; code=None]", + "[target_api; code=Some(404)]" + PRIVATE, PRIVATE): + self.assertEqual(condition_category(prefix + suffix), "unclassified") + def test_scope_capture_reports_only_booleans_and_fixed_states(self): sandbox = {"metadata": {"name": "test", "namespace": "work", "uid": "sandbox", "annotations": {"kars.azure.com/namespace-uid": "runtime"}}} diff --git a/controller/src/reconciler/credential_source_diagnostics.rs b/controller/src/reconciler/credential_source_diagnostics.rs new file mode 100644 index 000000000..669328efc --- /dev/null +++ b/controller/src/reconciler/credential_source_diagnostics.rs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Project only fixed failure categories and HTTP status from governed input errors. + +pub(super) fn classify(reason: &str) -> (&'static str, Option<u16>) { + let category = match reason { + "Credential bindings require the exact workspace grant and one to three sources" + | "Credential sources must be unique and ordered workspace, Team, target with explicit safe key grants" => { + "binding_shape" + } + "An exact workspace credential grant is required" + | "Credential grant is stale, unready, or replaced" => "grant_identity", + "Credential target was replaced" + | "Credential target requires a complete supported UID-bound identity" => "target_identity", + "Credential owner is not the target" + | "Credential owners cannot cross workspaces" + | "Credential owner is outside the authorized ancestry" => "owner_authority", + "Selected credential source was replaced" => "source_identity", + "Source purpose, target, workspace, type or grant identity is invalid" => "source_metadata", + "Credential source key grant or exact owner does not match" + | "Credential source has a foreign owner; it is not adopted" => "source_owner", + "Legacy credentials require explicit operator UID/resourceVersion/key-name review before source migration" => { + "legacy_review" + } + "Existing credential bundle is not owned by the exact target" => "bundle_owner", + "Previously bound credential bundle disappeared; explicit operator recovery is required" => { + "bundle_missing" + } + "Credential target changed before bundle write" => "target_changed", + "Credential grant changed before bundle write" => "grant_changed", + "Credential source changed before bundle write" + | "Credential source changed during read" => "source_changed", + _ => "unclassified", + }; + if category != "unclassified" { + return (category, None); + } + for (stage, category) in [ + ("Read credential grant", "grant_api"), + ("Recheck live credential grant", "grant_api"), + ("Verify credential workspace", "namespace_api"), + ("Read credential target", "target_api"), + ("Read selected source identity", "source_metadata_api"), + ("Read selected agent credentials", "source_value_api"), + ("Read owned credential bundle", "bundle_read_api"), + ("Create owned credential bundle anchor", "bundle_create_api"), + ( + "Record actual credential bundle CREATE UID", + "bundle_bind_api", + ), + ("Write UID-fenced credential bundle", "bundle_write_api"), + ("Recheck credential source", "source_recheck_api"), + ("Bind source to captured target UID", "source_bind_api"), + ( + "Import only reviewed legacy credential keys", + "legacy_import_api", + ), + ("Inspect legacy credential identity", "legacy_identity_api"), + ( + "Inspect legacy credential namespace", + "legacy_namespace_api", + ), + ("Inspect legacy credential key names", "legacy_metadata_api"), + ( + "Verify selected legacy credential owner", + "legacy_target_api", + ), + ("Read reviewed legacy credentials", "legacy_value_api"), + ] { + let Some(detail) = reason + .strip_prefix(stage) + .and_then(|value| value.strip_prefix(": ")) + else { + continue; + }; + if detail == "Kubernetes transport or serialization failure" { + return (category, None); + } + if let Some(code) = detail + .strip_prefix("Kubernetes status ") + .and_then(|value| value.parse::<u16>().ok()) + .filter(|code| (100..=599).contains(code)) + { + return (category, Some(code)); + } + } + ("unclassified", None) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn governed_diagnostics_keep_only_known_category_and_http_status() { + assert_eq!( + classify("Read credential target: Kubernetes status 404"), + ("target_api", Some(404)) + ); + assert_eq!( + classify("Credential source key grant or exact owner does not match"), + ("source_owner", None) + ); + assert_eq!( + classify( + "Create owned credential bundle anchor: Kubernetes transport or serialization failure" + ), + ("bundle_create_api", None) + ); + for text in [ + "PRIVATE_VALUE", + "Read credential target: Kubernetes status 404 PRIVATE_VALUE", + "Read credential target: Kubernetes status 999", + "Read credential target PRIVATE_VALUE: Kubernetes status 404", + "Credential source key grant or exact owner does not match PRIVATE_VALUE", + ] { + assert_eq!(classify(text), ("unclassified", None)); + } + } +} diff --git a/controller/src/reconciler/credential_sources.rs b/controller/src/reconciler/credential_sources.rs index ee2fc3a2d..d45ceb9fe 100644 --- a/controller/src/reconciler/credential_sources.rs +++ b/controller/src/reconciler/credential_sources.rs @@ -21,6 +21,8 @@ use kube::{ use serde_json::{Value, json}; use std::collections::BTreeMap; +#[path = "credential_source_diagnostics.rs"] +mod diagnostics; #[path = "credential_source_projection.rs"] mod projection; #[path = "credential_source_workloads.rs"] @@ -62,6 +64,13 @@ pub(crate) fn validate_owned_deployment( pub enum Error { #[error("CredentialSourceUnavailable: {0}")] Invalid(&'static str), + #[error( + "CredentialSourceUnavailable: governed credential source or operator grant is unavailable [{category}; code={code:?}]" + )] + Governed { + category: &'static str, + code: Option<u16>, + }, #[error("CredentialSourceUnavailable: {stage} failed (Kubernetes status {code:?})")] Api { stage: &'static str, @@ -233,8 +242,9 @@ async fn read_source( { let source = crate::credential_grants::sources::for_sandbox(client, sandbox) .await - .map_err(|_| { - Error::Invalid("governed credential source or operator grant is unavailable") + .map_err(|reason| { + let (category, code) = diagnostics::classify(&reason); + Error::Governed { category, code } })?; if sandbox .spec From 5a2d08f461ec3ae19efded2cd68f2caadaea720f Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 01:39:50 +0200 Subject: [PATCH 044/111] Split the agent graph without changing layout or interaction Preserve44declarations,13hooks, JSX/SVG/literals, state and cleanup behavior across boundedmodules (main632,max289children). ExactTS5.9.3 puremoduletyping and execution/layout/trace/format comparisons passed. React/Next rendering and fullframework qualification remain hosted; no redesign/styles/dependencies introduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/web/src/components/agent-graph.tsx | 959 +----------------- .../src/components/agent-graph/activity.ts | 165 +++ .../src/components/agent-graph/constants.ts | 29 + .../src/components/agent-graph/execution.ts | 172 ++++ .../src/components/agent-graph/inspectors.tsx | 289 ++++++ .../web/src/components/agent-graph/layout.ts | 269 +++++ .../web/src/components/agent-graph/types.ts | 78 ++ 7 files changed, 1032 insertions(+), 929 deletions(-) create mode 100644 bridge/web/src/components/agent-graph/activity.ts create mode 100644 bridge/web/src/components/agent-graph/constants.ts create mode 100644 bridge/web/src/components/agent-graph/execution.ts create mode 100644 bridge/web/src/components/agent-graph/inspectors.tsx create mode 100644 bridge/web/src/components/agent-graph/layout.ts create mode 100644 bridge/web/src/components/agent-graph/types.ts diff --git a/bridge/web/src/components/agent-graph.tsx b/bridge/web/src/components/agent-graph.tsx index b206c8286..452c77494 100644 --- a/bridge/web/src/components/agent-graph.tsx +++ b/bridge/web/src/components/agent-graph.tsx @@ -10,305 +10,25 @@ import type { SubAgent, } from "@/lib/types"; -type ToolEvent = Extract<ActivityEvent, { kind: "tool" }>; - -interface AgentAction { - id: string; - human: string; - raw: string; - args: string; - result: string; - ok: boolean | null; - round: number; - ms: number; - ts: string; - seq: number | null; - agentInstance: string | null; -} - -interface AgentExecution { - id: string; - displayName: string; - technicalName: string; - role: string; - relationship: string; - phase: string; - runtime: string | null; - model: string | null; - parent: string | null; - parentId: string | null; - observedAgentName: string | null; - observedAgentInstance: string | null; - isPrincipal: boolean; - aliases: string[]; - rounds: number; - toolCalls: number; - failures: number; - lastActivity: string | null; - destinations: string[]; - actions: AgentAction[]; - identitySearchText: string; - searchText: string; - traceQuery: string; -} - -type GraphLeaf = - | { kind: "action"; key: string; agent: AgentExecution; action: AgentAction; label: string; searchText: string } - | { kind: "folded-actions"; key: string; agent: AgentExecution; actions: AgentAction[]; label: string; searchText: string } - | { kind: "destination"; key: string; agent: AgentExecution; destination: string; label: string; searchText: string } - | { kind: "folded-destinations"; key: string; agent: AgentExecution; destinations: string[]; label: string; searchText: string }; - -type GraphSelection = - | { kind: "agent"; key: string; agent: AgentExecution } - | { kind: "edge"; key: string; parent: AgentExecution; child: AgentExecution } - | GraphAggregate - | GraphLeaf; - -interface AgentLayout { - agent: AgentExecution; - x: number; - y: number; - leaves: Array<GraphLeaf & { x: number; y: number }>; -} - -interface GraphAggregate { - kind: "specialist-aggregate"; - key: string; - parent: AgentExecution; - agents: AgentExecution[]; - expanded: boolean; - label: string; - searchText: string; -} - -interface AggregateLayout { - aggregate: GraphAggregate; - x: number; - y: number; -} - -const AGENT_WIDTH = 248; -const AGENT_HEIGHT = 128; -const LEAF_WIDTH = 194; -const LEAF_HEIGHT = 42; -const LEAF_GAP = 9; -const LANE_WIDTH = 500; -const CLUSTER_WIDTH = AGENT_WIDTH + 28 + LEAF_WIDTH; -const GRAPH_PADDING = 40; -const ACTION_LIMIT = 3; -const DESTINATION_LIMIT = 2; -const SPECIALIST_LIMIT = 6; -const VISIBLE_AGENT_BUDGET = 24; -const COLLAPSED_DEPTH_LIMIT = 4; -const BASELINE_LAYER_LIMIT = 6; -const RECENT_ACTIVITY_MS = 90_000; - -function normalize(value: string): string { - return value.trim().toLowerCase(); -} - -function humanizeTool(name: string): string { - const tool = name.toLowerCase().replaceAll("-", "_"); - if (/(browser_)?navigate|open_url|goto/.test(tool)) return "Opened a browser page"; - if (/screenshot|capture_screen/.test(tool)) return "Captured a screenshot"; - if (/browser_(click|dblclick)|click_element/.test(tool)) return "Clicked a page control"; - if (/fill_form|browser_fill|select_option/.test(tool)) return "Filled in a form"; - if (/browser_type|press_key|keyboard/.test(tool)) return "Entered text on a page"; - if (/browser_snapshot|page_snapshot|accessibility_tree/.test(tool)) return "Inspected a browser page"; - if (/browser_wait|wait_for/.test(tool)) return "Waited for a page update"; - if (/network_request|network_requests/.test(tool)) return "Inspected browser network activity"; - if (/file_upload/.test(tool)) return "Uploaded a file"; - if (/(web_)?search|brave|tavily|exa|perplexity/.test(tool)) return "Searched the web"; - if (/fetch|http|curl|crawl|download/.test(tool)) return "Retrieved network content"; - if (/pull_request|create_pr|open_pr/.test(tool)) return "Worked with a pull request"; - if (/git|commit|branch|push|pull/.test(tool)) return "Worked with source control"; - if (/write|create_file|save|edit|patch|append/.test(tool)) return "Updated a file"; - if (/read|view|list|glob|grep|find|search_files/.test(tool)) return "Inspected files"; - if (/shell|bash|exec|run_command|terminal/.test(tool)) return "Ran a command"; - if (/message|handoff|send|relay/.test(tool)) return "Sent an agent message"; - return "Used a governed tool"; -} - -function meaningfulDestination(host: string): boolean { - const value = host.toLowerCase().replace(/^\[|\]$/g, ""); - return !/^(localhost|0\.0\.0\.0|127(?:\.\d+){3}|::1)(:\d+)?$/.test(value); -} - -function destinationsFrom(event: ToolEvent): string[] { - const values = `${event.args_preview} ${event.result_preview}`; - const destinations = new Set<string>(); - for (const match of values.matchAll(/https?:\/\/([^/\s"')\]]+)/gi)) { - const host = match[1].toLowerCase().replace(/[.,;]+$/, ""); - if (meaningfulDestination(host)) destinations.add(host); - } - return [...destinations]; -} - -function formatLastActivity(value: string | null): string { - if (!value) return "No recorded activity"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - const months = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - const pad = (part: number) => String(part).padStart(2, "0"); - return `${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; -} - -function phaseKind(phase: string): "active" | "failed" | "paused" | "complete" | "idle" { - const value = phase.toLowerCase(); - if (/(failed|degraded|error|blocked)/.test(value)) return "failed"; - if (/(running|launching|active|working|executing)/.test(value)) return "active"; - if (/(paused|hibernating|suspended|waiting)/.test(value)) return "paused"; - if (/(completed|finished|succeeded|delivered)/.test(value)) return "complete"; - return "idle"; -} - -function phaseTone(phase: string): string { - switch (phaseKind(phase)) { - case "failed": - return "border-danger/35 bg-danger/10 text-danger"; - case "active": - return "border-signal/35 bg-signal/10 text-signal"; - case "paused": - return "border-accent/35 bg-accent/10 text-accent"; - case "complete": - return "border-signal/25 bg-signal/[0.06] text-foreground-muted"; - default: - return "border-border bg-surface-muted text-foreground-muted"; - } -} - -function actionFromEvent(event: ActivityEvent, index: number): AgentAction { - if (event.kind === "round") { - return { - id: `round-${event.agent ?? "principal"}-${event.round}-${index}`, - human: `Completed model round ${event.round + 1}`, - raw: "model.round", - args: `${event.tool_calls} tool call${event.tool_calls === 1 ? "" : "s"} requested`, - result: `${event.finish_reason || "unknown finish"}; ${event.total_tokens.toLocaleString("en-US")} tokens`, - ok: null, - round: event.round, - ms: event.ms, - ts: event.ts, - seq: event.seq ?? null, - agentInstance: event.agentInstance ?? null, - }; - } - return { - id: `tool-${event.agent ?? "principal"}-${event.round}-${index}`, - human: humanizeTool(event.name), - raw: event.name, - args: event.args_preview, - result: event.result_preview, - ok: event.ok, - round: event.round, - ms: event.ms, - ts: event.ts, - seq: event.seq ?? null, - agentInstance: event.agentInstance ?? null, - }; -} - -function leavesFor(agent: AgentExecution): GraphLeaf[] { - const foldedActions = agent.actions.slice(0, Math.max(0, agent.actions.length - ACTION_LIMIT)); - const visibleActions = agent.actions.slice(-ACTION_LIMIT); - const visibleDestinations = agent.destinations.slice(0, DESTINATION_LIMIT); - const foldedDestinations = agent.destinations.slice(DESTINATION_LIMIT); - const leaves: GraphLeaf[] = visibleActions.map((action) => ({ - kind: "action", - key: `action:${agent.id}:${action.id}`, - agent, - action, - label: action.human, - searchText: normalize(`${action.human} ${action.raw} ${action.args} ${action.result}`), - })); - if (foldedActions.length > 0) { - leaves.unshift({ - kind: "folded-actions", - key: `folded-actions:${agent.id}`, - agent, - actions: foldedActions, - label: `+${foldedActions.length} action${foldedActions.length === 1 ? "" : "s"}`, - searchText: normalize(foldedActions.flatMap((action) => [action.human, action.raw, action.args, action.result]).join(" ")), - }); - } - leaves.push(...visibleDestinations.map((destination) => ({ - kind: "destination" as const, - key: `destination:${agent.id}:${destination}`, - agent, - destination, - label: destination, - searchText: normalize(destination), - }))); - if (foldedDestinations.length > 0) { - leaves.push({ - kind: "folded-destinations", - key: `folded-destinations:${agent.id}`, - agent, - destinations: foldedDestinations, - label: `+${foldedDestinations.length} destination${foldedDestinations.length === 1 ? "" : "s"}`, - searchText: normalize(foldedDestinations.join(" ")), - }); - } - return leaves; -} - -function ellipsis(value: string, length: number): string { - return value.length > length ? `${value.slice(0, length - 1)}…` : value; -} - -function queryAgentIdentity(agent: AgentExecution): string { - return agent.isPrincipal - ? "principal" - : normalize(agent.observedAgentName ?? agent.technicalName); -} - -function exactActionQuery(agent: AgentExecution, action: AgentAction): string { - const parameters = new URLSearchParams({ - agent: queryAgentIdentity(agent), - round: String(action.round), - ts: action.ts, - tool: action.raw, - args: action.args, - result: action.result, - }); - if (action.seq != null) parameters.set("seq", String(action.seq)); - if (action.agentInstance) parameters.set("instance", action.agentInstance); - return `action:${parameters.toString()}`; -} - -function exactActionsQuery(agent: AgentExecution, actions: AgentAction[]): string { - const through = actions.at(-1)?.ts ?? ""; - const parameters = new URLSearchParams({ - agent: queryAgentIdentity(agent), - through, - }); - const sequenceValues = actions.flatMap((action) => - action.seq == null ? [] : [String(action.seq)] - ); - if (sequenceValues.length === actions.length && sequenceValues.length > 0) { - parameters.set("seqs", sequenceValues.join(",")); - } else { - parameters.set( - "events", - JSON.stringify(actions.map((action) => ({ - round: action.round, - ts: action.ts, - tool: action.raw, - args: action.args, - result: action.result, - instance: action.agentInstance, - }))), - ); - } - const instances = [...new Set(actions.flatMap((action) => - action.agentInstance ? [action.agentInstance] : [] - ))]; - if (instances.length === 1) parameters.set("instance", instances[0]); - return `actions:${parameters.toString()}`; -} +import { + ellipsis, + exactActionQuery, + exactActionsQuery, + normalize, + phaseKind, + phaseTone, +} from "./agent-graph/activity"; +import { + AGENT_HEIGHT, + AGENT_WIDTH, + LEAF_HEIGHT, + LEAF_WIDTH, + RECENT_ACTIVITY_MS, +} from "./agent-graph/constants"; +import { buildAgentExecutions } from "./agent-graph/execution"; +import { ProofPoints, SelectionInspector } from "./agent-graph/inspectors"; +import { buildGraphLayout } from "./agent-graph/layout"; +import type { AgentExecution, GraphLeaf, GraphSelection } from "./agent-graph/types"; export function AgentGraph({ running, @@ -396,354 +116,19 @@ export function AgentGraph({ && clientNow - timestamp <= RECENT_ACTIVITY_MS; }; - const agents = useMemo<AgentExecution[]>(() => { - const rootInactive = Boolean( - !running - && agentPhase - && /(completed|failed|hibernating|paused|idle|finished)/i.test(agentPhase), - ); - const definitions = [ - { - id: "principal", - displayName: agentLabel, - technicalName: name ?? agentLabel, - role: "Principal", - phase: agentPhase ?? (running ? "Running" : events.length > 0 ? "Finished" : "Ready"), - runtime: agentRuntime, - model: agentModel, - parent: null, - isPrincipal: true, - aliases: ["principal", agentLabel, name ?? ""].filter(Boolean), - }, - ...subAgents.map((agent) => ({ - id: `sub-${agent.name}`, - displayName: agent.logical_agent_id ?? agent.role ?? agent.name, - technicalName: agent.name, - role: agent.role ?? "Specialist", - phase: rootInactive ? agentPhase! : agent.phase ?? "Discovered", - runtime: agent.runtime, - model: agent.model, - parent: agent.parent, - isPrincipal: false, - aliases: [agent.name, agent.logical_agent_id ?? "", agent.role ?? ""].filter(Boolean), - })), - ]; - - const records = new Map<string, ActivityEvent[]>( - definitions.map((definition) => [definition.id, []]), - ); - const aliasToId = new Map<string, string>(); - for (const definition of definitions) { - for (const alias of definition.aliases) aliasToId.set(normalize(alias), definition.id); - } - - for (const event of events) { - let owner = "principal"; - if (event.agentRole === "subagent" && event.agent) { - const instance = event.agentInstance ?? event.agent; - owner = aliasToId.get(normalize(instance)) - ?? aliasToId.get(normalize(event.agent)) - ?? `live-${instance}`; - if (!records.has(owner)) records.set(owner, []); - } - records.get(owner)?.push(event); - } - - const allDefinitions = [...definitions]; - for (const [id] of records) { - if (!id.startsWith("live-")) continue; - const technicalName = id.slice(5); - allDefinitions.push({ - id, - displayName: technicalName, - technicalName, - role: "Specialist", - phase: running ? "Active" : "Observed", - runtime: null, - model: null, - parent: null, - isPrincipal: false, - aliases: [technicalName], - }); - } - - const completeAliasToId = new Map<string, string>(); - for (const definition of allDefinitions) { - for (const alias of definition.aliases) completeAliasToId.set(normalize(alias), definition.id); - completeAliasToId.set(normalize(definition.technicalName), definition.id); - } - const namesById = new Map(allDefinitions.map((definition) => [definition.id, definition.displayName])); - - return allDefinitions.map((definition) => { - const agentEvents = records.get(definition.id) ?? []; - const actions = agentEvents - .map(actionFromEvent) - .sort((left, right) => left.ts.localeCompare(right.ts)); - const tools = agentEvents.filter( - (event): event is ToolEvent => event.kind === "tool", - ); - const destinations = [...new Set(tools.flatMap(destinationsFrom))].sort(); - const lastActivity = agentEvents.reduce<string | null>( - (latest, event) => (!latest || event.ts > latest ? event.ts : latest), - null, - ); - const observedAgentName = agentEvents.find((event) => event.agent)?.agent ?? null; - const observedAgentInstance = - agentEvents.find((event) => event.agentInstance)?.agentInstance ?? null; - const parentId = definition.isPrincipal - ? null - : completeAliasToId.get(normalize(definition.parent ?? "")) ?? "principal"; - const parentName = parentId ? namesById.get(parentId) ?? agentLabel : null; - const relationship = definition.isPrincipal - ? "Orchestration root responsible for the execution" - : `Specialist delegated by ${parentName}`; - const searchText = [ - definition.displayName, - definition.technicalName, - definition.role, - relationship, - definition.runtime, - definition.model, - definition.phase, - ...destinations, - ...actions.flatMap((action) => [action.human, action.raw, action.args, action.result]), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - const identitySearchText = [ - definition.displayName, - definition.technicalName, - definition.role, - relationship, - definition.runtime, - definition.model, - definition.phase, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - - return { - ...definition, - relationship, - parentId, - observedAgentName, - observedAgentInstance, - rounds: agentEvents.filter((event) => event.kind === "round").length, - toolCalls: tools.length, - failures: tools.filter((event) => !event.ok).length, - lastActivity, - destinations, - actions, - identitySearchText, - searchText, - traceQuery: definition.isPrincipal - ? "agent:principal" - : observedAgentInstance - ? `agent-instance:${normalize(observedAgentInstance)}` - : `agent:${normalize(observedAgentName ?? definition.technicalName)}`, - }; - }); - }, [agentLabel, agentModel, agentPhase, agentRuntime, events, name, running, subAgents]); + const agents = useMemo<AgentExecution[]>(() => buildAgentExecutions({ + agentLabel, + agentModel, + agentPhase, + agentRuntime, + events, + name, + running, + subAgents, + }), [agentLabel, agentModel, agentPhase, agentRuntime, events, name, running, subAgents]); const normalizedQuery = normalize(query); - const graph = useMemo(() => { - const byId = new Map(agents.map((agent) => [agent.id, agent])); - const principal = agents.find((agent) => agent.isPrincipal) ?? agents[0]; - const parentById = new Map<string, string | null>(); - - for (const agent of agents) { - if (agent.isPrincipal) { - parentById.set(agent.id, null); - continue; - } - const candidate = agent.parentId && byId.has(agent.parentId) - ? agent.parentId - : principal.id; - const seen = new Set([agent.id]); - let cursor: string | null = candidate; - let cyclic = false; - while (cursor) { - if (seen.has(cursor)) { - cyclic = true; - break; - } - seen.add(cursor); - cursor = byId.get(cursor)?.parentId ?? null; - } - parentById.set(agent.id, cyclic ? principal.id : candidate); - } - - const childrenByParent = new Map<string, AgentExecution[]>(); - for (const agent of agents) { - const parentId = parentById.get(agent.id); - if (!parentId) continue; - childrenByParent.set(parentId, [...(childrenByParent.get(parentId) ?? []), agent]); - } - - const depthCache = new Map<string, number>([[principal.id, 0]]); - const depthOf = (agentId: string): number => { - const cached = depthCache.get(agentId); - if (cached != null) return cached; - const parentId = parentById.get(agentId); - const depth = parentId ? depthOf(parentId) + 1 : 0; - depthCache.set(agentId, depth); - return depth; - }; - - const selectedPath = new Set<string>([principal.id, selectedAgentId]); - let selectedParentId = parentById.get(selectedAgentId); - while (selectedParentId) { - selectedPath.add(selectedParentId); - selectedParentId = parentById.get(selectedParentId); - } - const searchPath = new Set<string>(); - if (normalizedQuery) { - for (const agent of agents) { - if (!agent.searchText.includes(normalizedQuery)) continue; - searchPath.add(agent.id); - let parentId = parentById.get(agent.id); - while (parentId) { - if (searchPath.has(parentId)) break; - searchPath.add(parentId); - parentId = parentById.get(parentId); - } - } - } - - const baselineVisible = new Set<string>([principal.id]); - const baselineLayerCounts = new Map<number, number>([[0, 1]]); - const queue: AgentExecution[] = [principal]; - for (let index = 0; index < queue.length && baselineVisible.size < VISIBLE_AGENT_BUDGET; index += 1) { - const parent = queue[index]; - const parentDepth = depthOf(parent.id); - if (parentDepth >= COLLAPSED_DEPTH_LIMIT) continue; - const childDepth = parentDepth + 1; - const children = childrenByParent.get(parent.id) ?? []; - for (const child of children.slice(0, SPECIALIST_LIMIT)) { - if (baselineVisible.size >= VISIBLE_AGENT_BUDGET) break; - const layerCount = baselineLayerCounts.get(childDepth) ?? 0; - if (layerCount >= BASELINE_LAYER_LIMIT) break; - baselineVisible.add(child.id); - baselineLayerCounts.set(childDepth, layerCount + 1); - queue.push(child); - } - } - - const visible = new Set<string>([ - ...baselineVisible, - ...selectedPath, - ...searchPath, - ]); - let expandedChanged = true; - while (expandedChanged) { - expandedChanged = false; - for (const parentId of expandedGroups) { - if (!visible.has(parentId)) continue; - for (const child of childrenByParent.get(parentId) ?? []) { - if (visible.has(child.id)) continue; - visible.add(child.id); - expandedChanged = true; - } - } - } - - const aggregates: GraphAggregate[] = []; - for (const parent of agents) { - if (!visible.has(parent.id)) continue; - const children = childrenByParent.get(parent.id) ?? []; - const expanded = expandedGroups.has(parent.id); - const hidden = children.filter((child) => !visible.has(child.id)); - if (hidden.length === 0 && !expanded) continue; - const aggregateAgents = expanded ? children : hidden; - if (aggregateAgents.length === 0) continue; - aggregates.push({ - kind: "specialist-aggregate", - key: `specialist-aggregate:${parent.id}`, - parent, - agents: aggregateAgents, - expanded, - label: expanded - ? `Collapse ${children.length} specialists` - : `+${hidden.length} specialist${hidden.length === 1 ? "" : "s"}`, - searchText: normalize([ - "specialists agents descendants folded expand collapse", - ...aggregateAgents.map((agent) => agent.searchText), - ].filter(Boolean).join(" ")), - }); - } - - type LayerItem = - | { kind: "agent"; agent: AgentExecution } - | { kind: "aggregate"; aggregate: GraphAggregate }; - const layers = new Map<number, LayerItem[]>(); - for (const agent of agents) { - if (!visible.has(agent.id)) continue; - const depth = depthOf(agent.id); - layers.set(depth, [...(layers.get(depth) ?? []), { kind: "agent", agent }]); - } - for (const aggregate of aggregates) { - const depth = depthOf(aggregate.parent.id) + 1; - layers.set(depth, [...(layers.get(depth) ?? []), { kind: "aggregate", aggregate }]); - } - - const orderedLayers = [...layers.entries()].sort(([left], [right]) => left - right); - const largestLayer = Math.max(1, ...orderedLayers.map(([, layer]) => layer.length)); - const width = Math.max(760, largestLayer * LANE_WIDTH + GRAPH_PADDING * 2); - const layouts: AgentLayout[] = []; - const aggregateLayouts: AggregateLayout[] = []; - let rankY = 54; - - for (const [, layer] of orderedLayers) { - const leafSets = layer.map((item) => item.kind === "agent" ? leavesFor(item.agent) : []); - const rankHeight = Math.max( - AGENT_HEIGHT, - ...leafSets.map((leaves) => Math.max(AGENT_HEIGHT, leaves.length * (LEAF_HEIGHT + LEAF_GAP) - LEAF_GAP)), - ); - const layerWidth = layer.length * LANE_WIDTH; - const layerStart = (width - layerWidth) / 2; - layer.forEach((item, index) => { - const laneStart = layerStart + index * LANE_WIDTH; - if (item.kind === "aggregate") { - aggregateLayouts.push({ - aggregate: item.aggregate, - x: laneStart + (LANE_WIDTH - AGENT_WIDTH) / 2, - y: rankY + (rankHeight - 86) / 2, - }); - return; - } - const leaves = leafSets[index]; - const leafStackHeight = leaves.length > 0 - ? leaves.length * (LEAF_HEIGHT + LEAF_GAP) - LEAF_GAP - : 0; - const x = laneStart + (LANE_WIDTH - CLUSTER_WIDTH) / 2; - const y = rankY + Math.max(0, (rankHeight - AGENT_HEIGHT) / 2); - const leafY = rankY + Math.max(0, (rankHeight - leafStackHeight) / 2); - layouts.push({ - agent: item.agent, - x, - y, - leaves: leaves.map((leaf, leafIndex) => ({ - ...leaf, - x: x + AGENT_WIDTH + 28, - y: leafY + leafIndex * (LEAF_HEIGHT + LEAF_GAP), - })), - }); - }); - rankY += rankHeight + 116; - } - - return { - width, - height: Math.max(330, rankY - 62), - layouts, - aggregateLayouts, - byId, - parentById, - byAgentId: new Map(layouts.map((layout) => [layout.agent.id, layout])), - }; - }, [agents, expandedGroups, normalizedQuery, selectedAgentId]); + const graph = useMemo(() => buildGraphLayout(agents, expandedGroups, normalizedQuery, selectedAgentId), [agents, expandedGroups, normalizedQuery, selectedAgentId]); const selections = useMemo(() => { const values = new Map<string, GraphSelection>(); @@ -1245,287 +630,3 @@ export function AgentGraph({ } return graphSection; } - -function SelectionInspector({ selection }: { selection: GraphSelection }) { - if (selection.kind === "agent") return <AgentInspector agent={selection.agent} />; - if (selection.kind === "edge") { - const childKind = phaseKind(selection.child.phase); - return ( - <InspectorShell eyebrow="Delegation relationship" title={`${selection.parent.displayName} → ${selection.child.displayName}`}> - <p className="text-xs leading-relaxed text-foreground-muted"> - Runtime metadata identifies{" "} - <span className="font-medium text-foreground">{selection.parent.displayName}</span> - {" as the parent of "} - <span className="font-medium text-foreground">{selection.child.displayName}</span> - {selection.child.role ? ` as ${selection.child.role}` : ""}. The child is currently{" "} - <span className={childKind === "failed" ? "font-medium text-danger" : "font-medium text-foreground"}> - {selection.child.phase} - </span>. - </p> - <p className="mt-2 text-[10px] leading-relaxed text-foreground-muted"> - No delegation-event ledger is attached to this trace. The drill-down below focuses the child agent's exact retained activity, not an inferred edge event. - </p> - <div className="mt-3 grid gap-2 sm:grid-cols-3"> - <Metric label="Child runtime" value={selection.child.runtime ?? "Not reported"} small /> - <Metric label="Child model" value={selection.child.model ?? "Not reported"} small /> - <Metric label="Latest activity" value={formatLastActivity(selection.child.lastActivity)} small /> - </div> - </InspectorShell> - ); - } - if (selection.kind === "specialist-aggregate") { - return ( - <InspectorShell eyebrow="Folded specialist group" title={selection.label}> - <p className="text-xs text-foreground-muted"> - Specialists sharing <span className="font-medium text-foreground">{selection.parent.displayName}</span> as their runtime parent. - </p> - <FoldedList - items={selection.agents.map((agent) => ({ - title: `${agent.displayName} · ${agent.role}`, - detail: `${agent.technicalName} · ${agent.model ?? "model not reported"} · ${agent.phase}`, - failed: phaseKind(agent.phase) === "failed", - }))} - /> - </InspectorShell> - ); - } - if (selection.kind === "action") { - const action = selection.action; - return ( - <InspectorShell eyebrow="Recorded action" title={action.human}> - <div className="flex flex-wrap gap-2 text-[10px] text-foreground-muted"> - <span className="rounded-full border border-border px-2 py-1">Agent {selection.agent.displayName}</span> - <span className="rounded-full border border-border px-2 py-1">Round {action.round + 1}</span> - <span className="rounded-full border border-border px-2 py-1">{action.ms} ms</span> - <span className={`rounded-full border px-2 py-1 ${ - action.ok === false ? "border-danger/30 text-danger" : action.ok === true ? "border-signal/30 text-signal" : "border-border" - }`}> - {action.ok === false ? "Failed" : action.ok === true ? "Succeeded" : "Model round"} - </span> - <span className="rounded-full border border-border px-2 py-1">{formatLastActivity(action.ts)}</span> - </div> - <p className="mt-3 break-all rounded-lg bg-surface-muted/45 px-3 py-2 font-mono text-[10px]"> - <span className="font-sans font-medium text-foreground-muted">Raw tool: </span>{action.raw} - </p> - <div className="mt-2 grid gap-2 text-[10px] sm:grid-cols-2"> - <DetailBlock label="Arguments" value={action.args || "No input preview retained"} /> - <DetailBlock label={action.ok === false ? "Failure / result" : "Result"} value={action.result || "No result preview retained"} /> - </div> - </InspectorShell> - ); - } - if (selection.kind === "folded-actions") { - return ( - <InspectorShell eyebrow="Folded action cluster" title={`${selection.actions.length} earlier actions`}> - <FoldedList - items={selection.actions.map((action) => ({ - title: action.human, - detail: `${action.raw} · round ${action.round + 1} · ${action.ms} ms · ${formatLastActivity(action.ts)}`, - failed: action.ok === false, - }))} - /> - </InspectorShell> - ); - } - if (selection.kind === "destination") { - return ( - <InspectorShell eyebrow="Network destination" title={selection.destination}> - <p className="text-xs text-foreground-muted"> - Referenced by retained action evidence from <span className="font-medium text-foreground">{selection.agent.displayName}</span>. - </p> - </InspectorShell> - ); - } - return ( - <InspectorShell eyebrow="Folded destination cluster" title={`${selection.destinations.length} additional destinations`}> - <FoldedList items={selection.destinations.map((destination) => ({ title: destination, detail: "Referenced in retained action evidence" }))} /> - </InspectorShell> - ); -} - -function AgentInspector({ agent }: { agent: AgentExecution }) { - const latest = agent.actions.at(-1); - return ( - <InspectorShell eyebrow={agent.isPrincipal ? "Principal agent" : "Specialist agent"} title={agent.displayName}> - <div className="flex flex-wrap items-center gap-2"> - <span className={`rounded-full border px-2 py-1 text-[10px] font-medium ${phaseTone(agent.phase)}`}> - {agent.phase} - </span> - <span className="text-[11px] text-foreground-muted">{agent.relationship}</span> - </div> - <dl className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> - <Metric label="Role" value={agent.role} small /> - <Metric label="Runtime" value={agent.runtime ?? "Not reported"} small /> - <Metric label="Model" value={agent.model ?? "Not reported"} small /> - <Metric label="Exact agent name" value={agent.technicalName} small /> - </dl> - <div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> - <Metric label="Retained rounds" value={agent.rounds} /> - <Metric label="Tool calls" value={agent.toolCalls} /> - <Metric label="Failures" value={agent.failures} danger={agent.failures > 0} /> - <Metric label="Latest activity" value={formatLastActivity(agent.lastActivity)} small /> - </div> - <div className="mt-2 rounded-lg border border-border bg-surface px-3 py-2"> - <p className="text-[9px] font-medium uppercase tracking-wide text-foreground-muted">Latest action</p> - <p className="mt-0.5 text-xs font-medium">{latest?.human ?? "No retained action yet"}</p> - </div> - </InspectorShell> - ); -} - -function InspectorShell({ - eyebrow, - title, - children, -}: { - eyebrow: string; - title: string; - children: React.ReactNode; -}) { - return ( - <div className="mt-4 rounded-xl border border-signal/25 bg-signal/[0.035] p-4" aria-live="polite"> - <p className="text-[9px] font-semibold uppercase tracking-[0.16em] text-signal">{eyebrow}</p> - <h3 className="mt-0.5 break-words text-sm font-semibold">{title}</h3> - <div className="mt-2">{children}</div> - </div> - ); -} - -function DetailBlock({ label, value }: { label: string; value: string }) { - return ( - <p className="break-words rounded-lg bg-surface-muted/45 px-3 py-2"> - <span className="font-medium text-foreground-muted">{label}: </span> - <span className="font-mono">{value}</span> - </p> - ); -} - -function FoldedList({ - items, -}: { - items: Array<{ title: string; detail: string; failed?: boolean }>; -}) { - return ( - <ol className="max-h-56 space-y-1.5 overflow-y-auto pr-1"> - {items.map((item, index) => ( - <li key={`${item.title}-${index}`} className="flex gap-2 rounded-lg border border-border bg-surface px-3 py-2"> - <span className={`mt-1 h-1.5 w-1.5 shrink-0 rounded-full ${item.failed ? "bg-danger" : "bg-signal"}`} /> - <span className="min-w-0"> - <span className="block text-[11px] font-medium">{item.title}</span> - <span className="block break-all font-mono text-[9px] text-foreground-muted">{item.detail}</span> - </span> - </li> - ))} - </ol> - ); -} - -function Metric({ - label, - value, - danger = false, - small = false, -}: { - label: string; - value: string | number; - danger?: boolean; - small?: boolean; -}) { - return ( - <div className="rounded-md border border-border bg-surface px-2 py-1.5"> - <dt className="text-[9px] uppercase tracking-wide text-foreground-muted">{label}</dt> - <dd className={`mt-0.5 break-words ${small ? "text-[10px] leading-tight" : "font-semibold tabular-nums"} ${danger ? "text-danger" : ""}`}> - {value} - </dd> - </div> - ); -} - -function ProofPoints({ - envelopeDigest, - identity, - subCount, - receipt, -}: { - envelopeDigest: string | null; - identity: AgentIdentity | null; - subCount: number; - receipt: Receipt | null; -}) { - const short = (value: string, head = 10, tail = 6) => - value.length > head + tail + 1 - ? `${value.slice(0, head)}...${value.slice(-tail)}` - : value; - const points = [ - { - when: "At admission", - title: "Trust envelope signed", - detail: envelopeDigest - ? `Digest ${short(envelopeDigest.replace(/^sha256:/, ""))} - tier, budget, tools, and reach were sealed before launch.` - : "Tier, budget, tools, and reach are sealed into a signed envelope before launch.", - proven: Boolean(envelopeDigest), - }, - { - when: "At registration", - title: "Agent mesh identity (DID)", - detail: identity?.did - ? `${short(identity.did, 16, 8)} - signed mesh participant${identity.reputation_score != null ? `, reputation ${identity.reputation_score}` : ""}.` - : "No per-run DID registration proof is attached to this retained view.", - proven: Boolean(identity?.did), - }, - { - when: "At spawn", - title: "Sub-agent attenuation enforced", - detail: - subCount > 0 - ? `${subCount} specialist${subCount === 1 ? "" : "s"} spawned after the controller verified each envelope was a strict subset of the principal's authority.` - : "If the principal delegates, the controller rejects any sub-agent envelope that is not a strict authority subset.", - proven: subCount > 0, - }, - { - when: "At delivery", - title: "Governance receipt (DSSE)", - detail: receipt - ? `${receipt.scheme || "DSSE"} - key ${short(receipt.key_id || "-", 8, 6)}${receipt.inclusion_seq != null ? ` - inclusion log #${receipt.inclusion_seq}` : ""}.` - : "No per-run DSSE receipt object is attached to this retained view.", - proven: Boolean(receipt), - }, - ]; - const verified = points.filter((point) => point.proven).length; - const notRetained = points.length - verified; - - return ( - <details className="mt-4 rounded-xl border border-border bg-surface-muted/20"> - <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-medium text-foreground-muted"> - <Icon name="seal" size={13} /> - Cryptographic proofs and attestations - <span className="ml-auto text-[10px]"> - {verified} verified · {notRetained} not retained - </span> - </summary> - <ol className="space-y-2 border-t border-border p-3"> - {points.map((point) => ( - <li key={point.title} className="flex gap-2.5"> - <span className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] ${point.proven ? "bg-signal/15 text-signal" : "border border-dashed border-border text-foreground-muted"}`}> - {point.proven ? "ok" : "n/a"} - </span> - <div className="min-w-0"> - <p className="text-[11px] font-medium"> - {point.title} - <span className="ml-2 rounded-full border border-border px-1.5 py-0.5 text-[9px] font-normal text-foreground-muted"> - {point.when} - </span> - </p> - <p className="text-[11px] leading-relaxed text-foreground-muted">{point.detail}</p> - </div> - </li> - ))} - </ol> - <p className="border-t border-border px-3 py-2 text-[10px] leading-relaxed text-foreground-muted"> - “Verified” means the exact per-run proof object is attached here. “Not retained” means this - archived run predates that retained evidence surface; it is not counted as cryptographic proof, - even when the platform control was enforced. - </p> - </details> - ); -} diff --git a/bridge/web/src/components/agent-graph/activity.ts b/bridge/web/src/components/agent-graph/activity.ts new file mode 100644 index 000000000..c25c8e696 --- /dev/null +++ b/bridge/web/src/components/agent-graph/activity.ts @@ -0,0 +1,165 @@ +import type { ActivityEvent } from "@/lib/types"; +import type { AgentAction, AgentExecution, ToolEvent } from "./types"; + +export function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function humanizeTool(name: string): string { + const tool = name.toLowerCase().replaceAll("-", "_"); + if (/(browser_)?navigate|open_url|goto/.test(tool)) return "Opened a browser page"; + if (/screenshot|capture_screen/.test(tool)) return "Captured a screenshot"; + if (/browser_(click|dblclick)|click_element/.test(tool)) return "Clicked a page control"; + if (/fill_form|browser_fill|select_option/.test(tool)) return "Filled in a form"; + if (/browser_type|press_key|keyboard/.test(tool)) return "Entered text on a page"; + if (/browser_snapshot|page_snapshot|accessibility_tree/.test(tool)) return "Inspected a browser page"; + if (/browser_wait|wait_for/.test(tool)) return "Waited for a page update"; + if (/network_request|network_requests/.test(tool)) return "Inspected browser network activity"; + if (/file_upload/.test(tool)) return "Uploaded a file"; + if (/(web_)?search|brave|tavily|exa|perplexity/.test(tool)) return "Searched the web"; + if (/fetch|http|curl|crawl|download/.test(tool)) return "Retrieved network content"; + if (/pull_request|create_pr|open_pr/.test(tool)) return "Worked with a pull request"; + if (/git|commit|branch|push|pull/.test(tool)) return "Worked with source control"; + if (/write|create_file|save|edit|patch|append/.test(tool)) return "Updated a file"; + if (/read|view|list|glob|grep|find|search_files/.test(tool)) return "Inspected files"; + if (/shell|bash|exec|run_command|terminal/.test(tool)) return "Ran a command"; + if (/message|handoff|send|relay/.test(tool)) return "Sent an agent message"; + return "Used a governed tool"; +} + +function meaningfulDestination(host: string): boolean { + const value = host.toLowerCase().replace(/^\[|\]$/g, ""); + return !/^(localhost|0\.0\.0\.0|127(?:\.\d+){3}|::1)(:\d+)?$/.test(value); +} + +export function destinationsFrom(event: ToolEvent): string[] { + const values = `${event.args_preview} ${event.result_preview}`; + const destinations = new Set<string>(); + for (const match of values.matchAll(/https?:\/\/([^/\s"')\]]+)/gi)) { + const host = match[1].toLowerCase().replace(/[.,;]+$/, ""); + if (meaningfulDestination(host)) destinations.add(host); + } + return [...destinations]; +} + +export function formatLastActivity(value: string | null): string { + if (!value) return "No recorded activity"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + const pad = (part: number) => String(part).padStart(2, "0"); + return `${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; +} + +export function phaseKind(phase: string): "active" | "failed" | "paused" | "complete" | "idle" { + const value = phase.toLowerCase(); + if (/(failed|degraded|error|blocked)/.test(value)) return "failed"; + if (/(running|launching|active|working|executing)/.test(value)) return "active"; + if (/(paused|hibernating|suspended|waiting)/.test(value)) return "paused"; + if (/(completed|finished|succeeded|delivered)/.test(value)) return "complete"; + return "idle"; +} + +export function phaseTone(phase: string): string { + switch (phaseKind(phase)) { + case "failed": + return "border-danger/35 bg-danger/10 text-danger"; + case "active": + return "border-signal/35 bg-signal/10 text-signal"; + case "paused": + return "border-accent/35 bg-accent/10 text-accent"; + case "complete": + return "border-signal/25 bg-signal/[0.06] text-foreground-muted"; + default: + return "border-border bg-surface-muted text-foreground-muted"; + } +} + +export function actionFromEvent(event: ActivityEvent, index: number): AgentAction { + if (event.kind === "round") { + return { + id: `round-${event.agent ?? "principal"}-${event.round}-${index}`, + human: `Completed model round ${event.round + 1}`, + raw: "model.round", + args: `${event.tool_calls} tool call${event.tool_calls === 1 ? "" : "s"} requested`, + result: `${event.finish_reason || "unknown finish"}; ${event.total_tokens.toLocaleString("en-US")} tokens`, + ok: null, + round: event.round, + ms: event.ms, + ts: event.ts, + seq: event.seq ?? null, + agentInstance: event.agentInstance ?? null, + }; + } + return { + id: `tool-${event.agent ?? "principal"}-${event.round}-${index}`, + human: humanizeTool(event.name), + raw: event.name, + args: event.args_preview, + result: event.result_preview, + ok: event.ok, + round: event.round, + ms: event.ms, + ts: event.ts, + seq: event.seq ?? null, + agentInstance: event.agentInstance ?? null, + }; +} + +export function ellipsis(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +function queryAgentIdentity(agent: AgentExecution): string { + return agent.isPrincipal + ? "principal" + : normalize(agent.observedAgentName ?? agent.technicalName); +} + +export function exactActionQuery(agent: AgentExecution, action: AgentAction): string { + const parameters = new URLSearchParams({ + agent: queryAgentIdentity(agent), + round: String(action.round), + ts: action.ts, + tool: action.raw, + args: action.args, + result: action.result, + }); + if (action.seq != null) parameters.set("seq", String(action.seq)); + if (action.agentInstance) parameters.set("instance", action.agentInstance); + return `action:${parameters.toString()}`; +} + +export function exactActionsQuery(agent: AgentExecution, actions: AgentAction[]): string { + const through = actions.at(-1)?.ts ?? ""; + const parameters = new URLSearchParams({ + agent: queryAgentIdentity(agent), + through, + }); + const sequenceValues = actions.flatMap((action) => + action.seq == null ? [] : [String(action.seq)] + ); + if (sequenceValues.length === actions.length && sequenceValues.length > 0) { + parameters.set("seqs", sequenceValues.join(",")); + } else { + parameters.set( + "events", + JSON.stringify(actions.map((action) => ({ + round: action.round, + ts: action.ts, + tool: action.raw, + args: action.args, + result: action.result, + instance: action.agentInstance, + }))), + ); + } + const instances = [...new Set(actions.flatMap((action) => + action.agentInstance ? [action.agentInstance] : [] + ))]; + if (instances.length === 1) parameters.set("instance", instances[0]); + return `actions:${parameters.toString()}`; +} diff --git a/bridge/web/src/components/agent-graph/constants.ts b/bridge/web/src/components/agent-graph/constants.ts new file mode 100644 index 000000000..abb3d5745 --- /dev/null +++ b/bridge/web/src/components/agent-graph/constants.ts @@ -0,0 +1,29 @@ +export const AGENT_WIDTH = 248; + +export const AGENT_HEIGHT = 128; + +export const LEAF_WIDTH = 194; + +export const LEAF_HEIGHT = 42; + +export const LEAF_GAP = 9; + +export const LANE_WIDTH = 500; + +export const CLUSTER_WIDTH = AGENT_WIDTH + 28 + LEAF_WIDTH; + +export const GRAPH_PADDING = 40; + +export const ACTION_LIMIT = 3; + +export const DESTINATION_LIMIT = 2; + +export const SPECIALIST_LIMIT = 6; + +export const VISIBLE_AGENT_BUDGET = 24; + +export const COLLAPSED_DEPTH_LIMIT = 4; + +export const BASELINE_LAYER_LIMIT = 6; + +export const RECENT_ACTIVITY_MS = 90_000; diff --git a/bridge/web/src/components/agent-graph/execution.ts b/bridge/web/src/components/agent-graph/execution.ts new file mode 100644 index 000000000..70db068b5 --- /dev/null +++ b/bridge/web/src/components/agent-graph/execution.ts @@ -0,0 +1,172 @@ +import type { ActivityEvent, SubAgent } from "@/lib/types"; +import { actionFromEvent, destinationsFrom, normalize } from "./activity"; +import type { AgentExecution, ToolEvent } from "./types"; + +export function buildAgentExecutions({ + agentLabel, + agentModel, + agentPhase, + agentRuntime, + events, + name, + running, + subAgents, +}: { + agentLabel: string; + agentModel: string | null; + agentPhase: string | null; + agentRuntime: string | null; + events: ActivityEvent[]; + name: string | undefined; + running: boolean; + subAgents: SubAgent[]; +}): AgentExecution[] { + const rootInactive = Boolean( + !running + && agentPhase + && /(completed|failed|hibernating|paused|idle|finished)/i.test(agentPhase), + ); + const definitions = [ + { + id: "principal", + displayName: agentLabel, + technicalName: name ?? agentLabel, + role: "Principal", + phase: agentPhase ?? (running ? "Running" : events.length > 0 ? "Finished" : "Ready"), + runtime: agentRuntime, + model: agentModel, + parent: null, + isPrincipal: true, + aliases: ["principal", agentLabel, name ?? ""].filter(Boolean), + }, + ...subAgents.map((agent) => ({ + id: `sub-${agent.name}`, + displayName: agent.logical_agent_id ?? agent.role ?? agent.name, + technicalName: agent.name, + role: agent.role ?? "Specialist", + phase: rootInactive ? agentPhase! : agent.phase ?? "Discovered", + runtime: agent.runtime, + model: agent.model, + parent: agent.parent, + isPrincipal: false, + aliases: [agent.name, agent.logical_agent_id ?? "", agent.role ?? ""].filter(Boolean), + })), + ]; + + const records = new Map<string, ActivityEvent[]>( + definitions.map((definition) => [definition.id, []]), + ); + const aliasToId = new Map<string, string>(); + for (const definition of definitions) { + for (const alias of definition.aliases) aliasToId.set(normalize(alias), definition.id); + } + + for (const event of events) { + let owner = "principal"; + if (event.agentRole === "subagent" && event.agent) { + const instance = event.agentInstance ?? event.agent; + owner = aliasToId.get(normalize(instance)) + ?? aliasToId.get(normalize(event.agent)) + ?? `live-${instance}`; + if (!records.has(owner)) records.set(owner, []); + } + records.get(owner)?.push(event); + } + + const allDefinitions = [...definitions]; + for (const [id] of records) { + if (!id.startsWith("live-")) continue; + const technicalName = id.slice(5); + allDefinitions.push({ + id, + displayName: technicalName, + technicalName, + role: "Specialist", + phase: running ? "Active" : "Observed", + runtime: null, + model: null, + parent: null, + isPrincipal: false, + aliases: [technicalName], + }); + } + + const completeAliasToId = new Map<string, string>(); + for (const definition of allDefinitions) { + for (const alias of definition.aliases) completeAliasToId.set(normalize(alias), definition.id); + completeAliasToId.set(normalize(definition.technicalName), definition.id); + } + const namesById = new Map(allDefinitions.map((definition) => [definition.id, definition.displayName])); + + return allDefinitions.map((definition) => { + const agentEvents = records.get(definition.id) ?? []; + const actions = agentEvents + .map(actionFromEvent) + .sort((left, right) => left.ts.localeCompare(right.ts)); + const tools = agentEvents.filter( + (event): event is ToolEvent => event.kind === "tool", + ); + const destinations = [...new Set(tools.flatMap(destinationsFrom))].sort(); + const lastActivity = agentEvents.reduce<string | null>( + (latest, event) => (!latest || event.ts > latest ? event.ts : latest), + null, + ); + const observedAgentName = agentEvents.find((event) => event.agent)?.agent ?? null; + const observedAgentInstance = + agentEvents.find((event) => event.agentInstance)?.agentInstance ?? null; + const parentId = definition.isPrincipal + ? null + : completeAliasToId.get(normalize(definition.parent ?? "")) ?? "principal"; + const parentName = parentId ? namesById.get(parentId) ?? agentLabel : null; + const relationship = definition.isPrincipal + ? "Orchestration root responsible for the execution" + : `Specialist delegated by ${parentName}`; + const searchText = [ + definition.displayName, + definition.technicalName, + definition.role, + relationship, + definition.runtime, + definition.model, + definition.phase, + ...destinations, + ...actions.flatMap((action) => [action.human, action.raw, action.args, action.result]), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + const identitySearchText = [ + definition.displayName, + definition.technicalName, + definition.role, + relationship, + definition.runtime, + definition.model, + definition.phase, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return { + ...definition, + relationship, + parentId, + observedAgentName, + observedAgentInstance, + rounds: agentEvents.filter((event) => event.kind === "round").length, + toolCalls: tools.length, + failures: tools.filter((event) => !event.ok).length, + lastActivity, + destinations, + actions, + identitySearchText, + searchText, + traceQuery: definition.isPrincipal + ? "agent:principal" + : observedAgentInstance + ? `agent-instance:${normalize(observedAgentInstance)}` + : `agent:${normalize(observedAgentName ?? definition.technicalName)}`, + }; + }); + } diff --git a/bridge/web/src/components/agent-graph/inspectors.tsx b/bridge/web/src/components/agent-graph/inspectors.tsx new file mode 100644 index 000000000..039343bdd --- /dev/null +++ b/bridge/web/src/components/agent-graph/inspectors.tsx @@ -0,0 +1,289 @@ +import type * as React from "react"; +import { Icon } from "@/components/icon"; +import type { AgentIdentity, Receipt } from "@/lib/types"; +import { formatLastActivity, phaseKind, phaseTone } from "./activity"; +import type { AgentExecution, GraphSelection } from "./types"; + +export function SelectionInspector({ selection }: { selection: GraphSelection }) { + if (selection.kind === "agent") return <AgentInspector agent={selection.agent} />; + if (selection.kind === "edge") { + const childKind = phaseKind(selection.child.phase); + return ( + <InspectorShell eyebrow="Delegation relationship" title={`${selection.parent.displayName} → ${selection.child.displayName}`}> + <p className="text-xs leading-relaxed text-foreground-muted"> + Runtime metadata identifies{" "} + <span className="font-medium text-foreground">{selection.parent.displayName}</span> + {" as the parent of "} + <span className="font-medium text-foreground">{selection.child.displayName}</span> + {selection.child.role ? ` as ${selection.child.role}` : ""}. The child is currently{" "} + <span className={childKind === "failed" ? "font-medium text-danger" : "font-medium text-foreground"}> + {selection.child.phase} + </span>. + </p> + <p className="mt-2 text-[10px] leading-relaxed text-foreground-muted"> + No delegation-event ledger is attached to this trace. The drill-down below focuses the child agent's exact retained activity, not an inferred edge event. + </p> + <div className="mt-3 grid gap-2 sm:grid-cols-3"> + <Metric label="Child runtime" value={selection.child.runtime ?? "Not reported"} small /> + <Metric label="Child model" value={selection.child.model ?? "Not reported"} small /> + <Metric label="Latest activity" value={formatLastActivity(selection.child.lastActivity)} small /> + </div> + </InspectorShell> + ); + } + if (selection.kind === "specialist-aggregate") { + return ( + <InspectorShell eyebrow="Folded specialist group" title={selection.label}> + <p className="text-xs text-foreground-muted"> + Specialists sharing <span className="font-medium text-foreground">{selection.parent.displayName}</span> as their runtime parent. + </p> + <FoldedList + items={selection.agents.map((agent) => ({ + title: `${agent.displayName} · ${agent.role}`, + detail: `${agent.technicalName} · ${agent.model ?? "model not reported"} · ${agent.phase}`, + failed: phaseKind(agent.phase) === "failed", + }))} + /> + </InspectorShell> + ); + } + if (selection.kind === "action") { + const action = selection.action; + return ( + <InspectorShell eyebrow="Recorded action" title={action.human}> + <div className="flex flex-wrap gap-2 text-[10px] text-foreground-muted"> + <span className="rounded-full border border-border px-2 py-1">Agent {selection.agent.displayName}</span> + <span className="rounded-full border border-border px-2 py-1">Round {action.round + 1}</span> + <span className="rounded-full border border-border px-2 py-1">{action.ms} ms</span> + <span className={`rounded-full border px-2 py-1 ${ + action.ok === false ? "border-danger/30 text-danger" : action.ok === true ? "border-signal/30 text-signal" : "border-border" + }`}> + {action.ok === false ? "Failed" : action.ok === true ? "Succeeded" : "Model round"} + </span> + <span className="rounded-full border border-border px-2 py-1">{formatLastActivity(action.ts)}</span> + </div> + <p className="mt-3 break-all rounded-lg bg-surface-muted/45 px-3 py-2 font-mono text-[10px]"> + <span className="font-sans font-medium text-foreground-muted">Raw tool: </span>{action.raw} + </p> + <div className="mt-2 grid gap-2 text-[10px] sm:grid-cols-2"> + <DetailBlock label="Arguments" value={action.args || "No input preview retained"} /> + <DetailBlock label={action.ok === false ? "Failure / result" : "Result"} value={action.result || "No result preview retained"} /> + </div> + </InspectorShell> + ); + } + if (selection.kind === "folded-actions") { + return ( + <InspectorShell eyebrow="Folded action cluster" title={`${selection.actions.length} earlier actions`}> + <FoldedList + items={selection.actions.map((action) => ({ + title: action.human, + detail: `${action.raw} · round ${action.round + 1} · ${action.ms} ms · ${formatLastActivity(action.ts)}`, + failed: action.ok === false, + }))} + /> + </InspectorShell> + ); + } + if (selection.kind === "destination") { + return ( + <InspectorShell eyebrow="Network destination" title={selection.destination}> + <p className="text-xs text-foreground-muted"> + Referenced by retained action evidence from <span className="font-medium text-foreground">{selection.agent.displayName}</span>. + </p> + </InspectorShell> + ); + } + return ( + <InspectorShell eyebrow="Folded destination cluster" title={`${selection.destinations.length} additional destinations`}> + <FoldedList items={selection.destinations.map((destination) => ({ title: destination, detail: "Referenced in retained action evidence" }))} /> + </InspectorShell> + ); +} + +function AgentInspector({ agent }: { agent: AgentExecution }) { + const latest = agent.actions.at(-1); + return ( + <InspectorShell eyebrow={agent.isPrincipal ? "Principal agent" : "Specialist agent"} title={agent.displayName}> + <div className="flex flex-wrap items-center gap-2"> + <span className={`rounded-full border px-2 py-1 text-[10px] font-medium ${phaseTone(agent.phase)}`}> + {agent.phase} + </span> + <span className="text-[11px] text-foreground-muted">{agent.relationship}</span> + </div> + <dl className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> + <Metric label="Role" value={agent.role} small /> + <Metric label="Runtime" value={agent.runtime ?? "Not reported"} small /> + <Metric label="Model" value={agent.model ?? "Not reported"} small /> + <Metric label="Exact agent name" value={agent.technicalName} small /> + </dl> + <div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> + <Metric label="Retained rounds" value={agent.rounds} /> + <Metric label="Tool calls" value={agent.toolCalls} /> + <Metric label="Failures" value={agent.failures} danger={agent.failures > 0} /> + <Metric label="Latest activity" value={formatLastActivity(agent.lastActivity)} small /> + </div> + <div className="mt-2 rounded-lg border border-border bg-surface px-3 py-2"> + <p className="text-[9px] font-medium uppercase tracking-wide text-foreground-muted">Latest action</p> + <p className="mt-0.5 text-xs font-medium">{latest?.human ?? "No retained action yet"}</p> + </div> + </InspectorShell> + ); +} + +function InspectorShell({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( + <div className="mt-4 rounded-xl border border-signal/25 bg-signal/[0.035] p-4" aria-live="polite"> + <p className="text-[9px] font-semibold uppercase tracking-[0.16em] text-signal">{eyebrow}</p> + <h3 className="mt-0.5 break-words text-sm font-semibold">{title}</h3> + <div className="mt-2">{children}</div> + </div> + ); +} + +function DetailBlock({ label, value }: { label: string; value: string }) { + return ( + <p className="break-words rounded-lg bg-surface-muted/45 px-3 py-2"> + <span className="font-medium text-foreground-muted">{label}: </span> + <span className="font-mono">{value}</span> + </p> + ); +} + +function FoldedList({ + items, +}: { + items: Array<{ title: string; detail: string; failed?: boolean }>; +}) { + return ( + <ol className="max-h-56 space-y-1.5 overflow-y-auto pr-1"> + {items.map((item, index) => ( + <li key={`${item.title}-${index}`} className="flex gap-2 rounded-lg border border-border bg-surface px-3 py-2"> + <span className={`mt-1 h-1.5 w-1.5 shrink-0 rounded-full ${item.failed ? "bg-danger" : "bg-signal"}`} /> + <span className="min-w-0"> + <span className="block text-[11px] font-medium">{item.title}</span> + <span className="block break-all font-mono text-[9px] text-foreground-muted">{item.detail}</span> + </span> + </li> + ))} + </ol> + ); +} + +function Metric({ + label, + value, + danger = false, + small = false, +}: { + label: string; + value: string | number; + danger?: boolean; + small?: boolean; +}) { + return ( + <div className="rounded-md border border-border bg-surface px-2 py-1.5"> + <dt className="text-[9px] uppercase tracking-wide text-foreground-muted">{label}</dt> + <dd className={`mt-0.5 break-words ${small ? "text-[10px] leading-tight" : "font-semibold tabular-nums"} ${danger ? "text-danger" : ""}`}> + {value} + </dd> + </div> + ); +} + +export function ProofPoints({ + envelopeDigest, + identity, + subCount, + receipt, +}: { + envelopeDigest: string | null; + identity: AgentIdentity | null; + subCount: number; + receipt: Receipt | null; +}) { + const short = (value: string, head = 10, tail = 6) => + value.length > head + tail + 1 + ? `${value.slice(0, head)}...${value.slice(-tail)}` + : value; + const points = [ + { + when: "At admission", + title: "Trust envelope signed", + detail: envelopeDigest + ? `Digest ${short(envelopeDigest.replace(/^sha256:/, ""))} - tier, budget, tools, and reach were sealed before launch.` + : "Tier, budget, tools, and reach are sealed into a signed envelope before launch.", + proven: Boolean(envelopeDigest), + }, + { + when: "At registration", + title: "Agent mesh identity (DID)", + detail: identity?.did + ? `${short(identity.did, 16, 8)} - signed mesh participant${identity.reputation_score != null ? `, reputation ${identity.reputation_score}` : ""}.` + : "No per-run DID registration proof is attached to this retained view.", + proven: Boolean(identity?.did), + }, + { + when: "At spawn", + title: "Sub-agent attenuation enforced", + detail: + subCount > 0 + ? `${subCount} specialist${subCount === 1 ? "" : "s"} spawned after the controller verified each envelope was a strict subset of the principal's authority.` + : "If the principal delegates, the controller rejects any sub-agent envelope that is not a strict authority subset.", + proven: subCount > 0, + }, + { + when: "At delivery", + title: "Governance receipt (DSSE)", + detail: receipt + ? `${receipt.scheme || "DSSE"} - key ${short(receipt.key_id || "-", 8, 6)}${receipt.inclusion_seq != null ? ` - inclusion log #${receipt.inclusion_seq}` : ""}.` + : "No per-run DSSE receipt object is attached to this retained view.", + proven: Boolean(receipt), + }, + ]; + const verified = points.filter((point) => point.proven).length; + const notRetained = points.length - verified; + + return ( + <details className="mt-4 rounded-xl border border-border bg-surface-muted/20"> + <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-medium text-foreground-muted"> + <Icon name="seal" size={13} /> + Cryptographic proofs and attestations + <span className="ml-auto text-[10px]"> + {verified} verified · {notRetained} not retained + </span> + </summary> + <ol className="space-y-2 border-t border-border p-3"> + {points.map((point) => ( + <li key={point.title} className="flex gap-2.5"> + <span className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] ${point.proven ? "bg-signal/15 text-signal" : "border border-dashed border-border text-foreground-muted"}`}> + {point.proven ? "ok" : "n/a"} + </span> + <div className="min-w-0"> + <p className="text-[11px] font-medium"> + {point.title} + <span className="ml-2 rounded-full border border-border px-1.5 py-0.5 text-[9px] font-normal text-foreground-muted"> + {point.when} + </span> + </p> + <p className="text-[11px] leading-relaxed text-foreground-muted">{point.detail}</p> + </div> + </li> + ))} + </ol> + <p className="border-t border-border px-3 py-2 text-[10px] leading-relaxed text-foreground-muted"> + “Verified” means the exact per-run proof object is attached here. “Not retained” means this + archived run predates that retained evidence surface; it is not counted as cryptographic proof, + even when the platform control was enforced. + </p> + </details> + ); +} diff --git a/bridge/web/src/components/agent-graph/layout.ts b/bridge/web/src/components/agent-graph/layout.ts new file mode 100644 index 000000000..1c41cb2b0 --- /dev/null +++ b/bridge/web/src/components/agent-graph/layout.ts @@ -0,0 +1,269 @@ +import { normalize } from "./activity"; +import { + ACTION_LIMIT, + AGENT_HEIGHT, + AGENT_WIDTH, + BASELINE_LAYER_LIMIT, + CLUSTER_WIDTH, + COLLAPSED_DEPTH_LIMIT, + DESTINATION_LIMIT, + GRAPH_PADDING, + LANE_WIDTH, + LEAF_GAP, + LEAF_HEIGHT, + SPECIALIST_LIMIT, + VISIBLE_AGENT_BUDGET, +} from "./constants"; +import type { + AgentExecution, + AgentLayout, + AggregateLayout, + GraphAggregate, + GraphLeaf, +} from "./types"; + +function leavesFor(agent: AgentExecution): GraphLeaf[] { + const foldedActions = agent.actions.slice(0, Math.max(0, agent.actions.length - ACTION_LIMIT)); + const visibleActions = agent.actions.slice(-ACTION_LIMIT); + const visibleDestinations = agent.destinations.slice(0, DESTINATION_LIMIT); + const foldedDestinations = agent.destinations.slice(DESTINATION_LIMIT); + const leaves: GraphLeaf[] = visibleActions.map((action) => ({ + kind: "action", + key: `action:${agent.id}:${action.id}`, + agent, + action, + label: action.human, + searchText: normalize(`${action.human} ${action.raw} ${action.args} ${action.result}`), + })); + if (foldedActions.length > 0) { + leaves.unshift({ + kind: "folded-actions", + key: `folded-actions:${agent.id}`, + agent, + actions: foldedActions, + label: `+${foldedActions.length} action${foldedActions.length === 1 ? "" : "s"}`, + searchText: normalize(foldedActions.flatMap((action) => [action.human, action.raw, action.args, action.result]).join(" ")), + }); + } + leaves.push(...visibleDestinations.map((destination) => ({ + kind: "destination" as const, + key: `destination:${agent.id}:${destination}`, + agent, + destination, + label: destination, + searchText: normalize(destination), + }))); + if (foldedDestinations.length > 0) { + leaves.push({ + kind: "folded-destinations", + key: `folded-destinations:${agent.id}`, + agent, + destinations: foldedDestinations, + label: `+${foldedDestinations.length} destination${foldedDestinations.length === 1 ? "" : "s"}`, + searchText: normalize(foldedDestinations.join(" ")), + }); + } + return leaves; +} + +export function buildGraphLayout( + agents: AgentExecution[], + expandedGroups: Set<string>, + normalizedQuery: string, + selectedAgentId: string, +) { + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const principal = agents.find((agent) => agent.isPrincipal) ?? agents[0]; + const parentById = new Map<string, string | null>(); + + for (const agent of agents) { + if (agent.isPrincipal) { + parentById.set(agent.id, null); + continue; + } + const candidate = agent.parentId && byId.has(agent.parentId) + ? agent.parentId + : principal.id; + const seen = new Set([agent.id]); + let cursor: string | null = candidate; + let cyclic = false; + while (cursor) { + if (seen.has(cursor)) { + cyclic = true; + break; + } + seen.add(cursor); + cursor = byId.get(cursor)?.parentId ?? null; + } + parentById.set(agent.id, cyclic ? principal.id : candidate); + } + + const childrenByParent = new Map<string, AgentExecution[]>(); + for (const agent of agents) { + const parentId = parentById.get(agent.id); + if (!parentId) continue; + childrenByParent.set(parentId, [...(childrenByParent.get(parentId) ?? []), agent]); + } + + const depthCache = new Map<string, number>([[principal.id, 0]]); + const depthOf = (agentId: string): number => { + const cached = depthCache.get(agentId); + if (cached != null) return cached; + const parentId = parentById.get(agentId); + const depth = parentId ? depthOf(parentId) + 1 : 0; + depthCache.set(agentId, depth); + return depth; + }; + + const selectedPath = new Set<string>([principal.id, selectedAgentId]); + let selectedParentId = parentById.get(selectedAgentId); + while (selectedParentId) { + selectedPath.add(selectedParentId); + selectedParentId = parentById.get(selectedParentId); + } + const searchPath = new Set<string>(); + if (normalizedQuery) { + for (const agent of agents) { + if (!agent.searchText.includes(normalizedQuery)) continue; + searchPath.add(agent.id); + let parentId = parentById.get(agent.id); + while (parentId) { + if (searchPath.has(parentId)) break; + searchPath.add(parentId); + parentId = parentById.get(parentId); + } + } + } + + const baselineVisible = new Set<string>([principal.id]); + const baselineLayerCounts = new Map<number, number>([[0, 1]]); + const queue: AgentExecution[] = [principal]; + for (let index = 0; index < queue.length && baselineVisible.size < VISIBLE_AGENT_BUDGET; index += 1) { + const parent = queue[index]; + const parentDepth = depthOf(parent.id); + if (parentDepth >= COLLAPSED_DEPTH_LIMIT) continue; + const childDepth = parentDepth + 1; + const children = childrenByParent.get(parent.id) ?? []; + for (const child of children.slice(0, SPECIALIST_LIMIT)) { + if (baselineVisible.size >= VISIBLE_AGENT_BUDGET) break; + const layerCount = baselineLayerCounts.get(childDepth) ?? 0; + if (layerCount >= BASELINE_LAYER_LIMIT) break; + baselineVisible.add(child.id); + baselineLayerCounts.set(childDepth, layerCount + 1); + queue.push(child); + } + } + + const visible = new Set<string>([ + ...baselineVisible, + ...selectedPath, + ...searchPath, + ]); + let expandedChanged = true; + while (expandedChanged) { + expandedChanged = false; + for (const parentId of expandedGroups) { + if (!visible.has(parentId)) continue; + for (const child of childrenByParent.get(parentId) ?? []) { + if (visible.has(child.id)) continue; + visible.add(child.id); + expandedChanged = true; + } + } + } + + const aggregates: GraphAggregate[] = []; + for (const parent of agents) { + if (!visible.has(parent.id)) continue; + const children = childrenByParent.get(parent.id) ?? []; + const expanded = expandedGroups.has(parent.id); + const hidden = children.filter((child) => !visible.has(child.id)); + if (hidden.length === 0 && !expanded) continue; + const aggregateAgents = expanded ? children : hidden; + if (aggregateAgents.length === 0) continue; + aggregates.push({ + kind: "specialist-aggregate", + key: `specialist-aggregate:${parent.id}`, + parent, + agents: aggregateAgents, + expanded, + label: expanded + ? `Collapse ${children.length} specialists` + : `+${hidden.length} specialist${hidden.length === 1 ? "" : "s"}`, + searchText: normalize([ + "specialists agents descendants folded expand collapse", + ...aggregateAgents.map((agent) => agent.searchText), + ].filter(Boolean).join(" ")), + }); + } + + type LayerItem = + | { kind: "agent"; agent: AgentExecution } + | { kind: "aggregate"; aggregate: GraphAggregate }; + const layers = new Map<number, LayerItem[]>(); + for (const agent of agents) { + if (!visible.has(agent.id)) continue; + const depth = depthOf(agent.id); + layers.set(depth, [...(layers.get(depth) ?? []), { kind: "agent", agent }]); + } + for (const aggregate of aggregates) { + const depth = depthOf(aggregate.parent.id) + 1; + layers.set(depth, [...(layers.get(depth) ?? []), { kind: "aggregate", aggregate }]); + } + + const orderedLayers = [...layers.entries()].sort(([left], [right]) => left - right); + const largestLayer = Math.max(1, ...orderedLayers.map(([, layer]) => layer.length)); + const width = Math.max(760, largestLayer * LANE_WIDTH + GRAPH_PADDING * 2); + const layouts: AgentLayout[] = []; + const aggregateLayouts: AggregateLayout[] = []; + let rankY = 54; + + for (const [, layer] of orderedLayers) { + const leafSets = layer.map((item) => item.kind === "agent" ? leavesFor(item.agent) : []); + const rankHeight = Math.max( + AGENT_HEIGHT, + ...leafSets.map((leaves) => Math.max(AGENT_HEIGHT, leaves.length * (LEAF_HEIGHT + LEAF_GAP) - LEAF_GAP)), + ); + const layerWidth = layer.length * LANE_WIDTH; + const layerStart = (width - layerWidth) / 2; + layer.forEach((item, index) => { + const laneStart = layerStart + index * LANE_WIDTH; + if (item.kind === "aggregate") { + aggregateLayouts.push({ + aggregate: item.aggregate, + x: laneStart + (LANE_WIDTH - AGENT_WIDTH) / 2, + y: rankY + (rankHeight - 86) / 2, + }); + return; + } + const leaves = leafSets[index]; + const leafStackHeight = leaves.length > 0 + ? leaves.length * (LEAF_HEIGHT + LEAF_GAP) - LEAF_GAP + : 0; + const x = laneStart + (LANE_WIDTH - CLUSTER_WIDTH) / 2; + const y = rankY + Math.max(0, (rankHeight - AGENT_HEIGHT) / 2); + const leafY = rankY + Math.max(0, (rankHeight - leafStackHeight) / 2); + layouts.push({ + agent: item.agent, + x, + y, + leaves: leaves.map((leaf, leafIndex) => ({ + ...leaf, + x: x + AGENT_WIDTH + 28, + y: leafY + leafIndex * (LEAF_HEIGHT + LEAF_GAP), + })), + }); + }); + rankY += rankHeight + 116; + } + + return { + width, + height: Math.max(330, rankY - 62), + layouts, + aggregateLayouts, + byId, + parentById, + byAgentId: new Map(layouts.map((layout) => [layout.agent.id, layout])), + }; + } diff --git a/bridge/web/src/components/agent-graph/types.ts b/bridge/web/src/components/agent-graph/types.ts new file mode 100644 index 000000000..a5a3e7348 --- /dev/null +++ b/bridge/web/src/components/agent-graph/types.ts @@ -0,0 +1,78 @@ +import type { ActivityEvent } from "@/lib/types"; + +export type ToolEvent = Extract<ActivityEvent, { kind: "tool" }>; + +export interface AgentAction { + id: string; + human: string; + raw: string; + args: string; + result: string; + ok: boolean | null; + round: number; + ms: number; + ts: string; + seq: number | null; + agentInstance: string | null; +} + +export interface AgentExecution { + id: string; + displayName: string; + technicalName: string; + role: string; + relationship: string; + phase: string; + runtime: string | null; + model: string | null; + parent: string | null; + parentId: string | null; + observedAgentName: string | null; + observedAgentInstance: string | null; + isPrincipal: boolean; + aliases: string[]; + rounds: number; + toolCalls: number; + failures: number; + lastActivity: string | null; + destinations: string[]; + actions: AgentAction[]; + identitySearchText: string; + searchText: string; + traceQuery: string; +} + +export type GraphLeaf = + | { kind: "action"; key: string; agent: AgentExecution; action: AgentAction; label: string; searchText: string } + | { kind: "folded-actions"; key: string; agent: AgentExecution; actions: AgentAction[]; label: string; searchText: string } + | { kind: "destination"; key: string; agent: AgentExecution; destination: string; label: string; searchText: string } + | { kind: "folded-destinations"; key: string; agent: AgentExecution; destinations: string[]; label: string; searchText: string }; + +export type GraphSelection = + | { kind: "agent"; key: string; agent: AgentExecution } + | { kind: "edge"; key: string; parent: AgentExecution; child: AgentExecution } + | GraphAggregate + | GraphLeaf; + +export interface AgentLayout { + agent: AgentExecution; + x: number; + y: number; + leaves: Array<GraphLeaf & { x: number; y: number }>; +} + +export interface GraphAggregate { + kind: "specialist-aggregate"; + key: string; + parent: AgentExecution; + agents: AgentExecution[]; + expanded: boolean; + label: string; + searchText: string; +} + +export interface AggregateLayout { + aggregate: GraphAggregate; + x: number; + y: number; +} From 4608531d02cea9ce63734c8699e6b2402de0f7a1 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 01:51:52 +0200 Subject: [PATCH 045/111] Enroll observer targets through the real reviewed private-scope workflow Replace the fixture's raw observationTargets patch with exact Sandbox UID and runtime Deployment review through the same public CLI. Preserve existing grant/update fences and refusal of unsupported private consumer states. No fake epochs, Ready conditions, or authority bypass; live observer qualification remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 7 ++++++ .../tests/native-qualification.test.ts | 5 ++++ bridge/tests/native-credentials/enrollment.py | 14 ++++++++++- .../native-credentials/observation_cases.py | 10 ++++---- .../native-credentials/test_enrollment.py | 24 +++++++++++++++++-- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 219d424a0..24cbff438 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -33,6 +33,13 @@ receipts/epochs, actual writer capability reviews, and continued denial of broad Secret listing. Ready conditions still come only from the controller; fixture state changes are not substituted for these live checks. +The observer native case likewise adds its exact Sandbox UID through the +operator preview/apply path with an explicit runtime Deployment review. A raw +patch to `observationTargets` is not private-scope qualification. If existing +private consumers require separate retirement or recovery, that refusal remains +visible and the observer lane stays unqualified; the fixture does not fabricate +a qualified epoch or bypass the required operator lifecycle. + Set `core.namespace` independently from the chart's `namespace`. BFF/web default workspace and provider operations use the configured core namespace; the optional Teams Secret remains in the dedicated Bridge integration namespace. diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts index e5407d13a..82d45f964 100644 --- a/bridge/teams-gateway/tests/native-qualification.test.ts +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -100,6 +100,11 @@ describe("Monorepo native prerequisite", () => { expect(continuity).toContain("scope_snapshot(setup, scopes) == before_update"); expect(continuity).toContain('"verb": "use-agent-credentials"'); expect(continuity).toContain("expected=(403,)"); + const observation = read("tests/native-credentials/observation_cases.py") + .split(" def enable(self):", 2)[1].split(" def ready(self):", 1)[0]; + expect(observation).toContain("enroll(self.setup, CORE, writer"); + expect(observation).toContain("previous=grant, observations=["); + expect(observation).not.toContain('self.setup.admin.patch(resource(CORE, "karscredentialgrants"'); }); it("qualifies actual CNI traffic and never treats API existence as enforcement", () => { diff --git a/bridge/tests/native-credentials/enrollment.py b/bridge/tests/native-credentials/enrollment.py index bd89d0912..6a65ec54b 100644 --- a/bridge/tests/native-credentials/enrollment.py +++ b/bridge/tests/native-credentials/enrollment.py @@ -8,7 +8,7 @@ CLI = ROOT / ".native/core/cli/dist/index.js" -def enroll(setup, namespace, writer, keys, *, previous=None): +def enroll(setup, namespace, writer, keys, *, previous=None, observations=()): require(CLI.is_file(), "The exact core CLI must be built before native enrollment") path = resource(namespace, "karscredentialgrants", "workspace") existing = setup.admin.optional(path) @@ -50,6 +50,16 @@ def enroll(setup, namespace, writer, keys, *, previous=None): "--private-root", CORE, "--private-controller-profile", "service-accounts", "--private-consumer", f"{BRIDGE}/Deployment/kars-bridge-bff", ] + expected_observations = list(observations) + for target in expected_observations: + require( + isinstance(target, dict) and set(target) == {"kind", "namespace", "name", "uid"} + and target["kind"] == "KarsSandbox" and target["namespace"] == namespace + and all(isinstance(target[key], str) and target[key] for key in ("name", "uid")), + "Native observation enrollment requires exact workspace Sandbox identities", + ) + args.extend(["--observe", target["name"], "--private-consumer", + f"kars-{target['name']}/Deployment/{target['name']}"]) for key in keys: args.extend(["--agent-key", key]) try: @@ -74,6 +84,7 @@ def enroll(setup, namespace, writer, keys, *, previous=None): and spec.get("workspaceUid") == uid(workspace) and spec.get("writers") == [expected_writer] and spec.get("agentKeys") == keys + and spec.get("observationTargets", []) == expected_observations and isinstance(spec.get("privateActivation"), dict) and spec.get("privateActivation", {}).get("phase") == "reviewed", "Operator preview did not bind the requested native identities and keys", @@ -90,6 +101,7 @@ def enroll(setup, namespace, writer, keys, *, previous=None): and recorded.get("workspaceUid") == uid(workspace) and recorded.get("writers") == [expected_writer] and recorded.get("agentKeys") == keys + and recorded.get("observationTargets", []) == expected_observations and isinstance(activation, dict) and activation.get("phase") == "qualified", "The recorded native grant differs from its operator review", diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py index a2e0a454e..18b293c96 100644 --- a/bridge/tests/native-credentials/observation_cases.py +++ b/bridge/tests/native-credentials/observation_cases.py @@ -10,6 +10,7 @@ from lifecycle_cases import running from credential_cases import SOURCE, selection +from enrollment import enroll from native_api import BRIDGE, CORE, WRITER, command, core, private_file, require, resource, uid, until from private_tls import call, forward from runtime_state import runtime_state @@ -106,10 +107,11 @@ def enable(self): ) command("kubectl", "apply", "-f", "-", stdin=manifest) until("BFF retains API connectivity under existing Cilium isolation", self.bff.ready, 30) - self.setup.admin.patch(resource(CORE, "karscredentialgrants", "workspace"), {"spec": { - "observationTargets": [{"kind": "KarsSandbox", "namespace": CORE, - "name": target["sandbox"], "uid": uid(value)}], - }}) + grant = self.setup.ready_grant(CORE) + writer = self.setup.admin.get(core(BRIDGE, "serviceaccounts", WRITER)) + enroll(self.setup, CORE, writer, grant["spec"]["agentKeys"], previous=grant, observations=[ + {"kind": "KarsSandbox", "namespace": CORE, "name": target["sandbox"], "uid": uid(value)}, + ]) self.ready() until("real BFF-to-observer9447 and router-to-verifier9448", lambda: self.public().get("available") is True, 240) diff --git a/bridge/tests/native-credentials/test_enrollment.py b/bridge/tests/native-credentials/test_enrollment.py index 9afc82546..55f6c430e 100644 --- a/bridge/tests/native-credentials/test_enrollment.py +++ b/bridge/tests/native-credentials/test_enrollment.py @@ -73,13 +73,14 @@ def private_file(self, name, data): self.review_files.append(path) return path - def enroll(self, *, previous=None, keys=None): + def enroll(self, *, previous=None, keys=None, observations=()): with patch.object(enrollment, "ROOT", self.root), \ patch.object(enrollment, "CLI", self.cli), \ patch.object(operator_diagnostics, "command", side_effect=self.command), \ patch.object(enrollment, "private_file", side_effect=self.private_file): return enrollment.enroll(self.setup, self.namespace, self.writer, - self.keys if keys is None else keys, previous=previous) + self.keys if keys is None else keys, previous=previous, + observations=observations) def test_uses_real_public_preview_apply_and_then_controller_readiness(self): self.assertEqual(self.enroll(), self.grant) @@ -138,6 +139,25 @@ def test_existing_update_rejects_changed_incarnations_and_non_key_authority(self self.enroll(previous=copy.deepcopy(self.grant)) self.assertEqual(self.commands, []) + def test_observation_target_and_private_consumer_are_explicitly_reviewed(self): + target = {"kind": "KarsSandbox", "namespace": self.namespace, "name": "observer", "uid": "target"} + self.review["spec"]["observationTargets"] = [target] + self.grant["spec"]["observationTargets"] = [target] + self.enroll(observations=[target]) + args = self.commands[0][0] + self.assertIn("--observe", args) + self.assertIn("observer", args) + self.assertIn("kars-observer/Deployment/observer", args) + self.commands.clear() + self.review["spec"]["observationTargets"] = [{**target, "uid": "replaced"}] + with self.assertRaises(Failure): + self.enroll(observations=[target]) + self.assertEqual(len(self.commands), 1) + self.commands.clear() + with self.assertRaises(Failure): + self.enroll(observations=[{**target, "namespace": "other"}]) + self.assertEqual(self.commands, []) + def test_operator_apply_failure_cannot_become_ready_or_a_direct_create_fallback(self): self.setup.ready_grant = Mock(return_value=self.grant) original = self.command From 25a60e763d813d9f35d4c96349208a76fbb44f51 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 02:01:14 +0200 Subject: [PATCH 046/111] Split Team detail panels without moving server data or state Preserve all page/panel function bodies and force-dynamic behavior exactly. Only presentation helpers move to a server module; no new client boundary, hooks, fetch ordering, URL or JSX changes. AST parity checked; hosted web build remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../src/app/workspace/teams/[name]/page.tsx | 269 +---------------- .../teams/[name]/team-detail-panels.tsx | 271 ++++++++++++++++++ 2 files changed, 272 insertions(+), 268 deletions(-) create mode 100644 bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx diff --git a/bridge/web/src/app/workspace/teams/[name]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/page.tsx index 74ef21029..eb09855c1 100644 --- a/bridge/web/src/app/workspace/teams/[name]/page.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/page.tsx @@ -47,7 +47,7 @@ import { JourneyRail, teamBeat } from "@/components/journey-rail"; import { analyzeTeamRun } from "@/lib/team-run-evidence"; import { TeamRunFlow } from "@/components/team-run-flow"; import { ExecutionExplorer } from "@/components/execution-explorer"; -import type { ReactNode } from "react"; +import { Access, NowHero, TeamServerTabs } from "./team-detail-panels"; import { EngineeringIntake } from "./engineering-intake"; import { TeamTiming } from "@/components/team-timing"; import { @@ -700,270 +700,3 @@ export default async function TeamDetailPage({ </div> ); } - -type TeamServerTab = { - id: string; - label: string; - badge?: number | string | null; - node: ReactNode; - live?: boolean; -}; - -function TeamServerTabs({ - tabs, - active, - basePath, -}: { - tabs: TeamServerTab[]; - active?: string; - basePath: string; -}) { - const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; - return ( - <div> - <div - role="tablist" - aria-label="Team sections" - className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" - > - {tabs.map((tab) => { - const selected = tab.id === current.id; - return ( - <Link - key={tab.id} - href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} - role="tab" - aria-selected={selected} - className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ - selected - ? "bg-signal/10 text-foreground" - : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" - }`} - > - {tab.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} - {tab.label} - {tab.badge != null && tab.badge !== 0 && ( - <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ - selected - ? "bg-signal/20 text-signal" - : "bg-surface-muted text-foreground-muted" - }`}> - {tab.badge} - </span> - )} - </Link> - ); - })} - </div> - <div role="tabpanel" className="kb-rise space-y-6"> - {current.node} - </div> - </div> - ); -} - -function NowHero({ - teamName, - health, - active, - runRunning, - runInFlight, - everyMinutes, - commonsEntries, - nextRunAt, - lastRunAt, - delivered, - generated, - latestRun, - latestOutcome, - lifecycleMode, - runtimeState, - idleDeadlineAt, -}: { - teamName: string; - health: string | null; - active: boolean; - runRunning: boolean; - runInFlight: boolean; - everyMinutes: number | null; - commonsEntries: number; - nextRunAt: string | null; - lastRunAt: string | null; - delivered: number; - generated: number; - latestRun: string | null; - latestOutcome: "paused" | "running" | "delivered" | "delivered_with_issues" | "incomplete" | "failed" | null; - lifecycleMode: TeamDetail["lifecycle_mode"]; - runtimeState: TeamDetail["runtime_state"]; - idleDeadlineAt: string | null; -}) { - const tone: Record<string, string> = { - Healthy: "border-emerald-500/30 bg-emerald-500/5", - Watching: "border-sky-500/30 bg-sky-500/5", - AwaitingReview: "border-amber-500/30 bg-amber-500/5", - Unproductive: "border-amber-500/30 bg-amber-500/5", - Stalled: "border-rose-500/30 bg-rose-500/5", - Hibernating: "border-border bg-surface-muted/40", - }; - const cls = tone[health ?? ""] ?? "border-border bg-surface"; - const headline = !active - ? "Hibernating — no runs are being generated" - : health === "AwaitingReview" - ? "Awaiting your review — no further assignment or memory promotion will proceed" - : health === "Stalled" - ? "On watch, but recent runs aren't delivering — needs a look" - : health === "Unproductive" - ? "On watch — runs are costly relative to outcomes" - : everyMinutes - ? "On watch — generating governed runs on cadence" - : "On watch — waiting for queued work or Run now"; - - const mode: { label: string; dot: string; note: string } = !active - ? { - label: "Hibernating", - dot: "bg-foreground-muted", - note: "Paused — no sandbox is running and no runs are minted until you resume.", - } - : health === "AwaitingReview" - ? { - label: "Waiting on your decision", - dot: "bg-amber-500", - note: - "The latest governed outcome is retained in Inbox. The team will not promote it to shared memory or start dependent work until you approve or deny it.", - } - : runRunning - ? { - label: "Working now", - dot: "bg-signal", - note: "A run sandbox is live and executing the charter right now.", - } - : runInFlight - ? { - label: "Starting — run in flight", - dot: "bg-amber-500", - note: - "A run has been launched and is materializing (or recovering). If it never reaches Working, check the latest run below for a materialization or gateway error — it is NOT idle.", - } - : lifecycleMode === "persistent" - ? { - label: "Online — waiting for work", - dot: "bg-sky-500", - note: - "The stable principal stays online between assignments. No assignment is active right now; the next queued task reuses this same principal and its approved memory.", - } - : lifecycleMode === "resourceOptimized" && runtimeState === "Hibernating" - ? { - label: "Hibernating — resumes on demand", - dot: "bg-foreground-muted", - note: - "The stable principal is suspended to save resources. The next queued task resumes the same principal identity with approved memory intact.", - } - : lifecycleMode === "resourceOptimized" - ? { - label: "Warm — no assignment active", - dot: "bg-sky-500", - note: - `The stable principal is retained between assignments${ - idleDeadlineAt ? ` until ${new Date(idleDeadlineAt).toLocaleTimeString()}` : "" - }, then hibernates. The next task reuses the same identity and approved memory.`, - } - : { - label: "Idle — spins up on demand", - dot: "bg-sky-500", - note: - `Ephemeral mode starts a fresh governed sandbox on the next ${everyMinutes ? "cadence tick" : "task or Run now"}. ` + - `It rehydrates ${commonsEntries} approved ${commonsEntries === 1 ? "memory" : "memories"} and tears the sandbox down after delivery.`, - }; - - return ( - <section className={`kb-rise rounded-xl border p-5 ${cls}`}> - <div className="flex flex-wrap items-center justify-between gap-3"> - <div className="min-w-0"> - <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Right now</p> - <p className="mt-0.5 text-sm font-medium">{headline}</p> - </div> - <div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm"> - <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-2.5 py-1"> - <span className={`inline-block h-2 w-2 rounded-full ${mode.dot} ${runRunning ? "animate-pulse" : ""}`} /> - <span className="text-xs font-medium">{mode.label}</span> - </span> - <span className="inline-flex items-baseline gap-1.5"> - <span className="text-xs text-foreground-muted">Delivered</span> - <span className="font-semibold tabular-nums"> - {delivered} - <span className="text-foreground-muted">/{generated}</span> - </span> - </span> - {active && nextRunAt && ( - <span className="inline-flex items-baseline gap-1.5"> - <span className="text-xs text-foreground-muted">Next tick</span> - <span className="font-medium">{new Date(nextRunAt).toLocaleTimeString()}</span> - </span> - )} - {!active && lastRunAt && ( - <span className="inline-flex items-baseline gap-1.5"> - <span className="text-xs text-foreground-muted">Last run</span> - <span className="font-medium">{new Date(lastRunAt).toLocaleDateString()}</span> - </span> - )} - </div> - </div> - <p className="mt-3 flex items-start gap-2 border-t border-border/60 pt-3 text-xs text-foreground-muted"> - <span aria-hidden>♻️</span> - <span>{mode.note}</span> - </p> - {latestRun && ( - <p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-foreground-muted"> - <span>Latest team run</span> - <Link - href={`/workspace/teams/${encodeURIComponent(teamName)}/runs/${encodeURIComponent(latestRun)}`} - className="font-mono text-signal hover:underline" - > - {latestRun} - </Link> - {latestOutcome && ( - <span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${ - latestOutcome === "delivered" - ? "bg-signal/10 text-signal" - : latestOutcome === "running" - ? "bg-sky-500/10 text-sky-600" - : latestOutcome === "paused" - ? "bg-surface-muted text-foreground-muted" - : latestOutcome === "failed" - ? "bg-danger/10 text-danger" - : "bg-warning/10 text-warning" - }`}> - {latestOutcome === "delivered_with_issues" - ? "Delivered with issues" - : latestOutcome.replace(/_/g, " ")} - </span> - )} - </p> - )} - </section> - ); -} - -function Access({ label, value, isDefault, href }: { label: string; value: string; isDefault?: boolean; href?: string }) { - return ( - <div className="rounded-lg border border-border px-3 py-2"> - <dt className="flex items-center gap-1.5 text-[11px] uppercase tracking-wide text-foreground-muted"> - {label} - {isDefault && ( - <span className="rounded-full bg-surface-muted px-1.5 text-[9px] font-medium normal-case tracking-normal text-foreground-muted" title="Inherited from the cluster — not explicitly set on this team."> - default - </span> - )} - </dt> - <dd className="mt-0.5 text-sm"> - {href ? ( - <a href={href} className="text-signal underline-offset-2 hover:underline"> - {value} - </a> - ) : ( - value - )} - </dd> - </div> - ); -} diff --git a/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx b/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx new file mode 100644 index 000000000..64f22b325 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx @@ -0,0 +1,271 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import type { TeamDetail } from "@/lib/types"; + + +type TeamServerTab = { + id: string; + label: string; + badge?: number | string | null; + node: ReactNode; + live?: boolean; +}; + +export function TeamServerTabs({ + tabs, + active, + basePath, +}: { + tabs: TeamServerTab[]; + active?: string; + basePath: string; +}) { + const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; + return ( + <div> + <div + role="tablist" + aria-label="Team sections" + className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" + > + {tabs.map((tab) => { + const selected = tab.id === current.id; + return ( + <Link + key={tab.id} + href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} + role="tab" + aria-selected={selected} + className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ + selected + ? "bg-signal/10 text-foreground" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" + }`} + > + {tab.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} + {tab.label} + {tab.badge != null && tab.badge !== 0 && ( + <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ + selected + ? "bg-signal/20 text-signal" + : "bg-surface-muted text-foreground-muted" + }`}> + {tab.badge} + </span> + )} + </Link> + ); + })} + </div> + <div role="tabpanel" className="kb-rise space-y-6"> + {current.node} + </div> + </div> + ); +} + +export function NowHero({ + teamName, + health, + active, + runRunning, + runInFlight, + everyMinutes, + commonsEntries, + nextRunAt, + lastRunAt, + delivered, + generated, + latestRun, + latestOutcome, + lifecycleMode, + runtimeState, + idleDeadlineAt, +}: { + teamName: string; + health: string | null; + active: boolean; + runRunning: boolean; + runInFlight: boolean; + everyMinutes: number | null; + commonsEntries: number; + nextRunAt: string | null; + lastRunAt: string | null; + delivered: number; + generated: number; + latestRun: string | null; + latestOutcome: "paused" | "running" | "delivered" | "delivered_with_issues" | "incomplete" | "failed" | null; + lifecycleMode: TeamDetail["lifecycle_mode"]; + runtimeState: TeamDetail["runtime_state"]; + idleDeadlineAt: string | null; +}) { + const tone: Record<string, string> = { + Healthy: "border-emerald-500/30 bg-emerald-500/5", + Watching: "border-sky-500/30 bg-sky-500/5", + AwaitingReview: "border-amber-500/30 bg-amber-500/5", + Unproductive: "border-amber-500/30 bg-amber-500/5", + Stalled: "border-rose-500/30 bg-rose-500/5", + Hibernating: "border-border bg-surface-muted/40", + }; + const cls = tone[health ?? ""] ?? "border-border bg-surface"; + const headline = !active + ? "Hibernating — no runs are being generated" + : health === "AwaitingReview" + ? "Awaiting your review — no further assignment or memory promotion will proceed" + : health === "Stalled" + ? "On watch, but recent runs aren't delivering — needs a look" + : health === "Unproductive" + ? "On watch — runs are costly relative to outcomes" + : everyMinutes + ? "On watch — generating governed runs on cadence" + : "On watch — waiting for queued work or Run now"; + + const mode: { label: string; dot: string; note: string } = !active + ? { + label: "Hibernating", + dot: "bg-foreground-muted", + note: "Paused — no sandbox is running and no runs are minted until you resume.", + } + : health === "AwaitingReview" + ? { + label: "Waiting on your decision", + dot: "bg-amber-500", + note: + "The latest governed outcome is retained in Inbox. The team will not promote it to shared memory or start dependent work until you approve or deny it.", + } + : runRunning + ? { + label: "Working now", + dot: "bg-signal", + note: "A run sandbox is live and executing the charter right now.", + } + : runInFlight + ? { + label: "Starting — run in flight", + dot: "bg-amber-500", + note: + "A run has been launched and is materializing (or recovering). If it never reaches Working, check the latest run below for a materialization or gateway error — it is NOT idle.", + } + : lifecycleMode === "persistent" + ? { + label: "Online — waiting for work", + dot: "bg-sky-500", + note: + "The stable principal stays online between assignments. No assignment is active right now; the next queued task reuses this same principal and its approved memory.", + } + : lifecycleMode === "resourceOptimized" && runtimeState === "Hibernating" + ? { + label: "Hibernating — resumes on demand", + dot: "bg-foreground-muted", + note: + "The stable principal is suspended to save resources. The next queued task resumes the same principal identity with approved memory intact.", + } + : lifecycleMode === "resourceOptimized" + ? { + label: "Warm — no assignment active", + dot: "bg-sky-500", + note: + `The stable principal is retained between assignments${ + idleDeadlineAt ? ` until ${new Date(idleDeadlineAt).toLocaleTimeString()}` : "" + }, then hibernates. The next task reuses the same identity and approved memory.`, + } + : { + label: "Idle — spins up on demand", + dot: "bg-sky-500", + note: + `Ephemeral mode starts a fresh governed sandbox on the next ${everyMinutes ? "cadence tick" : "task or Run now"}. ` + + `It rehydrates ${commonsEntries} approved ${commonsEntries === 1 ? "memory" : "memories"} and tears the sandbox down after delivery.`, + }; + + return ( + <section className={`kb-rise rounded-xl border p-5 ${cls}`}> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="min-w-0"> + <p className="text-[11px] uppercase tracking-wide text-foreground-muted">Right now</p> + <p className="mt-0.5 text-sm font-medium">{headline}</p> + </div> + <div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm"> + <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-2.5 py-1"> + <span className={`inline-block h-2 w-2 rounded-full ${mode.dot} ${runRunning ? "animate-pulse" : ""}`} /> + <span className="text-xs font-medium">{mode.label}</span> + </span> + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">Delivered</span> + <span className="font-semibold tabular-nums"> + {delivered} + <span className="text-foreground-muted">/{generated}</span> + </span> + </span> + {active && nextRunAt && ( + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">Next tick</span> + <span className="font-medium">{new Date(nextRunAt).toLocaleTimeString()}</span> + </span> + )} + {!active && lastRunAt && ( + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">Last run</span> + <span className="font-medium">{new Date(lastRunAt).toLocaleDateString()}</span> + </span> + )} + </div> + </div> + <p className="mt-3 flex items-start gap-2 border-t border-border/60 pt-3 text-xs text-foreground-muted"> + <span aria-hidden>♻️</span> + <span>{mode.note}</span> + </p> + {latestRun && ( + <p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-foreground-muted"> + <span>Latest team run</span> + <Link + href={`/workspace/teams/${encodeURIComponent(teamName)}/runs/${encodeURIComponent(latestRun)}`} + className="font-mono text-signal hover:underline" + > + {latestRun} + </Link> + {latestOutcome && ( + <span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${ + latestOutcome === "delivered" + ? "bg-signal/10 text-signal" + : latestOutcome === "running" + ? "bg-sky-500/10 text-sky-600" + : latestOutcome === "paused" + ? "bg-surface-muted text-foreground-muted" + : latestOutcome === "failed" + ? "bg-danger/10 text-danger" + : "bg-warning/10 text-warning" + }`}> + {latestOutcome === "delivered_with_issues" + ? "Delivered with issues" + : latestOutcome.replace(/_/g, " ")} + </span> + )} + </p> + )} + </section> + ); +} + +export function Access({ label, value, isDefault, href }: { label: string; value: string; isDefault?: boolean; href?: string }) { + return ( + <div className="rounded-lg border border-border px-3 py-2"> + <dt className="flex items-center gap-1.5 text-[11px] uppercase tracking-wide text-foreground-muted"> + {label} + {isDefault && ( + <span className="rounded-full bg-surface-muted px-1.5 text-[9px] font-medium normal-case tracking-normal text-foreground-muted" title="Inherited from the cluster — not explicitly set on this team."> + default + </span> + )} + </dt> + <dd className="mt-0.5 text-sm"> + {href ? ( + <a href={href} className="text-signal underline-offset-2 hover:underline"> + {value} + </a> + ) : ( + value + )} + </dd> + </div> + ); +} From a264ef2d49f038fb654eb451109a3483672b4bed Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 02:30:41 +0200 Subject: [PATCH 047/111] Split intake presentation without introducing a new state boundary Keep the existing form in a hook-free ordinary renderer, not a new component boundary. Preserve60inputs,42hooks,8root handlers, payload/server-action/public-export and JSX/literal semantics; boundedmodules<=696lines. ExactTS parser/symbol and35purehelper/payload comparisons passed; independent review and fullframework execution remain required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../web/src/app/workspace/new/intake-flow.tsx | 864 ++---------------- .../workspace/new/intake-flow/controls.tsx | 166 ++++ .../app/workspace/new/intake-flow/helpers.ts | 11 + .../workspace/new/intake-flow/review-types.ts | 76 ++ .../app/workspace/new/intake-flow/review.tsx | 696 ++++++++++++++ 5 files changed, 1013 insertions(+), 800 deletions(-) create mode 100644 bridge/web/src/app/workspace/new/intake-flow/controls.tsx create mode 100644 bridge/web/src/app/workspace/new/intake-flow/helpers.ts create mode 100644 bridge/web/src/app/workspace/new/intake-flow/review-types.ts create mode 100644 bridge/web/src/app/workspace/new/intake-flow/review.tsx diff --git a/bridge/web/src/app/workspace/new/intake-flow.tsx b/bridge/web/src/app/workspace/new/intake-flow.tsx index cbbf54e00..0f66a0f9e 100644 --- a/bridge/web/src/app/workspace/new/intake-flow.tsx +++ b/bridge/web/src/app/workspace/new/intake-flow.tsx @@ -13,16 +13,11 @@ // KarsSandbox. import { useEffect, useMemo, useRef, useState } from "react"; -import { useFormStatus } from "react-dom"; import { useActionState } from "react"; -import { SegmentedTier } from "@/components/segmented-tier"; import { OrchestrationCube } from "@/components/orchestration-cube"; import { Icon } from "@/components/icon"; import { JourneyRail } from "@/components/journey-rail"; -import { humanizeMcp } from "@/lib/format"; -import { EnvelopeReveal } from "./envelope-reveal"; import { LoopDesigner } from "@/components/loop-designer"; -import { RepoAccess } from "@/components/repo-access"; import { createMissionAction, validateMissionAction, @@ -39,13 +34,8 @@ import type { ValidationResult, } from "@/lib/types"; -const TIER_CONSEQUENCE: Record<number, string> = { - 1: "Manual — the mission proposes every step and does nothing on its own. You perform each action.", - 2: "Shared — the mission acts only on low-risk steps; everything else waits for your approval.", - 3: "Conditional — the mission acts on its own but pauses for your approval before anything that costs money, touches external systems, or can't be undone.", - 4: "Supervised — the mission runs autonomously with periodic checkpoints you sign off on.", - 5: "Full — the mission runs autonomously within its budget and time limit; you review the result.", -}; +import { modelKey } from "./intake-flow/helpers"; +import { renderReview } from "./intake-flow/review"; const EXAMPLES = [ "Audit our README for outdated install steps and propose fixes.", @@ -53,18 +43,6 @@ const EXAMPLES = [ "Summarize this contract's risk and obligations.", ]; -function modelKey(provider: string, deployment: string) { - return `${provider}::${deployment}`; -} - -function moveFallback(routes: string[], index: number, delta: number): string[] { - const next = index + delta; - if (next < 0 || next >= routes.length) return routes; - const copy = [...routes]; - [copy[index], copy[next]] = [copy[next], copy[index]]; - return copy; -} - export function IntakeFlow({ options, efficiency, initialObjective }: { options: Options; efficiency?: Efficiency | null; initialObjective?: string }) { const [state, formAction] = useActionState<IntakeState, FormData>( createMissionAction, @@ -557,780 +535,66 @@ export function IntakeFlow({ options, efficiency, initialObjective }: { options: } // Step 2 — the editable package + hard launch gate. - return ( - <form action={formAction} className="space-y-5"> - <input type="hidden" name="objective" value={objective} /> - <input type="hidden" name="tier" value={tier} /> - <input type="hidden" name="budget_tokens" value={budgetTokens} /> - <input type="hidden" name="launch" value={launch ? "on" : "off"} /> - <input type="hidden" name="blueprint_json" value={JSON.stringify(blueprint)} /> - <input type="hidden" name="delegation_json" value={JSON.stringify(delegation)} /> - - <JourneyRail current={launch ? "launch" : "review"} /> - - {rationale !== null || proposed ? ( - <EnvelopeReveal - blueprint={blueprint} - tier={tier} - budgetTokens={budgetTokens} - rationale={rationale} - source={composeSource} - recommended={recommendedRoute} - modelBasis={modelBasis} - delegation={delegation} - /> - ) : null} - {composeNote && ( - <div className="rounded-xl border border-border bg-surface-muted/50 p-4 text-sm text-foreground-muted"> - {composeNote} - </div> - )} - - <PackageSection title="Objective" subtitle="Restate it clearly — edit if Bridge misread you."> - <textarea - value={objective} - onChange={(e) => setObjective(e.target.value)} - rows={3} - className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - /> - </PackageSection> - - <details open className="group rounded-xl border border-border bg-surface-muted/20 [&_summary::-webkit-details-marker]:hidden"> - <summary className="flex cursor-pointer items-center justify-between gap-3 px-5 py-4 text-sm"> - <span className="min-w-0"> - <span className="font-medium">Composed package — review & edit</span> - <span className="ml-2 text-xs text-foreground-muted"> - Model & harness, instructions, tools, network, isolation, memory — composed for you. Every field is editable; collapse if you just want the defaults. - </span> - </span> - <span aria-hidden className="shrink-0 text-foreground-muted transition-transform group-open:rotate-90">▸</span> - </summary> - <div className="space-y-5 border-t border-border p-4"> - - <PackageSection - title="Model & harness" - subtitle="What the mission reasons with, and the agent runtime it runs on." - > - {options.provider && ( - <div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-surface-muted/40 px-3 py-2.5"> - <Icon name="link" size={16} /> - <div className="min-w-0"> - <p className="text-xs font-medium"> - This cluster serves models via{" "} - <span className="text-foreground">{options.provider.label}</span> - <span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-foreground-muted"> - inherited - </span> - </p> - <p className="mt-0.5 text-[11px] text-foreground-muted">{options.provider.note}</p> - </div> - </div> - )} - <div className="grid gap-4 sm:grid-cols-2"> - <div> - <label className="text-xs font-medium text-foreground-muted">Model</label> - {options.models.length === 0 ? ( - <p className="mt-1.5 rounded-lg bg-surface-muted px-3 py-2 text-xs text-foreground-muted"> - No models are listed for this cluster — the mission will use the configured default - {options.default_model ? ` (${options.default_model})` : ""}. - </p> - ) : ( - <select - value={model} - onChange={(event) => { - const route = event.target.value; - setModel(route); - setModelFallbacks((current) => current.filter((fallback) => fallback !== route)); - }} - className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - > - {options.models.map((m) => ( - <option key={modelKey(m.provider, m.deployment)} value={modelKey(m.provider, m.deployment)}> - {m.deployment} - {m.is_default ? " (default)" : ""} - </option> - ))} - </select> - )} - <p className="mt-1 text-[11px] text-foreground-muted"> - A default is pre-selected for the objective; switch to any model - {options.provider ? ` ${options.provider.label}` : " your cluster"} serves. - </p> - <label className="mt-3 block text-xs text-foreground-muted"> - Qualified fallback routes - <select - multiple - value={modelFallbacks} - onChange={(event) => { - const selected = new Set( - Array.from(event.currentTarget.selectedOptions, (option) => option.value), - ); - setModelFallbacks((current) => [ - ...current.filter((route) => selected.has(route)), - ...Array.from(selected).filter((route) => !current.includes(route)), - ].slice(0, 8)); - }} - className="mt-1.5 min-h-24 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" - > - {options.models - .map((option) => modelKey(option.provider, option.deployment)) - .filter((route) => route !== model) - .map((route) => ( - <option key={route} value={route}> - {route} - </option> - ))} - </select> - {modelFallbacks.map((route, index) => ( - <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> - <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> - <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setModelFallbacks((current) => moveFallback(current, index, -1))}>↑</button> - <button type="button" aria-label={`Move ${route} later`} disabled={index === modelFallbacks.length - 1} onClick={() => setModelFallbacks((current) => moveFallback(current, index, 1))}>↓</button> - </span> - ))} - <span className="mt-1 block text-[11px]"> - Preflight rejects any fallback that lacks atomic evidence for this exact package and its selected resources. - </span> - </label> - {recommendedModel && ( - <div className="mt-2 flex items-start gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-2"> - <Icon name="lightbulb" size={14} /> - <div className="min-w-0 text-[11px]"> - <p className="font-medium text-foreground"> - {efficiency?.recommended_low_confidence - ? "Insufficient evidence for automatic recommendation" - : "Recommended by the efficiency frontier"} - </p> - <p className="mt-0.5 text-foreground-muted"> - {recommendedModel.deployment} - {recommendedStats - ? ` — ${Math.round(recommendedStats.acceptance_rate * 100)}% accepted across ${recommendedStats.runs} run${recommendedStats.runs === 1 ? "" : "s"}, ${recommendedStats.tokens_per_outcome.toLocaleString()} tokens/outcome` - : " — learned from completed runs on this cluster"} - {efficiency?.recommended_low_confidence - ? " — not selected automatically" - : ""} - </p> - </div> - </div> - )} - </div> - <div> - <label className="text-xs font-medium text-foreground-muted">Harness</label> - <select - value={runtime} - onChange={(e) => changeRuntime(e.target.value)} - className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - > - {(() => { - const rts = options.runtimes.length - ? options.runtimes - : [{ kind: "OpenClaw", label: "OpenClaw", wired: true, status: "ready" as const, note: "" }]; - const ready = rts.filter((r) => r.status === "ready"); - const needsImage = rts.filter((r) => r.status === "needs_image"); - const unavailable = rts.filter((r) => r.status === "unavailable"); - const opt = (r: (typeof rts)[number]) => ( - <option key={r.kind} value={r.kind} disabled={!r.wired}> - {r.label} - {r.status === "needs_image" ? " — image not configured here" : r.status === "unavailable" ? " — not available" : ""} - </option> - ); - return ( - <> - {ready.length > 0 && <optgroup label="Ready on this cluster">{ready.map(opt)}</optgroup>} - {needsImage.length > 0 && <optgroup label="Supported — needs runtime image">{needsImage.map(opt)}</optgroup>} - {unavailable.length > 0 && <optgroup label="Not available yet">{unavailable.map(opt)}</optgroup>} - </> - ); - })()} - </select> - <p className="mt-1 text-[11px] text-foreground-muted"> - {options.runtimes.filter((r) => r.wired).length} harness{options.runtimes.filter((r) => r.wired).length === 1 ? "" : "es"} can run on this cluster right now. Others are supported by the runtime but need their image configured by an operator. Team members can each use a different ready harness. - </p> - </div> - </div> - </PackageSection> - - <PackageSection - title="Instructions" - subtitle="The mission's system prompt — how it should behave, in addition to the objective." - > - <textarea - value={instructions} - onChange={(e) => setInstructions(e.target.value)} - rows={4} - placeholder="e.g. Be meticulous. Verify every claim against a primary source and cite file paths. Never change code without a passing test." - className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm leading-relaxed placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - /> - </PackageSection> - - <PackageSection - title="Execution plan" - subtitle="Roles, dependencies, phases, capabilities, tool-call bounds, synthesis, and deliverables. This is typed and runtime-neutral." - > - {executionPlanDraft ? ( - <> - <textarea - value={executionPlanDraft} - onChange={(event) => { - const next = event.target.value; - setExecutionPlanDraft(next); - try { - const parsed = JSON.parse(next) as import("@/lib/types").ExecutionPlan; - setExecutionPlan(parsed); - setExecutionPlanError(null); - } catch { - setExecutionPlanError("The execution plan must be valid JSON before validation or launch."); - } - }} - rows={18} - spellCheck={false} - className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - /> - {executionPlanError && ( - <p className="mt-2 text-xs text-danger">{executionPlanError}</p> - )} - </> - ) : ( - <p className="text-xs text-foreground-muted"> - Single-agent execution — no worker plan was proposed. Recompose the mission to request decomposition. - </p> - )} - </PackageSection> - - {loopDirective.trim() && ( - <PackageSection - title="Loop — operating contract" - subtitle="The feedback loop the harness runs and any sub-agents inherit. Composed from the loop you reviewed." - > - <div className="flex items-center justify-between gap-3"> - <p className="text-xs text-foreground-muted"> - This loop is part of the launched package (folded into the agent’s instructions). - </p> - {cameViaLoopReview && ( - <button - type="button" - onClick={goBackToLoopReview} - className="shrink-0 rounded-lg border border-accent/40 bg-accent/[0.06] px-3 py-1.5 text-xs font-medium text-accent transition hover:bg-accent/10" - > - Adjust loop → - </button> - )} - </div> - <pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface p-3 font-mono text-[11px] leading-relaxed text-foreground"> - {loopDirective.trim()} - </pre> - </PackageSection> - )} - - <PackageSection - title="Tools & connected services" - subtitle="The tool policy that bounds what it may call, and the MCP services it may use." - > - <label className="text-xs font-medium text-foreground-muted">Tool policy</label> - <select - value={toolPolicy} - onChange={(e) => setToolPolicy(e.target.value)} - className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - > - <option value="">None — model only (no governed tools)</option> - {options.tool_policies.map((t) => ( - <option key={t.name} value={t.name}> - {t.name} - {t.summary ? ` · ${t.summary}` : ""} - </option> - ))} - </select> - - <div className="mt-4"> - <label className="text-xs font-medium text-foreground-muted">Connected services (MCP)</label> - {options.mcp_profiles.length > 0 && ( - <div className="mt-1.5 flex flex-wrap items-center gap-1.5"> - <span className="text-[11px] text-foreground-muted">Vetted bundles:</span> - {options.mcp_profiles.map((prof) => { - const active = prof.servers.length > 0 && prof.servers.every((s) => mcp.includes(s)); - return ( - <button - key={prof.name} - type="button" - title={prof.summary ?? `${prof.servers.length} server(s): ${prof.servers.join(", ")}`} - onClick={() => - setMcp((cur) => - active - ? cur.filter((x) => !prof.servers.includes(x)) - : [...new Set([...cur, ...prof.servers])], - ) - } - className={`rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${active ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground"}`} - > - {active ? "✓ " : "+ "}{prof.name} - </button> - ); - })} - </div> - )} - {options.mcp_servers.length === 0 ? ( - <p className="mt-1.5 text-xs text-foreground-muted"> - No services are connected. Connect MCP servers in the Operator Console to give the - mission more tools. - </p> - ) : ( - <ul className="mt-1.5 space-y-1.5"> - {options.mcp_servers.map((m) => { - const checked = mcp.includes(m.name); - return ( - <li key={m.name}> - <label className="flex items-center gap-2.5 text-sm"> - <input - type="checkbox" - checked={checked} - onChange={(e) => - setMcp((cur) => - e.target.checked ? [...cur, m.name] : cur.filter((x) => x !== m.name), - ) - } - className="h-4 w-4 accent-[var(--signal)]" - /> - <span className="font-medium">{humanizeMcp(m.name)}</span> - {m.summary && <span className="text-xs text-foreground-muted">{m.summary}</span>} - </label> - </li> - ); - })} - </ul> - )} - {mcpNeedsPolicy && ( - <p role="alert" className="mt-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning"> - Connected services need a tool policy to bound them. Select a tool policy above, or - clear the services. - </p> - )} - </div> - <div className="mt-4"> - <label className="text-xs font-medium text-foreground-muted">Approved skills</label> - {options.skills.length === 0 ? ( - <p className="mt-1.5 text-xs text-foreground-muted">No approved skills are available.</p> - ) : ( - <ul className="mt-1.5 space-y-1.5"> - {options.skills.map((skill) => ( - <li key={skill.name}> - <label className="flex items-center gap-2.5 text-sm"> - <input - type="checkbox" - checked={skills.includes(skill.name)} - onChange={(e) => - setSkills((current) => - e.target.checked - ? [...current, skill.name] - : current.filter((name) => name !== skill.name), - ) - } - className="h-4 w-4 accent-[var(--signal)]" - /> - <span className="font-medium">{skill.name}</span> - {skill.summary && ( - <span className="text-xs text-foreground-muted">{skill.summary}</span> - )} - </label> - </li> - ))} - </ul> - )} - </div> - </PackageSection> - - <PackageSection - title="Network egress" - subtitle="Exactly which external hosts the mission may reach. Empty means no extra egress beyond the model path." - > - <div className="mb-3 inline-flex rounded-lg border border-border bg-surface p-1 text-xs"> - <button - type="button" - onClick={() => setEgressMode("strict")} - className={`rounded-md px-3 py-1.5 font-medium transition ${egressMode === "strict" ? "bg-signal text-signal-fg" : "text-foreground-muted hover:text-foreground"}`} - > - Strict - </button> - <button - type="button" - onClick={() => setEgressMode("learning")} - className={`rounded-md px-3 py-1.5 font-medium transition ${egressMode === "learning" ? "bg-accent text-accent-fg" : "text-foreground-muted hover:text-foreground"}`} - > - Learning - </button> - </div> - <p className="mb-3 text-xs text-foreground-muted"> - {egressMode === "strict" - ? "Only the hosts below are reachable from the first run — everything else is denied. The safe default." - : "The mission starts in Learn mode: it observes which hosts the agent actually reaches (nothing is blocked yet), so you can review and enforce the learned set afterward. Use for exploratory work when the host set isn't known up front."} - </p> - <div className={egressMode === "learning" ? "opacity-50" : ""}> - <EgressEditor egress={egress} onChange={setEgress} /> - </div> - </PackageSection> - - <PackageSection title="Isolation" subtitle="The sandbox hardening the mission runs inside."> - <div className="space-y-1.5"> - {(options.isolation.length - ? options.isolation - : [{ value: "standard", label: "Standard", note: "" }] - ).map((iso) => ( - <label key={iso.value} className="flex items-start gap-2.5 text-sm"> - <input - type="radio" - name="isolation_radio" - checked={isolation === iso.value} - onChange={() => setIsolation(iso.value)} - className="mt-0.5 h-4 w-4 accent-[var(--signal)]" - /> - <span> - <span className="font-medium">{iso.label}</span> - {iso.note && <span className="ml-1.5 text-xs text-foreground-muted">{iso.note}</span>} - </span> - </label> - ))} - </div> - </PackageSection> - - {options.memories.length > 0 && ( - <PackageSection - title="Shared memory" - subtitle="A shared knowledge store the mission reads and writes (optional)." - > - <select - value={memory} - onChange={(e) => setMemory(e.target.value)} - aria-label="Shared memory store" - className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - > - <option value="">None — this mission keeps its own context</option> - {options.memories.map((m) => ( - <option key={m.name} value={m.name}> - {m.name} - {m.summary ? ` · ${m.summary}` : ""} - </option> - ))} - </select> - </PackageSection> - )} - </div> - </details> - - <PackageSection title="Autonomy" subtitle="How much the mission may do on its own."> - <SegmentedTier name="tier_display" value={tier} onChange={setTier} /> - <p className="mt-3 rounded-lg bg-surface-muted px-3 py-2 text-xs text-foreground-muted"> - {TIER_CONSEQUENCE[tier]} - {" "}Delegated sub-roles may hold at most <span className="font-medium text-foreground">Tier {ceiling}</span> — one below the mission. - </p> - </PackageSection> - - <PackageSection title="Budget" subtitle="An optional token ceiling for the whole mission."> - <div className="flex items-center gap-2"> - <input - type="number" - min={0} - value={budgetTokens} - onChange={(e) => setBudgetTokens(e.target.value)} - placeholder="e.g. 200000" - className="w-48 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - /> - <span className="text-xs text-foreground-muted">tokens — leave blank for no cap</span> - </div> - </PackageSection> - - <PackageSection title="Governance envelope" subtitle="The hard limits this mission runs under."> - <ul className="space-y-1 text-sm text-foreground-muted"> - <li>• Acts at <span className="font-medium text-foreground">Tier {tier}</span> autonomy.</li> - <li>• Delegated sub-roles can hold at most <span className="font-medium text-foreground">Tier {ceiling}</span> — never more than the mission.</li> - {tier <= 3 && ( - <li>• Pauses for your approval before any priced, external, or irreversible action.</li> - )} - <li>• Reaches only the {egress.length === 0 ? "model path" : `${egress.length} host${egress.length === 1 ? "" : "s"} you allowed`}; all other egress is denied at the sandbox boundary.</li> - <li>• Every decision and steer is recorded in a signed Governance Receipt.</li> - </ul> - </PackageSection> - - {/* Pre-flight validation (§20) — prove launch-ready before anything runs. */} - <section className="rounded-2xl border border-border bg-surface p-5 shadow-sm"> - <div className="flex items-start justify-between gap-3"> - <div> - <h2 className="text-sm font-semibold">Pre-flight check</h2> - <p className="mt-0.5 text-xs text-foreground-muted"> - Validate the package against the live cluster before anything runs — tools, services, - memory, model, and network are checked. - </p> - </div> - <button - type="button" - onClick={runValidation} - disabled={validating || mcpNeedsPolicy || executionPlanError !== null} - className="shrink-0 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted disabled:opacity-50" - > - {validating ? "Checking…" : "Validate package"} - </button> - </div> - {/* Validation animates the same orchestration cube with a live feed of - what's being checked against the live cluster — so validate feels as - alive as compose, and the Execute button only appears once green. */} - {validating && ( - <div className="mt-4"> - <OrchestrationCube - title="Validating against the live cluster" - done={false} - active={0} - phases={[ - { icon: "brain", label: "Resolving the model on the cluster", detail: model ? model.split("::")[1] ?? model : "controller default" }, - { icon: "wrench", label: "Checking tool policy + connected services", detail: `${mcp.length} service${mcp.length === 1 ? "" : "s"}${toolPolicy ? ` · ${toolPolicy}` : ""}` }, - { icon: "globe", label: "Verifying egress reachability", detail: egress.length ? egress.map((e) => e.host).slice(0, 3).join(", ") : "model path only" }, - { icon: "shield", label: "Proving the envelope is launch-ready", detail: `Tier ${tier} · capability + budget checks` }, - ]} - /> - </div> - )} - {/* Loud, honest feedback in every branch — never a dead button. */} - {mcpNeedsPolicy && ( - <p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> - Connected services (MCP) require a tool policy to bound them. Pick a tool policy above, - then validate. - </p> - )} - {validationError && ( - <div className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger"> - <span className="font-semibold">Pre-flight could not complete.</span> {validationError} - </div> - )} - {validation && ( - <> - {!validationFresh && ( - <p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> - You edited the package since this ran — these results are stale. Re-validate to launch. - </p> - )} - <ul className="mt-3 space-y-1.5"> - {validation.checks.map((c) => ( - <li key={c.id} className="flex items-start gap-2 text-sm"> - <CheckMark status={c.status} /> - <span> - <span className="font-medium">{c.label}</span> - <span className="ml-1.5 text-xs text-foreground-muted">{c.detail}</span> - </span> - </li> - ))} - </ul> - {validationFresh && !validation.ok && ( - <p className="mt-2 text-xs font-medium text-danger"> - Fix the failing checks above before launching. - </p> - )} - </> - )} - </section> - - <RepoAccess /> - - <label className="flex items-center gap-2.5 rounded-lg border border-border bg-surface px-4 py-3 text-sm"> - <input - type="checkbox" - checked={launch} - onChange={(e) => setLaunch(e.target.checked)} - className="h-4 w-4 accent-[var(--signal)]" - /> - <span> - Launch immediately after creating.{" "} - <span className="text-foreground-muted"> - Leave unchecked to create a governed draft you launch when ready. - </span> - </span> - </label> - - {state.error && ( - <p role="alert" className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger"> - {state.error} - </p> - )} - - <div className="flex items-center justify-between rounded-xl border border-border bg-surface-muted/50 px-4 py-3"> - <p className="text-xs font-medium text-foreground-muted">Nothing has started yet.</p> - <div className="flex items-center gap-3"> - <button - type="button" - onClick={goBack} - className="rounded-lg px-3 py-2 text-sm text-foreground-muted hover:text-foreground" - > - {cameViaLoopReview ? "← Back to loop" : "← Back"} - </button> - <CreateButton - disabled={ - mcpNeedsPolicy - || executionPlanError !== null - || (launch && !(validationFresh && validation!.ok)) - } - launch={launch} - needsValidation={launch && !(validationFresh && validation?.ok === true)} - /> - </div> - </div> - </form> - ); -} - -function CheckMark({ status }: { status: "pass" | "fail" | "warn" }) { - const map = { - pass: { c: "text-ok", s: "✓" }, - warn: { c: "text-warning", s: "!" }, - fail: { c: "text-danger", s: "✕" }, - } as const; - const m = map[status]; - return ( - <span className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-bold ${m.c}`} aria-hidden> - {m.s} - </span> - ); -} - -function CreateButton({ - disabled, - launch, - needsValidation, -}: { - disabled: boolean; - launch: boolean; - needsValidation: boolean; -}) { - const { pending } = useFormStatus(); - const label = pending - ? "Creating…" - : needsValidation - ? "Validate to launch" - : launch - ? "Create & launch" - : "Create draft"; - return ( - <button - type="submit" - disabled={pending || disabled} - className="rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal disabled:opacity-50" - > - {label} - </button> - ); -} - -function EgressEditor({ - egress, - onChange, -}: { - egress: BlueprintEgress[]; - onChange: (e: BlueprintEgress[]) => void; -}) { - const [host, setHost] = useState(""); - const [port, setPort] = useState("443"); - const [err, setErr] = useState<string | null>(null); - - // A permissive hostname / IPv4 check — rejects schemes, paths, spaces, and - // obvious junk so a bad allowlist entry can't silently reach the controller. - const HOST_RE = /^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^(?:\d{1,3}\.){3}\d{1,3}$|^localhost$/; - - function add() { - setErr(null); - const h = host.trim().toLowerCase(); - if (!h) return; - if (h.includes("/") || h.includes(":") || h.includes(" ")) { - setErr("Enter a bare hostname (no scheme, port, or path) — set the port separately."); - return; - } - if (!HOST_RE.test(h)) { - setErr("That doesn't look like a valid hostname or IP."); - return; - } - let p: number | null = null; - if (port.trim() !== "") { - const n = Number(port); - if (!Number.isInteger(n) || n < 1 || n > 65535) { - setErr("Port must be a whole number between 1 and 65535."); - return; - } - p = n; - } - if (egress.some((e) => e.host === h && e.port === p)) { - setErr("That host:port is already in the allowlist."); - return; - } - onChange([...egress, { host: h, port: p }]); - setHost(""); - setPort("443"); - } - - return ( - <div className="space-y-2"> - {egress.length > 0 && ( - <ul className="space-y-1.5"> - {egress.map((e, i) => ( - <li - key={`${e.host}:${e.port ?? ""}:${i}`} - className="flex items-center justify-between rounded-lg bg-surface-muted px-3 py-1.5 text-sm" - > - <span className="font-mono text-xs"> - {e.host} - {e.port ? `:${e.port}` : ""} - </span> - <button - type="button" - onClick={() => onChange(egress.filter((_, j) => j !== i))} - className="text-xs text-foreground-muted hover:text-danger" - > - Remove - </button> - </li> - ))} - </ul> - )} - <div className="flex items-center gap-2"> - <input - value={host} - onChange={(e) => setHost(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - add(); - } - }} - placeholder="host, e.g. api.github.com" - className="flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - /> - <input - value={port} - onChange={(e) => setPort(e.target.value)} - placeholder="443" - inputMode="numeric" - className="w-20 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" - /> - <button - type="button" - onClick={add} - className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition hover:bg-surface-muted" - > - Add - </button> - </div> - {err && <p className="text-xs text-danger">{err}</p>} - </div> - ); -} - -function PackageSection({ - title, - subtitle, - children, -}: { - title: string; - subtitle: string; - children: React.ReactNode; -}) { - return ( - <section className="rounded-2xl border border-border bg-surface p-5 shadow-sm"> - <h2 className="text-sm font-semibold">{title}</h2> - <p className="mt-0.5 text-xs text-foreground-muted">{subtitle}</p> - <div className="mt-3">{children}</div> - </section> - ); + return renderReview({ + formAction, + objective, + tier, + budgetTokens, + launch, + blueprint, + delegation, + rationale, + proposed, + composeSource, + recommendedRoute, + modelBasis, + composeNote, + setObjective, + options, + model, + setModel, + setModelFallbacks, + modelFallbacks, + recommendedModel, + efficiency, + recommendedStats, + runtime, + changeRuntime, + instructions, + setInstructions, + executionPlanDraft, + setExecutionPlanDraft, + setExecutionPlan, + setExecutionPlanError, + executionPlanError, + loopDirective, + cameViaLoopReview, + goBackToLoopReview, + toolPolicy, + setToolPolicy, + mcp, + setMcp, + mcpNeedsPolicy, + skills, + setSkills, + setEgressMode, + egressMode, + egress, + setEgress, + isolation, + setIsolation, + memory, + setMemory, + setTier, + ceiling, + setBudgetTokens, + runValidation, + validating, + validationError, + validation, + validationFresh, + setLaunch, + state, + goBack, + }); } diff --git a/bridge/web/src/app/workspace/new/intake-flow/controls.tsx b/bridge/web/src/app/workspace/new/intake-flow/controls.tsx new file mode 100644 index 000000000..83922737e --- /dev/null +++ b/bridge/web/src/app/workspace/new/intake-flow/controls.tsx @@ -0,0 +1,166 @@ +import type * as React from "react"; +import { useState } from "react"; +import { useFormStatus } from "react-dom"; +import type { BlueprintEgress } from "@/lib/types"; + +export function CheckMark({ status }: { status: "pass" | "fail" | "warn" }) { + const map = { + pass: { c: "text-ok", s: "✓" }, + warn: { c: "text-warning", s: "!" }, + fail: { c: "text-danger", s: "✕" }, + } as const; + const m = map[status]; + return ( + <span className={`mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-bold ${m.c}`} aria-hidden> + {m.s} + </span> + ); +} + +export function CreateButton({ + disabled, + launch, + needsValidation, +}: { + disabled: boolean; + launch: boolean; + needsValidation: boolean; +}) { + const { pending } = useFormStatus(); + const label = pending + ? "Creating…" + : needsValidation + ? "Validate to launch" + : launch + ? "Create & launch" + : "Create draft"; + return ( + <button + type="submit" + disabled={pending || disabled} + className="rounded-lg bg-signal px-5 py-2.5 text-sm font-semibold text-signal-fg shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal disabled:opacity-50" + > + {label} + </button> + ); +} + +export function EgressEditor({ + egress, + onChange, +}: { + egress: BlueprintEgress[]; + onChange: (e: BlueprintEgress[]) => void; +}) { + const [host, setHost] = useState(""); + const [port, setPort] = useState("443"); + const [err, setErr] = useState<string | null>(null); + + // A permissive hostname / IPv4 check — rejects schemes, paths, spaces, and + // obvious junk so a bad allowlist entry can't silently reach the controller. + const HOST_RE = /^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$|^(?:\d{1,3}\.){3}\d{1,3}$|^localhost$/; + + function add() { + setErr(null); + const h = host.trim().toLowerCase(); + if (!h) return; + if (h.includes("/") || h.includes(":") || h.includes(" ")) { + setErr("Enter a bare hostname (no scheme, port, or path) — set the port separately."); + return; + } + if (!HOST_RE.test(h)) { + setErr("That doesn't look like a valid hostname or IP."); + return; + } + let p: number | null = null; + if (port.trim() !== "") { + const n = Number(port); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + setErr("Port must be a whole number between 1 and 65535."); + return; + } + p = n; + } + if (egress.some((e) => e.host === h && e.port === p)) { + setErr("That host:port is already in the allowlist."); + return; + } + onChange([...egress, { host: h, port: p }]); + setHost(""); + setPort("443"); + } + + return ( + <div className="space-y-2"> + {egress.length > 0 && ( + <ul className="space-y-1.5"> + {egress.map((e, i) => ( + <li + key={`${e.host}:${e.port ?? ""}:${i}`} + className="flex items-center justify-between rounded-lg bg-surface-muted px-3 py-1.5 text-sm" + > + <span className="font-mono text-xs"> + {e.host} + {e.port ? `:${e.port}` : ""} + </span> + <button + type="button" + onClick={() => onChange(egress.filter((_, j) => j !== i))} + className="text-xs text-foreground-muted hover:text-danger" + > + Remove + </button> + </li> + ))} + </ul> + )} + <div className="flex items-center gap-2"> + <input + value={host} + onChange={(e) => setHost(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + placeholder="host, e.g. api.github.com" + className="flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <input + value={port} + onChange={(e) => setPort(e.target.value)} + placeholder="443" + inputMode="numeric" + className="w-20 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <button + type="button" + onClick={add} + className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition hover:bg-surface-muted" + > + Add + </button> + </div> + {err && <p className="text-xs text-danger">{err}</p>} + </div> + ); +} + +export function PackageSection({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: React.ReactNode; +}) { + return ( + <section className="rounded-2xl border border-border bg-surface p-5 shadow-sm"> + <h2 className="text-sm font-semibold">{title}</h2> + <p className="mt-0.5 text-xs text-foreground-muted">{subtitle}</p> + <div className="mt-3">{children}</div> + </section> + ); +} diff --git a/bridge/web/src/app/workspace/new/intake-flow/helpers.ts b/bridge/web/src/app/workspace/new/intake-flow/helpers.ts new file mode 100644 index 000000000..18aa0166f --- /dev/null +++ b/bridge/web/src/app/workspace/new/intake-flow/helpers.ts @@ -0,0 +1,11 @@ +export function modelKey(provider: string, deployment: string) { + return `${provider}::${deployment}`; +} + +export function moveFallback(routes: string[], index: number, delta: number): string[] { + const next = index + delta; + if (next < 0 || next >= routes.length) return routes; + const copy = [...routes]; + [copy[index], copy[next]] = [copy[next], copy[index]]; + return copy; +} diff --git a/bridge/web/src/app/workspace/new/intake-flow/review-types.ts b/bridge/web/src/app/workspace/new/intake-flow/review-types.ts new file mode 100644 index 000000000..b6313f74e --- /dev/null +++ b/bridge/web/src/app/workspace/new/intake-flow/review-types.ts @@ -0,0 +1,76 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { + Blueprint, + BlueprintEgress, + Efficiency, + ExecutionPlan, + MissionDelegation, + Options, + ValidationResult, +} from "@/lib/types"; +import type { IntakeState } from "../actions"; + +type Setter<T> = Dispatch<SetStateAction<T>>; + +export interface ReviewProps { + formAction: (payload: FormData) => void; + objective: string; + tier: number; + budgetTokens: string; + launch: boolean; + blueprint: Blueprint; + delegation: MissionDelegation; + rationale: string | null; + proposed: boolean; + composeSource: string | null; + recommendedRoute: string | null; + modelBasis: string | null; + composeNote: string | null; + setObjective: Setter<string>; + options: Options; + model: string; + setModel: Setter<string>; + setModelFallbacks: Setter<string[]>; + modelFallbacks: string[]; + recommendedModel: Options["models"][number] | null; + efficiency: Efficiency | null | undefined; + recommendedStats: Efficiency["routes"][number] | null; + runtime: string; + changeRuntime: (nextRuntime: string) => void; + instructions: string; + setInstructions: Setter<string>; + executionPlanDraft: string; + setExecutionPlanDraft: Setter<string>; + setExecutionPlan: Setter<ExecutionPlan | null>; + setExecutionPlanError: Setter<string | null>; + executionPlanError: string | null; + loopDirective: string; + cameViaLoopReview: boolean; + goBackToLoopReview: () => void; + toolPolicy: string; + setToolPolicy: Setter<string>; + mcp: string[]; + setMcp: Setter<string[]>; + mcpNeedsPolicy: boolean; + skills: string[]; + setSkills: Setter<string[]>; + setEgressMode: Setter<"strict" | "learning">; + egressMode: "strict" | "learning"; + egress: BlueprintEgress[]; + setEgress: Setter<BlueprintEgress[]>; + isolation: string; + setIsolation: Setter<string>; + memory: string; + setMemory: Setter<string>; + setTier: Setter<number>; + ceiling: number; + setBudgetTokens: Setter<string>; + runValidation: () => Promise<void>; + validating: boolean; + validationError: string | null; + validation: ValidationResult | null; + validationFresh: boolean; + setLaunch: Setter<boolean>; + state: IntakeState; + goBack: () => void; +} diff --git a/bridge/web/src/app/workspace/new/intake-flow/review.tsx b/bridge/web/src/app/workspace/new/intake-flow/review.tsx new file mode 100644 index 000000000..805c43a9e --- /dev/null +++ b/bridge/web/src/app/workspace/new/intake-flow/review.tsx @@ -0,0 +1,696 @@ +import { SegmentedTier } from "@/components/segmented-tier"; +import { OrchestrationCube } from "@/components/orchestration-cube"; +import { Icon } from "@/components/icon"; +import { JourneyRail } from "@/components/journey-rail"; +import { humanizeMcp } from "@/lib/format"; +import { RepoAccess } from "@/components/repo-access"; +import { EnvelopeReveal } from "../envelope-reveal"; +import { CheckMark, CreateButton, EgressEditor, PackageSection } from "./controls"; +import { modelKey, moveFallback } from "./helpers"; +import type { ReviewProps } from "./review-types"; + +const TIER_CONSEQUENCE: Record<number, string> = { + 1: "Manual — the mission proposes every step and does nothing on its own. You perform each action.", + 2: "Shared — the mission acts only on low-risk steps; everything else waits for your approval.", + 3: "Conditional — the mission acts on its own but pauses for your approval before anything that costs money, touches external systems, or can't be undone.", + 4: "Supervised — the mission runs autonomously with periodic checkpoints you sign off on.", + 5: "Full — the mission runs autonomously within its budget and time limit; you review the result.", +}; + +export function renderReview({ + formAction, + objective, + tier, + budgetTokens, + launch, + blueprint, + delegation, + rationale, + proposed, + composeSource, + recommendedRoute, + modelBasis, + composeNote, + setObjective, + options, + model, + setModel, + setModelFallbacks, + modelFallbacks, + recommendedModel, + efficiency, + recommendedStats, + runtime, + changeRuntime, + instructions, + setInstructions, + executionPlanDraft, + setExecutionPlanDraft, + setExecutionPlan, + setExecutionPlanError, + executionPlanError, + loopDirective, + cameViaLoopReview, + goBackToLoopReview, + toolPolicy, + setToolPolicy, + mcp, + setMcp, + mcpNeedsPolicy, + skills, + setSkills, + setEgressMode, + egressMode, + egress, + setEgress, + isolation, + setIsolation, + memory, + setMemory, + setTier, + ceiling, + setBudgetTokens, + runValidation, + validating, + validationError, + validation, + validationFresh, + setLaunch, + state, + goBack, +}: ReviewProps) { + return ( + <form action={formAction} className="space-y-5"> + <input type="hidden" name="objective" value={objective} /> + <input type="hidden" name="tier" value={tier} /> + <input type="hidden" name="budget_tokens" value={budgetTokens} /> + <input type="hidden" name="launch" value={launch ? "on" : "off"} /> + <input type="hidden" name="blueprint_json" value={JSON.stringify(blueprint)} /> + <input type="hidden" name="delegation_json" value={JSON.stringify(delegation)} /> + + <JourneyRail current={launch ? "launch" : "review"} /> + + {rationale !== null || proposed ? ( + <EnvelopeReveal + blueprint={blueprint} + tier={tier} + budgetTokens={budgetTokens} + rationale={rationale} + source={composeSource} + recommended={recommendedRoute} + modelBasis={modelBasis} + delegation={delegation} + /> + ) : null} + {composeNote && ( + <div className="rounded-xl border border-border bg-surface-muted/50 p-4 text-sm text-foreground-muted"> + {composeNote} + </div> + )} + + <PackageSection title="Objective" subtitle="Restate it clearly — edit if Bridge misread you."> + <textarea + value={objective} + onChange={(e) => setObjective(e.target.value)} + rows={3} + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </PackageSection> + + <details open className="group rounded-xl border border-border bg-surface-muted/20 [&_summary::-webkit-details-marker]:hidden"> + <summary className="flex cursor-pointer items-center justify-between gap-3 px-5 py-4 text-sm"> + <span className="min-w-0"> + <span className="font-medium">Composed package — review & edit</span> + <span className="ml-2 text-xs text-foreground-muted"> + Model & harness, instructions, tools, network, isolation, memory — composed for you. Every field is editable; collapse if you just want the defaults. + </span> + </span> + <span aria-hidden className="shrink-0 text-foreground-muted transition-transform group-open:rotate-90">▸</span> + </summary> + <div className="space-y-5 border-t border-border p-4"> + + <PackageSection + title="Model & harness" + subtitle="What the mission reasons with, and the agent runtime it runs on." + > + {options.provider && ( + <div className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-surface-muted/40 px-3 py-2.5"> + <Icon name="link" size={16} /> + <div className="min-w-0"> + <p className="text-xs font-medium"> + This cluster serves models via{" "} + <span className="text-foreground">{options.provider.label}</span> + <span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-foreground-muted"> + inherited + </span> + </p> + <p className="mt-0.5 text-[11px] text-foreground-muted">{options.provider.note}</p> + </div> + </div> + )} + <div className="grid gap-4 sm:grid-cols-2"> + <div> + <label className="text-xs font-medium text-foreground-muted">Model</label> + {options.models.length === 0 ? ( + <p className="mt-1.5 rounded-lg bg-surface-muted px-3 py-2 text-xs text-foreground-muted"> + No models are listed for this cluster — the mission will use the configured default + {options.default_model ? ` (${options.default_model})` : ""}. + </p> + ) : ( + <select + value={model} + onChange={(event) => { + const route = event.target.value; + setModel(route); + setModelFallbacks((current) => current.filter((fallback) => fallback !== route)); + }} + className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {options.models.map((m) => ( + <option key={modelKey(m.provider, m.deployment)} value={modelKey(m.provider, m.deployment)}> + {m.deployment} + {m.is_default ? " (default)" : ""} + </option> + ))} + </select> + )} + <p className="mt-1 text-[11px] text-foreground-muted"> + A default is pre-selected for the objective; switch to any model + {options.provider ? ` ${options.provider.label}` : " your cluster"} serves. + </p> + <label className="mt-3 block text-xs text-foreground-muted"> + Qualified fallback routes + <select + multiple + value={modelFallbacks} + onChange={(event) => { + const selected = new Set( + Array.from(event.currentTarget.selectedOptions, (option) => option.value), + ); + setModelFallbacks((current) => [ + ...current.filter((route) => selected.has(route)), + ...Array.from(selected).filter((route) => !current.includes(route)), + ].slice(0, 8)); + }} + className="mt-1.5 min-h-24 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + {options.models + .map((option) => modelKey(option.provider, option.deployment)) + .filter((route) => route !== model) + .map((route) => ( + <option key={route} value={route}> + {route} + </option> + ))} + </select> + {modelFallbacks.map((route, index) => ( + <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> + <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> + <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setModelFallbacks((current) => moveFallback(current, index, -1))}>↑</button> + <button type="button" aria-label={`Move ${route} later`} disabled={index === modelFallbacks.length - 1} onClick={() => setModelFallbacks((current) => moveFallback(current, index, 1))}>↓</button> + </span> + ))} + <span className="mt-1 block text-[11px]"> + Preflight rejects any fallback that lacks atomic evidence for this exact package and its selected resources. + </span> + </label> + {recommendedModel && ( + <div className="mt-2 flex items-start gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-2"> + <Icon name="lightbulb" size={14} /> + <div className="min-w-0 text-[11px]"> + <p className="font-medium text-foreground"> + {efficiency?.recommended_low_confidence + ? "Insufficient evidence for automatic recommendation" + : "Recommended by the efficiency frontier"} + </p> + <p className="mt-0.5 text-foreground-muted"> + {recommendedModel.deployment} + {recommendedStats + ? ` — ${Math.round(recommendedStats.acceptance_rate * 100)}% accepted across ${recommendedStats.runs} run${recommendedStats.runs === 1 ? "" : "s"}, ${recommendedStats.tokens_per_outcome.toLocaleString()} tokens/outcome` + : " — learned from completed runs on this cluster"} + {efficiency?.recommended_low_confidence + ? " — not selected automatically" + : ""} + </p> + </div> + </div> + )} + </div> + <div> + <label className="text-xs font-medium text-foreground-muted">Harness</label> + <select + value={runtime} + onChange={(e) => changeRuntime(e.target.value)} + className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + {(() => { + const rts = options.runtimes.length + ? options.runtimes + : [{ kind: "OpenClaw", label: "OpenClaw", wired: true, status: "ready" as const, note: "" }]; + const ready = rts.filter((r) => r.status === "ready"); + const needsImage = rts.filter((r) => r.status === "needs_image"); + const unavailable = rts.filter((r) => r.status === "unavailable"); + const opt = (r: (typeof rts)[number]) => ( + <option key={r.kind} value={r.kind} disabled={!r.wired}> + {r.label} + {r.status === "needs_image" ? " — image not configured here" : r.status === "unavailable" ? " — not available" : ""} + </option> + ); + return ( + <> + {ready.length > 0 && <optgroup label="Ready on this cluster">{ready.map(opt)}</optgroup>} + {needsImage.length > 0 && <optgroup label="Supported — needs runtime image">{needsImage.map(opt)}</optgroup>} + {unavailable.length > 0 && <optgroup label="Not available yet">{unavailable.map(opt)}</optgroup>} + </> + ); + })()} + </select> + <p className="mt-1 text-[11px] text-foreground-muted"> + {options.runtimes.filter((r) => r.wired).length} harness{options.runtimes.filter((r) => r.wired).length === 1 ? "" : "es"} can run on this cluster right now. Others are supported by the runtime but need their image configured by an operator. Team members can each use a different ready harness. + </p> + </div> + </div> + </PackageSection> + + <PackageSection + title="Instructions" + subtitle="The mission's system prompt — how it should behave, in addition to the objective." + > + <textarea + value={instructions} + onChange={(e) => setInstructions(e.target.value)} + rows={4} + placeholder="e.g. Be meticulous. Verify every claim against a primary source and cite file paths. Never change code without a passing test." + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 text-sm leading-relaxed placeholder:text-foreground-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + </PackageSection> + + <PackageSection + title="Execution plan" + subtitle="Roles, dependencies, phases, capabilities, tool-call bounds, synthesis, and deliverables. This is typed and runtime-neutral." + > + {executionPlanDraft ? ( + <> + <textarea + value={executionPlanDraft} + onChange={(event) => { + const next = event.target.value; + setExecutionPlanDraft(next); + try { + const parsed = JSON.parse(next) as import("@/lib/types").ExecutionPlan; + setExecutionPlan(parsed); + setExecutionPlanError(null); + } catch { + setExecutionPlanError("The execution plan must be valid JSON before validation or launch."); + } + }} + rows={18} + spellCheck={false} + className="w-full resize-y rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + {executionPlanError && ( + <p className="mt-2 text-xs text-danger">{executionPlanError}</p> + )} + </> + ) : ( + <p className="text-xs text-foreground-muted"> + Single-agent execution — no worker plan was proposed. Recompose the mission to request decomposition. + </p> + )} + </PackageSection> + + {loopDirective.trim() && ( + <PackageSection + title="Loop — operating contract" + subtitle="The feedback loop the harness runs and any sub-agents inherit. Composed from the loop you reviewed." + > + <div className="flex items-center justify-between gap-3"> + <p className="text-xs text-foreground-muted"> + This loop is part of the launched package (folded into the agent’s instructions). + </p> + {cameViaLoopReview && ( + <button + type="button" + onClick={goBackToLoopReview} + className="shrink-0 rounded-lg border border-accent/40 bg-accent/[0.06] px-3 py-1.5 text-xs font-medium text-accent transition hover:bg-accent/10" + > + Adjust loop → + </button> + )} + </div> + <pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface p-3 font-mono text-[11px] leading-relaxed text-foreground"> + {loopDirective.trim()} + </pre> + </PackageSection> + )} + + <PackageSection + title="Tools & connected services" + subtitle="The tool policy that bounds what it may call, and the MCP services it may use." + > + <label className="text-xs font-medium text-foreground-muted">Tool policy</label> + <select + value={toolPolicy} + onChange={(e) => setToolPolicy(e.target.value)} + className="mt-1.5 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <option value="">None — model only (no governed tools)</option> + {options.tool_policies.map((t) => ( + <option key={t.name} value={t.name}> + {t.name} + {t.summary ? ` · ${t.summary}` : ""} + </option> + ))} + </select> + + <div className="mt-4"> + <label className="text-xs font-medium text-foreground-muted">Connected services (MCP)</label> + {options.mcp_profiles.length > 0 && ( + <div className="mt-1.5 flex flex-wrap items-center gap-1.5"> + <span className="text-[11px] text-foreground-muted">Vetted bundles:</span> + {options.mcp_profiles.map((prof) => { + const active = prof.servers.length > 0 && prof.servers.every((s) => mcp.includes(s)); + return ( + <button + key={prof.name} + type="button" + title={prof.summary ?? `${prof.servers.length} server(s): ${prof.servers.join(", ")}`} + onClick={() => + setMcp((cur) => + active + ? cur.filter((x) => !prof.servers.includes(x)) + : [...new Set([...cur, ...prof.servers])], + ) + } + className={`rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${active ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground"}`} + > + {active ? "✓ " : "+ "}{prof.name} + </button> + ); + })} + </div> + )} + {options.mcp_servers.length === 0 ? ( + <p className="mt-1.5 text-xs text-foreground-muted"> + No services are connected. Connect MCP servers in the Operator Console to give the + mission more tools. + </p> + ) : ( + <ul className="mt-1.5 space-y-1.5"> + {options.mcp_servers.map((m) => { + const checked = mcp.includes(m.name); + return ( + <li key={m.name}> + <label className="flex items-center gap-2.5 text-sm"> + <input + type="checkbox" + checked={checked} + onChange={(e) => + setMcp((cur) => + e.target.checked ? [...cur, m.name] : cur.filter((x) => x !== m.name), + ) + } + className="h-4 w-4 accent-[var(--signal)]" + /> + <span className="font-medium">{humanizeMcp(m.name)}</span> + {m.summary && <span className="text-xs text-foreground-muted">{m.summary}</span>} + </label> + </li> + ); + })} + </ul> + )} + {mcpNeedsPolicy && ( + <p role="alert" className="mt-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning"> + Connected services need a tool policy to bound them. Select a tool policy above, or + clear the services. + </p> + )} + </div> + <div className="mt-4"> + <label className="text-xs font-medium text-foreground-muted">Approved skills</label> + {options.skills.length === 0 ? ( + <p className="mt-1.5 text-xs text-foreground-muted">No approved skills are available.</p> + ) : ( + <ul className="mt-1.5 space-y-1.5"> + {options.skills.map((skill) => ( + <li key={skill.name}> + <label className="flex items-center gap-2.5 text-sm"> + <input + type="checkbox" + checked={skills.includes(skill.name)} + onChange={(e) => + setSkills((current) => + e.target.checked + ? [...current, skill.name] + : current.filter((name) => name !== skill.name), + ) + } + className="h-4 w-4 accent-[var(--signal)]" + /> + <span className="font-medium">{skill.name}</span> + {skill.summary && ( + <span className="text-xs text-foreground-muted">{skill.summary}</span> + )} + </label> + </li> + ))} + </ul> + )} + </div> + </PackageSection> + + <PackageSection + title="Network egress" + subtitle="Exactly which external hosts the mission may reach. Empty means no extra egress beyond the model path." + > + <div className="mb-3 inline-flex rounded-lg border border-border bg-surface p-1 text-xs"> + <button + type="button" + onClick={() => setEgressMode("strict")} + className={`rounded-md px-3 py-1.5 font-medium transition ${egressMode === "strict" ? "bg-signal text-signal-fg" : "text-foreground-muted hover:text-foreground"}`} + > + Strict + </button> + <button + type="button" + onClick={() => setEgressMode("learning")} + className={`rounded-md px-3 py-1.5 font-medium transition ${egressMode === "learning" ? "bg-accent text-accent-fg" : "text-foreground-muted hover:text-foreground"}`} + > + Learning + </button> + </div> + <p className="mb-3 text-xs text-foreground-muted"> + {egressMode === "strict" + ? "Only the hosts below are reachable from the first run — everything else is denied. The safe default." + : "The mission starts in Learn mode: it observes which hosts the agent actually reaches (nothing is blocked yet), so you can review and enforce the learned set afterward. Use for exploratory work when the host set isn't known up front."} + </p> + <div className={egressMode === "learning" ? "opacity-50" : ""}> + <EgressEditor egress={egress} onChange={setEgress} /> + </div> + </PackageSection> + + <PackageSection title="Isolation" subtitle="The sandbox hardening the mission runs inside."> + <div className="space-y-1.5"> + {(options.isolation.length + ? options.isolation + : [{ value: "standard", label: "Standard", note: "" }] + ).map((iso) => ( + <label key={iso.value} className="flex items-start gap-2.5 text-sm"> + <input + type="radio" + name="isolation_radio" + checked={isolation === iso.value} + onChange={() => setIsolation(iso.value)} + className="mt-0.5 h-4 w-4 accent-[var(--signal)]" + /> + <span> + <span className="font-medium">{iso.label}</span> + {iso.note && <span className="ml-1.5 text-xs text-foreground-muted">{iso.note}</span>} + </span> + </label> + ))} + </div> + </PackageSection> + + {options.memories.length > 0 && ( + <PackageSection + title="Shared memory" + subtitle="A shared knowledge store the mission reads and writes (optional)." + > + <select + value={memory} + onChange={(e) => setMemory(e.target.value)} + aria-label="Shared memory store" + className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + > + <option value="">None — this mission keeps its own context</option> + {options.memories.map((m) => ( + <option key={m.name} value={m.name}> + {m.name} + {m.summary ? ` · ${m.summary}` : ""} + </option> + ))} + </select> + </PackageSection> + )} + </div> + </details> + + <PackageSection title="Autonomy" subtitle="How much the mission may do on its own."> + <SegmentedTier name="tier_display" value={tier} onChange={setTier} /> + <p className="mt-3 rounded-lg bg-surface-muted px-3 py-2 text-xs text-foreground-muted"> + {TIER_CONSEQUENCE[tier]} + {" "}Delegated sub-roles may hold at most <span className="font-medium text-foreground">Tier {ceiling}</span> — one below the mission. + </p> + </PackageSection> + + <PackageSection title="Budget" subtitle="An optional token ceiling for the whole mission."> + <div className="flex items-center gap-2"> + <input + type="number" + min={0} + value={budgetTokens} + onChange={(e) => setBudgetTokens(e.target.value)} + placeholder="e.g. 200000" + className="w-48 rounded-lg border border-border bg-surface px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal" + /> + <span className="text-xs text-foreground-muted">tokens — leave blank for no cap</span> + </div> + </PackageSection> + + <PackageSection title="Governance envelope" subtitle="The hard limits this mission runs under."> + <ul className="space-y-1 text-sm text-foreground-muted"> + <li>• Acts at <span className="font-medium text-foreground">Tier {tier}</span> autonomy.</li> + <li>• Delegated sub-roles can hold at most <span className="font-medium text-foreground">Tier {ceiling}</span> — never more than the mission.</li> + {tier <= 3 && ( + <li>• Pauses for your approval before any priced, external, or irreversible action.</li> + )} + <li>• Reaches only the {egress.length === 0 ? "model path" : `${egress.length} host${egress.length === 1 ? "" : "s"} you allowed`}; all other egress is denied at the sandbox boundary.</li> + <li>• Every decision and steer is recorded in a signed Governance Receipt.</li> + </ul> + </PackageSection> + + {/* Pre-flight validation (§20) — prove launch-ready before anything runs. */} + <section className="rounded-2xl border border-border bg-surface p-5 shadow-sm"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Pre-flight check</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + Validate the package against the live cluster before anything runs — tools, services, + memory, model, and network are checked. + </p> + </div> + <button + type="button" + onClick={runValidation} + disabled={validating || mcpNeedsPolicy || executionPlanError !== null} + className="shrink-0 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium transition hover:bg-surface-muted disabled:opacity-50" + > + {validating ? "Checking…" : "Validate package"} + </button> + </div> + {/* Validation animates the same orchestration cube with a live feed of + what's being checked against the live cluster — so validate feels as + alive as compose, and the Execute button only appears once green. */} + {validating && ( + <div className="mt-4"> + <OrchestrationCube + title="Validating against the live cluster" + done={false} + active={0} + phases={[ + { icon: "brain", label: "Resolving the model on the cluster", detail: model ? model.split("::")[1] ?? model : "controller default" }, + { icon: "wrench", label: "Checking tool policy + connected services", detail: `${mcp.length} service${mcp.length === 1 ? "" : "s"}${toolPolicy ? ` · ${toolPolicy}` : ""}` }, + { icon: "globe", label: "Verifying egress reachability", detail: egress.length ? egress.map((e) => e.host).slice(0, 3).join(", ") : "model path only" }, + { icon: "shield", label: "Proving the envelope is launch-ready", detail: `Tier ${tier} · capability + budget checks` }, + ]} + /> + </div> + )} + {/* Loud, honest feedback in every branch — never a dead button. */} + {mcpNeedsPolicy && ( + <p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> + Connected services (MCP) require a tool policy to bound them. Pick a tool policy above, + then validate. + </p> + )} + {validationError && ( + <div className="mt-3 rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger"> + <span className="font-semibold">Pre-flight could not complete.</span> {validationError} + </div> + )} + {validation && ( + <> + {!validationFresh && ( + <p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> + You edited the package since this ran — these results are stale. Re-validate to launch. + </p> + )} + <ul className="mt-3 space-y-1.5"> + {validation.checks.map((c) => ( + <li key={c.id} className="flex items-start gap-2 text-sm"> + <CheckMark status={c.status} /> + <span> + <span className="font-medium">{c.label}</span> + <span className="ml-1.5 text-xs text-foreground-muted">{c.detail}</span> + </span> + </li> + ))} + </ul> + {validationFresh && !validation.ok && ( + <p className="mt-2 text-xs font-medium text-danger"> + Fix the failing checks above before launching. + </p> + )} + </> + )} + </section> + + <RepoAccess /> + + <label className="flex items-center gap-2.5 rounded-lg border border-border bg-surface px-4 py-3 text-sm"> + <input + type="checkbox" + checked={launch} + onChange={(e) => setLaunch(e.target.checked)} + className="h-4 w-4 accent-[var(--signal)]" + /> + <span> + Launch immediately after creating.{" "} + <span className="text-foreground-muted"> + Leave unchecked to create a governed draft you launch when ready. + </span> + </span> + </label> + + {state.error && ( + <p role="alert" className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger"> + {state.error} + </p> + )} + + <div className="flex items-center justify-between rounded-xl border border-border bg-surface-muted/50 px-4 py-3"> + <p className="text-xs font-medium text-foreground-muted">Nothing has started yet.</p> + <div className="flex items-center gap-3"> + <button + type="button" + onClick={goBack} + className="rounded-lg px-3 py-2 text-sm text-foreground-muted hover:text-foreground" + > + {cameViaLoopReview ? "← Back to loop" : "← Back"} + </button> + <CreateButton + disabled={ + mcpNeedsPolicy + || executionPlanError !== null + || (launch && !(validationFresh && validation!.ok)) + } + launch={launch} + needsValidation={launch && !(validationFresh && validation?.ok === true)} + /> + </div> + </div> + </form> + ); +} From 3650c15753842d15e38799b6134af4d0c4e5ccfc Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 02:36:32 +0200 Subject: [PATCH 048/111] Separate Mission presentation while preserving server data flow Dependency-aware exact declaration extraction preserves18original declarations, JSX/literals and every function body. Fetches, redirects, default route export and force-dynamic setting remain unchanged. Files612/640lines; parser/parity and available scoped lint passed, fullNextqualification remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../missions/[name]/mission-detail-panels.tsx | 640 +++++++++++++++++ .../app/workspace/missions/[name]/page.tsx | 651 +----------------- 2 files changed, 645 insertions(+), 646 deletions(-) create mode 100644 bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx new file mode 100644 index 000000000..115c277d6 --- /dev/null +++ b/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx @@ -0,0 +1,640 @@ +// Mission detail server presentation; page fetching and routing stay in ./page. + +import Link from "next/link"; +import { DeliverableView, DeliverableBody } from "@/components/deliverable-view"; +import { ProvenanceStory } from "@/components/provenance-story"; +import type { ReactNode } from "react"; +import { egressScope, humanizeMcp } from "@/lib/format"; +import { Icon } from "@/components/icon"; +import { type Composition, type MissionResult, type MissionArtifact, type AgentIdentity, type TaskDetail } from "@/lib/types"; + + +/** Infer the review kind from the produced artifact set, for typed routing + * (§16): code → review as a change, docs → prose, data → values. */ +/** True when an artifact is prose (markdown/plain text) that should render as a + * formatted document rather than a raw monospace dump. Code/data files + * (json/csv/yaml/source) stay verbatim in <pre>. Extensionless files are + * treated as prose — agents commonly write briefings with no extension. */ +function isProseArtifact(name: string): boolean { + const dot = name.lastIndexOf("."); + if (dot < 0) return true; // no extension → prose + const ext = name.slice(dot + 1).toLowerCase(); + return ["md", "mdx", "markdown", "txt", "text", "rst", "adoc"].includes(ext); +} + +type MissionServerTab = { + id: string; + label: string; + badge?: number | string | null; + node: ReactNode; + live?: boolean; +}; + +export function MissionServerTabs({ + tabs, + active, + basePath, +}: { + tabs: MissionServerTab[]; + active?: string; + basePath: string; +}) { + const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; + return ( + <div> + <div + role="tablist" + aria-label="Mission sections" + className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" + > + {tabs.map((tab) => { + const selected = tab.id === current.id; + return ( + <Link + key={tab.id} + href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} + role="tab" + aria-selected={selected} + className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ + selected + ? "bg-signal/10 text-foreground" + : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" + }`} + > + {tab.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} + {tab.label} + {tab.badge != null && tab.badge !== 0 && ( + <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ + selected + ? "bg-signal/20 text-signal" + : "bg-surface-muted text-foreground-muted" + }`}> + {tab.badge} + </span> + )} + </Link> + ); + })} + </div> + <div role="tabpanel" className="kb-rise space-y-5"> + {current.node} + </div> + </div> + ); +} + +export function EnvelopeFact({ label, value }: { label: string; value: string }) { + return ( + <span className="inline-flex items-baseline gap-1.5"> + <span className="text-xs text-foreground-muted">{label}</span> + <span className="font-medium">{value}</span> + </span> + ); +} + +/** The mission objective, rendered so a multi-step, command-laden brief is + * readable instead of collapsing into one wall of text. The header shows a + * clamped one/two-line summary (the first meaningful line); the full brief is + * behind a native disclosure that preserves line breaks. */ +function objectiveSummary(objective: string): string { + const firstLine = objective + .split("\n") + .map((l) => l.trim()) + .find((l) => l.length > 0); + return firstLine ?? objective.trim(); +} + +export function ObjectiveBlock({ objective }: { objective: string }) { + const trimmed = (objective ?? "").trim(); + if (!trimmed) { + return <p className="mt-1 text-sm text-foreground-muted">No objective set.</p>; + } + const summary = objectiveSummary(trimmed); + const hasMore = summary.length < trimmed.length; + return ( + <div className="mt-1"> + <p className="line-clamp-2 text-sm text-foreground-muted">{summary}</p> + {hasMore && ( + <details className="group mt-1.5"> + <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs font-medium text-signal hover:underline [&::-webkit-details-marker]:hidden"> + <span className="transition-transform group-open:rotate-90" aria-hidden>›</span> + <span className="group-open:hidden">Show full brief</span> + <span className="hidden group-open:inline">Hide brief</span> + </summary> + <pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/50 px-4 py-3 font-mono text-xs leading-relaxed text-foreground-muted"> + {trimmed} + </pre> + </details> + )} + </div> + ); +} + +/** ONE primary, state-driven next step for the mission (audit f11). It tells the + * user what to do now in plain language and links to the single relevant place, + * rather than presenting every control at once. The full controls live in the + * tabs below; this is the signpost, not a duplicate action surface. */ +export function NextStep({ + status, +}: { + status: import("@/components/mission-status").MissionStatus; +}) { + const map: Record<string, { tone: string; title: string; body: string; cta?: { href: string; label: string } }> = { + drafting: { + tone: "border-signal/30 bg-signal/[0.05]", + title: "Ready to launch", + body: "Review the composed plan below — model, tools, network, autonomy, budget — then launch it in the Execution panel when you're happy.", + }, + deploying: { + tone: "border-signal/30 bg-signal/[0.05]", + title: "Deploying — the agent is coming online", + body: "Each provisioning step below is a real, verified event. This page updates itself live; no need to refresh.", + }, + running: { + tone: "border-signal/30 bg-signal/[0.05]", + title: "Running", + body: "Watch the agent work in the Activity tab. If it needs a decision it will ask you here and in your Inbox.", + }, + needs_you: { + tone: "border-warning/40 bg-warning/10", + title: "This mission needs your decision", + body: "It paused for your approval before a priced, external, or irreversible step.", + cta: { href: "/workspace/inbox", label: "Open the inbox →" }, + }, + done: { + tone: "border-ok/40 bg-ok/10", + title: "Delivered", + body: "The deliverable is ready. Review it and accept or request changes in the Deliverable tab; the signed receipt is in the Receipt tab.", + }, + failed: { + tone: "border-danger/40 bg-danger/10", + title: "This run didn't complete", + body: "Open the Run failed tab below for a full diagnosis — the likely cause, how far it got, the runtime's exact reason, and one-click ways to re-compose or re-run.", + }, + }; + const m = map[status] ?? map.drafting; + return ( + <div className={`flex flex-wrap items-center justify-between gap-3 rounded-xl border px-5 py-3.5 ${m.tone}`}> + <div className="min-w-0"> + <p className="text-sm font-semibold">{m.title}</p> + <p className="mt-0.5 text-xs text-foreground-muted">{m.body}</p> + </div> + {m.cta && ( + <Link href={m.cta.href} className="shrink-0 rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90"> + {m.cta.label} + </Link> + )} + </div> + ); +} + +export function AgentIdentityCard({ identity }: { identity: AgentIdentity }) { + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">Agent mesh identity</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The running agent's real, harness-neutral identity on the encrypted agent mesh — + discovered live from the registry. This is how work is delivered and verified across any + runtime. + </p> + <dl className="mt-3 space-y-2 text-sm"> + <div className="flex flex-wrap items-baseline gap-x-2"> + <dt className="text-xs text-foreground-muted">DID</dt> + <dd className="font-mono text-xs break-all">{identity.did}</dd> + </div> + {identity.capabilities.length > 0 && ( + <div> + <dt className="text-xs text-foreground-muted">Advertised capabilities</dt> + <dd className="mt-1 flex flex-wrap gap-1.5"> + {identity.capabilities.map((c) => ( + <span key={c} className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs"> + {c} + </span> + ))} + </dd> + </div> + )} + {identity.last_seen && ( + <div className="flex flex-wrap items-baseline gap-x-2"> + <dt className="text-xs text-foreground-muted">Last seen on the mesh</dt> + <dd className="text-xs font-medium">{new Date(identity.last_seen).toLocaleString()}</dd> + </div> + )} + </dl> + </section> + ); +} + +export function ArtifactsPanel({ ns, task, artifacts, pullRequests, activity, egress, tokens }: { ns: string; task: string; artifacts: MissionArtifact[]; pullRequests: import("@/lib/types").PullRequestRef[]; activity: import("@/lib/types").ActivityEvent[]; egress: string[]; tokens: number | null }) { + const fmtSize = (n: number | null) => + n == null ? "" : n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`; + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <div className="flex items-start justify-between gap-3"> + <div> + <h2 className="text-sm font-semibold">Artifacts</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + The complete set of files the agent produced through its native loop over the mesh — + captured by the controller into a durable, cluster-native record. + </p> + </div> + <span className="shrink-0 rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium"> + {artifacts.length} file{artifacts.length === 1 ? "" : "s"} + </span> + </div> + {/* Pull requests are a first-class delivery type — a PR the agent opened is + an artifact, shown here as a chip (not only in the deliverable prose). */} + {pullRequests.length > 0 && ( + <div className="mt-4 rounded-lg border border-signal/20 bg-signal/[0.03] p-4"> + <h3 className="text-xs font-semibold">Pull requests opened</h3> + <ul className="mt-2 flex flex-wrap gap-2"> + {pullRequests.map((pr) => ( + <li key={pr.url}> + <a + href={pr.url} + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-1.5 hover:bg-signal/10" + title={`Pull request on ${pr.repo}`} + > + <Icon name="branch" size={13} className="shrink-0 text-signal" /> + <span className="text-xs font-medium text-signal">PR #{pr.number}</span> + <span className="font-mono text-[11px] text-foreground-muted">{pr.repo}</span> + <span aria-hidden className="text-[11px] text-foreground-muted">↗</span> + </a> + </li> + ))} + </ul> + </div> + )} + {/* How this was made — the plain-language provenance story over the real trace. */} + <div className="mt-4 rounded-lg border border-border bg-background/40 p-4"> + <h3 className="text-xs font-semibold">How this was made</h3> + <div className="mt-2"><ProvenanceStory activity={activity} egress={egress} tokens={tokens} /></div> + </div> + <ul className="mt-4 divide-y divide-border rounded-lg border border-border"> + {artifacts.map((a, i) => ( + <li key={a.name}> + <details open={i === 0} className="group"> + <summary className="flex cursor-pointer items-center justify-between gap-3 px-4 py-2.5 hover:bg-surface-muted/50"> + <span className="flex items-center gap-2 font-mono text-xs"> + <span aria-hidden className="text-foreground-muted transition-transform group-open:rotate-180">⌄</span> + {a.name} + </span> + <span className="flex shrink-0 items-center gap-3 text-xs text-foreground-muted"> + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + target="_blank" + rel="noopener noreferrer" + className="text-signal hover:underline" + > + {a.content_truncated ? "Open full" : "Open"} + </a> + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + download={a.name} + className="inline-flex items-center gap-1 font-medium text-signal hover:underline" + > + <Icon name="download" size={12} /> + Download + </a> + <span> + {a.content == null ? "binary · " : ""} + {fmtSize(a.size_bytes)} + </span> + </span> + </summary> + {a.content_truncated ? ( + <div className="space-y-3 border-t border-border bg-surface-muted/20 px-4 py-3"> + <p className="text-xs text-foreground-muted"> + Showing a bounded preview of {(a.content_bytes ?? a.size_bytes ?? 0).toLocaleString()} bytes. + </p> + {a.content ? ( + <pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/30 p-3 font-mono text-xs leading-relaxed"> + {a.content} + </pre> + ) : null} + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + target="_blank" + rel="noopener noreferrer" + className="inline-flex text-xs font-medium text-signal hover:underline" + > + Open full artifact ↗ + </a> + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + download={a.name} + className="inline-flex items-center gap-1 text-xs font-medium text-signal hover:underline" + > + <Icon name="download" size={12} /> + Download artifact + </a> + </div> + ) : a.content != null ? ( + isProseArtifact(a.name) ? ( + <div className="max-h-96 overflow-auto border-t border-border bg-surface-muted/20 px-4 py-3"> + <DeliverableBody output={a.content} /> + </div> + ) : ( + <pre className="max-h-96 overflow-auto whitespace-pre-wrap border-t border-border bg-surface-muted/30 px-4 py-3 font-mono text-xs leading-relaxed"> + {a.content} + </pre> + ) + ) : ( + <p className="border-t border-border bg-surface-muted/30 px-4 py-3 text-xs text-foreground-muted"> + Binary artifact — use{" "} + <a + href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} + download={a.name} + className="text-signal hover:underline" + > + Download + </a>{" "} + to fetch the full file. + </p> + )} + </details> + </li> + ))} + </ul> + </section> + ); +} + +export function ResultPanel({ result }: { result: MissionResult }) { + return ( + <div className="space-y-3"> + {result.source === "single_turn" && ( + <div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2 text-xs text-foreground-muted"> + <span aria-hidden className="mt-0.5 text-amber-600">ℹ</span> + <span> + <span className="font-medium text-foreground">Single-turn completion.</span> The full + agent loop (tools + sub-agents) was unavailable on this run, so this is one model turn — + the Activity tab will show no tool calls. Re-run to try the full loop again. + </span> + </div> + )} + <DeliverableView + output={result.output} + model={result.model} + totalTokens={result.total_tokens} + finishedAt={result.finished_at} + /> + </div> + ); +} + +/** Analyse a run failure reason into a plain-language cause + a specific remedy, + * and (when relevant) flag that the harness itself is the problem. */ +function analyzeFailure(reason: string, harness: string | null): { cause: string; remedy: string; harnessIssue: boolean } { + const r = (reason || "").toLowerCase(); + const chatGateway = !!harness && /hermes|gateway|channel/.test(harness.toLowerCase()); + if (r.includes("did not come online") || r.includes("not yet discoverable") || r.includes("mesh registry") || r.includes("not discoverable")) { + return { + cause: chatGateway + ? `The agent never registered on the encrypted mesh. The “${harness}” harness is a chat-gateway — it waits for inbound channel messages and does not execute a one-shot mission on its own, so it never came online to do autonomous work.` + : "The agent sandbox didn't register on the encrypted mesh within the startup window. This is usually a slow container image pull or node pressure delaying the pod — occasionally a crashed agent container.", + remedy: chatGateway + ? "Re-compose this mission on the OpenClaw harness (built for autonomous missions), or drive this one through its channel." + : "Re-run it — a fresh sandbox often comes up cleanly. If it repeats, an operator can inspect the sandbox for image-pull or crash errors.", + harnessIssue: chatGateway, + }; + } + if (r.includes("no progress heartbeat") || r.includes("timed out") || r.includes("timeout")) { + return { + cause: "The agent started but stopped making progress, so the controller timed the run out after a period with no heartbeat.", + remedy: "Re-run it. If it stalls repeatedly, narrow the objective or raise the token/time budget in the envelope.", + harnessIssue: false, + }; + } + if (r.includes("content safety") || r.includes("jailbreak") || r.includes("blocked by")) { + return { + cause: "A content-safety policy blocked the run before it could deliver.", + remedy: "Adjust the objective to avoid the flagged content, or ask an operator about the content-safety floor.", + harnessIssue: false, + }; + } + if (r.includes("budget") || r.includes("token cap") || r.includes("out of tokens")) { + return { + cause: "The run hit its token budget before producing a deliverable.", + remedy: "Re-run with a higher token budget in the envelope.", + harnessIssue: false, + }; + } + return { + cause: "The run ended with an error before producing a deliverable.", + remedy: "Re-run it, or re-compose with a different harness or model.", + harnessIssue: false, + }; +} + +/** Real, actionable troubleshooting for a failed run: what happened, how far the + * provisioning got (which stage it stopped at), and what to do next. When live + * cluster evidence is available (pod/container status + the agent's own log + * tail), it uses the evidence-derived diagnosis and SHOWS the proof; otherwise + * it falls back to analysing the recorded reason. */ +export function FailureDiagnostic({ + task, + troubleshoot, +}: { + task: TaskDetail; + troubleshoot: import("@/lib/types").Troubleshoot | null; +}) { + const reason = task.result?.output ?? task.execution_detail ?? "The run ended with an error."; + const harness = task.composition?.runtime ?? null; + // Prefer the live, evidence-derived diagnosis from the cluster; fall back to + // the local reason analysis when the troubleshoot endpoint is unavailable. + const local = analyzeFailure(reason, harness); + const cause = troubleshoot?.cause ?? local.cause; + const remedy = troubleshoot?.remedy ?? local.remedy; + const harnessIssue = troubleshoot?.harness_issue ?? local.harnessIssue; + const meshAcknowledged = task.assignment_events.some( + (event) => event.event_type === "acknowledged" || event.state === "Running", + ); + + // How far provisioning got — the same stages the deploy timeline tracks. The + // first un-reached stage is where it stopped. + const stages: { label: string; reached: boolean }[] = [ + { label: "Launch approved", reached: task.launched }, + { label: "Sandbox provisioned", reached: !!task.sandbox }, + { label: "Agent online on the mesh", reached: meshAcknowledged || !!task.agent_identity?.last_seen }, + { label: "First activity (model round / tool call)", reached: (task.activity?.length ?? 0) > 0 }, + ]; + const stoppedAt = stages.findIndex((s) => !s.reached); + + return ( + <section className="space-y-4 rounded-xl border border-amber-500/40 bg-amber-500/5 p-6"> + <div className="flex items-start gap-3"> + <span className="mt-0.5 text-warning" aria-hidden> + <Icon name="target" size={18} /> + </span> + <div> + <h2 className="text-sm font-semibold">Run did not complete</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {troubleshoot + ? "Diagnosed from the sandbox's live pod status and the agent's own logs." + : "Here's what happened and how to fix it."} + </p> + </div> + </div> + + {/* Likely cause + remedy. */} + <div className="rounded-lg border border-amber-500/30 bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Likely cause</p> + <p className="mt-1 text-sm">{cause}</p> + <p className="mt-3 text-xs font-semibold uppercase tracking-wide text-foreground-muted">What to do</p> + <p className="mt-1 text-sm">{remedy}</p> + </div> + + {/* The real smoking-gun evidence pulled from the agent's logs. */} + {troubleshoot && troubleshoot.evidence.length > 0 && ( + <div className="rounded-lg border border-danger/30 bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Evidence — from the agent's own logs</p> + <ul className="mt-2 space-y-1"> + {troubleshoot.evidence.map((e, i) => ( + <li key={i} className="rounded bg-danger/5 px-2 py-1 font-mono text-[11px] leading-relaxed text-danger">{e}</li> + ))} + </ul> + </div> + )} + + {/* Live container status. */} + {troubleshoot && troubleshoot.containers.length > 0 && ( + <div className="rounded-lg border border-border bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted"> + Sandbox pod {troubleshoot.pod_summary ? `(${troubleshoot.pod_summary} ready)` : ""} + </p> + <ul className="mt-2 grid gap-1.5 sm:grid-cols-2"> + {troubleshoot.containers.map((c) => ( + <li key={c.name} className="flex items-center gap-2 text-xs"> + <span aria-hidden className={c.ready ? "text-ok" : "text-danger"}>{c.ready ? "✓" : "✗"}</span> + <span className="font-mono">{c.name}</span> + <span className="text-foreground-muted"> + {c.state}{c.reason ? ` · ${c.reason}` : ""}{c.restarts > 0 ? ` · ${c.restarts}↻` : ""} + </span> + </li> + ))} + </ul> + </div> + )} + + {/* How far it got. */} + <div className="rounded-lg border border-border bg-surface p-4"> + <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">How far it got</p> + <ol className="mt-2 space-y-1.5"> + {stages.map((s, i) => { + const isStop = i === stoppedAt; + return ( + <li key={s.label} className="flex items-center gap-2 text-sm"> + <span aria-hidden className={s.reached ? "text-ok" : isStop ? "text-danger" : "text-foreground-muted"}> + {s.reached ? "✓" : isStop ? "✗" : "•"} + </span> + <span className={s.reached ? "" : isStop ? "font-medium text-danger" : "text-foreground-muted"}> + {s.label} + {isStop && <span className="ml-1.5 text-xs font-normal text-danger">— stopped here</span>} + </span> + </li> + ); + })} + </ol> + </div> + + {/* The raw agent log tail — the exact evidence, for the record. */} + <details className="rounded-lg border border-border bg-surface"> + <summary className="cursor-pointer px-4 py-2.5 text-xs font-semibold"> + {troubleshoot && troubleshoot.agent_log_tail.length > 0 ? "Agent log tail (live)" : "Runtime's exact reason"} + </summary> + {troubleshoot && troubleshoot.agent_log_tail.length > 0 ? ( + <pre className="max-h-72 overflow-auto border-t border-border px-4 py-3 font-mono text-[10px] leading-relaxed text-foreground-muted">{troubleshoot.agent_log_tail.join("\n")}</pre> + ) : ( + <p className="border-t border-border px-4 py-3 font-mono text-xs leading-relaxed text-foreground-muted">{reason}</p> + )} + </details> + + {/* Actions. */} + <div className="flex flex-wrap gap-2"> + <Link + href={`/workspace/new?intent=${encodeURIComponent(task.objective)}`} + className="rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90" + > + {harnessIssue ? "Re-compose on OpenClaw →" : "Re-compose from this intent →"} + </Link> + </div> + {task.result?.finished_at && ( + <p className="text-xs text-foreground-muted">Failed {new Date(task.result.finished_at).toLocaleString()}</p> + )} + </section> + ); +} + +export function CompositionPanel({ composition, launched }: { composition: Composition; launched: boolean }) { + const c = composition; + return ( + <section className="rounded-xl border border-border bg-surface p-6"> + <h2 className="text-sm font-semibold">How this mission runs</h2> + <p className="mt-0.5 text-xs text-foreground-muted"> + {launched + ? "The effective configuration the running sandbox is using — read from the materialized policy and sandbox (including any defaults the controller applied)." + : "The planned configuration you composed — what this mission will run with once launched."} + </p> + <dl className="mt-4 grid gap-x-8 gap-y-4 sm:grid-cols-2"> + <Fact label="Model" value={c.model} /> + <Fact label="Harness" value={c.runtime} /> + <Fact label="Tool policy" value={c.tool_policy ?? "None — model only"} /> + <Fact label="Isolation" value={c.isolation} /> + <Fact + label="Connected services" + value={c.mcp_servers.length ? c.mcp_servers.map(humanizeMcp).join(", ") : "None"} + /> + <Fact label="Shared memory" value={c.memory ?? "None"} /> + </dl> + <div className="mt-4 border-t border-border pt-4"> + <p className="text-xs font-medium text-foreground-muted">Network egress</p> + {c.egress.length === 0 ? ( + <p className="mt-1 text-sm">Model path only — all other egress denied at the boundary.</p> + ) : ( + <> + <ul className="mt-1.5 flex flex-wrap gap-1.5"> + {c.egress.map((e) => { + const scope = egressScope(e); + return ( + <li key={e} className="inline-flex items-center gap-1.5 rounded-full bg-surface-muted px-2.5 py-1 font-mono text-xs"> + <span + className={`h-1.5 w-1.5 rounded-full ${scope === "internal" ? "bg-sky-500" : "bg-amber-500"}`} + title={scope === "internal" ? "Internal — in-cluster / private" : "External — public internet"} + aria-hidden + /> + {e} + </li> + ); + })} + </ul> + <p className="mt-1.5 text-[11px] text-foreground-muted"> + <span className="inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-sky-500" aria-hidden /> internal</span> + <span className="ml-3 inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden /> external</span> + <span className="ml-2">— same boundary, labelled for clarity.</span> + </p> + </> + )} + </div> + {c.instructions && ( + <div className="mt-4 border-t border-border pt-4"> + <p className="text-xs font-medium text-foreground-muted">Instructions</p> + <p className="mt-1.5 whitespace-pre-wrap rounded-lg bg-surface-muted px-3 py-2 text-sm leading-relaxed"> + {c.instructions} + </p> + </div> + )} + </section> + ); +} + +function Fact({ label, value }: { label: string; value: string | null }) { + return ( + <div> + <dt className="text-xs text-foreground-muted">{label}</dt> + <dd className="mt-0.5 text-sm font-medium">{value ?? "—"}</dd> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/missions/[name]/page.tsx b/bridge/web/src/app/workspace/missions/[name]/page.tsx index e2a5ce9fe..28d55c995 100644 --- a/bridge/web/src/app/workspace/missions/[name]/page.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/page.tsx @@ -6,7 +6,6 @@ // and the Governance Receipt. No Kubernetes vocabulary surfaces. import Link from "next/link"; -import { DeliverableView, DeliverableBody } from "@/components/deliverable-view"; import { notFound, redirect } from "next/navigation"; import { ExecutionExplorer } from "@/components/execution-explorer"; import { LiveRefresh, LivePulse } from "@/components/live-refresh"; @@ -29,31 +28,21 @@ import { AuditReportDownload } from "@/components/audit-report"; import { ReliabilityRunner } from "./reliability-runner"; import { BudgetRecovery } from "./budget-recovery"; import { PromoteMission } from "./promote-mission"; -import { ProvenanceStory } from "@/components/provenance-story"; import { EgressRequest } from "./egress-request"; import { DeleteMissionControl } from "./delete-control"; import { HonestState } from "@/components/honest-state"; import { PageHeader } from "@/components/ui"; - import { TaskApprovalsPanel } from "@/app/tasks/[name]/task-approvals-panel"; import { ExecutionPanel } from "@/app/tasks/[name]/execution-panel"; -import { - BffError, - getReceipt, - getReview, - getScorecard, - getTroubleshoot, - getTask, - getCompliancePack, - listTaskApprovals, -} from "@/lib/bff"; +import { BffError, getReceipt, getReview, getScorecard, getTroubleshoot, getTask, getCompliancePack, listTaskApprovals } from "@/lib/bff"; import { authWired, defaultNamespace, operatorIdentity } from "@/lib/config"; import { currentPrincipal } from "@/lib/session"; -import type { ReactNode } from "react"; -import { egressScope, humanizeMcp } from "@/lib/format"; +import { egressScope } from "@/lib/format"; import { Icon } from "@/components/icon"; import { HaltButton } from "./halt-button"; -import { TIER_LABELS, type Composition, type MissionResult, type MissionArtifact, type AgentIdentity, type TaskDetail } from "@/lib/types"; +import { TIER_LABELS, type MissionArtifact } from "@/lib/types"; +import { MissionServerTabs, EnvelopeFact, ObjectiveBlock, NextStep, AgentIdentityCard, ArtifactsPanel, ResultPanel, FailureDiagnostic, CompositionPanel } from "./mission-detail-panels"; + export const dynamic = "force-dynamic"; @@ -67,19 +56,6 @@ const AUTONOMY_INTERACTION: Record<number, string> = { 5: "Full — it runs to completion within budget; you review the result. Only hard-stops would ask you.", }; -/** Infer the review kind from the produced artifact set, for typed routing - * (§16): code → review as a change, docs → prose, data → values. */ -/** True when an artifact is prose (markdown/plain text) that should render as a - * formatted document rather than a raw monospace dump. Code/data files - * (json/csv/yaml/source) stay verbatim in <pre>. Extensionless files are - * treated as prose — agents commonly write briefings with no extension. */ -function isProseArtifact(name: string): boolean { - const dot = name.lastIndexOf("."); - if (dot < 0) return true; // no extension → prose - const ext = name.slice(dot + 1).toLowerCase(); - return ["md", "mdx", "markdown", "txt", "text", "rst", "adoc"].includes(ext); -} - function reviewKind(artifacts: MissionArtifact[] | undefined): string { if (!artifacts || artifacts.length === 0) return "output"; const exts = artifacts.map((a) => a.name.split(".").pop()?.toLowerCase() ?? ""); @@ -634,620 +610,3 @@ export default async function MissionDetail({ </div> ); } - -type MissionServerTab = { - id: string; - label: string; - badge?: number | string | null; - node: ReactNode; - live?: boolean; -}; - -function MissionServerTabs({ - tabs, - active, - basePath, -}: { - tabs: MissionServerTab[]; - active?: string; - basePath: string; -}) { - const current = tabs.find((tab) => tab.id === active) ?? tabs[0]; - return ( - <div> - <div - role="tablist" - aria-label="Mission sections" - className="sticky top-[57px] z-10 -mx-1 mb-5 flex gap-1 overflow-x-auto rounded-xl border border-border bg-surface/80 p-1 backdrop-blur supports-[backdrop-filter]:bg-surface/70" - > - {tabs.map((tab) => { - const selected = tab.id === current.id; - return ( - <Link - key={tab.id} - href={`${basePath}?tab=${encodeURIComponent(tab.id)}`} - role="tab" - aria-selected={selected} - className={`relative flex shrink-0 items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${ - selected - ? "bg-signal/10 text-foreground" - : "text-foreground-muted hover:bg-surface-muted hover:text-foreground" - }`} - > - {tab.live && <span className="h-1.5 w-1.5 rounded-full bg-signal kb-pulse" />} - {tab.label} - {tab.badge != null && tab.badge !== 0 && ( - <span className={`rounded-full px-1.5 text-[11px] tabular-nums ${ - selected - ? "bg-signal/20 text-signal" - : "bg-surface-muted text-foreground-muted" - }`}> - {tab.badge} - </span> - )} - </Link> - ); - })} - </div> - <div role="tabpanel" className="kb-rise space-y-5"> - {current.node} - </div> - </div> - ); -} - -function EnvelopeFact({ label, value }: { label: string; value: string }) { - return ( - <span className="inline-flex items-baseline gap-1.5"> - <span className="text-xs text-foreground-muted">{label}</span> - <span className="font-medium">{value}</span> - </span> - ); -} - -/** The mission objective, rendered so a multi-step, command-laden brief is - * readable instead of collapsing into one wall of text. The header shows a - * clamped one/two-line summary (the first meaningful line); the full brief is - * behind a native disclosure that preserves line breaks. */ -function objectiveSummary(objective: string): string { - const firstLine = objective - .split("\n") - .map((l) => l.trim()) - .find((l) => l.length > 0); - return firstLine ?? objective.trim(); -} - -function ObjectiveBlock({ objective }: { objective: string }) { - const trimmed = (objective ?? "").trim(); - if (!trimmed) { - return <p className="mt-1 text-sm text-foreground-muted">No objective set.</p>; - } - const summary = objectiveSummary(trimmed); - const hasMore = summary.length < trimmed.length; - return ( - <div className="mt-1"> - <p className="line-clamp-2 text-sm text-foreground-muted">{summary}</p> - {hasMore && ( - <details className="group mt-1.5"> - <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs font-medium text-signal hover:underline [&::-webkit-details-marker]:hidden"> - <span className="transition-transform group-open:rotate-90" aria-hidden>›</span> - <span className="group-open:hidden">Show full brief</span> - <span className="hidden group-open:inline">Hide brief</span> - </summary> - <pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/50 px-4 py-3 font-mono text-xs leading-relaxed text-foreground-muted"> - {trimmed} - </pre> - </details> - )} - </div> - ); -} - -/** ONE primary, state-driven next step for the mission (audit f11). It tells the - * user what to do now in plain language and links to the single relevant place, - * rather than presenting every control at once. The full controls live in the - * tabs below; this is the signpost, not a duplicate action surface. */ -function NextStep({ - status, -}: { - status: import("@/components/mission-status").MissionStatus; -}) { - const map: Record<string, { tone: string; title: string; body: string; cta?: { href: string; label: string } }> = { - drafting: { - tone: "border-signal/30 bg-signal/[0.05]", - title: "Ready to launch", - body: "Review the composed plan below — model, tools, network, autonomy, budget — then launch it in the Execution panel when you're happy.", - }, - deploying: { - tone: "border-signal/30 bg-signal/[0.05]", - title: "Deploying — the agent is coming online", - body: "Each provisioning step below is a real, verified event. This page updates itself live; no need to refresh.", - }, - running: { - tone: "border-signal/30 bg-signal/[0.05]", - title: "Running", - body: "Watch the agent work in the Activity tab. If it needs a decision it will ask you here and in your Inbox.", - }, - needs_you: { - tone: "border-warning/40 bg-warning/10", - title: "This mission needs your decision", - body: "It paused for your approval before a priced, external, or irreversible step.", - cta: { href: "/workspace/inbox", label: "Open the inbox →" }, - }, - done: { - tone: "border-ok/40 bg-ok/10", - title: "Delivered", - body: "The deliverable is ready. Review it and accept or request changes in the Deliverable tab; the signed receipt is in the Receipt tab.", - }, - failed: { - tone: "border-danger/40 bg-danger/10", - title: "This run didn't complete", - body: "Open the Run failed tab below for a full diagnosis — the likely cause, how far it got, the runtime's exact reason, and one-click ways to re-compose or re-run.", - }, - }; - const m = map[status] ?? map.drafting; - return ( - <div className={`flex flex-wrap items-center justify-between gap-3 rounded-xl border px-5 py-3.5 ${m.tone}`}> - <div className="min-w-0"> - <p className="text-sm font-semibold">{m.title}</p> - <p className="mt-0.5 text-xs text-foreground-muted">{m.body}</p> - </div> - {m.cta && ( - <Link href={m.cta.href} className="shrink-0 rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90"> - {m.cta.label} - </Link> - )} - </div> - ); -} - -function AgentIdentityCard({ identity }: { identity: AgentIdentity }) { - return ( - <section className="rounded-xl border border-border bg-surface p-6"> - <h2 className="text-sm font-semibold">Agent mesh identity</h2> - <p className="mt-0.5 text-xs text-foreground-muted"> - The running agent's real, harness-neutral identity on the encrypted agent mesh — - discovered live from the registry. This is how work is delivered and verified across any - runtime. - </p> - <dl className="mt-3 space-y-2 text-sm"> - <div className="flex flex-wrap items-baseline gap-x-2"> - <dt className="text-xs text-foreground-muted">DID</dt> - <dd className="font-mono text-xs break-all">{identity.did}</dd> - </div> - {identity.capabilities.length > 0 && ( - <div> - <dt className="text-xs text-foreground-muted">Advertised capabilities</dt> - <dd className="mt-1 flex flex-wrap gap-1.5"> - {identity.capabilities.map((c) => ( - <span key={c} className="rounded-full bg-surface-muted px-2 py-0.5 font-mono text-xs"> - {c} - </span> - ))} - </dd> - </div> - )} - {identity.last_seen && ( - <div className="flex flex-wrap items-baseline gap-x-2"> - <dt className="text-xs text-foreground-muted">Last seen on the mesh</dt> - <dd className="text-xs font-medium">{new Date(identity.last_seen).toLocaleString()}</dd> - </div> - )} - </dl> - </section> - ); -} - -function ArtifactsPanel({ ns, task, artifacts, pullRequests, activity, egress, tokens }: { ns: string; task: string; artifacts: MissionArtifact[]; pullRequests: import("@/lib/types").PullRequestRef[]; activity: import("@/lib/types").ActivityEvent[]; egress: string[]; tokens: number | null }) { - const fmtSize = (n: number | null) => - n == null ? "" : n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`; - return ( - <section className="rounded-xl border border-border bg-surface p-6"> - <div className="flex items-start justify-between gap-3"> - <div> - <h2 className="text-sm font-semibold">Artifacts</h2> - <p className="mt-0.5 text-xs text-foreground-muted"> - The complete set of files the agent produced through its native loop over the mesh — - captured by the controller into a durable, cluster-native record. - </p> - </div> - <span className="shrink-0 rounded-full bg-surface-muted px-2.5 py-1 text-xs font-medium"> - {artifacts.length} file{artifacts.length === 1 ? "" : "s"} - </span> - </div> - {/* Pull requests are a first-class delivery type — a PR the agent opened is - an artifact, shown here as a chip (not only in the deliverable prose). */} - {pullRequests.length > 0 && ( - <div className="mt-4 rounded-lg border border-signal/20 bg-signal/[0.03] p-4"> - <h3 className="text-xs font-semibold">Pull requests opened</h3> - <ul className="mt-2 flex flex-wrap gap-2"> - {pullRequests.map((pr) => ( - <li key={pr.url}> - <a - href={pr.url} - target="_blank" - rel="noreferrer" - className="inline-flex items-center gap-2 rounded-lg border border-signal/30 bg-signal/5 px-2.5 py-1.5 hover:bg-signal/10" - title={`Pull request on ${pr.repo}`} - > - <Icon name="branch" size={13} className="shrink-0 text-signal" /> - <span className="text-xs font-medium text-signal">PR #{pr.number}</span> - <span className="font-mono text-[11px] text-foreground-muted">{pr.repo}</span> - <span aria-hidden className="text-[11px] text-foreground-muted">↗</span> - </a> - </li> - ))} - </ul> - </div> - )} - {/* How this was made — the plain-language provenance story over the real trace. */} - <div className="mt-4 rounded-lg border border-border bg-background/40 p-4"> - <h3 className="text-xs font-semibold">How this was made</h3> - <div className="mt-2"><ProvenanceStory activity={activity} egress={egress} tokens={tokens} /></div> - </div> - <ul className="mt-4 divide-y divide-border rounded-lg border border-border"> - {artifacts.map((a, i) => ( - <li key={a.name}> - <details open={i === 0} className="group"> - <summary className="flex cursor-pointer items-center justify-between gap-3 px-4 py-2.5 hover:bg-surface-muted/50"> - <span className="flex items-center gap-2 font-mono text-xs"> - <span aria-hidden className="text-foreground-muted transition-transform group-open:rotate-180">⌄</span> - {a.name} - </span> - <span className="flex shrink-0 items-center gap-3 text-xs text-foreground-muted"> - <a - href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} - target="_blank" - rel="noopener noreferrer" - className="text-signal hover:underline" - > - {a.content_truncated ? "Open full" : "Open"} - </a> - <a - href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} - download={a.name} - className="inline-flex items-center gap-1 font-medium text-signal hover:underline" - > - <Icon name="download" size={12} /> - Download - </a> - <span> - {a.content == null ? "binary · " : ""} - {fmtSize(a.size_bytes)} - </span> - </span> - </summary> - {a.content_truncated ? ( - <div className="space-y-3 border-t border-border bg-surface-muted/20 px-4 py-3"> - <p className="text-xs text-foreground-muted"> - Showing a bounded preview of {(a.content_bytes ?? a.size_bytes ?? 0).toLocaleString()} bytes. - </p> - {a.content ? ( - <pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-surface-muted/30 p-3 font-mono text-xs leading-relaxed"> - {a.content} - </pre> - ) : null} - <a - href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} - target="_blank" - rel="noopener noreferrer" - className="inline-flex text-xs font-medium text-signal hover:underline" - > - Open full artifact ↗ - </a> - <a - href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} - download={a.name} - className="inline-flex items-center gap-1 text-xs font-medium text-signal hover:underline" - > - <Icon name="download" size={12} /> - Download artifact - </a> - </div> - ) : a.content != null ? ( - isProseArtifact(a.name) ? ( - <div className="max-h-96 overflow-auto border-t border-border bg-surface-muted/20 px-4 py-3"> - <DeliverableBody output={a.content} /> - </div> - ) : ( - <pre className="max-h-96 overflow-auto whitespace-pre-wrap border-t border-border bg-surface-muted/30 px-4 py-3 font-mono text-xs leading-relaxed"> - {a.content} - </pre> - ) - ) : ( - <p className="border-t border-border bg-surface-muted/30 px-4 py-3 text-xs text-foreground-muted"> - Binary artifact — use{" "} - <a - href={`/api/namespaces/${encodeURIComponent(ns)}/tasks/${encodeURIComponent(task)}/artifact/${encodeURIComponent(a.name)}`} - download={a.name} - className="text-signal hover:underline" - > - Download - </a>{" "} - to fetch the full file. - </p> - )} - </details> - </li> - ))} - </ul> - </section> - ); -} - -function ResultPanel({ result }: { result: MissionResult }) { - return ( - <div className="space-y-3"> - {result.source === "single_turn" && ( - <div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/[0.06] px-3 py-2 text-xs text-foreground-muted"> - <span aria-hidden className="mt-0.5 text-amber-600">ℹ</span> - <span> - <span className="font-medium text-foreground">Single-turn completion.</span> The full - agent loop (tools + sub-agents) was unavailable on this run, so this is one model turn — - the Activity tab will show no tool calls. Re-run to try the full loop again. - </span> - </div> - )} - <DeliverableView - output={result.output} - model={result.model} - totalTokens={result.total_tokens} - finishedAt={result.finished_at} - /> - </div> - ); -} - -/** Analyse a run failure reason into a plain-language cause + a specific remedy, - * and (when relevant) flag that the harness itself is the problem. */ -function analyzeFailure(reason: string, harness: string | null): { cause: string; remedy: string; harnessIssue: boolean } { - const r = (reason || "").toLowerCase(); - const chatGateway = !!harness && /hermes|gateway|channel/.test(harness.toLowerCase()); - if (r.includes("did not come online") || r.includes("not yet discoverable") || r.includes("mesh registry") || r.includes("not discoverable")) { - return { - cause: chatGateway - ? `The agent never registered on the encrypted mesh. The “${harness}” harness is a chat-gateway — it waits for inbound channel messages and does not execute a one-shot mission on its own, so it never came online to do autonomous work.` - : "The agent sandbox didn't register on the encrypted mesh within the startup window. This is usually a slow container image pull or node pressure delaying the pod — occasionally a crashed agent container.", - remedy: chatGateway - ? "Re-compose this mission on the OpenClaw harness (built for autonomous missions), or drive this one through its channel." - : "Re-run it — a fresh sandbox often comes up cleanly. If it repeats, an operator can inspect the sandbox for image-pull or crash errors.", - harnessIssue: chatGateway, - }; - } - if (r.includes("no progress heartbeat") || r.includes("timed out") || r.includes("timeout")) { - return { - cause: "The agent started but stopped making progress, so the controller timed the run out after a period with no heartbeat.", - remedy: "Re-run it. If it stalls repeatedly, narrow the objective or raise the token/time budget in the envelope.", - harnessIssue: false, - }; - } - if (r.includes("content safety") || r.includes("jailbreak") || r.includes("blocked by")) { - return { - cause: "A content-safety policy blocked the run before it could deliver.", - remedy: "Adjust the objective to avoid the flagged content, or ask an operator about the content-safety floor.", - harnessIssue: false, - }; - } - if (r.includes("budget") || r.includes("token cap") || r.includes("out of tokens")) { - return { - cause: "The run hit its token budget before producing a deliverable.", - remedy: "Re-run with a higher token budget in the envelope.", - harnessIssue: false, - }; - } - return { - cause: "The run ended with an error before producing a deliverable.", - remedy: "Re-run it, or re-compose with a different harness or model.", - harnessIssue: false, - }; -} - -/** Real, actionable troubleshooting for a failed run: what happened, how far the - * provisioning got (which stage it stopped at), and what to do next. When live - * cluster evidence is available (pod/container status + the agent's own log - * tail), it uses the evidence-derived diagnosis and SHOWS the proof; otherwise - * it falls back to analysing the recorded reason. */ -function FailureDiagnostic({ - task, - troubleshoot, -}: { - task: TaskDetail; - troubleshoot: import("@/lib/types").Troubleshoot | null; -}) { - const reason = task.result?.output ?? task.execution_detail ?? "The run ended with an error."; - const harness = task.composition?.runtime ?? null; - // Prefer the live, evidence-derived diagnosis from the cluster; fall back to - // the local reason analysis when the troubleshoot endpoint is unavailable. - const local = analyzeFailure(reason, harness); - const cause = troubleshoot?.cause ?? local.cause; - const remedy = troubleshoot?.remedy ?? local.remedy; - const harnessIssue = troubleshoot?.harness_issue ?? local.harnessIssue; - const meshAcknowledged = task.assignment_events.some( - (event) => event.event_type === "acknowledged" || event.state === "Running", - ); - - // How far provisioning got — the same stages the deploy timeline tracks. The - // first un-reached stage is where it stopped. - const stages: { label: string; reached: boolean }[] = [ - { label: "Launch approved", reached: task.launched }, - { label: "Sandbox provisioned", reached: !!task.sandbox }, - { label: "Agent online on the mesh", reached: meshAcknowledged || !!task.agent_identity?.last_seen }, - { label: "First activity (model round / tool call)", reached: (task.activity?.length ?? 0) > 0 }, - ]; - const stoppedAt = stages.findIndex((s) => !s.reached); - - return ( - <section className="space-y-4 rounded-xl border border-amber-500/40 bg-amber-500/5 p-6"> - <div className="flex items-start gap-3"> - <span className="mt-0.5 text-warning" aria-hidden> - <Icon name="target" size={18} /> - </span> - <div> - <h2 className="text-sm font-semibold">Run did not complete</h2> - <p className="mt-0.5 text-xs text-foreground-muted"> - {troubleshoot - ? "Diagnosed from the sandbox's live pod status and the agent's own logs." - : "Here's what happened and how to fix it."} - </p> - </div> - </div> - - {/* Likely cause + remedy. */} - <div className="rounded-lg border border-amber-500/30 bg-surface p-4"> - <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Likely cause</p> - <p className="mt-1 text-sm">{cause}</p> - <p className="mt-3 text-xs font-semibold uppercase tracking-wide text-foreground-muted">What to do</p> - <p className="mt-1 text-sm">{remedy}</p> - </div> - - {/* The real smoking-gun evidence pulled from the agent's logs. */} - {troubleshoot && troubleshoot.evidence.length > 0 && ( - <div className="rounded-lg border border-danger/30 bg-surface p-4"> - <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">Evidence — from the agent's own logs</p> - <ul className="mt-2 space-y-1"> - {troubleshoot.evidence.map((e, i) => ( - <li key={i} className="rounded bg-danger/5 px-2 py-1 font-mono text-[11px] leading-relaxed text-danger">{e}</li> - ))} - </ul> - </div> - )} - - {/* Live container status. */} - {troubleshoot && troubleshoot.containers.length > 0 && ( - <div className="rounded-lg border border-border bg-surface p-4"> - <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted"> - Sandbox pod {troubleshoot.pod_summary ? `(${troubleshoot.pod_summary} ready)` : ""} - </p> - <ul className="mt-2 grid gap-1.5 sm:grid-cols-2"> - {troubleshoot.containers.map((c) => ( - <li key={c.name} className="flex items-center gap-2 text-xs"> - <span aria-hidden className={c.ready ? "text-ok" : "text-danger"}>{c.ready ? "✓" : "✗"}</span> - <span className="font-mono">{c.name}</span> - <span className="text-foreground-muted"> - {c.state}{c.reason ? ` · ${c.reason}` : ""}{c.restarts > 0 ? ` · ${c.restarts}↻` : ""} - </span> - </li> - ))} - </ul> - </div> - )} - - {/* How far it got. */} - <div className="rounded-lg border border-border bg-surface p-4"> - <p className="text-xs font-semibold uppercase tracking-wide text-foreground-muted">How far it got</p> - <ol className="mt-2 space-y-1.5"> - {stages.map((s, i) => { - const isStop = i === stoppedAt; - return ( - <li key={s.label} className="flex items-center gap-2 text-sm"> - <span aria-hidden className={s.reached ? "text-ok" : isStop ? "text-danger" : "text-foreground-muted"}> - {s.reached ? "✓" : isStop ? "✗" : "•"} - </span> - <span className={s.reached ? "" : isStop ? "font-medium text-danger" : "text-foreground-muted"}> - {s.label} - {isStop && <span className="ml-1.5 text-xs font-normal text-danger">— stopped here</span>} - </span> - </li> - ); - })} - </ol> - </div> - - {/* The raw agent log tail — the exact evidence, for the record. */} - <details className="rounded-lg border border-border bg-surface"> - <summary className="cursor-pointer px-4 py-2.5 text-xs font-semibold"> - {troubleshoot && troubleshoot.agent_log_tail.length > 0 ? "Agent log tail (live)" : "Runtime's exact reason"} - </summary> - {troubleshoot && troubleshoot.agent_log_tail.length > 0 ? ( - <pre className="max-h-72 overflow-auto border-t border-border px-4 py-3 font-mono text-[10px] leading-relaxed text-foreground-muted">{troubleshoot.agent_log_tail.join("\n")}</pre> - ) : ( - <p className="border-t border-border px-4 py-3 font-mono text-xs leading-relaxed text-foreground-muted">{reason}</p> - )} - </details> - - {/* Actions. */} - <div className="flex flex-wrap gap-2"> - <Link - href={`/workspace/new?intent=${encodeURIComponent(task.objective)}`} - className="rounded-lg bg-signal px-4 py-2 text-xs font-semibold text-signal-fg hover:opacity-90" - > - {harnessIssue ? "Re-compose on OpenClaw →" : "Re-compose from this intent →"} - </Link> - </div> - {task.result?.finished_at && ( - <p className="text-xs text-foreground-muted">Failed {new Date(task.result.finished_at).toLocaleString()}</p> - )} - </section> - ); -} - -function CompositionPanel({ composition, launched }: { composition: Composition; launched: boolean }) { - const c = composition; - return ( - <section className="rounded-xl border border-border bg-surface p-6"> - <h2 className="text-sm font-semibold">How this mission runs</h2> - <p className="mt-0.5 text-xs text-foreground-muted"> - {launched - ? "The effective configuration the running sandbox is using — read from the materialized policy and sandbox (including any defaults the controller applied)." - : "The planned configuration you composed — what this mission will run with once launched."} - </p> - <dl className="mt-4 grid gap-x-8 gap-y-4 sm:grid-cols-2"> - <Fact label="Model" value={c.model} /> - <Fact label="Harness" value={c.runtime} /> - <Fact label="Tool policy" value={c.tool_policy ?? "None — model only"} /> - <Fact label="Isolation" value={c.isolation} /> - <Fact - label="Connected services" - value={c.mcp_servers.length ? c.mcp_servers.map(humanizeMcp).join(", ") : "None"} - /> - <Fact label="Shared memory" value={c.memory ?? "None"} /> - </dl> - <div className="mt-4 border-t border-border pt-4"> - <p className="text-xs font-medium text-foreground-muted">Network egress</p> - {c.egress.length === 0 ? ( - <p className="mt-1 text-sm">Model path only — all other egress denied at the boundary.</p> - ) : ( - <> - <ul className="mt-1.5 flex flex-wrap gap-1.5"> - {c.egress.map((e) => { - const scope = egressScope(e); - return ( - <li key={e} className="inline-flex items-center gap-1.5 rounded-full bg-surface-muted px-2.5 py-1 font-mono text-xs"> - <span - className={`h-1.5 w-1.5 rounded-full ${scope === "internal" ? "bg-sky-500" : "bg-amber-500"}`} - title={scope === "internal" ? "Internal — in-cluster / private" : "External — public internet"} - aria-hidden - /> - {e} - </li> - ); - })} - </ul> - <p className="mt-1.5 text-[11px] text-foreground-muted"> - <span className="inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-sky-500" aria-hidden /> internal</span> - <span className="ml-3 inline-flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden /> external</span> - <span className="ml-2">— same boundary, labelled for clarity.</span> - </p> - </> - )} - </div> - {c.instructions && ( - <div className="mt-4 border-t border-border pt-4"> - <p className="text-xs font-medium text-foreground-muted">Instructions</p> - <p className="mt-1.5 whitespace-pre-wrap rounded-lg bg-surface-muted px-3 py-2 text-sm leading-relaxed"> - {c.instructions} - </p> - </div> - )} - </section> - ); -} - -function Fact({ label, value }: { label: string; value: string | null }) { - return ( - <div> - <dt className="text-xs text-foreground-muted">{label}</dt> - <dd className="mt-0.5 text-sm font-medium">{value ?? "—"}</dd> - </div> - ); -} From 6c5aadf0cbf0eaa5bd0b9b3c0da509fbe05620fe Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 02:46:05 +0200 Subject: [PATCH 049/111] Bound Team composer review panels without moving state or handlers Two hook-free ordinary render functions expand back to the exact original TeamComposer body;30context inputs are identity-bound. Keep all state/hooks/handlers/hidden payloads/approval checks andDOM structure unchanged. Files765/295/40lines; AST reconstruction and scoped lint passed, fullframework execution remains required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../teams/new/team-composer-panel-types.ts | 40 +++ .../teams/new/team-composer-panels.tsx | 295 ++++++++++++++++ .../app/workspace/teams/new/team-composer.tsx | 318 ++---------------- 3 files changed, 371 insertions(+), 282 deletions(-) create mode 100644 bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts create mode 100644 bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx diff --git a/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts b/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts new file mode 100644 index 000000000..99767f867 --- /dev/null +++ b/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts @@ -0,0 +1,40 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { Options } from "@/lib/types"; + +export type Role = { id: number; name: string; system_prompt: string; runtime: string; model: string; skills: string[] }; + +export interface GovernancePanelInput { + name: string; + options: Options; + mcp: string[]; + setMcp: Dispatch<SetStateAction<string[]>>; + toolPolicy: string; + setToolPolicy: Dispatch<SetStateAction<string>>; + commons: string; + setCommons: Dispatch<SetStateAction<string>>; + memory: string; + setMemory: Dispatch<SetStateAction<string>>; + selectedMemoryOption: Options["memories"][number] | null; + runtime: string; + setRuntime: Dispatch<SetStateAction<string>>; + model: string; + setModel: Dispatch<SetStateAction<string>>; + modelFallbacks: string[]; + setModelFallbacks: Dispatch<SetStateAction<string[]>>; + egressMode: "learning" | "strict"; + setEgressMode: Dispatch<SetStateAction<"learning" | "strict">>; + egressText: string; + setEgressText: Dispatch<SetStateAction<string>>; +} + +export interface OrgPanelInput { + name: string; + tier: number; + roles: Role[]; + options: Options; + addFromArchetype: (id: string) => void; + addRole: () => void; + addNote: string | null; + patchRole: (id: number, patch: Partial<Role>) => void; + removeRole: (id: number) => void; +} diff --git a/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx b/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx new file mode 100644 index 000000000..24e9ed90d --- /dev/null +++ b/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { Icon } from "@/components/icon"; +import { MEMBER_ARCHETYPES } from "@/lib/member-archetypes"; +import type { GovernancePanelInput, OrgPanelInput } from "./team-composer-panel-types"; + +function moveFallback(routes: string[], index: number, delta: number): string[] { + const next = index + delta; + if (next < 0 || next >= routes.length) return routes; + const copy = [...routes]; + [copy[index], copy[next]] = [copy[next], copy[index]]; + return copy; +} + +export function renderGovernancePanel({ name, options, mcp, setMcp, toolPolicy, setToolPolicy, commons, setCommons, memory, setMemory, selectedMemoryOption, runtime, setRuntime, model, setModel, modelFallbacks, setModelFallbacks, egressMode, setEgressMode, egressText, setEgressText }: GovernancePanelInput) { + return ( +<details className="mt-3"> + <summary className="cursor-pointer text-xs font-medium text-foreground-muted hover:text-foreground"> + Advanced governance & access{mcp.length > 0 ? ` · ${mcp.length} MCP selected` : ""} + </summary> + <fieldset className="mt-3 rounded-lg border border-border p-3"> + <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="shield" size={13} /> Governance & access</legend> + <div className="grid gap-3 sm:grid-cols-2"> + <label className="text-xs text-foreground-muted"> + Tool policy + <select aria-label="Tool policy" value={toolPolicy} onChange={(e) => setToolPolicy(e.target.value)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">cluster default (kars-default)</option> + {options.tool_policies.map((tp) => <option key={tp.name} value={tp.name}>{tp.name}{tp.summary ? ` — ${tp.summary}` : ""}</option>)} + </select> + </label> + <label className="text-xs text-foreground-muted"> + Knowledge commons name + <input + value={commons} + onChange={(e) => setCommons(e.target.value)} + placeholder={`${name || "<team>"} (default)`} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + /> + <span className="mt-1 block text-[11px] text-foreground-muted"> + The team's durable shared archive and backlog namespace. Leave blank to use the + team default. + </span> + </label> + <label className="text-xs text-foreground-muted"> + Runtime memory backend + <select + aria-label="Team runtime memory" + value={memory} + onChange={(e) => setMemory(e.target.value)} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + <option value="">None — rely on the team's own commons</option> + {options.memories.map((entry) => ( + <option key={entry.name} value={entry.name}> + {entry.name} + {entry.summary ? ` · ${entry.summary}` : ""} + {entry.qualified_routes?.length ? " · qualified" : " · unqualified"} + </option> + ))} + </select> + {selectedMemoryOption && ( + <span className="mt-1 block text-[11px] text-foreground-muted"> + {selectedMemoryOption.backend ?? "unknown backend"} + {selectedMemoryOption.compiled_digest ? ` · digest ${selectedMemoryOption.compiled_digest}` : ""} + {selectedMemoryOption.readiness ? ` · ${selectedMemoryOption.readiness}` : ""} + {selectedMemoryOption.qualified_routes?.length + ? ` · qualified on ${selectedMemoryOption.qualified_routes.join(", ")}` + : " · not resource-qualified"} + </span> + )} + </label> + <label className="text-xs text-foreground-muted"> + Harness (runtime for every run) + <select aria-label="Team harness" value={runtime} onChange={(e) => setRuntime(e.target.value)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">sandbox default (OpenClaw)</option> + {options.runtimes.filter((rt) => rt.wired).map((rt) => <option key={rt.kind} value={rt.kind}>{rt.label}</option>)} + </select> + </label> + <label className="text-xs text-foreground-muted"> + Principal/default model + <select aria-label="Team principal model" value={model} onChange={(event) => { + const route = event.target.value; + setModel(route); + setModelFallbacks((current) => current.filter((fallback) => fallback !== route)); + }} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="">cluster default</option> + {options.models.map((option) => ( + <option key={`${option.provider}::${option.deployment}`} value={`${option.provider}::${option.deployment}`}> + {option.deployment} · {option.provider}{option.is_default ? " (default)" : ""} + </option> + ))} + </select> + </label> + <label className="text-xs text-foreground-muted sm:col-span-2"> + Qualified fallback routes + <select + multiple + aria-label="Team model fallback routes" + value={modelFallbacks} + onChange={(event) => { + const selected = new Set( + Array.from(event.currentTarget.selectedOptions, (option) => option.value), + ); + setModelFallbacks((current) => [ + ...current.filter((route) => selected.has(route)), + ...Array.from(selected).filter((route) => !current.includes(route)), + ].slice(0, 8)); + }} + className="mt-1 min-h-24 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" + > + {options.models + .map((option) => `${option.provider}::${option.deployment}`) + .filter((route) => route !== model) + .map((route) => ( + <option key={route} value={route}> + {route} + </option> + ))} + </select> + {modelFallbacks.map((route, index) => ( + <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> + <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> + <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setModelFallbacks((current) => moveFallback(current, index, -1))}>↑</button> + <button type="button" aria-label={`Move ${route} later`} disabled={index === modelFallbacks.length - 1} onClick={() => setModelFallbacks((current) => moveFallback(current, index, 1))}>↓</button> + </span> + ))} + <span className="mt-1 block text-[11px]"> + Bridge accepts a fallback only when retained evidence proves the complete Team plan and resources on that route. + </span> + </label> + <fieldset className="sm:col-span-2"> + <legend className="text-xs text-foreground-muted">Connected services (MCP)</legend> + {options.mcp_servers.length === 0 ? ( + <p className="mt-1.5 text-xs text-foreground-muted">No MCP servers are installed.</p> + ) : ( + <div className="mt-1.5 grid gap-2 sm:grid-cols-2"> + {options.mcp_servers.map((server) => { + const checked = mcp.includes(server.name); + return ( + <label key={server.name} className="flex items-start gap-2 rounded-lg border border-border px-3 py-2 text-sm"> + <input + type="checkbox" + checked={checked} + disabled={!checked && mcp.length >= 8} + onChange={(event) => + setMcp((current) => + event.target.checked + ? [...new Set([...current, server.name])] + : current.filter((name) => name !== server.name), + ) + } + className="mt-0.5 h-3.5 w-3.5 rounded border-border" + /> + <span> + <span className="font-medium text-foreground">{server.name}</span> + {server.summary && <span className="block text-[11px] text-foreground-muted">{server.summary}</span>} + <span className="block text-[11px] text-foreground-muted"> + {server.mode ? `mode ${server.mode}` : "mode unknown"} + {server.discovered_tools?.length ? ` · tools ${server.discovered_tools.slice(0, 4).join(", ")}` : ""} + {server.tool_schema_digest ? ` · schema ${server.tool_schema_digest}` : " · schema missing"} + {server.qualified_routes?.length + ? ` · qualified ${server.qualified_routes.join(", ")}` + : " · not resource-qualified"} + </span> + </span> + </label> + ); + })} + </div> + )} + </fieldset> + <label className="text-xs text-foreground-muted"> + Egress mode + <select value={egressMode} onChange={(event) => setEgressMode(event.target.value as "learning" | "strict")} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> + <option value="learning">Learning · observe new public hosts</option> + <option value="strict">Strict · enforce reviewed hosts only</option> + </select> + </label> + <label className="text-xs text-foreground-muted sm:col-span-2"> + External hosts (one host[:port] per line) + <textarea value={egressText} onChange={(event) => setEgressText(event.target.value)} rows={3} placeholder={"api.example.com:443\nstatus.example.com:443"} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> + <span className="mt-1 block text-[11px] text-foreground-muted">Public DNS only. Private/internal targets stay fail-closed; expose those through an approved in-cluster MCP or service integration.</span> + </label> + </div> + <p className="mt-2 text-[11px] text-foreground-muted">Every team run is bounded by a tool policy — leave unset to inherit the cluster default. Selected MCP services and the runtime memory backend are inherited by every run and validated before launch. The knowledge commons remains the team's durable shared archive. The harness is the runtime every run executes on (a chat-only adapter is corrected to OpenClaw).</p> + </fieldset> + </details> + ); +} + +export function renderOrgPanel({ name, tier, roles, options, addFromArchetype, addRole, addNote, patchRole, removeRole }: OrgPanelInput) { + return ( +<div className="kb-card p-5 sm:p-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-sm font-semibold">Org chart</h2> + <p className="mt-0.5 text-xs text-foreground-muted">Each role’s authority is a verified subset of the team’s — members can run different harnesses & models.</p> + </div> + <div className="flex items-center gap-2"> + <select + value="" + onChange={(e) => { if (e.target.value) addFromArchetype(e.target.value); e.target.value = ""; }} + className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-muted hover:bg-surface-muted" + title="Add a pre-defined member archetype (e.g. Rust Engineer, Financial Analyst)" + > + <option value="">+ Add from archetype…</option> + {MEMBER_ARCHETYPES.map((a) => ( + <option key={a.id} value={a.id}>{a.icon} {a.title}</option> + ))} + </select> + <button type="button" onClick={addRole} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">+ Add role</button> + </div> + {addNote && ( + <p role="status" aria-live="polite" className="mt-1.5 text-right text-[11px] font-medium text-signal">{addNote}</p> + )} + </div> + + {/* Principal node */} + <div className="mt-4 rounded-xl border border-signal/40 bg-signal/[0.05] p-3"> + <div className="flex items-center justify-between"> + <p className="text-sm font-semibold">{name || "this team"} <span className="font-normal text-foreground-muted">· Principal</span></p> + <span className="text-xs text-foreground-muted">Tier {tier} · grants members up to Tier {Math.max(1, tier - 1)}</span> + </div> + </div> + + {/* Role nodes — connected to the principal as a visual org tree. */} + <div className="relative mt-3 space-y-3 kb-stagger sm:pl-6"> + <span aria-hidden className="pointer-events-none absolute left-3 top-0 hidden h-full w-px bg-border sm:block" /> + {roles.map((r) => ( + <div key={r.id} className="relative rounded-xl border border-border bg-surface p-3"> + <span aria-hidden className="pointer-events-none absolute -left-3 top-6 hidden h-px w-3 bg-border sm:block" /> + <div className="flex items-center gap-2"> + <input value={r.name} onChange={(e) => patchRole(r.id, { name: e.target.value })} placeholder="role name (e.g. triager)" className="flex-1 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm font-medium" /> + <span className="text-[11px] text-foreground-muted">Member</span> + <button type="button" onClick={() => removeRole(r.id)} className="text-xs text-foreground-muted hover:text-danger">Remove</button> + </div> + <textarea value={r.system_prompt} onChange={(e) => patchRole(r.id, { system_prompt: e.target.value })} rows={2} placeholder="what this role does…" className="mt-2 w-full resize-y rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" /> + <div className="mt-2 grid gap-2 sm:grid-cols-2"> + <select aria-label="Role model" value={r.model} onChange={(e) => patchRole(r.id, { model: e.target.value })} className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs"> + <option value="">model: team default</option> + {options.models.map((m) => <option key={`${m.provider}::${m.deployment}`} value={`${m.provider}::${m.deployment}`}>{m.deployment}</option>)} + </select> + <select aria-label="Role harness" value={r.runtime} onChange={(e) => patchRole(r.id, { runtime: e.target.value })} className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs"> + <option value="">harness: OpenClaw</option> + {options.runtimes.filter((rt) => rt.wired).map((rt) => <option key={rt.kind} value={rt.kind}>{rt.label}</option>)} + </select> + </div> + {/* Per-role skills — a real picker from the attested KarsSkills the + cluster offers, so "Skills" isn't a taught concept with no control. */} + {options.skills.length > 0 ? ( + <div className="mt-2"> + <p className="text-[11px] text-foreground-muted">Skills (attested capability bundles this role acquires)</p> + <div className="mt-1 flex flex-wrap gap-1.5"> + {options.skills.map((sk) => { + const on = r.skills.includes(sk.name); + return ( + <button + key={sk.name} + type="button" + title={[ + sk.summary, + sk.version ? `version ${sk.version}` : null, + sk.version_digest ? `digest ${sk.version_digest}` : null, + sk.recipe ? `recipe ${sk.recipe}` : null, + sk.qualified_routes?.length + ? `qualified ${sk.qualified_routes.join(", ")}` + : "not resource-qualified", + ].filter(Boolean).join(" · ") || undefined} + onClick={() => + patchRole(r.id, { + skills: on ? r.skills.filter((x) => x !== sk.name) : [...r.skills, sk.name], + }) + } + className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${ + on ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground" + }`} + > + {on ? "✓ " : ""}{sk.name} + </button> + ); + })} + </div> + </div> + ) : ( + r.skills.length > 0 && ( + <p className="mt-2 text-[11px] text-foreground-muted">Skills: {r.skills.join(", ")}</p> + ) + )} + </div> + ))} + {roles.length === 0 && <p className="text-xs text-foreground-muted">No roles — add at least one, or the team runs as a single principal.</p>} + </div> + </div> + ); +} diff --git a/bridge/web/src/app/workspace/teams/new/team-composer.tsx b/bridge/web/src/app/workspace/teams/new/team-composer.tsx index 69d5b1026..1b1cdb8fe 100644 --- a/bridge/web/src/app/workspace/teams/new/team-composer.tsx +++ b/bridge/web/src/app/workspace/teams/new/team-composer.tsx @@ -22,11 +22,11 @@ import { OrchestrationCube } from "@/components/orchestration-cube"; import { LoopDesigner } from "@/components/loop-designer"; import { Icon, type IconName } from "@/components/icon"; import { MEMBER_ARCHETYPES } from "@/lib/member-archetypes"; +import { renderGovernancePanel, renderOrgPanel } from "./team-composer-panels"; +import type { Role } from "./team-composer-panel-types"; const init: NewTeamState = { error: null }; -type Role = { id: number; name: string; system_prompt: string; runtime: string; model: string; skills: string[] }; - let RID = 1; function proposeRoles(): Omit<Role, "id">[] { @@ -47,14 +47,6 @@ function parseEgressLines(value: string): { host: string; port?: number }[] { }); } -function moveFallback(routes: string[], index: number, delta: number): string[] { - const next = index + delta; - if (next < 0 || next >= routes.length) return routes; - const copy = [...routes]; - [copy[index], copy[next]] = [copy[next], copy[index]]; - return copy; -} - export function TeamComposer({ options, profile, initialCharter }: { options: Options; profile?: ProfileSummary | null; initialCharter?: string }) { const availableSkillNames = useMemo( () => new Set(options.skills.map((skill) => skill.name)), @@ -488,282 +480,44 @@ export function TeamComposer({ options, profile, initialCharter }: { options: Op {/* Advanced governance — real team-level access controls the create API supports: the tool policy that bounds every run, and the shared knowledge commons its runs read/write. Defaults are safe when unset. */} - <details className="mt-3"> - <summary className="cursor-pointer text-xs font-medium text-foreground-muted hover:text-foreground"> - Advanced governance & access{mcp.length > 0 ? ` · ${mcp.length} MCP selected` : ""} - </summary> - <fieldset className="mt-3 rounded-lg border border-border p-3"> - <legend className="flex items-center gap-1.5 px-1 text-xs font-medium text-foreground-muted"><Icon name="shield" size={13} /> Governance & access</legend> - <div className="grid gap-3 sm:grid-cols-2"> - <label className="text-xs text-foreground-muted"> - Tool policy - <select aria-label="Tool policy" value={toolPolicy} onChange={(e) => setToolPolicy(e.target.value)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> - <option value="">cluster default (kars-default)</option> - {options.tool_policies.map((tp) => <option key={tp.name} value={tp.name}>{tp.name}{tp.summary ? ` — ${tp.summary}` : ""}</option>)} - </select> - </label> - <label className="text-xs text-foreground-muted"> - Knowledge commons name - <input - value={commons} - onChange={(e) => setCommons(e.target.value)} - placeholder={`${name || "<team>"} (default)`} - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" - /> - <span className="mt-1 block text-[11px] text-foreground-muted"> - The team's durable shared archive and backlog namespace. Leave blank to use the - team default. - </span> - </label> - <label className="text-xs text-foreground-muted"> - Runtime memory backend - <select - aria-label="Team runtime memory" - value={memory} - onChange={(e) => setMemory(e.target.value)} - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" - > - <option value="">None — rely on the team's own commons</option> - {options.memories.map((entry) => ( - <option key={entry.name} value={entry.name}> - {entry.name} - {entry.summary ? ` · ${entry.summary}` : ""} - {entry.qualified_routes?.length ? " · qualified" : " · unqualified"} - </option> - ))} - </select> - {selectedMemoryOption && ( - <span className="mt-1 block text-[11px] text-foreground-muted"> - {selectedMemoryOption.backend ?? "unknown backend"} - {selectedMemoryOption.compiled_digest ? ` · digest ${selectedMemoryOption.compiled_digest}` : ""} - {selectedMemoryOption.readiness ? ` · ${selectedMemoryOption.readiness}` : ""} - {selectedMemoryOption.qualified_routes?.length - ? ` · qualified on ${selectedMemoryOption.qualified_routes.join(", ")}` - : " · not resource-qualified"} - </span> - )} - </label> - <label className="text-xs text-foreground-muted"> - Harness (runtime for every run) - <select aria-label="Team harness" value={runtime} onChange={(e) => setRuntime(e.target.value)} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> - <option value="">sandbox default (OpenClaw)</option> - {options.runtimes.filter((rt) => rt.wired).map((rt) => <option key={rt.kind} value={rt.kind}>{rt.label}</option>)} - </select> - </label> - <label className="text-xs text-foreground-muted"> - Principal/default model - <select aria-label="Team principal model" value={model} onChange={(event) => { - const route = event.target.value; - setModel(route); - setModelFallbacks((current) => current.filter((fallback) => fallback !== route)); - }} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> - <option value="">cluster default</option> - {options.models.map((option) => ( - <option key={`${option.provider}::${option.deployment}`} value={`${option.provider}::${option.deployment}`}> - {option.deployment} · {option.provider}{option.is_default ? " (default)" : ""} - </option> - ))} - </select> - </label> - <label className="text-xs text-foreground-muted sm:col-span-2"> - Qualified fallback routes - <select - multiple - aria-label="Team model fallback routes" - value={modelFallbacks} - onChange={(event) => { - const selected = new Set( - Array.from(event.currentTarget.selectedOptions, (option) => option.value), - ); - setModelFallbacks((current) => [ - ...current.filter((route) => selected.has(route)), - ...Array.from(selected).filter((route) => !current.includes(route)), - ].slice(0, 8)); - }} - className="mt-1 min-h-24 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm" - > - {options.models - .map((option) => `${option.provider}::${option.deployment}`) - .filter((route) => route !== model) - .map((route) => ( - <option key={route} value={route}> - {route} - </option> - ))} - </select> - {modelFallbacks.map((route, index) => ( - <span key={route} className="mt-1 flex items-center gap-1 rounded border border-border bg-surface px-2 py-1"> - <span className="min-w-0 flex-1 truncate">{index + 1}. {route}</span> - <button type="button" aria-label={`Move ${route} earlier`} disabled={index === 0} onClick={() => setModelFallbacks((current) => moveFallback(current, index, -1))}>↑</button> - <button type="button" aria-label={`Move ${route} later`} disabled={index === modelFallbacks.length - 1} onClick={() => setModelFallbacks((current) => moveFallback(current, index, 1))}>↓</button> - </span> - ))} - <span className="mt-1 block text-[11px]"> - Bridge accepts a fallback only when retained evidence proves the complete Team plan and resources on that route. - </span> - </label> - <fieldset className="sm:col-span-2"> - <legend className="text-xs text-foreground-muted">Connected services (MCP)</legend> - {options.mcp_servers.length === 0 ? ( - <p className="mt-1.5 text-xs text-foreground-muted">No MCP servers are installed.</p> - ) : ( - <div className="mt-1.5 grid gap-2 sm:grid-cols-2"> - {options.mcp_servers.map((server) => { - const checked = mcp.includes(server.name); - return ( - <label key={server.name} className="flex items-start gap-2 rounded-lg border border-border px-3 py-2 text-sm"> - <input - type="checkbox" - checked={checked} - disabled={!checked && mcp.length >= 8} - onChange={(event) => - setMcp((current) => - event.target.checked - ? [...new Set([...current, server.name])] - : current.filter((name) => name !== server.name), - ) - } - className="mt-0.5 h-3.5 w-3.5 rounded border-border" - /> - <span> - <span className="font-medium text-foreground">{server.name}</span> - {server.summary && <span className="block text-[11px] text-foreground-muted">{server.summary}</span>} - <span className="block text-[11px] text-foreground-muted"> - {server.mode ? `mode ${server.mode}` : "mode unknown"} - {server.discovered_tools?.length ? ` · tools ${server.discovered_tools.slice(0, 4).join(", ")}` : ""} - {server.tool_schema_digest ? ` · schema ${server.tool_schema_digest}` : " · schema missing"} - {server.qualified_routes?.length - ? ` · qualified ${server.qualified_routes.join(", ")}` - : " · not resource-qualified"} - </span> - </span> - </label> - ); - })} - </div> - )} - </fieldset> - <label className="text-xs text-foreground-muted"> - Egress mode - <select value={egressMode} onChange={(event) => setEgressMode(event.target.value as "learning" | "strict")} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"> - <option value="learning">Learning · observe new public hosts</option> - <option value="strict">Strict · enforce reviewed hosts only</option> - </select> - </label> - <label className="text-xs text-foreground-muted sm:col-span-2"> - External hosts (one host[:port] per line) - <textarea value={egressText} onChange={(event) => setEgressText(event.target.value)} rows={3} placeholder={"api.example.com:443\nstatus.example.com:443"} className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 font-mono text-xs" /> - <span className="mt-1 block text-[11px] text-foreground-muted">Public DNS only. Private/internal targets stay fail-closed; expose those through an approved in-cluster MCP or service integration.</span> - </label> - </div> - <p className="mt-2 text-[11px] text-foreground-muted">Every team run is bounded by a tool policy — leave unset to inherit the cluster default. Selected MCP services and the runtime memory backend are inherited by every run and validated before launch. The knowledge commons remains the team's durable shared archive. The harness is the runtime every run executes on (a chat-only adapter is corrected to OpenClaw).</p> - </fieldset> - </details> + {renderGovernancePanel({ + name, + options, + mcp, + setMcp, + toolPolicy, + setToolPolicy, + commons, + setCommons, + memory, + setMemory, + selectedMemoryOption, + runtime, + setRuntime, + model, + setModel, + modelFallbacks, + setModelFallbacks, + egressMode, + setEgressMode, + egressText, + setEgressText, + })} <p className="mt-3 text-xs text-foreground-muted">Charter: <span className="text-foreground">{charter}</span></p> </div> {/* The org chart. */} - <div className="kb-card p-5 sm:p-6"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-sm font-semibold">Org chart</h2> - <p className="mt-0.5 text-xs text-foreground-muted">Each role’s authority is a verified subset of the team’s — members can run different harnesses & models.</p> - </div> - <div className="flex items-center gap-2"> - <select - value="" - onChange={(e) => { if (e.target.value) addFromArchetype(e.target.value); e.target.value = ""; }} - className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-foreground-muted hover:bg-surface-muted" - title="Add a pre-defined member archetype (e.g. Rust Engineer, Financial Analyst)" - > - <option value="">+ Add from archetype…</option> - {MEMBER_ARCHETYPES.map((a) => ( - <option key={a.id} value={a.id}>{a.icon} {a.title}</option> - ))} - </select> - <button type="button" onClick={addRole} className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium hover:bg-surface-muted">+ Add role</button> - </div> - {addNote && ( - <p role="status" aria-live="polite" className="mt-1.5 text-right text-[11px] font-medium text-signal">{addNote}</p> - )} - </div> - - {/* Principal node */} - <div className="mt-4 rounded-xl border border-signal/40 bg-signal/[0.05] p-3"> - <div className="flex items-center justify-between"> - <p className="text-sm font-semibold">{name || "this team"} <span className="font-normal text-foreground-muted">· Principal</span></p> - <span className="text-xs text-foreground-muted">Tier {tier} · grants members up to Tier {Math.max(1, tier - 1)}</span> - </div> - </div> - - {/* Role nodes — connected to the principal as a visual org tree. */} - <div className="relative mt-3 space-y-3 kb-stagger sm:pl-6"> - <span aria-hidden className="pointer-events-none absolute left-3 top-0 hidden h-full w-px bg-border sm:block" /> - {roles.map((r) => ( - <div key={r.id} className="relative rounded-xl border border-border bg-surface p-3"> - <span aria-hidden className="pointer-events-none absolute -left-3 top-6 hidden h-px w-3 bg-border sm:block" /> - <div className="flex items-center gap-2"> - <input value={r.name} onChange={(e) => patchRole(r.id, { name: e.target.value })} placeholder="role name (e.g. triager)" className="flex-1 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm font-medium" /> - <span className="text-[11px] text-foreground-muted">Member</span> - <button type="button" onClick={() => removeRole(r.id)} className="text-xs text-foreground-muted hover:text-danger">Remove</button> - </div> - <textarea value={r.system_prompt} onChange={(e) => patchRole(r.id, { system_prompt: e.target.value })} rows={2} placeholder="what this role does…" className="mt-2 w-full resize-y rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs" /> - <div className="mt-2 grid gap-2 sm:grid-cols-2"> - <select aria-label="Role model" value={r.model} onChange={(e) => patchRole(r.id, { model: e.target.value })} className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs"> - <option value="">model: team default</option> - {options.models.map((m) => <option key={`${m.provider}::${m.deployment}`} value={`${m.provider}::${m.deployment}`}>{m.deployment}</option>)} - </select> - <select aria-label="Role harness" value={r.runtime} onChange={(e) => patchRole(r.id, { runtime: e.target.value })} className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs"> - <option value="">harness: OpenClaw</option> - {options.runtimes.filter((rt) => rt.wired).map((rt) => <option key={rt.kind} value={rt.kind}>{rt.label}</option>)} - </select> - </div> - {/* Per-role skills — a real picker from the attested KarsSkills the - cluster offers, so "Skills" isn't a taught concept with no control. */} - {options.skills.length > 0 ? ( - <div className="mt-2"> - <p className="text-[11px] text-foreground-muted">Skills (attested capability bundles this role acquires)</p> - <div className="mt-1 flex flex-wrap gap-1.5"> - {options.skills.map((sk) => { - const on = r.skills.includes(sk.name); - return ( - <button - key={sk.name} - type="button" - title={[ - sk.summary, - sk.version ? `version ${sk.version}` : null, - sk.version_digest ? `digest ${sk.version_digest}` : null, - sk.recipe ? `recipe ${sk.recipe}` : null, - sk.qualified_routes?.length - ? `qualified ${sk.qualified_routes.join(", ")}` - : "not resource-qualified", - ].filter(Boolean).join(" · ") || undefined} - onClick={() => - patchRole(r.id, { - skills: on ? r.skills.filter((x) => x !== sk.name) : [...r.skills, sk.name], - }) - } - className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${ - on ? "border-signal/40 bg-signal/10 text-signal" : "border-border text-foreground-muted hover:text-foreground" - }`} - > - {on ? "✓ " : ""}{sk.name} - </button> - ); - })} - </div> - </div> - ) : ( - r.skills.length > 0 && ( - <p className="mt-2 text-[11px] text-foreground-muted">Skills: {r.skills.join(", ")}</p> - ) - )} - </div> - ))} - {roles.length === 0 && <p className="text-xs text-foreground-muted">No roles — add at least one, or the team runs as a single principal.</p>} - </div> - </div> + {renderOrgPanel({ + name, + tier, + roles, + options, + addFromArchetype, + addRole, + addNote, + patchRole, + removeRole, + })} <div className="kb-card p-5 sm:p-6"> <h2 className="text-sm font-semibold">Typed execution plan</h2> From 3f7a014cd56f575f94cec7e6b2a366dd3a200d6f Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 03:03:48 +0200 Subject: [PATCH 050/111] Retain fixed SRE staging failure phases in native diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- tests/e2e/sre_authority/common.py | 10 ++++++++++ tests/e2e/sre_authority/harness_test.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/e2e/sre_authority/common.py b/tests/e2e/sre_authority/common.py index 582a3332c..cea1657c4 100644 --- a/tests/e2e/sre_authority/common.py +++ b/tests/e2e/sre_authority/common.py @@ -63,6 +63,16 @@ def command_site(): def command_error_category(stderr): + stages = { + "registrar", "controller-review", "release-inventory", "prerequisite-chart-render", + "action-schema-review", "helm-compatibility", "action-schema-migration", + "core-schema-preparation", "helm-server-dry-run", "helm-upgrade", + "template-ownership-review", "schema-publication", "template-authority-write", + "controller-rollout", + } + observed = set(re.findall(r"^SRE-STAGE-FAILURE ([a-z-]+)$", stderr, re.MULTILINE)) & stages + if observed: + return "sre-stage:" + (next(iter(observed)) if len(observed) == 1 else "ambiguous") status = re.search(r"Error from server \((Forbidden|Unauthorized|Invalid|NotFound|" r"AlreadyExists|Conflict|BadRequest|InternalError|ServiceUnavailable)\)", stderr) if status: diff --git a/tests/e2e/sre_authority/harness_test.py b/tests/e2e/sre_authority/harness_test.py index 1cc9a662f..bb2d57d10 100644 --- a/tests/e2e/sre_authority/harness_test.py +++ b/tests/e2e/sre_authority/harness_test.py @@ -35,6 +35,20 @@ def json(self): class HarnessTests(unittest.TestCase): + def test_sre_stage_diagnostics_keep_only_fixed_known_phase_names(self): + from sre_authority.common import command_error_category + private = "DO-NOT-EMIT-PRIVATE-DATA" + for stage in ("action-schema-review", "helm-server-dry-run", "core-schema-preparation"): + value = command_error_category(f"{private}\nSRE-STAGE-FAILURE {stage}\n{private}") + self.assertEqual(value, "sre-stage:" + stage) + self.assertNotIn(private, value) + for value in (f"SRE-STAGE-FAILURE {private}", + f"SRE-STAGE-FAILURE action-schema-review {private}"): + self.assertEqual(command_error_category(value), "unclassified") + self.assertEqual(command_error_category( + "SRE-STAGE-FAILURE action-schema-review\nSRE-STAGE-FAILURE helm-server-dry-run"), + "sre-stage:ambiguous") + def test_hermes_runtime_assertion_verifies_exact_pin_and_runtime_without_blanket_standin_acceptance(self): helper = Path(__file__).resolve().parents[1] / "sre-authority.sh" cases = [ From cced23c58ebdee70582f36a6750b17aa172aad95 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 03:19:25 +0200 Subject: [PATCH 051/111] Preserve secret-safe governed-service failure evidence before cleanup Keep the HTTP, identity and scope contract unchanged; retain fixed stage facts instead of private command or response data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 2 +- docs/governed-services.md | 18 ++- tests/e2e/governed-services.sh | 88 +++++++++++- tests/e2e/governed_services_test.py | 216 ++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/governed_services_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e357d7df..52f686ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -424,7 +424,7 @@ jobs: version: v1.30.5 - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 - name: Check public-schema diagnostic privacy - run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test + run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test governed_services_test - name: Create the same disposable API server as the real harness run: kind create cluster --name kars-e2e --config tests/e2e/kind-config.yaml --kubeconfig "$KUBECONFIG" - name: Prove native historical Helm wait orders CRDs before hooks and permits schema upgrades diff --git a/docs/governed-services.md b/docs/governed-services.md index 3fdc351a5..f6cbbd329 100644 --- a/docs/governed-services.md +++ b/docs/governed-services.md @@ -9,10 +9,20 @@ telemetry. They do **not** deliver assignments, run agents, create approvals, grant capabilities, install resources, or provide a durable execution ledger. **Qualification gate:** the combined source includes the operator-authorized -[SRE identity/migration prerequisite](how-to/sre-authority.md). Its real-API -migration acceptance (#551) remains pending; merging its implementation locally -does not establish successful hosted qualification or make this candidate ready -for deployment. Changing mounts alone does not establish operator-only authority. +[SRE identity/migration prerequisite](how-to/sre-authority.md). Qualification +requires successful real-API migration and governed-service lifecycle checks on +the actual candidate; merging an implementation locally is not deployment +qualification. Changing mounts alone does not establish operator-only authority. + +The Kind service gate reports failures as `GOVERNED-SERVICES-FAILURE` followed +by a bounded JSON record before removing its private temporary files. The record +contains a fixed stage/category, numeric expected/actual HTTP status, and boolean +credential-presence, identity-presence and scope-comparison results. Status `0` +means no valid HTTP status was recorded, not an authentication denial. Boolean +`false` can also mean that a later check was not reached; interpret it with the +reported stage. Raw command errors, tokens, identities, scope IDs and response +bodies are not emitted. The original failure and owned port-forward/file cleanup +remain mandatory; these diagnostics do not qualify the failing operation. ## Identity and operator authentication diff --git a/tests/e2e/governed-services.sh b/tests/e2e/governed-services.sh index 2371013ef..9bfb97e2d 100644 --- a/tests/e2e/governed-services.sh +++ b/tests/e2e/governed-services.sh @@ -8,30 +8,71 @@ test_governed_services() ( local k=(kubectl --context kind-kars-e2e) local scratch forward_pid="" port="" token agent_token scope request_id new_scope code local sandbox_uid namespace_uid + local stage=setup category=command expected_status=0 actual_status=0 + local token_present=false agent_token_present=false tokens_distinct=false + local sandbox_present=false namespace_present=false forward_started=false + local scope_changed=false sandbox_preserved=false telemetry_scope_matches=false scratch=$(mktemp -d) || return 1 + exec 3>&2 + exec 2>"$scratch/commands.log" + governed_failure() { + printf 'GOVERNED-SERVICES-FAILURE {"stage":"%s","category":"%s","expectedHttpStatus":%s,"httpStatus":%s,"operatorTokenPresent":%s,"agentTokenPresent":%s,"tokensDistinct":%s,"sandboxUidPresent":%s,"namespaceUidPresent":%s,"forwardStarted":%s,"scopeChanged":%s,"sandboxPreserved":%s,"telemetryScopeMatches":%s}\n' \ + "$stage" "$category" "$expected_status" "$actual_status" \ + "$token_present" "$agent_token_present" "$tokens_distinct" \ + "$sandbox_present" "$namespace_present" "$forward_started" \ + "$scope_changed" "$sandbox_preserved" "$telemetry_scope_matches" >&3 + } + service_stage() { + stage="$1" + category=assertion + expected_status=0 + actual_status=0 + } cleanup_governed_smoke() { + local result=$? + [ "$result" -eq 0 ] || governed_failure if [ -n "$forward_pid" ]; then kill "$forward_pid" 2>/dev/null || true wait "$forward_pid" 2>/dev/null || true fi - rm -f "$scratch/forward.log" "$scratch/response.json" "$scratch/request.json" - rmdir "$scratch" + if ! rm -f "$scratch/forward.log" "$scratch/response.json" "$scratch/request.json" "$scratch/commands.log" \ + || ! rmdir "$scratch"; then + service_stage cleanup + category=cleanup + governed_failure + result=1 + fi + trap - EXIT + exit "$result" } trap cleanup_governed_smoke EXIT # Values travel only through the test process and curl's stdin, not argv/logs. + service_stage operator-token-read token=$("${k[@]}" get secret router-services-admin -n kars-e2e-test \ --request-timeout=20s -o go-template='{{index .data "control-token" | base64decode}}') || return 1 + [ -z "$token" ] || token_present=true + service_stage agent-token-read agent_token=$("${k[@]}" get secret router-admin-token -n kars-e2e-test \ --request-timeout=20s -o go-template='{{index .data "token" | base64decode}}') || return 1 + [ -z "$agent_token" ] || agent_token_present=true + [ "$token" = "$agent_token" ] || tokens_distinct=true + service_stage credential-distinctness [ -n "$token" ] && [ -n "$agent_token" ] && [ "$token" != "$agent_token" ] || return 1 + service_stage sandbox-identity-read sandbox_uid=$("${k[@]}" get karssandbox e2e-test -n kars-system \ --request-timeout=20s -o jsonpath='{.metadata.uid}') || return 1 + [ -z "$sandbox_uid" ] || sandbox_present=true + service_stage namespace-identity-read namespace_uid=$("${k[@]}" get namespace kars-e2e-test \ --request-timeout=20s -o jsonpath='{.metadata.uid}') || return 1 + [ -z "$namespace_uid" ] || namespace_present=true + service_stage identity-presence [ -n "$sandbox_uid" ] && [ -n "$namespace_uid" ] || return 1 + service_stage deployment-read "${k[@]}" get deployment e2e-test -n kars-e2e-test --request-timeout=20s -o json \ >"$scratch/response.json" || return 1 + service_stage private-mount-isolation python3 - "$scratch/response.json" <<'PY' || return 1 import json, sys pod = json.load(open(sys.argv[1]))["spec"]["template"]["spec"] @@ -45,20 +86,25 @@ for container in pod["containers"]: assert not mounts PY + service_stage port-forward-start "${k[@]}" port-forward --address 127.0.0.1 service/e2e-test -n kars-e2e-test :8443 \ >"$scratch/forward.log" 2>&1 & forward_pid=$! local deadline=$(($(date +%s) + 30)) while [ "$(date +%s)" -lt "$deadline" ]; do - kill -0 "$forward_pid" 2>/dev/null || return 1 + kill -0 "$forward_pid" 2>/dev/null || { category=process-exited; return 1; } port=$(sed -n 's/^Forwarding from 127\.0\.0\.1:\([0-9]*\) ->.*/\1/p' "$scratch/forward.log" | head -1) [ -z "$port" ] || break sleep 1 done - [ -n "$port" ] || return 1 + [ -n "$port" ] || { category=deadline; return 1; } + forward_started=true service_request() { local expected="$1" method="$2" path="$3" bearer="${4:-}" + expected_status="$expected" + actual_status=0 + category=http-transport local args=(--disable --silent --show-error --noproxy 127.0.0.1 --connect-timeout 5 --max-time 15 --config - --request "$method" --url "http://127.0.0.1:$port$path" --output "$scratch/response.json" --write-out '%{http_code}') @@ -69,10 +115,15 @@ PY { [ -z "$bearer" ] || printf 'header = "Authorization: Bearer %s"\n' "$bearer"; } \ | curl "${args[@]}" ) || return 1 + case "$code" in + [1-5][0-9][0-9]) actual_status="$code" ;; + *) category=invalid-http-status; return 1 ;; + esac if [ "$code" != "$expected" ]; then - printf 'Governed service %s %s returned %s, expected %s\n' "$method" "$path" "$code" "$expected" >&2 + category=http-status return 1 fi + category=assertion } service_body() { python3 - "$scratch/request.json" "$@" <<'PY' @@ -94,9 +145,13 @@ print(value) PY } + service_stage anonymous-read-denial service_request 401 GET /internal/access-requests || return 1 + service_stage agent-read-denial service_request 401 GET /internal/access-requests "$agent_token" || return 1 + service_stage operator-read service_request 200 GET /internal/access-requests "$token" || return 1 + service_stage scope-identity python3 - "$scratch/response.json" "$sandbox_uid" "$namespace_uid" <<'PY' || return 1 import json, sys response = json.load(open(sys.argv[1])) @@ -106,29 +161,52 @@ assert identity["namespace_uid"] == sys.argv[3] assert identity.get("task") is None assert response["enforcement_changed"] is False PY + service_stage scope-id scope=$(response_field scope.id) || return 1 + service_stage access-request-body service_body scope_id "$scope" kind egress target example.invalid reason fixture || return 1 + service_stage access-request service_request 202 POST /v1/access-request || return 1 + service_stage request-id request_id=$(response_field request.request_id) || return 1 + service_stage decision-body service_body scope_id "$scope" request_id "$request_id" verdict approved || return 1 + service_stage agent-decision-denial service_request 401 POST /internal/access-requests/decision "$agent_token" || return 1 + service_stage operator-decision service_request 200 POST /internal/access-requests/decision "$token" || return 1 + service_stage decision-response python3 - "$scratch/response.json" <<'PY' || return 1 import json, sys response = json.load(open(sys.argv[1])) assert response["request"]["status"] == "approved" assert response["enforcement_changed"] is False PY + service_stage reset-body service_body scope_id "$scope" assignment_id fixture-assignment || return 1 + service_stage agent-reset-denial service_request 401 POST /internal/access-requests/reset "$agent_token" || return 1 + service_stage operator-reset service_request 200 POST /internal/access-requests/reset "$token" || return 1 + service_stage reset-scope-id new_scope=$(response_field scope.id) || return 1 + service_stage reset-scope-change + [ "$new_scope" = "$scope" ] || scope_changed=true [ "$new_scope" != "$scope" ] || return 1 + service_stage reset-sandbox-preservation [ "$(response_field scope.identity.sandbox.uid)" = "$sandbox_uid" ] || return 1 + sandbox_preserved=true + service_stage stale-decision-body service_body scope_id "$scope" request_id "$request_id" verdict approved || return 1 + service_stage stale-decision-denial service_request 409 POST /internal/access-requests/decision "$token" || return 1 + service_stage stale-request-body service_body scope_id "$scope" kind egress target example.invalid || return 1 + service_stage stale-request-denial service_request 409 POST /v1/access-request || return 1 + service_stage telemetry-read service_request 200 GET /telemetry/cursor || return 1 + service_stage telemetry-scope [ "$(response_field scope_id)" = "$new_scope" ] || return 1 + telemetry_scope_matches=true ) diff --git a/tests/e2e/governed_services_test.py b/tests/e2e/governed_services_test.py new file mode 100644 index 000000000..85a62a683 --- /dev/null +++ b/tests/e2e/governed_services_test.py @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Execute the real shell gate with offline command fixtures, not native auth.""" + +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("governed-services.sh") +PRIVATE = "PRIVATE-FIXTURE-DO-NOT-EMIT" +FIXTURE = r'''#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import signal +import sys +import tempfile +import time + +root = Path(os.environ["FIXTURE_ROOT"]) +mode = os.environ["FIXTURE_MODE"] +private = "PRIVATE-FIXTURE-DO-NOT-EMIT" +name = Path(sys.argv[0]).name +args = sys.argv[1:] +if name != "mktemp": + print(private, file=sys.stderr) +if name == "mktemp": + scratch = tempfile.mkdtemp(dir=root) + (root / "scratch-path").write_text(scratch) + print(scratch) +elif name == "kubectl": + assert args[:2] == ["--context", "kind-kars-e2e"] + args = args[2:] + if args[0] == "port-forward": + assert "--address" in args and "127.0.0.1" in args and ":8443" in args + (root / "forward-pid").write_text(str(os.getpid())) + if mode == "forward-exit": + sys.exit(1) + def stopped(_signal, _frame): + (root / "forward-stopped").write_text("true") + sys.exit(0) + signal.signal(signal.SIGTERM, stopped) + print("Forwarding from 127.0.0.1:18443 -> 8443", flush=True) + while True: + time.sleep(1) + elif args[1] == "secret": + if mode == "token-read-failure": + sys.exit(1) + if args[2] == "router-services-admin": + print(private + "-operator") + else: + print(private + ("-operator" if mode == "equal-tokens" else "-agent")) + elif args[1] == "karssandbox": + print(private + "-sandbox") + elif args[1] == "namespace": + print(private + "-namespace") + elif args[1] == "deployment": + print(json.dumps({"spec": {"template": {"spec": { + "volumes": [{"name": "private", "secret": {"secretName": "router-services-admin"}}], + "containers": [ + {"name": "inference-router", "volumeMounts": [{ + "name": "private", "mountPath": "/etc/kars/services", "readOnly": True}]}, + {"name": "agent", "volumeMounts": []}, + ], + }}}})) + else: + raise AssertionError("Unexpected fixture operation") +elif name == "curl": + assert private not in " ".join(args) + count = root / "request-count" + index = int(count.read_text()) if count.exists() else 0 + count.write_text(str(index + 1)) + headers = sys.stdin.read() + bearers = ["", "-agent", "-operator", "", "-agent", "-operator", + "-agent", "-operator", "-operator", "", ""] + expected_bearer = bearers[index] + if expected_bearer: + assert private + expected_bearer in headers + else: + assert "Authorization" not in headers + expected = [401, 401, 200, 202, 401, 200, 401, 200, 409, 409, 200][index] + identity = {"sandbox": {"namespace": "kars-system", "name": "e2e-test", + "uid": private + "-sandbox"}, + "namespace_uid": private + "-namespace", "task": None} + scope = private + ("-scope-new" if index >= 7 else "-scope-old") + if mode == "same-scope" and index == 7: + scope = private + "-scope-old" + if mode == "wrong-sandbox" and index == 7: + identity["sandbox"]["uid"] = private + "-replacement" + response = {"scope": {"id": scope, "identity": identity}, "enforcement_changed": False, + "request": {"request_id": private + "-request", "status": "approved"}, + "scope_id": scope, "private": private} + if mode == "wrong-telemetry" and index == 10: + response["scope_id"] = private + "-unrelated" + output = Path(args[args.index("--output") + 1]) + output.write_text(private if mode == "malformed-json" and index == 2 else json.dumps(response)) + if mode == "transport-failure": + print("000", end="") + sys.exit(7) + if mode == "invalid-http-status": + print(private, end="") + else: + print(403 if mode == "wrong-status" else expected, end="") +else: + raise AssertionError("Unexpected fixture command") +''' + + +class GovernedServicesDiagnosticsTests(unittest.TestCase): + def run_gate(self, mode): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + commands = root / "bin" + commands.mkdir() + for name in ("kubectl", "curl", "mktemp"): + executable = commands / name + executable.write_text(FIXTURE) + executable.chmod(0o700) + env = dict(os.environ, FIXTURE_ROOT=directory, FIXTURE_MODE=mode, + PATH=str(commands) + os.pathsep + os.environ["PATH"]) + try: + result = subprocess.run([ + "bash", "-c", + 'set -euo pipefail; source "$1"; ' + 'if test_governed_services; then exit 0; else exit 1; fi', + "governed-services-test", str(SCRIPT), + ], env=env, text=True, capture_output=True, timeout=20) + scratch = Path((root / "scratch-path").read_text()) + self.assertFalse(scratch.exists(), "Credential-bearing scratch files were retained") + count = root / "request-count" + requests = int(count.read_text()) if count.exists() else 0 + if (root / "forward-pid").exists() and mode != "forward-exit": + self.assertTrue((root / "forward-stopped").exists(), "Owned forward was not stopped") + finally: + pid_file = root / "forward-pid" + if pid_file.exists() and mode != "forward-exit" and not (root / "forward-stopped").exists(): + try: + os.kill(int(pid_file.read_text()), signal.SIGTERM) + except ProcessLookupError: + pass + self.assertNotIn(PRIVATE, result.stdout + result.stderr) + return result, requests + + def failure(self, mode, stage, category, requests=None): + result, count = self.run_gate(mode) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + lines = result.stderr.splitlines() + self.assertEqual(len(lines), 1, result.stderr) + prefix = "GOVERNED-SERVICES-FAILURE " + self.assertTrue(lines[0].startswith(prefix), result.stderr) + fact = json.loads(lines[0][len(prefix):]) + self.assertEqual(fact["stage"], stage) + self.assertEqual(fact["category"], category) + self.assertEqual(set(fact), { + "stage", "category", "expectedHttpStatus", "httpStatus", "operatorTokenPresent", + "agentTokenPresent", "tokensDistinct", "sandboxUidPresent", "namespaceUidPresent", + "forwardStarted", "scopeChanged", "sandboxPreserved", "telemetryScopeMatches", + }) + for key, value in fact.items(): + if key not in {"stage", "category", "expectedHttpStatus", "httpStatus"}: + self.assertIsInstance(value, bool) + if requests is not None: + self.assertEqual(count, requests) + return fact + + def test_unchanged_positive_sequence_has_no_failure_diagnostic(self): + result, requests = self.run_gate("success") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout + result.stderr, "") + self.assertEqual(requests, 11) + + def test_forward_exit_is_reported_before_private_log_cleanup(self): + fact = self.failure("forward-exit", "port-forward-start", "process-exited", 0) + self.assertFalse(fact["forwardStarted"]) + + def test_unchanged_scope_still_fails_and_cleans_up(self): + fact = self.failure("same-scope", "reset-scope-change", "assertion", 8) + self.assertFalse(fact["scopeChanged"]) + self.assertTrue(fact["forwardStarted"]) + + def test_identity_and_telemetry_comparisons_keep_their_failures(self): + self.failure("wrong-sandbox", "reset-sandbox-preservation", "assertion", 8) + self.failure("wrong-telemetry", "telemetry-scope", "assertion", 11) + + def test_equal_credentials_are_reported_only_as_booleans(self): + fact = self.failure("equal-tokens", "credential-distinctness", "assertion", 0) + self.assertTrue(fact["operatorTokenPresent"]) + self.assertTrue(fact["agentTokenPresent"]) + self.assertFalse(fact["tokensDistinct"]) + + def test_command_failure_retains_only_its_known_stage(self): + self.failure("token-read-failure", "operator-token-read", "assertion", 0) + + def test_http_mismatch_retains_only_numeric_status(self): + fact = self.failure("wrong-status", "anonymous-read-denial", "http-status", 1) + self.assertEqual((fact["expectedHttpStatus"], fact["httpStatus"]), (401, 403)) + + def test_transport_or_non_numeric_status_cannot_leak_private_text(self): + fact = self.failure("transport-failure", "anonymous-read-denial", "http-transport", 1) + self.assertEqual(fact["httpStatus"], 0) + fact = self.failure("invalid-http-status", "anonymous-read-denial", "invalid-http-status", 1) + self.assertEqual(fact["httpStatus"], 0) + + def test_malformed_response_still_fails_without_publishing_the_body(self): + self.failure("malformed-json", "scope-identity", "assertion", 3) + + +if __name__ == "__main__": + unittest.main() From c54793fa947ba7bc1a7647ffd1087977b2b5d53c Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 04:02:51 +0200 Subject: [PATCH 052/111] Retire and requalify reviewed late observer runtime scopes Preserve shared qualification, Task and Sandbox identity, held intent and private credential rotation. Fence both Deployment creation and update through explicit receipt phases and formats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/lib/private-activation-continuity.ts | 25 +- .../lib/private-activation-late-scope.test.ts | 377 +++++++++ cli/src/lib/private-activation-late-scope.ts | 489 +++++++++++ controller/src/private_activation.rs | 1 + .../src/private_activation/late_scope.rs | 790 ++++++++++++++++++ controller/src/private_activation/runtime.rs | 1 + docs/how-to/governed-credential-grants.md | 71 +- 7 files changed, 1748 insertions(+), 6 deletions(-) create mode 100644 cli/src/lib/private-activation-late-scope.test.ts create mode 100644 cli/src/lib/private-activation-late-scope.ts create mode 100644 controller/src/private_activation/late_scope.rs diff --git a/cli/src/lib/private-activation-continuity.ts b/cli/src/lib/private-activation-continuity.ts index e46ca8591..1ec30f1c5 100644 --- a/cli/src/lib/private-activation-continuity.ts +++ b/cli/src/lib/private-activation-continuity.ts @@ -11,6 +11,7 @@ import { import { replicaIntent, retirementBinding, retirementReview, retirementState, type RootRetirement, } from "./private-activation-retirement.js"; +import { reviewLateScope, stageLateScope } from "./private-activation-late-scope.js"; const RETIREMENT = "kars.azure.com/private-root-retirement"; // Unlike other private annotations, this existing field is operator-only, @@ -208,13 +209,20 @@ export async function reviewPrivateContinuity( throw new Error("Original private root restore is incomplete; resume its exact review before adding another workspace"); } const continuity = { proof, state, sealed }; - for (const scope of activation.namespaces) await scopePlan(execute, activation, scope, continuity); + for (const scope of activation.namespaces) { + const plan = await scopePlan(execute, activation, scope, continuity); + if (recoverIntent && plan === "Late") { + console.error(`Private enrollment of ${scope.namespace.name} requires reviewed runtime suspension, retirement of all old Pod UIDs, ` + + "controller admin-key rotation and restoration of the original suspension/replica intent. " + + "Task, Sandbox, namespace and stored customer data are retained; Pod-local ephemeral state is restarted. Shared root and other grants are not reset."); + } + } return continuity; } async function scopePlan( execute: Execute, activation: PrivateActivation, scope: NamespaceReview, continuity: PrivateContinuity, -): Promise<"Qualified" | "Stamping" | "Pending" | "New"> { +): Promise<"Qualified" | "Stamping" | "Pending" | "New" | "Late"> { const original = continuity.proof.activation.namespaces.find(value => value.namespace.name === scope.namespace.name); if (original) { if (scopeBinding(scope) !== scopeBinding(original) || (scope.epoch !== undefined && scope.epoch !== original.epoch)) throw new Error(failure); @@ -225,11 +233,19 @@ async function scopePlan( const namespace = await read(execute, "namespace", scope.namespace.name); if (reviewed(namespace).uid !== scope.namespace.uid) throw new Error(failure); const raw = at(namespace, "metadata", "annotations", SCOPE); + if (raw !== undefined && record(JSON.parse(String(raw))).version === 4) { + const plan = await reviewLateScope(execute, activation, scope, digest(continuity.proof)); + if (!plan) throw new Error(failure); + if (plan === "Qualified") await qualifiedScope(execute, activation, scope); + return plan; + } if (raw === undefined) { if (scope.epoch !== undefined || Object.keys(record(at(namespace, "metadata", "annotations") ?? {})) .some(key => key.startsWith(PRIVATE_PREFIX))) { throw new Error("Additional namespace has unproven private lifecycle state; preserve it for explicit recovery"); } + const late = await reviewLateScope(execute, activation, scope, digest(continuity.proof)); + if (late) return late; await consumers(execute, activation, scope, [], false); return "New"; } @@ -300,6 +316,11 @@ export async function stageSharedActivation( const plan = await scopePlan(execute, activation, scope, continuity); if (plan === "Qualified") continue; await assertSealed(execute, continuity); + if (plan === "Late") { + await stageLateScope(execute, activation, scope, digest(continuity.proof), () => assertSealed(execute, continuity)); + await qualifiedScope(execute, activation, scope); + continue; + } const receipt: ScopeQualification = { version: 3, root: digest(continuity.proof), binding: scopeBinding(scope), phase: "Pending" }; if (plan === "New") { await patchNamespace(execute, scope, { ...annotations(activation, scope, "Pending"), [SCOPE]: encoded(receipt) }, diff --git a/cli/src/lib/private-activation-late-scope.test.ts b/cli/src/lib/private-activation-late-scope.test.ts new file mode 100644 index 000000000..ab42291ac --- /dev/null +++ b/cli/src/lib/private-activation-late-scope.test.ts @@ -0,0 +1,377 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { applyReviewedGrant } from "../commands/credential-grants.js"; +import { continuityFixture, privateAuthoritySnapshot } from "./private-activation-fixtures.js"; +import { PRIVATE_PREFIX as P, canonical, type Execute } from "./private-activation.js"; + +const HISTORY = `${P}root-retirement`; +const VERSION = "kars.azure.com/services-credential-version"; +const ADMIN = "router-services-admin"; +const SOURCE = "kars.azure.com/sandbox-uid"; +const NS = "kars.azure.com/namespace-uid"; +const consumer = "kars-late/Deployment/late"; +const AUTHORIZATION = `sha256:${"a".repeat(64)}`; + +async function setup(suspended: boolean | null = null) { + const f = continuityFixture(); + await applyReviewedGrant(f.execute, await f.document()); + await applyReviewedGrant(f.execute, await f.document("second")); + const namespace: any = { kind: "Namespace", metadata: { + name: "kars-late", uid: "runtime-ns", resourceVersion: "1", annotations: { + "kars.azure.com/namespace-claim-version": "v1", "kars.azure.com/sandbox-name": "late", + "kars.azure.com/sandbox-namespace": "work", [SOURCE]: "sandbox", + } } }; + const task: any = { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsTask", metadata: { + name: "task", namespace: "work", uid: "task-uid", resourceVersion: "1", generation: 1, + }, spec: { execution: { launch: true }, objective: "Retain real Task authority" }, + status: { phase: "Ready", sandboxRef: { name: "late" }, observedGeneration: 1, + envelopeDigest: AUTHORIZATION, conditions: [{ type: "Ready", status: "True" }] } }; + const sandbox: any = { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsSandbox", metadata: { + name: "late", namespace: "work", uid: "sandbox", resourceVersion: "1", generation: 1, + annotations: { [NS]: "runtime-ns" }, ownerReferences: [{ + apiVersion: "kars.azure.com/v1alpha1", kind: "KarsTask", name: "task", uid: "task-uid", controller: true, + }] }, spec: { credentialsRef: { name: "kars-credential-bundle-source", uid: "bundle-uid" }, + ...(suspended === null ? {} : { suspended }) }, status: { phase: "Running", observedGeneration: 1, + conditions: [{ type: "Ready", status: "True", observedGeneration: 1 }] } }; + const secret: any = { kind: "Secret", type: "Opaque", metadata: { + name: ADMIN, namespace: "kars-late", uid: "admin-secret", resourceVersion: "1", + labels: { "app.kubernetes.io/managed-by": "kars-controller" }, + annotations: { [SOURCE]: "sandbox", [NS]: "runtime-ns" }, + }, data: { "control-token": Buffer.from("A".repeat(64)).toString("base64") } }; + const deployment: any = { apiVersion: "apps/v1", kind: "Deployment", metadata: { + name: "late", namespace: "kars-late", uid: "late-deployment", resourceVersion: "1", generation: 1, + labels: { "kars.azure.com/sandbox": "late", "kars.azure.com/component": "sandbox" }, + annotations: { "kars.azure.com/credential-sandbox-uid": "sandbox", "kars.azure.com/credential-namespace-uid": "runtime-ns" }, + }, spec: { replicas: suspended ? 0 : 1, selector: { matchLabels: { app: "late" } }, template: { + metadata: { labels: { app: "late" }, annotations: { [VERSION]: "admin-secret:1" } }, + spec: { automountServiceAccountToken: false, volumes: [ + { name: "governed-services-control", secret: { secretName: ADMIN, items: [{ key: "control-token", path: "control-token" }] } }, + { name: "optional-app", secret: { secretName: "router-github-app", optional: true } }, + ], containers: [{ name: "inference-router", image: "fixture", env: [ + { name: "KARS_SERVICE_IDENTITY_JSON", value: JSON.stringify({ + task: { uid: "task-uid", namespace: "work", name: "task" }, + task_authorization: AUTHORIZATION, task_generation: 1, + }) }, + ], volumeMounts: [{ name: "governed-services-control", mountPath: "/etc/kars/services", readOnly: true }] }] } } }, + status: { observedGeneration: 1, updatedReplicas: suspended ? 0 : 1, availableReplicas: suspended ? 0 : 1 } }; + const projection = { kind: "Secret", metadata: { name: "projection", uid: "projection-uid", resourceVersion: "8" }, + data: { CUSTOMER_KEY: "preserved-value" } }; + const source = { kind: "Secret", metadata: { name: "source", uid: "source-uid", resourceVersion: "9" }, + data: { CUSTOMER_KEY: "preserved-source" } }; + for (const [kind, object, ns] of [ + ["namespace", namespace, ""], ["karstask", task, "work"], ["karssandbox", sandbox, "work"], + ["deployments.apps", deployment, "kars-late"], ["secret", secret, "kars-late"], + ["secret", projection, "kars-late"], ["secret", source, "work"], + ] as const) f.objects.set(f.key(kind, object.metadata.name, ns), object); + const pod = (uid: string) => { + f.objects.set(f.key("replicasets.apps", "late-rs", "kars-late"), { + kind: "ReplicaSet", metadata: { name: "late-rs", namespace: "kars-late", uid: "late-rs-uid", resourceVersion: "1", + ownerReferences: [{ apiVersion: "apps/v1", kind: "Deployment", name: "late", uid: "late-deployment", controller: true }] }, + spec: { template: structuredClone(deployment.spec.template) }, + }); + return { kind: "Pod", metadata: { name: uid, namespace: "kars-late", uid, resourceVersion: "1", + annotations: structuredClone(deployment.spec.template.metadata.annotations), + ownerReferences: [{ apiVersion: "apps/v1", kind: "ReplicaSet", name: "late-rs", uid: "late-rs-uid", controller: true }] }, + spec: structuredClone(deployment.spec.template.spec) }; + }; + f.pods.set("kars-late", suspended ? [] : [pod("old-running"), { + ...pod("old-terminating"), metadata: { ...pod("old-terminating").metadata, deletionTimestamp: "2026-09-12T01:00:00Z" }, + }]); + let rotate = true; + let keepPods = false; + const updateDeployment = () => { + deployment.metadata.resourceVersion = String(Number(deployment.metadata.resourceVersion) + 1); + deployment.metadata.generation++; + deployment.status = { observedGeneration: deployment.metadata.generation, + updatedReplicas: deployment.spec.replicas, availableReplicas: deployment.spec.replicas }; + }; + const execute: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] !== "patch") return result; + const patch = JSON.parse(args[args.indexOf("-p") + 1]!); + if (args[1] === "karssandbox" && patch.spec) { + sandbox.metadata.generation++; + sandbox.status.observedGeneration = sandbox.metadata.generation; + if (patch.spec.suspended === null) delete sandbox.spec.suspended; + if (sandbox.spec.suspended !== true) { + deployment.spec.replicas = 1; + updateDeployment(); + f.pods.set("kars-late", [pod("new-current")]); + } + } + if (args[1] === "deployments.apps" && patch.spec?.replicas === 0 && !keepPods) f.pods.set("kars-late", []); + if (args[1] === "namespace" && args[2] === "kars-late" + && JSON.parse(namespace.metadata.annotations[HISTORY]).phase === "Rotating") { + expect(sandbox.spec.suspended).toBe(true); + expect(f.pods.get("kars-late")).toEqual([]); + expect(deployment.spec.replicas).toBe(0); + if (rotate) secret.data["control-token"] = Buffer.from("B".repeat(64)).toString("base64"); + secret.metadata.resourceVersion = "2"; + secret.metadata.annotations[`${P}epoch`] = namespace.metadata.annotations[`${P}epoch`]; + deployment.spec.template.metadata.annotations[`${P}epoch`] = namespace.metadata.annotations[`${P}epoch`]; + deployment.spec.template.metadata.annotations[VERSION] = "admin-secret:2"; + updateDeployment(); + } + return result; + }; + const document = (run = execute) => f.document("work", [consumer], run); + const preserved = () => structuredClone({ root: f.namespace("core"), reader: privateAuthoritySnapshot(f.namespace("reader")), + otherGrant: f.grant("second"), otherAuthority: f.authority.get(f.grant("second").metadata.uid), + rootDeployment: f.deployment, rootPods: f.pods.get("core"), source, projection, task }); + f.calls.length = 0; + return { ...f, namespace, task, sandbox, secret, deployment, document, execute, preserved, + refuseRotation: () => { rotate = false; }, keepPods: () => { keepPods = true; } }; +} + +describe("reviewed late runtime private enrollment", () => { + beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it.each([null, false, true])("retires real owned Pod UIDs, verifies token rotation and restores suspension %s without touching shared authority", async original => { + const f = await setup(original); + const before = f.preserved(); + const review = await f.document(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("controller admin-key rotation")); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.calls.filter(args => args[1] === "secret").every(args => args.includes("go-template={{json .metadata}}"))).toBe(true); + const oldKey = f.secret.data["control-token"]; + await applyReviewedGrant(f.execute, review); + expect(f.preserved()).toEqual(before); + expect(f.secret.metadata.uid).toBe("admin-secret"); + expect(f.secret.data["control-token"]).not.toBe(oldKey); + expect(f.sandbox.metadata.uid).toBe("sandbox"); + expect(f.sandbox.spec.suspended ?? null).toBe(original); + expect(f.deployment.metadata.uid).toBe("late-deployment"); + expect(f.deployment.spec.replicas).toBe(original ? 0 : 1); + const state = JSON.parse(f.namespace.metadata.annotations[HISTORY]); + expect(state.version).toBe(4); + expect(state.phase).toBe("Qualified"); + expect(state.runtime.task.authorization).toBe(AUTHORIZATION); + expect(state.captured).toEqual(original ? [] : ["old-running", "old-terminating"]); + expect(state.baseline.key).not.toBe(state.qualified.material.key); + expect(f.calls.some(args => args[0] === "delete")).toBe(false); + const activation = f.grant().spec.privateActivation; + expect(activation.namespaces.find((s: any) => s.namespace.name === "core").epoch) + .toBe(f.grant("second").spec.privateActivation.namespaces.find((s: any) => s.namespace.name === "core").epoch); + const qualified = await f.document(); + f.calls.length = 0; + await applyReviewedGrant(f.execute, qualified); + expect(f.calls.some(args => args[0] === "patch" && args[1] !== "karscredentialgrants.kars.azure.com")).toBe(false); + }); + + it.each(["sandbox-uid", "namespace-uid", "claim", "deployment-owner", "task-owner", "task-spec", "unobserved", + "host", "budget-token", "other-private", "pod-owner", "pod-template", "job", "secret-provenance", "missing-secret"])( + "refuses unsupported %s without any mutation", async fault => { + const f = await setup(); + if (fault === "sandbox-uid") f.sandbox.metadata.uid = "replaced"; + if (fault === "namespace-uid") f.namespace.metadata.uid = "replaced"; + if (fault === "claim") delete f.namespace.metadata.annotations["kars.azure.com/namespace-claim-version"]; + if (fault === "deployment-owner") f.deployment.metadata.ownerReferences = [{ kind: "KarsSandbox" }]; + if (fault === "task-owner") f.sandbox.metadata.ownerReferences[0].uid = "replaced"; + if (fault === "task-spec") { f.task.metadata.generation++; f.task.status.observedGeneration++; } + if (fault === "unobserved") f.sandbox.metadata.generation++; + if (fault === "host") f.deployment.spec.template.spec.hostPID = true; + if (fault === "budget-token") f.deployment.spec.template.spec.volumes.push({ + name: "budget", projected: { sources: [{ serviceAccountToken: { audience: "kars.azure.com/governed-inference-budget" } }] }, + }); + if (fault === "other-private") f.objects.set(f.key("secret", "router-github-app", "kars-late"), { + metadata: { name: "router-github-app", uid: "app", resourceVersion: "1" }, + }); + if (fault === "pod-owner") f.pods.get("kars-late")![0].metadata.ownerReferences[0].uid = "replaced"; + if (fault === "pod-template") f.pods.get("kars-late")![0].spec.hostNetwork = true; + if (fault === "job") f.pods.get("kars-late")![0].metadata.ownerReferences[0].kind = "Job"; + if (fault === "secret-provenance") delete f.secret.metadata.annotations[SOURCE]; + if (fault === "missing-secret") f.objects.delete(f.key("secret", ADMIN, "kars-late")); + await expect(f.document()).rejects.toThrow(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it.each(["template", "deployment-rv", "namespace-rv", "sandbox-spec"])("rejects changed %s after preview", async fault => { + const f = await setup(); + const review = await f.document(); + if (fault === "template") f.deployment.spec.template.spec.containers[0].image = "changed"; + if (fault === "deployment-rv") f.deployment.metadata.resourceVersion = "2"; + if (fault === "namespace-rv") f.namespace.metadata.resourceVersion = "2"; + if (fault === "sandbox-spec") { f.sandbox.spec.isolation = "changed"; f.sandbox.metadata.generation++; } + f.calls.length = 0; + await expect(applyReviewedGrant(f.execute, review)).rejects.toThrow(); + expect(f.calls.every(args => ["get", "auth"].includes(args[0]!))).toBe(true); + }); + + it.each(["Pausing", "suspend", "Retired", "Rotating", "Restoring", "resume", "Qualified"])( + "resumes lost acknowledgement at %s with original identity, attempt, captured intent and epoch", async boundary => { + const f = await setup(false); + const before = f.preserved(); + let interrupted = false; + const lost: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] !== "patch" || interrupted) return result; + const patch = JSON.parse(args[args.indexOf("-p") + 1]!); + const phase = args[1] === "namespace" && args[2] === "kars-late" + ? JSON.parse(f.namespace.metadata.annotations[HISTORY]).phase : undefined; + if (phase === boundary || (args[1] === "karssandbox" + && ((boundary === "suspend" && patch.spec.suspended === true) || (boundary === "resume" && patch.spec.suspended === false)))) { + interrupted = true; + throw new Error("lost acknowledgement"); + } + return result; + }; + await expect(applyReviewedGrant(lost, await f.document(lost))).rejects.toThrow("lost acknowledgement"); + expect(interrupted).toBe(true); + const state = JSON.parse(f.namespace.metadata.annotations[HISTORY]); + const review = await f.document(); + await applyReviewedGrant(f.execute, review); + const finished = JSON.parse(f.namespace.metadata.annotations[HISTORY]); + expect(state.runtime.task.authorization).toBe(AUTHORIZATION); + expect(finished.runtime.task.authorization).toBe(AUTHORIZATION); + expect(finished.attempt).toBe(state.attempt); + expect(finished.epoch).toBe(state.epoch ?? finished.epoch); + expect(finished.phase).toBe("Qualified"); + expect(finished.captured).toEqual(["old-running", "old-terminating"]); + expect(f.sandbox.spec.suspended).toBe(false); + expect(f.preserved()).toEqual(before); + }); + + it("rejects public epoch and version changes when actual old authentication bytes were reused", async () => { + const f = await setup(); + f.refuseRotation(); + const before = f.preserved(); + await expect(applyReviewedGrant(f.execute, await f.document())).rejects.toThrow("key was not rotated"); + expect(f.preserved()).toEqual(before); + expect(f.sandbox.spec.suspended).toBe(true); + expect(f.deployment.spec.replicas).toBe(0); + expect(JSON.parse(f.namespace.metadata.annotations[HISTORY]).phase).toBe("Rotating"); + }); + + it("does not adopt a custom token payload or extra keys", async () => { + for (const data of [{ "control-token": Buffer.from("custom").toString("base64") }, + { "control-token": Buffer.alloc(64, 193).toString("base64") }, + { "control-token": Buffer.from("A".repeat(64)).toString("base64"), other: "private" }]) { + const f = await setup(); + f.secret.data = data; + await expect(applyReviewedGrant(f.execute, await f.document())).rejects.toThrow("Customized or missing"); + expect(f.namespace.metadata.annotations[HISTORY]).toBeUndefined(); + } + }); + + it.each(["a".repeat(64), `SHA256:${"a".repeat(64)}`, `sha256:${"A".repeat(64)}`, "sha256:", + `sha256:${"a".repeat(63)}`, `sha256:${"a".repeat(65)}`, `${AUTHORIZATION}\n`])( + "rejects malformed Task authorization %s even if configured identity repeats it", async authorization => { + const f = await setup(); + f.task.status.envelopeDigest = authorization; + const env = f.deployment.spec.template.spec.containers[0].env[0]; + env.value = JSON.stringify({ ...JSON.parse(env.value), task_authorization: authorization }); + await expect(f.document()).rejects.toThrow("Task authorization"); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it("rejects a different valid prefixed Task digest than the reviewed configured identity", async () => { + const f = await setup(); + f.task.status.envelopeDigest = `sha256:${"b".repeat(64)}`; + await expect(f.document()).rejects.toThrow(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it.each(["a".repeat(64), `sha256:${"b".repeat(64)}`])( + "does not normalize or replace a changed recovery Task authorization %s", async authorization => { + const f = await setup(); + const stop: Execute = async (args, input) => { + const value = await f.execute(args, input); + if (args[0] === "patch" && args[1] === "namespace" && args[2] === "kars-late") throw new Error("interrupted"); + return value; + }; + await expect(applyReviewedGrant(stop, await f.document(stop))).rejects.toThrow("interrupted"); + const state = JSON.parse(f.namespace.metadata.annotations[HISTORY]); + expect(state.runtime.task.authorization).toBe(AUTHORIZATION); + state.runtime.task.authorization = authorization; + f.namespace.metadata.annotations[HISTORY] = canonical(state); + f.calls.length = 0; + await expect(f.document()).rejects.toThrow(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.namespace.metadata.annotations[`${P}epoch`]).toBeUndefined(); + }); + + it("keeps terminating consumers suspended and does not request new authority until actual retirement", async () => { + const f = await setup(); + f.keepPods(); + const token = f.secret.data["control-token"]; + const delayed: Execute = async (args, input) => { + const value = await f.execute(args, input); + if (args[0] === "patch" && args[1] === "deployments.apps") { + const elapsed = Date.now() + 121_000; + vi.spyOn(Date, "now").mockReturnValue(elapsed); + } + return value; + }; + await expect(applyReviewedGrant(delayed, await f.document(delayed))).rejects.toThrow("including terminating UIDs"); + expect(f.sandbox.spec.suspended).toBe(true); + expect(f.secret.data["control-token"]).toBe(token); + expect(f.namespace.metadata.annotations[`${P}epoch`]).toBeUndefined(); + expect(f.pods.get("kars-late")).toHaveLength(2); + expect(JSON.parse(f.namespace.metadata.annotations[HISTORY]).captured).toEqual(["old-running", "old-terminating"]); + vi.mocked(Date.now).mockRestore(); + f.pods.set("kars-late", []); + await applyReviewedGrant(f.execute, await f.document()); + expect(f.secret.data["control-token"]).not.toBe(token); + }); + + it.each(["karssandbox", "deployments.apps"])("does not overwrite a concurrent %s resourceVersion and safely resumes its original receipt", async kind => { + const f = await setup(); + const value = kind === "karssandbox" ? f.sandbox : f.deployment; + let conflicted = false; + const conflict: Execute = async (args, input) => { + if (!conflicted && args[0] === "patch" && args[1] === kind) { + conflicted = true; + value.metadata.resourceVersion = String(Number(value.metadata.resourceVersion) + 1); + } + return f.execute(args, input); + }; + await expect(applyReviewedGrant(conflict, await f.document(conflict))).rejects.toThrow(); + expect(conflicted).toBe(true); + const state = JSON.parse(f.namespace.metadata.annotations[HISTORY]); + expect(state.phase).toBe("Pausing"); + expect(f.secret.data["control-token"]).toBe(Buffer.from("A".repeat(64)).toString("base64")); + await applyReviewedGrant(f.execute, await f.document()); + expect(JSON.parse(f.namespace.metadata.annotations[HISTORY]).attempt).toBe(state.attempt); + }); + + it("rejects a key minted before the recorded retirement boundary instead of blessing its bytes", async () => { + const f = await setup(); + const premature: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] === "patch" && args[1] === "deployments.apps") { + f.secret.data["control-token"] = Buffer.from("C".repeat(64)).toString("base64"); + f.secret.metadata.resourceVersion = "2"; + } + return result; + }; + await expect(applyReviewedGrant(premature, await f.document(premature))).rejects.toThrow("key changed before retirement"); + expect(f.sandbox.spec.suspended).toBe(true); + expect(f.namespace.metadata.annotations[`${P}epoch`]).toBeUndefined(); + expect(JSON.parse(f.namespace.metadata.annotations[HISTORY]).phase).toBe("Pausing"); + }); + + it.each(["spec", "task", "uid", "receipt", "provenance", "new-pod"])("preserves suspension on changed recovery %s", async fault => { + const f = await setup(); + const stop: Execute = async (args, input) => { + const value = await f.execute(args, input); + if (args[0] === "patch" && args[1] === "namespace" && args[2] === "kars-late" + && JSON.parse(f.namespace.metadata.annotations[HISTORY]).phase === "Rotating") throw new Error("interrupted"); + return value; + }; + await expect(applyReviewedGrant(stop, await f.document(stop))).rejects.toThrow("interrupted"); + if (fault === "spec") f.sandbox.spec.isolation = "changed"; + if (fault === "task") f.task.spec.objective = "changed"; + if (fault === "uid") f.sandbox.metadata.uid = "changed"; + if (fault === "receipt") delete f.namespace.metadata.annotations[HISTORY]; + if (fault === "provenance") delete f.secret.metadata.annotations[SOURCE]; + if (fault === "new-pod") f.pods.set("kars-late", [{ kind: "Pod", metadata: { name: "new", uid: "new", resourceVersion: "1" }, spec: { containers: [] } }]); + f.calls.length = 0; + await expect(f.document()).rejects.toThrow(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.sandbox.spec.suspended).toBe(true); + expect(f.deployment.spec.replicas).toBe(0); + }); +}); diff --git a/cli/src/lib/private-activation-late-scope.ts b/cli/src/lib/private-activation-late-scope.ts new file mode 100644 index 000000000..287d7cc0b --- /dev/null +++ b/cli/src/lib/private-activation-late-scope.ts @@ -0,0 +1,489 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomBytes } from "node:crypto"; +import { + annotations, at, bundleDefinition, canonical, consumesPrivateAuthority, digest, patchNamespace, + PRIVATE_PREFIX as P, read, record, reviewed, reviewedOwner, template, templateDigest, + type Execute, type Json, type NamespaceReview, type PrivateActivation, type ReviewedObject, +} from "./private-activation.js"; +import { replicaIntent } from "./private-activation-retirement.js"; + +const HISTORY = "kars.azure.com/private-root-retirement"; +const ADMIN = "router-services-admin"; +const VERSION = "kars.azure.com/services-credential-version"; +const SOURCE = "kars.azure.com/sandbox-uid"; +const NS = "kars.azure.com/namespace-uid"; +const failure = "Late private runtime retirement changed or is unsupported; preserve the runtime and re-preview its original review"; +const phases = ["Pausing", "Retired", "Rotating", "Restoring", "Qualified"] as const; +type Phase = typeof phases[number]; +interface Runtime { + sandbox: ReviewedObject; + workspace: string; + spec: string; + owners: string; + generation: number; + suspended: boolean | null; + task?: { object: ReviewedObject; spec: string; generation: number; authorization: string }; +} +interface Material { object: ReviewedObject; key: string } +interface Receipt { + version: 4; + root: string; + binding: string; + attempt: string; + phase: Phase; + runtime: Runtime; + deployment: ReviewedObject; + structure: string; + replicas: number; + captured: string[]; + baseline: Material; + epoch?: string; + qualified?: { binding: string; template: string; material: Material }; +} + +function items(value: unknown): Json[] { + if (!Array.isArray(value)) throw new Error(failure); + return value as Json[]; +} +function text(value: unknown): string { + if (typeof value !== "string" || !value.length || value.length > 253) throw new Error(failure); + return value; +} +function hash(value: unknown): string { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) throw new Error(failure); + return value; +} +function taskAuthorization(value: unknown): string { + if (typeof value !== "string" || value.length !== 71 || !/^sha256:[a-f0-9]{64}$/.test(value)) { + throw new Error("Task authorization must retain its exact production sha256: digest"); + } + return value; +} +function generation(value: unknown): number { + const result = at(value, "metadata", "generation"); + if (typeof result !== "number" || !Number.isSafeInteger(result) || result < 1) throw new Error(failure); + return result; +} +function binding(scope: NamespaceReview): string { + return digest({ namespace: { name: scope.namespace.name, uid: scope.namespace.uid }, + consumers: scope.consumers.map(c => ({ kind: c.kind, name: c.object.name, + uid: c.object.uid, templateDigest: c.templateDigest })) }); +} +function structure(deployment: unknown): string { + const spec = structuredClone(record(at(deployment, "spec"))); + delete spec.replicas; + const meta = record(at(spec, "template", "metadata")); + const fields = record(meta.annotations ?? {}); + delete fields[`${P}epoch`]; + delete fields[VERSION]; + if (Object.keys(fields).length) meta.annotations = fields; + else delete meta.annotations; + return digest(spec); +} +function sandboxSpec(sandbox: unknown): string { + const spec = structuredClone(record(at(sandbox, "spec"))); + delete spec.suspended; + return digest(spec); +} +function suspended(sandbox: unknown): boolean | null { + const value = at(sandbox, "spec", "suspended"); + if (value !== undefined && value !== null && typeof value !== "boolean") throw new Error(failure); + return value ?? null; +} +function encoded(receipt: Receipt): string { + const value = canonical(receipt); + if (Buffer.byteLength(value) > 131_072) throw new Error("Late private retirement exceeds its bounded receipt size"); + return value; +} + +function receipt(namespace: unknown): Receipt | undefined { + const raw = at(namespace, "metadata", "annotations", HISTORY); + if (raw === undefined) return undefined; + if (typeof raw !== "string" || Buffer.byteLength(raw) > 131_072) throw new Error(failure); + const value = record(JSON.parse(raw)); + if (value.version !== 4) return undefined; + if (Object.keys(value).some(k => !["version", "root", "binding", "attempt", "phase", "runtime", + "deployment", "structure", "replicas", "captured", "baseline", "epoch", "qualified"].includes(k)) + || !phases.includes(value.phase as Phase)) throw new Error(failure); + for (const key of ["root", "binding", "attempt", "structure"]) hash(value[key]); + const runtime = record(value.runtime); + if (Object.keys(runtime).some(k => !["sandbox", "workspace", "spec", "owners", "generation", "suspended", "task"].includes(k))) throw new Error(failure); + for (const key of ["spec", "owners"]) hash(runtime[key]); + text(runtime.workspace); + if (runtime.suspended !== null && typeof runtime.suspended !== "boolean") throw new Error(failure); + for (const object of [runtime.sandbox, value.deployment, at(value.baseline, "object")]) reviewed({ metadata: object }); + generation({ metadata: runtime }); + hash(at(value.baseline, "key")); + if (runtime.task !== undefined) { + const task = record(runtime.task); + reviewed({ metadata: task.object }); + hash(task.spec); taskAuthorization(task.authorization); + generation({ metadata: task }); + } + replicaIntent({ spec: { replicas: value.replicas } }); + items(value.captured).forEach(text); + if (value.epoch !== undefined) hash(value.epoch); + if (["Rotating", "Restoring", "Qualified"].includes(String(value.phase)) !== (value.epoch !== undefined)) throw new Error(failure); + if (["Restoring", "Qualified"].includes(String(value.phase)) !== (value.qualified !== undefined)) throw new Error(failure); + if (value.qualified !== undefined) { + hash(at(value.qualified, "binding")); hash(at(value.qualified, "template")); + hash(at(value.qualified, "material", "key")); + reviewed({ metadata: at(value.qualified, "material", "object") }); + if (at(value.qualified, "material", "key") === at(value.baseline, "key") + || at(value.qualified, "material", "object", "uid") !== at(value.baseline, "object", "uid")) throw new Error(failure); + } + const result = value as unknown as Receipt; + if (encoded(result) !== raw) throw new Error(failure); + return result; +} + +async function namespaceFor(execute: Execute, scope: NamespaceReview): Promise<ReturnType<typeof record>> { + const namespace = await read(execute, "namespace", scope.namespace.name); + if (reviewed(namespace).uid !== scope.namespace.uid) throw new Error(failure); + return namespace; +} + +async function runtimeFor(execute: Execute, scope: NamespaceReview, namespace: unknown, deployment: unknown): Promise<Runtime> { + const fields = record(at(namespace, "metadata", "annotations")); + const name = text(fields["kars.azure.com/sandbox-name"]); + const workspace = text(fields["kars.azure.com/sandbox-namespace"]); + const sandbox = await read(execute, "karssandbox", name, workspace); + const identity = reviewed(sandbox); + const sourceUid = at(deployment, "metadata", "annotations", "kars.azure.com/credential-sandbox-uid"); + const namespaceUid = at(deployment, "metadata", "annotations", "kars.azure.com/credential-namespace-uid"); + const authored = items(at(deployment, "metadata", "managedFields") ?? []).some(field => + at(field, "manager") === "kars-controller/karssandbox" && at(field, "operation") === "Apply" + && at(field, "fieldsV1", "f:spec") !== undefined); + if (at(sandbox, "apiVersion") !== "kars.azure.com/v1alpha1" || at(sandbox, "kind") !== "KarsSandbox" + || fields["kars.azure.com/namespace-claim-version"] !== "v1" + || fields["kars.azure.com/namespace-prestage"] !== undefined + || fields[SOURCE] !== identity.uid || at(sandbox, "metadata", "annotations", NS) !== scope.namespace.uid + || scope.namespace.name !== `kars-${name}` || reviewed(deployment).name !== name + || items(at(namespace, "metadata", "ownerReferences") ?? []).length + || items(at(deployment, "metadata", "ownerReferences") ?? []).length + || at(deployment, "metadata", "namespace") !== scope.namespace.name + || (sourceUid !== undefined && sourceUid !== identity.uid) + || (namespaceUid !== undefined && namespaceUid !== scope.namespace.uid) + || (!authored && (sourceUid !== identity.uid || namespaceUid !== scope.namespace.uid)) + || at(deployment, "metadata", "labels", "kars.azure.com/sandbox") !== name + || at(deployment, "metadata", "labels", "kars.azure.com/component") !== "sandbox" + || at(sandbox, "spec", "githubBinding") != null + || at(sandbox, "metadata", "annotations", "kars.azure.com/github-grant-uid") !== undefined + || at(sandbox, "metadata", "annotations", "kars.azure.com/credential-rebind-task-uid") !== undefined + || at(sandbox, "status", "serviceObservation") != null) throw new Error(failure); + const owners = items(at(sandbox, "metadata", "ownerReferences") ?? []); + let task: Runtime["task"]; + if (owners.length) { + const owner = record(owners[0]); + if (owners.length !== 1 || owner.apiVersion !== "kars.azure.com/v1alpha1" + || owner.kind !== "KarsTask" || owner.controller !== true) throw new Error(failure); + const current = await read(execute, "karstask", text(owner.name), workspace); + const taskIdentity = reviewed(current); + const currentGeneration = generation(current); + const authorization = taskAuthorization(at(current, "status", "envelopeDigest")); + const router = items(at(template(deployment), "spec", "containers")).find(c => at(c, "name") === "inference-router"); + const env = items(at(router, "env") ?? []).filter(e => at(e, "name") === "KARS_SERVICE_IDENTITY_JSON"); + const raw = at(env[0], "value"); + const configured = env.length === 1 && typeof raw === "string" && raw.length <= 131_072 + ? record(JSON.parse(raw)) : {}; + if (taskIdentity.uid !== owner.uid || at(current, "spec", "execution", "launch") !== true + || at(current, "metadata", "annotations", "kars.azure.com/credential-rebind-pending") !== undefined + || at(current, "status", "phase") !== "Ready" || at(current, "status", "observedGeneration") !== currentGeneration + || at(current, "status", "sandboxRef", "name") !== name + || at(configured, "task", "uid") !== taskIdentity.uid + || configured.task_authorization !== authorization || configured.task_generation !== currentGeneration + || !items(at(current, "status", "conditions") ?? []).some(c => at(c, "type") === "Ready" && at(c, "status") === "True")) throw new Error(failure); + task = { object: taskIdentity, spec: digest({ spec: current.spec, owners: at(current, "metadata", "ownerReferences") ?? [] }), + generation: currentGeneration, authorization }; + } + return { sandbox: identity, workspace, spec: sandboxSpec(sandbox), owners: digest(owners), + generation: generation(sandbox), suspended: suspended(sandbox), ...(task ? { task } : {}) }; +} + +function sameRuntime(current: Runtime, original: Runtime, phase: Phase): void { + if (current.sandbox.uid !== original.sandbox.uid || current.workspace !== original.workspace + || current.spec !== original.spec || current.owners !== original.owners + || (current.task?.object.uid ?? "") !== (original.task?.object.uid ?? "") + || current.task?.spec !== original.task?.spec || current.task?.generation !== original.task?.generation + || current.task?.authorization !== original.task?.authorization) throw new Error(failure); + const allowed = phase === "Pausing" || phase === "Restoring" + ? [original.suspended, true] : [phase === "Qualified" ? original.suspended : true]; + if (!allowed.includes(current.suspended)) throw new Error(failure); + const expectedGeneration = original.generation + (original.suspended === true ? 0 + : current.suspended === true ? 1 : phase === "Restoring" || phase === "Qualified" ? 2 : 0); + if (current.generation !== expectedGeneration) throw new Error(failure); +} + +async function inventory(execute: Execute, scope: NamespaceReview): Promise<Json[]> { + const result = record(JSON.parse(await execute(["get", "pods", "-n", scope.namespace.name, "--chunk-size=0", "-o", "json"]))); + if (at(result, "metadata", "continue")) throw new Error("Late private retirement inventory is incomplete"); + const pods = items(result.items); + for (const pod of pods) { + reviewed(pod, true); + if (!await reviewedOwner(execute, pod, scope)) throw new Error("Unreviewed late private consumer preserved"); + } + return pods; +} + +async function secretMetadata(execute: Execute, scope: NamespaceReview, name: string): Promise<ReturnType<typeof record> | undefined> { + const raw = await execute(["get", "secret", name, "-n", scope.namespace.name, "--ignore-not-found", + "-o", "go-template={{json .metadata}}"]); + if (!raw.trim()) return undefined; + return record({ metadata: JSON.parse(raw) }); +} +function ownedMaterial(secret: unknown, scope: NamespaceReview, runtime: Runtime): ReviewedObject { + const identity = reviewed(secret); + if (identity.name !== ADMIN || at(secret, "metadata", "namespace") !== scope.namespace.name + || at(secret, "metadata", "labels", "app.kubernetes.io/managed-by") !== "kars-controller" + || at(secret, "metadata", "annotations", SOURCE) !== runtime.sandbox.uid + || at(secret, "metadata", "annotations", NS) !== scope.namespace.uid + || items(at(secret, "metadata", "ownerReferences") ?? []).length) throw new Error("Late private credential provenance is missing or conflicting"); + return identity; +} +async function materialInventory(execute: Execute, scope: NamespaceReview, runtime: Runtime): Promise<ReviewedObject> { + let admin: ReviewedObject | undefined; + for (const name of items(bundleDefinition().secrets).map(text)) { + const secret = await secretMetadata(execute, scope, name); + if (!secret) continue; + if (name !== ADMIN) throw new Error("Existing observer, TLS or App private material requires its owner-specific rotation; late admin-only enrollment preserved it"); + admin = ownedMaterial(secret, scope, runtime); + } + if (!admin) throw new Error("Late private runtime requires its existing controller-owned admin credential; missing material was not adopted"); + return admin; +} +async function material(execute: Execute, scope: NamespaceReview, runtime: Runtime): Promise<Material> { + const secret = await read(execute, "secret", ADMIN, scope.namespace.name); + const object = ownedMaterial(secret, scope, runtime); + const data = record(secret.data); + const token = typeof data["control-token"] === "string" ? Buffer.from(data["control-token"], "base64") : Buffer.alloc(0); + if (secret.type !== "Opaque" || Object.keys(data).join(",") !== "control-token" + || token.length !== 64 || token.toString("base64") !== data["control-token"] + || !/^[a-zA-Z0-9]{64}$/.test(token.toString("utf8"))) { + throw new Error("Customized or missing late private credential keys require explicit operator recovery"); + } + return { object, key: digest(token.toString("base64")) }; +} + +function supportedTemplate(deployment: unknown, scope: NamespaceReview, activation: PrivateActivation): void { + const current = structuredClone(record(deployment)); + const pod = record(template(current).spec); + let admin = false; + const removed = new Set<string>(); + pod.volumes = items(pod.volumes ?? []).filter(volume => { + if (at(volume, "secret", "secretName") === ADMIN) { + if (canonical(at(volume, "secret", "items")) !== canonical([{ key: "control-token", path: "control-token" }]) + || at(volume, "secret", "optional") === true || admin + || at(volume, "name") !== "governed-services-control") throw new Error(failure); + admin = true; + removed.add(text(at(volume, "name"))); + return false; + } + // The controller's legacy optional App mount has no authority when its + // Secret is absent. materialInventory rejects any existing App material. + if (at(volume, "secret", "secretName") === "router-github-app" && at(volume, "secret", "optional") === true) { + removed.add(text(at(volume, "name"))); + return false; + } + return true; + }); + for (const container of [...items(pod.containers ?? []), ...items(pod.initContainers ?? []), ...items(pod.ephemeralContainers ?? [])]) { + const c = record(container); + for (const mount of items(c.volumeMounts ?? []).filter(m => at(m, "name") === "governed-services-control")) { + if (c.name !== "inference-router" || canonical(mount) !== canonical({ + name: "governed-services-control", mountPath: "/etc/kars/services", readOnly: true, + })) throw new Error("Customized admin credential mount requires explicit recovery"); + } + c.volumeMounts = items(c.volumeMounts ?? []).filter(mount => !removed.has(String(at(mount, "name")))); + } + if (!admin || consumesPrivateAuthority(current, scope.namespace.name, activation)) { + throw new Error("Late enrollment only retires the reviewed runtime's controller-owned admin token, not host access, privileged tokens or other private authority"); + } +} + +async function current( + execute: Execute, activation: PrivateActivation, scope: NamespaceReview, root: string, state?: Receipt, +): Promise<{ runtime: Runtime; deployment: ReturnType<typeof record>; pods: Json[]; namespace: ReturnType<typeof record> }> { + if (scope.consumers.length !== 1 || scope.consumers[0]?.kind !== "Deployment") throw new Error(failure); + const consumer = scope.consumers[0]; + const namespace = await namespaceFor(execute, scope); + const deployment = await read(execute, "deployments.apps", consumer.object.name, scope.namespace.name); + if (reviewed(deployment).uid !== consumer.object.uid) throw new Error(failure); + const runtime = await runtimeFor(execute, scope, namespace, deployment); + if (state) { + sameRuntime(runtime, state.runtime, state.phase); + if (state.root !== root || state.deployment.uid !== consumer.object.uid || structure(deployment) !== state.structure + || encoded(receipt(namespace)!) !== encoded(state) + || (state.epoch !== undefined && scope.epoch !== undefined && scope.epoch !== state.epoch)) throw new Error(failure); + if (["Pausing", "Retired"].includes(state.phase) && binding(scope) !== state.binding) throw new Error(failure); + const replicas = replicaIntent(deployment); + if (!(["Pausing", "Restoring", "Qualified"].includes(state.phase) ? [0, state.replicas] : [0]).includes(replicas)) throw new Error(failure); + if (state.phase === "Qualified" && (replicas !== state.replicas + || binding(scope) !== state.qualified?.binding || templateDigest(deployment) !== state.qualified.template)) throw new Error(failure); + if (state.epoch) { + if (at(namespace, "metadata", "annotations", `${P}epoch`) !== state.epoch + || Object.entries(annotations(activation, scope, "Qualified")).some(([k, v]) => at(namespace, "metadata", "annotations", k) !== v)) throw new Error(failure); + } else if (Object.entries(annotations(activation, scope, "Pending")).some(([k, v]) => at(namespace, "metadata", "annotations", k) !== v) + || at(namespace, "metadata", "annotations", `${P}epoch`) !== undefined) throw new Error(failure); + } else { + if (templateDigest(deployment) !== consumer.templateDigest + || reviewed(deployment).resourceVersion !== consumer.object.resourceVersion + || at(deployment, "status", "observedGeneration") !== generation(deployment) + || replicaIntent(deployment) !== (runtime.suspended === true ? 0 : 1) + || at(template(deployment), "metadata", "annotations", `${P}epoch`) !== undefined) throw new Error(failure); + const sandbox = await read(execute, "karssandbox", runtime.sandbox.name, runtime.workspace); + if (reviewed(sandbox).resourceVersion !== runtime.sandbox.resourceVersion + || at(sandbox, "status", "observedGeneration") !== runtime.generation + || at(sandbox, "status", "phase") !== "Running" + || !items(at(sandbox, "status", "conditions") ?? []).some(condition => + at(condition, "type") === "Ready" && at(condition, "status") === "True" + && at(condition, "observedGeneration") === runtime.generation)) throw new Error(failure); + } + supportedTemplate(deployment, scope, activation); + const secret = await materialInventory(execute, scope, runtime); + if (state && secret.uid !== state.baseline.object.uid) throw new Error(failure); + if (state?.qualified && canonical(secret) !== canonical(state.qualified.material.object)) throw new Error(failure); + if (!state && at(template(deployment), "metadata", "annotations", VERSION) !== `${secret.uid}:${secret.resourceVersion}`) { + throw new Error("Reviewed runtime has not consumed its current controller-owned admin credential version"); + } + // During controller rotation only the exact token-version annotation and + // private epoch may change, never the reviewed executable Pod specification. + const liveScope = { ...scope, consumers: [{ ...consumer, templateDigest: templateDigest(deployment) }] }; + const pods = await inventory(execute, liveScope); + if (state && state.phase !== "Pausing" && state.phase !== "Qualified" && state.phase !== "Restoring" && pods.length) throw new Error(failure); + if (state && ["Qualified", "Restoring"].includes(state.phase) && pods.some(pod => + state.captured.includes(reviewed(pod, true).uid) || at(pod, "metadata", "annotations", `${P}epoch`) !== state.epoch + || at(pod, "metadata", "annotations", VERSION) !== `${state.qualified!.material.object.uid}:${state.qualified!.material.object.resourceVersion}`)) throw new Error(failure); + return { runtime, deployment, pods, namespace }; +} + +/** Read-only. Public activation JSON remains v1; recovery lives only in the existing operator-only namespace field. */ +export async function reviewLateScope( + execute: Execute, activation: PrivateActivation, scope: NamespaceReview, root: string, +): Promise<"Late" | "Qualified" | undefined> { + const namespace = await namespaceFor(execute, scope); + const state = receipt(namespace); + if (!state) { + if (at(namespace, "metadata", "annotations", HISTORY) !== undefined) return undefined; + if (at(namespace, "metadata", "annotations", "kars.azure.com/sandbox-name") === undefined) return undefined; + if (!scope.consumers.some(c => c.kind === "Deployment")) return undefined; + const deployment = await read(execute, "deployments.apps", scope.consumers[0]!.object.name, scope.namespace.name); + if (!consumesPrivateAuthority(deployment, scope.namespace.name, activation)) return undefined; + } + await current(execute, activation, scope, root, state); + if (state?.phase === "Qualified") { + scope.epoch = state.epoch; + return "Qualified"; + } + return "Late"; +} + +export async function stageLateScope( + execute: Execute, activation: PrivateActivation, scope: NamespaceReview, root: string, assertRoot: () => Promise<void>, +): Promise<void> { + let state = receipt(await namespaceFor(execute, scope)); + let live = await current(execute, activation, scope, root, state); + const save = async (next: Receipt, fields: Record<string, string> = {}) => { + await assertRoot(); + await patchNamespace(execute, scope, { ...fields, [HISTORY]: encoded(next) }, + { [HISTORY]: state ? encoded(state) : undefined }, true); + state = next; + }; + if (!state) { + const baseline = await material(execute, scope, live.runtime); + await save({ version: 4, root, binding: binding(scope), attempt: randomBytes(32).toString("hex"), phase: "Pausing", + runtime: live.runtime, deployment: reviewed(live.deployment), structure: structure(live.deployment), + replicas: replicaIntent(live.deployment), captured: live.pods.map(p => reviewed(p, true).uid).sort(), baseline }, + annotations(activation, scope, "Pending")); + } + if (!state) throw new Error(failure); + const deadline = Date.now() + 120_000; + if (state.phase === "Pausing") { + live = await current(execute, activation, scope, root, state); + if (live.runtime.suspended !== true) { + await assertRoot(); + await execute(["patch", "karssandbox", state.runtime.sandbox.name, "-n", state.runtime.workspace, "--type=merge", "-p", + JSON.stringify({ metadata: { uid: live.runtime.sandbox.uid, resourceVersion: live.runtime.sandbox.resourceVersion }, + spec: { suspended: true } })]); + } + live = await current(execute, activation, scope, root, state); + if (replicaIntent(live.deployment) !== 0) { + await assertRoot(); + await execute(["patch", "deployments.apps", state.deployment.name, "-n", scope.namespace.name, "--type=merge", "-p", + JSON.stringify({ metadata: { uid: state.deployment.uid, resourceVersion: reviewed(live.deployment).resourceVersion }, spec: { replicas: 0 } })]); + } + for (;;) { + live = await current(execute, activation, scope, root, state); + const captured = [...new Set([...state.captured, ...live.pods.map(p => reviewed(p, true).uid)])].sort(); + if (canonical(captured) !== canonical(state.captured)) await save({ ...state, captured }); + if (!live.pods.length && replicaIntent(live.deployment) === 0) break; + if (Date.now() >= deadline) throw new Error("Late private Pods, including terminating UIDs, remain; runtime suspension and recovery were preserved"); + await new Promise(resolve => setTimeout(resolve, 500)); + } + if ((await material(execute, scope, live.runtime)).key !== state.baseline.key) throw new Error("Late private key changed before retirement; no pre-retirement rotation was qualified"); + await save({ ...state, phase: "Retired" }); + } + if (state.phase === "Retired") { + await current(execute, activation, scope, root, state); + const epoch = randomBytes(32).toString("hex"); + const budget = activation.root.budgetTls; + scope.epoch = epoch; + await save({ ...state, phase: "Rotating", epoch }, { + ...annotations(activation, scope, "Qualified"), [`${P}epoch`]: epoch, + [`${P}parent-${state.deployment.uid}`]: epoch, + ...(budget ? { + [`${P}budget-qualified-bundle`]: activation.bundleRevision, + [`${P}budget-qualified-key`]: budget.keyDigest, [`${P}budget-qualified-secret`]: budget.secret.uid, + [`${P}budget-rotation-bundle`]: "", [`${P}budget-before-key`]: "", + } : {}), + }); + } + scope.epoch = state.epoch; + if (state.phase === "Rotating") { + for (;;) { + live = await current(execute, activation, scope, root, state); + const fresh = await material(execute, scope, live.runtime); + const metadata = await secretMetadata(execute, scope, ADMIN); + const version = `${fresh.object.uid}:${fresh.object.resourceVersion}`; + if (fresh.object.uid !== state.baseline.object.uid) throw new Error(failure); + if (at(metadata, "metadata", "annotations", `${P}epoch`) === state.epoch + && at(metadata, "metadata", "annotations", "kars.azure.com/services-credential-retired") === undefined + && at(template(live.deployment), "metadata", "annotations", `${P}epoch`) === state.epoch + && at(template(live.deployment), "metadata", "annotations", VERSION) === version) { + if (fresh.key === state.baseline.key || fresh.object.resourceVersion === state.baseline.object.resourceVersion) { + throw new Error("Late private authentication key was not rotated; epoch/version stamps alone cannot qualify"); + } + scope.consumers[0]!.templateDigest = templateDigest(live.deployment); + await save({ ...state, phase: "Restoring", qualified: { + binding: binding(scope), template: scope.consumers[0]!.templateDigest, material: fresh, + } }); + break; + } + if (Date.now() >= deadline) throw new Error("Controller has not reissued the retired private key and exact template; runtime remains suspended for re-preview"); + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + if (state.phase === "Restoring") { + live = await current(execute, activation, scope, root, state); + if (canonical(await material(execute, scope, live.runtime)) !== canonical(state.qualified!.material)) throw new Error(failure); + scope.consumers[0]!.templateDigest = state.qualified!.template; + if (live.runtime.suspended !== state.runtime.suspended) { + await assertRoot(); + await execute(["patch", "karssandbox", state.runtime.sandbox.name, "-n", state.runtime.workspace, "--type=merge", "-p", + JSON.stringify({ metadata: { uid: live.runtime.sandbox.uid, resourceVersion: live.runtime.sandbox.resourceVersion }, + spec: { suspended: state.runtime.suspended } })]); + } + for (;;) { + live = await current(execute, activation, scope, root, state); + if (replicaIntent(live.deployment) === state.replicas + && at(live.deployment, "status", "observedGeneration") === generation(live.deployment) + && (state.replicas === 0 || (at(live.deployment, "status", "updatedReplicas") === state.replicas + && at(live.deployment, "status", "availableReplicas") === state.replicas))) break; + if (Date.now() >= deadline) throw new Error("Late private restore is incomplete; original intent and new authority remain recorded for re-preview"); + await new Promise(resolve => setTimeout(resolve, 500)); + } + await save({ ...state, phase: "Qualified" }); + } + await current(execute, activation, scope, root, state); +} diff --git a/controller/src/private_activation.rs b/controller/src/private_activation.rs index 6ea898560..572b5fe37 100644 --- a/controller/src/private_activation.rs +++ b/controller/src/private_activation.rs @@ -4,6 +4,7 @@ //! Live qualification of the generic private capability, not core bootstrap. mod consumers; +mod late_scope; mod runtime; mod verification; diff --git a/controller/src/private_activation/late_scope.rs b/controller/src/private_activation/late_scope.rs new file mode 100644 index 000000000..eb3a28a7e --- /dev/null +++ b/controller/src/private_activation/late_scope.rs @@ -0,0 +1,790 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! An operator-owned late-enrollment receipt is also a controller resume fence. +//! A concurrent Sandbox unsuspend must not bypass post-retirement key checks. + +use super::{EPOCH, hash}; +use crate::crd::KarsSandbox; +use base64::{Engine, engine::general_purpose::STANDARD}; +use k8s_openapi::api::{ + apps::v1::Deployment, + core::v1::{Namespace, Secret}, +}; +use kube::{ + Api, Client, ResourceExt, + core::{ApiResource, DynamicObject, GroupVersionKind}, +}; +use serde_json::{Value, json}; + +const HISTORY: &str = "kars.azure.com/private-root-retirement"; +const ERROR: &str = "Late private runtime source, owner, intent or authentication changed; operator recovery remains required"; +const ADMIN: &str = "router-services-admin"; +const VERSION: &str = "kars.azure.com/services-credential-version"; + +fn fields(value: &Value, required: &str, optional: &str) -> bool { + value.as_object().is_some_and(|object| { + required + .split_whitespace() + .all(|key| object.contains_key(key)) + && object.keys().all(|key| { + required + .split_whitespace() + .chain(optional.split_whitespace()) + .any(|allowed| allowed == key.as_str()) + }) + }) +} +fn text(value: &Value) -> bool { + value + .as_str() + .is_some_and(|value| !value.is_empty() && value.len() <= 253) +} +fn hex(value: &Value) -> bool { + value.as_str().is_some_and(|value| { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} +fn identity(value: &Value) -> bool { + fields(value, "name uid resourceVersion", "") + && ["name", "uid", "resourceVersion"] + .iter() + .all(|key| text(&value[key])) +} +fn material(value: &Value) -> bool { + fields(value, "object key", "") + && identity(&value["object"]) + && value["object"]["name"] == ADMIN + && hex(&value["key"]) +} + +fn decode(raw: &str) -> Result<Value, String> { + if raw.len() > 131_072 { + return Err(ERROR.into()); + } + let state: Value = serde_json::from_str(raw).map_err(|_| ERROR)?; + let phase = state["phase"].as_str().ok_or(ERROR)?; + let epoch = state.get("epoch"); + // v1 retirement and v2 completion belong to the shared root, not runtime + // namespaces. v3 is the only preceding scoped receipt protocol. + match state["version"].as_u64() { + Some(3) + if fields(&state, "version root binding phase", "epoch") + && hex(&state["root"]) + && hex(&state["binding"]) + && match phase { + "Pending" => epoch.is_none(), + "Stamping" | "Qualified" => epoch.is_some_and(hex), + _ => false, + } => + { + return Ok(state); + } + Some(4) => {} + _ => return Err(ERROR.into()), + } + let runtime = &state["runtime"]; + let qualified = state.get("qualified"); + let task_valid = runtime.get("task").is_none_or(|task| { + fields(task, "object spec generation authorization", "") + && identity(&task["object"]) + && hex(&task["spec"]) + && task["generation"] + .as_i64() + .is_some_and(|generation| generation > 0) + && task_authorization(&task["authorization"]).is_ok() + }); + let phase_valid = match phase { + "Pausing" | "Retired" => epoch.is_none() && qualified.is_none(), + "Rotating" => epoch.is_some_and(hex) && qualified.is_none(), + "Restoring" | "Qualified" => { + epoch.is_some_and(hex) + && qualified.is_some_and(|value| { + fields(value, "binding template material", "") + && hex(&value["binding"]) + && hex(&value["template"]) + && material(&value["material"]) + && value["material"]["object"]["uid"] == state["baseline"]["object"]["uid"] + && value["material"]["object"]["resourceVersion"] + != state["baseline"]["object"]["resourceVersion"] + && value["material"]["key"] != state["baseline"]["key"] + }) + } + _ => false, + }; + if !fields( + &state, + "version root binding attempt phase runtime deployment structure replicas captured baseline", + "epoch qualified", + ) || !["root", "binding", "attempt", "structure"] + .iter() + .all(|key| hex(&state[key])) + || !fields( + runtime, + "sandbox workspace spec owners generation suspended", + "task", + ) + || !identity(&runtime["sandbox"]) + || !text(&runtime["workspace"]) + || !hex(&runtime["spec"]) + || !hex(&runtime["owners"]) + || !runtime["generation"] + .as_i64() + .is_some_and(|generation| generation > 0) + || !(runtime["suspended"].is_null() || runtime["suspended"].is_boolean()) + || !identity(&state["deployment"]) + || !material(&state["baseline"]) + || state["replicas"].as_u64() != Some(u64::from(runtime["suspended"] != true)) + || !state["captured"] + .as_array() + .is_some_and(|ids| ids.iter().all(text)) + || !task_valid + || !phase_valid + { + return Err(ERROR.into()); + } + Ok(state) +} + +async fn object( + client: &Client, + workspace: &str, + kind: &str, + plural: &str, + name: &str, +) -> Result<Value, String> { + let mut resource = + ApiResource::from_gvk(&GroupVersionKind::gvk("kars.azure.com", "v1alpha1", kind)); + resource.plural = plural.into(); + let value = Api::<DynamicObject>::namespaced_with(client.clone(), workspace, &resource) + .get(name) + .await + .map_err(|_| ERROR)?; + serde_json::to_value(value).map_err(|_| ERROR.into()) +} + +fn live(value: &Value, expected: &Value) -> bool { + value["metadata"]["uid"] + .as_str() + .is_some_and(|uid| !uid.is_empty()) + && value["metadata"]["uid"] == expected["uid"] + && value["metadata"]["name"] == expected["name"] + && value["metadata"]["resourceVersion"] + .as_str() + .is_some_and(|rv| !rv.is_empty()) + && value["metadata"]["deletionTimestamp"].is_null() +} + +fn owners(value: &Value) -> Value { + value["metadata"] + .get("ownerReferences") + .cloned() + .unwrap_or_else(|| json!([])) +} + +fn task_authorization(value: &Value) -> Result<&str, String> { + let value = value.as_str().ok_or(ERROR)?; + let digest = value.strip_prefix("sha256:").ok_or(ERROR)?; + if digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ERROR.into()); + } + Ok(value) +} + +fn intent(state: &Value, sandbox: &Value, task: Option<&Value>) -> Result<bool, String> { + let runtime = &state["runtime"]; + let phase = state["phase"].as_str().ok_or(ERROR)?; + if !["Pausing", "Retired", "Rotating", "Restoring"].contains(&phase) + || !live(sandbox, &runtime["sandbox"]) + || sandbox["metadata"]["namespace"] != runtime["workspace"] + || hash(&owners(sandbox)) != runtime["owners"].as_str().ok_or(ERROR)? + { + return Err(ERROR.into()); + } + let mut spec = sandbox["spec"].as_object().ok_or(ERROR)?.clone(); + let suspension = spec.remove("suspended").unwrap_or(Value::Null); + let original = &runtime["suspended"]; + if (!suspension.is_null() && !suspension.is_boolean()) + || (!original.is_null() && !original.is_boolean()) + || hash(&Value::Object(spec)) != runtime["spec"].as_str().ok_or(ERROR)? + || (suspension != *original && suspension != true) + { + return Err(ERROR.into()); + } + let generation = runtime["generation"] + .as_i64() + .filter(|v| *v > 0) + .ok_or(ERROR)?; + let increment = if *original == true { + 0 + } else if suspension == true { + 1 + } else if phase == "Restoring" { + 2 + } else { + 0 + }; + if sandbox["metadata"]["generation"].as_i64() != generation.checked_add(increment) { + return Err(ERROR.into()); + } + match (runtime.get("task"), task) { + (Some(expected), Some(task)) => { + if !live(task, &expected["object"]) + || task["metadata"]["namespace"] != runtime["workspace"] + || task["metadata"]["generation"] != expected["generation"] + || task["status"]["observedGeneration"] != expected["generation"] + || task["status"]["phase"] != "Ready" + || task_authorization(&task["status"]["envelopeDigest"])? + != task_authorization(&expected["authorization"])? + || task["spec"]["execution"]["launch"] != true + || hash(&json!({"spec":task["spec"],"owners":owners(task)})) + != expected["spec"].as_str().ok_or(ERROR)? + { + return Err(ERROR.into()); + } + } + (None, None) => {} + _ => return Err(ERROR.into()), + } + Ok(phase != "Restoring" || suspension == true) +} + +fn rotated( + state: &Value, + secret: &Secret, + namespace: &Namespace, + deployment: &Deployment, +) -> Result<(), String> { + let material = &state["qualified"]["material"]; + let baseline = &state["baseline"]; + let epoch = state["epoch"] + .as_str() + .filter(|v| v.len() == 64) + .ok_or(ERROR)?; + let token = secret + .data + .as_ref() + .and_then(|data| data.get("control-token")) + .ok_or(ERROR)?; + let digest = hash(&json!(STANDARD.encode(&token.0))); + let version = format!( + "{}:{}", + secret.uid().ok_or(ERROR)?, + secret.resource_version().ok_or(ERROR)? + ); + let annotations = deployment + .spec + .as_ref() + .and_then(|s| s.template.metadata.as_ref()) + .and_then(|m| m.annotations.as_ref()) + .ok_or(ERROR)?; + if secret.metadata.deletion_timestamp.is_some() + || secret.metadata.uid.as_deref() != material["object"]["uid"].as_str() + || secret.metadata.uid.as_deref() != baseline["object"]["uid"].as_str() + || secret.metadata.resource_version.as_deref() + != material["object"]["resourceVersion"].as_str() + || material["object"]["resourceVersion"] == baseline["object"]["resourceVersion"] + || secret.type_.as_deref() != Some("Opaque") + || secret.data.as_ref().is_none_or(|data| data.len() != 1) + || token.0.len() != 64 + || !token.0.iter().all(u8::is_ascii_alphanumeric) + || Some(digest.as_str()) != material["key"].as_str() + || Some(digest.as_str()) == baseline["key"].as_str() + || secret.annotations().get(EPOCH).map(String::as_str) != Some(epoch) + || namespace.annotations().get(EPOCH).map(String::as_str) != Some(epoch) + || annotations.get(EPOCH).map(String::as_str) != Some(epoch) + || annotations.get(VERSION) != Some(&version) + { + return Err(ERROR.into()); + } + Ok(()) +} + +pub(super) async fn fence( + client: &Client, + namespace: &Namespace, + sandbox: &KarsSandbox, + previous: Option<&Deployment>, + deployment: &mut Deployment, +) -> Result<(), String> { + let Some(raw) = namespace.annotations().get(HISTORY) else { + return Ok(()); + }; + let state = decode(raw)?; + if state["version"] == 3 { + return Ok(()); + } + let previous = previous.ok_or( + "Recorded late private Deployment disappeared; explicit operator recovery is required", + )?; + let uid = state["deployment"]["uid"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or(ERROR)?; + if previous.metadata.uid.as_deref() != Some(uid) + || previous.metadata.name.as_deref() != state["deployment"]["name"].as_str() + || sandbox.metadata.uid.as_deref() != state["runtime"]["sandbox"]["uid"].as_str() + { + return Err(ERROR.into()); + } + if state["phase"] == "Qualified" { + return Ok(()); + } + let workspace = sandbox.namespace().ok_or(ERROR)?; + let name = sandbox.name_any(); + let current = object(client, &workspace, "KarsSandbox", "karssandboxes", &name).await?; + let task = if let Some(expected) = state["runtime"].get("task") { + let name = expected["object"]["name"].as_str().ok_or(ERROR)?; + Some(object(client, &workspace, "KarsTask", "karstasks", name).await?) + } else { + None + }; + let hold = intent(&state, ¤t, task.as_ref())?; + if state["phase"] == "Restoring" { + let secret = Api::<Secret>::namespaced(client.clone(), &namespace.name_any()) + .get(ADMIN) + .await + .map_err(|_| ERROR)?; + rotated(&state, &secret, namespace, deployment)?; + } + if hold { + deployment.spec.as_mut().ok_or(ERROR)?.replicas = Some(0); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + }; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const RUNTIME_NS: &str = "/api/v1/namespaces/kars-runtime"; + const SOURCE: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/runtime"; + const TASK: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karstasks/task"; + const DEPLOYMENT: &str = "/apis/apps/v1/namespaces/kars-runtime/deployments/runtime"; + const PRIVATE_SECRET: &str = "/api/v1/namespaces/kars-runtime/secrets/router-services-admin"; + + #[derive(Default)] + struct ApiState { + objects: BTreeMap<String, Value>, + writes: Vec<(String, Value)>, + reads: Vec<String>, + } + + impl ApiState { + fn receipt(&self) -> Value { + serde_json::from_str( + self.objects[RUNTIME_NS]["metadata"]["annotations"][HISTORY] + .as_str() + .unwrap(), + ) + .unwrap() + } + fn set_receipt(&mut self, receipt: Value) { + self.objects.get_mut(RUNTIME_NS).unwrap()["metadata"]["annotations"][HISTORY] = + json!(receipt.to_string()); + } + } + + struct RuntimeApi { + _server: MockServer, + client: Client, + state: Arc<Mutex<ApiState>>, + sandbox: KarsSandbox, + desired: Deployment, + } + + impl RuntimeApi { + async fn apply(&mut self) -> Result<bool, String> { + crate::private_activation::apply_deployment( + &self.client, + &self.sandbox, + &mut self.desired, + ) + .await + } + + async fn rejects_annotation(&mut self, raw: String) { + self.state + .lock() + .unwrap() + .objects + .get_mut(RUNTIME_NS) + .unwrap()["metadata"]["annotations"][HISTORY] = json!(raw); + let before = self.state.lock().unwrap().objects.clone(); + assert!(self.apply().await.is_err()); + let state = self.state.lock().unwrap(); + assert!(state.reads.iter().any(|path| path == DEPLOYMENT)); + assert!(state.writes.is_empty()); + assert_eq!(state.objects, before); + } + } + + async fn runtime_api( + phase: Option<&str>, + previous_uid: Option<&str>, + suspended: bool, + ) -> RuntimeApi { + let mut state = ApiState::default(); + let activation = crate::private_activation::test_support::install( + &mut state.objects, + "core", + "core-uid", + "controller", + &[("kars-runtime", "runtime-ns")], + ); + let mut task = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTask", + "metadata":{"name":"task","namespace":"work","uid":"task-uid","resourceVersion":"1","generation":1}, + "spec":{"objective":"Retire the owned runtime","envelope":{"tier":1,"authorityCeiling":1,"delegationDepth":0},"execution":{"launch":true}}, + "status":{"phase":"Ready","observedGeneration":1,"sandboxRef":{"name":"runtime"}}}); + let actual: crate::kars_task::KarsTask = serde_json::from_value(task.clone()).unwrap(); + let authorization = actual.envelope_digest(); + assert!(authorization.starts_with("sha256:")); + task["status"]["envelopeDigest"] = json!(authorization); + let sandbox = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":"runtime","namespace":"work","uid":"sandbox","resourceVersion":"2", + "generation":if suspended {2} else {3}, + "annotations":{"kars.azure.com/namespace-uid":"runtime-ns"}, + "ownerReferences":[{"apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTask","name":"task","uid":"task-uid","controller":true}]}, + "spec":{"inferenceRef":{"name":"policy"},"credentialsRef":{"name":"kars-credential-bundle-fixture","uid":"bundle-uid"},"suspended":suspended}}); + let namespace = state.objects.get_mut(RUNTIME_NS).unwrap(); + for (key, value) in [ + ("kars.azure.com/namespace-claim-version", "v1"), + ("kars.azure.com/sandbox-name", "runtime"), + ("kars.azure.com/sandbox-namespace", "work"), + ("kars.azure.com/sandbox-uid", "sandbox"), + ] { + namespace["metadata"]["annotations"][key] = json!(value); + } + let epoch = namespace["metadata"]["annotations"][EPOCH].clone(); + namespace["metadata"]["annotations"]["kars.azure.com/private-parent-deployment"] = + epoch.clone(); + namespace["metadata"]["annotations"]["kars.azure.com/private-parent-foreign"] = + epoch.clone(); + let desired = json!({"apiVersion":"apps/v1","kind":"Deployment", + "metadata":{"name":"runtime","namespace":"kars-runtime"}, + "spec":{"replicas":1,"selector":{"matchLabels":{"app":"runtime"}}, + "template":{"metadata":{"annotations":{EPOCH:epoch,VERSION:"secret:2"}}, + "spec":{"containers":[{"name":"inference-router","image":"fixture"}]}}}}); + let before = json!({"object":{"name":ADMIN,"uid":"secret","resourceVersion":"1"}, + "key":hash(&json!(STANDARD.encode(vec![b'A';64])))}); + let fresh = json!({"object":{"name":ADMIN,"uid":"secret","resourceVersion":"2"}, + "key":hash(&json!(STANDARD.encode(vec![b'B';64])))}); + if let Some(phase) = phase { + let mut receipt = json!({"version":4,"phase":phase, + "root":hash(&activation),"binding":"b".repeat(64),"attempt":"c".repeat(64), + "runtime":{"sandbox":{"name":"runtime","uid":"sandbox","resourceVersion":"1"},"workspace":"work", + "spec":hash(&json!({"inferenceRef":{"name":"policy"},"credentialsRef":{"name":"kars-credential-bundle-fixture","uid":"bundle-uid"}})), + "owners":hash(&owners(&sandbox)),"generation":1,"suspended":false, + "task":{"object":{"name":"task","uid":"task-uid","resourceVersion":"1"},"generation":1, + "authorization":authorization,"spec":hash(&json!({"spec":task["spec"],"owners":owners(&task)}))}}, + "deployment":{"name":"runtime","uid":"deployment","resourceVersion":"1"}, + "structure":"d".repeat(64),"replicas":1,"captured":["old-pod"],"baseline":before,"epoch":epoch, + "qualified":{"binding":"b".repeat(64),"template":"d".repeat(64),"material":fresh}}); + if phase == "v3" { + receipt = json!({"version":3,"root":hash(&activation), + "binding":hash(&json!({"namespace":{"name":"kars-runtime","uid":"runtime-ns"},"consumers":[]})), + "phase":"Qualified","epoch":epoch}); + } else { + if !["Restoring", "Qualified"].contains(&phase) { + receipt.as_object_mut().unwrap().remove("qualified"); + } + if ["Pausing", "Retired"].contains(&phase) { + receipt.as_object_mut().unwrap().remove("epoch"); + } + } + namespace["metadata"]["annotations"][HISTORY] = json!(receipt.to_string()); + } + state.objects.insert(SOURCE.into(), sandbox.clone()); + state.objects.insert(TASK.into(), task); + state.objects.insert(PRIVATE_SECRET.into(), json!({ + "apiVersion":"v1","kind":"Secret","metadata":{"name":ADMIN,"namespace":"kars-runtime","uid":"secret","resourceVersion":"2", + "annotations":{EPOCH:epoch}},"type":"Opaque","data":{"control-token":STANDARD.encode(vec![b'B';64])} + })); + if let Some(uid) = previous_uid { + let mut existing = desired.clone(); + existing["metadata"]["uid"] = json!(uid); + existing["metadata"]["resourceVersion"] = json!("7"); + existing["spec"]["replicas"] = json!(0); + state.objects.insert(DEPLOYMENT.into(), existing); + } + let state = Arc::new(Mutex::new(state)); + let captured = state.clone(); + let server = MockServer::start().await; + Mock::given(|_: &wiremock::Request| true).respond_with(move |request: &wiremock::Request| { + let path = request.url.path(); + if request.method == "POST" && path.ends_with("/selfsubjectreviews") { + return ResponseTemplate::new(201).set_body_json(json!({ + "apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview", + "status":{"userInfo":{"username":"system:serviceaccount:core:kars-controller","uid":"controller"}} + })); + } + let mut state = captured.lock().unwrap(); + if request.method == "GET" { + state.reads.push(path.to_string()); + return state.objects.get(path).map_or_else( + || ResponseTemplate::new(404).set_body_json(json!({"apiVersion":"v1","kind":"Status", + "status":"Failure","reason":"NotFound","code":404,"message":"fixture object absent"})), + |value| ResponseTemplate::new(200).set_body_json(value), + ); + } + let body: Value = serde_json::from_slice(&request.body).unwrap(); + state.writes.push((format!("{} {path}", request.method), body.clone())); + if path == DEPLOYMENT || path == DEPLOYMENT.strip_suffix("/runtime").unwrap() { + let mut value = body; + if request.method == "PATCH" { + let prior = &state.objects[DEPLOYMENT]; + assert_eq!(value["metadata"]["uid"], prior["metadata"]["uid"]); + assert_eq!(value["metadata"]["resourceVersion"], prior["metadata"]["resourceVersion"]); + } else { + assert_eq!(request.method, "POST"); + assert!(!state.objects.contains_key(DEPLOYMENT)); + value["metadata"]["uid"] = json!("created"); + } + value["metadata"]["resourceVersion"] = json!("8"); + state.objects.insert(DEPLOYMENT.into(), value.clone()); + return ResponseTemplate::new(if request.method == "POST" {201} else {200}).set_body_json(value); + } + assert_eq!(request.method, "PATCH"); + assert_eq!(path, RUNTIME_NS); + let namespace = state.objects.get_mut(RUNTIME_NS).unwrap(); + assert_eq!(body["metadata"]["uid"], namespace["metadata"]["uid"]); + assert_eq!(body["metadata"]["resourceVersion"], namespace["metadata"]["resourceVersion"]); + for (key, value) in body["metadata"]["annotations"].as_object().unwrap() { + namespace["metadata"]["annotations"][key] = value.clone(); + } + namespace["metadata"]["resourceVersion"] = json!("9"); + ResponseTemplate::new(200).set_body_json(namespace.clone()) + }).mount(&server).await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + RuntimeApi { + _server: server, + client, + state, + sandbox: serde_json::from_value(sandbox).unwrap(), + desired: serde_json::from_value(desired).unwrap(), + } + } + + #[tokio::test] + async fn runtime_v4_rejects_missing_or_foreign_incarnations_in_every_phase_before_writes() { + for phase in ["Pausing", "Retired", "Rotating", "Restoring", "Qualified"] { + for fault in ["absent", "foreign", "unidentified", "missing-recorded-uid"] { + let uid = match fault { + "absent" => None, + "foreign" => Some("foreign"), + _ => Some("deployment"), + }; + let mut api = runtime_api(Some(phase), uid, false).await; + { + let mut state = api.state.lock().unwrap(); + if fault == "unidentified" { + state.objects.get_mut(DEPLOYMENT).unwrap()["metadata"] + .as_object_mut() + .unwrap() + .remove("uid"); + } + if fault == "missing-recorded-uid" { + let mut receipt = state.receipt(); + receipt["deployment"].as_object_mut().unwrap().remove("uid"); + state.set_receipt(receipt); + } + } + let receipt = api.state.lock().unwrap().receipt(); + api.rejects_annotation(receipt.to_string()).await; + } + } + } + + #[tokio::test] + async fn runtime_rotating_deletion_and_concurrent_unsuspend_cannot_create_a_replacement() { + let mut api = runtime_api(Some("Rotating"), Some("deployment"), true).await; + assert_eq!(api.apply().await, Ok(true)); + assert_eq!( + api.state.lock().unwrap().objects[DEPLOYMENT]["spec"]["replicas"], + 0 + ); + { + let mut state = api.state.lock().unwrap(); + assert_eq!(state.writes.len(), 1); + state.writes.clear(); + state.reads.clear(); + state.objects.remove(DEPLOYMENT); + let sandbox = state.objects.get_mut(SOURCE).unwrap(); + sandbox["spec"]["suspended"] = json!(false); + sandbox["metadata"]["generation"] = json!(3); + sandbox["metadata"]["resourceVersion"] = json!("3"); + api.sandbox = serde_json::from_value(sandbox.clone()).unwrap(); + } + api.desired.metadata.uid = None; + api.desired.metadata.resource_version = None; + api.desired.spec.as_mut().unwrap().replicas = Some(1); + let before = api.state.lock().unwrap().objects.clone(); + let error = api.apply().await.unwrap_err(); + assert!(error.contains("disappeared")); + let state = api.state.lock().unwrap(); + assert!(state.reads.iter().any(|path| path == DEPLOYMENT)); + assert!(state.writes.is_empty()); + assert_eq!(state.objects, before); + assert!(!state.objects.contains_key(DEPLOYMENT)); + } + + #[tokio::test] + async fn runtime_restore_checks_original_authority_and_new_key() { + for fault in "none bare wrong uppercase trailing-newline source generation owner missing-task reused-key".split_whitespace() { + let mut api = runtime_api(Some("Restoring"), Some("deployment"), false).await; + { + let mut state = api.state.lock().unwrap(); + let authorization = state.objects[TASK]["status"]["envelopeDigest"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + task_authorization(&json!(authorization)).unwrap(), + authorization + ); + match fault { + "source" => { + state.objects.get_mut(SOURCE).unwrap()["spec"]["credentialsRef"]["name"] = + json!("changed") + } + "generation" => { + state.objects.get_mut(SOURCE).unwrap()["metadata"]["generation"] = json!(4) + } + "owner" => { + state.objects.get_mut(SOURCE).unwrap()["metadata"]["ownerReferences"] = + json!([]) + } + "missing-task" => { + state.objects.remove(TASK); + } + "reused-key" => { + state.objects.get_mut(PRIVATE_SECRET).unwrap()["data"]["control-token"] = + json!(STANDARD.encode(vec![b'A'; 64])) + } + _ => {} + } + api.sandbox = serde_json::from_value(state.objects[SOURCE].clone()).unwrap(); + if ["bare", "wrong", "uppercase", "trailing-newline"].contains(&fault) { + let changed = match fault { + "bare" => authorization.trim_start_matches("sha256:").to_string(), + "wrong" => format!("sha256:{}", "f".repeat(64)), + "uppercase" => authorization.to_uppercase(), + _ => format!("{authorization}\n"), + }; + state.objects.get_mut(TASK).unwrap()["status"]["envelopeDigest"] = + json!(changed); + if fault != "wrong" { + let mut receipt = state.receipt(); + receipt["runtime"]["task"]["authorization"] = json!(changed); + state.set_receipt(receipt); + } + } + } + let result = api.apply().await; + let state = api.state.lock().unwrap(); + assert_eq!( + state.reads.iter().any(|path| path == TASK), + !["bare", "uppercase", "trailing-newline"].contains(&fault) + ); + if fault == "none" { + assert_eq!(result, Ok(true)); + assert_eq!(state.writes.len(), 1); + assert_eq!(state.objects[DEPLOYMENT]["metadata"]["uid"], "deployment"); + assert_eq!(state.objects[DEPLOYMENT]["spec"]["replicas"], 1); + } else { + assert!(result.is_err(), "{fault}"); + assert!(state.writes.is_empty(), "{fault}"); + assert_eq!(state.objects[DEPLOYMENT]["spec"]["replicas"], 0); + } + } + } + + #[tokio::test] + async fn runtime_present_invalid_receipts_never_bypass_deleted_runtime_fences() { + let values = [ + Value::Null, + json!(false), + json!(4), + json!("4"), + json!([]), + json!({}), + json!({"version":4}), + ]; + for raw in values + .iter() + .map(Value::to_string) + .chain(["{".into(), "".into()]) + { + let mut api = runtime_api(Some("Rotating"), None, false).await; + api.rejects_annotation(raw).await; + } + let versions = json!([null, true, "4", -1, 0, 1, 2, 3, 5, 4.0]); + for version in versions.as_array().unwrap() { + let mut api = runtime_api(Some("Rotating"), None, false).await; + let mut receipt = api.state.lock().unwrap().receipt(); + receipt["version"] = version.clone(); + api.rejects_annotation(receipt.to_string()).await; + } + } + + #[tokio::test] + async fn runtime_v4_requires_complete_typed_receipts_even_with_a_matching_deployment() { + for pointer in "/root /binding /runtime/spec /runtime/generation /runtime/task/authorization /epoch /baseline/key /captured /qualified/template".split_whitespace() { + let mut api = runtime_api(Some("Qualified"), Some("deployment"), false).await; + let mut receipt = api.state.lock().unwrap().receipt(); + *receipt.pointer_mut(pointer).unwrap() = Value::Null; + api.rejects_annotation(receipt.to_string()).await; + } + } + + #[tokio::test] + async fn runtime_without_v4_preserves_private_create_and_unqualified_core_dispatch() { + for phase in [None, Some("v3")] { + let mut api = runtime_api(phase, None, false).await; + assert_eq!(api.apply().await, Ok(true)); + let state = api.state.lock().unwrap(); + assert!( + state + .writes + .iter() + .any(|(path, _)| path.starts_with("POST ") && path.ends_with("/deployments")) + ); + assert_eq!(state.objects[DEPLOYMENT]["metadata"]["uid"], "created"); + assert_eq!(state.objects[DEPLOYMENT]["spec"]["replicas"], 1); + assert_eq!( + state.objects[RUNTIME_NS]["metadata"]["annotations"]["kars.azure.com/private-parent-created"], + "a".repeat(64) + ); + } + let mut api = runtime_api(None, None, false).await; + api.desired + .spec + .as_mut() + .unwrap() + .template + .metadata + .as_mut() + .unwrap() + .annotations + .as_mut() + .unwrap() + .remove(EPOCH); + assert_eq!(api.apply().await, Ok(false)); + let state = api.state.lock().unwrap(); + assert!(state.reads.is_empty()); + assert!(state.writes.is_empty()); + } +} diff --git a/controller/src/private_activation/runtime.rs b/controller/src/private_activation/runtime.rs index 5a6ec5bb1..d18b49d71 100644 --- a/controller/src/private_activation/runtime.rs +++ b/controller/src/private_activation/runtime.rs @@ -190,6 +190,7 @@ pub(crate) async fn apply_deployment( } let api = Api::<Deployment>::namespaced(client.clone(), &namespace_name); let previous = api.get_opt(&sandbox.name_any()).await.map_err(|_| ERROR)?; + super::late_scope::fence(client, &namespace, sandbox, previous.as_ref(), deployment).await?; let applied = if let Some(previous) = previous { live(&previous.metadata)?; if !approved_deployment(&namespace, &previous, &epoch) diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index a3a66a5d8..a876a85e0 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -379,10 +379,73 @@ complete. Pausing/retired attempts retain their original binding, captured UIDs, baseline and exposed-key history, including the existing post-retirement budget rotation requirement. -**Deliberate bounds:** additional scopes with existing private consumers require -owner-specific retirement/rotation; shared enrollment does not pause the root or -adopt those consumers. Existing marked templates and consuming Pod/Job instances -also require explicit recovery. Changed shared consumers, deleted original +### Late enrollment of an existing Task runtime + +The same `preview --observe <sandbox> --private-consumer +kars-<sandbox>/Deployment/<sandbox>` and `apply` commands now support a narrow +owner-specific retirement lifecycle. Preview is read-only and reports the effect +on stderr; its JSON and the private-consumption/v1 grant schema are unchanged. +The supported target is one live controller-owned Deployment in its exact +KarsSandbox namespace claim, optionally owned by a current Ready, launched +KarsTask. Namespace/Sandbox/Task UIDs, executable template, source specification, +Task authorization and controller ownership must agree. An unobserved Sandbox +generation or changed reviewed Deployment resourceVersion requires re-preview. +Task authorization is the production `sha256:<64 lowercase hexadecimal digits>` +string, retained byte-for-byte in the configured identity, receipt and recovery +checks. It is not interchangeable with a bare hexadecimal structural digest. +Only the existing controller-issued `router-services-admin` token may need +rotation. Missing or customized keys, existing observer/TLS/App material, +privileged token/host access and other consumers are not adopted. + +Apply records a root-bound version-4 receipt in the **target namespace's existing +operator-only `kars.azure.com/private-root-retirement` annotation**: + +1. `Pausing` saves original suspension and replica intent, source/owner hashes, + Secret UID/version and a digest of the existing authentication key. Apply, + unlike preview, reads this one bounded private token into operator memory; + neither the token nor source credential values are printed or stored in the + receipt. UID/resourceVersion-CAS sets `spec.suspended=true` and scales the + reviewed Deployment to zero. It never sets Task `execution.launch=false`. +2. Every old Pod UID, **including terminating Pods**, must disappear before + `Retired`. The namespace stays Pending and the original authentication-key + baseline must remain unchanged throughout retirement. +3. `Rotating` publishes only the target's new epoch/approved parent. The existing + controller `ensure_bound` quarantine/retired-consumer checks mint a genuinely + fresh admin token, preserving Secret UID. Apply waits for that token's actual + bytes to differ and for the exact controller-owned template to reference its + new Secret UID/resourceVersion and private epoch. A stamp alone cannot qualify. + Only these two template annotations may change; executable/source drift fails. +4. `Restoring` rechecks original owners/specifications, the new private key and + captured UID retirement before restoring the original suspension intent. + `Qualified` requires observed Deployment readiness (or the original suspended + zero replicas). Supported replica intent is the controller's zero/one policy. + +The controller independently honors this operator-owned in-progress receipt. +It forces zero replicas until `Restoring`, verifies the original raw Sandbox/Task +specification and owner/generation bindings, and checks the actual new token +digest and Secret/template versions at the restore boundary. Concurrent tenant +unsuspension cannot bypass the recorded retirement or substitute a different +source. Completed scopes return to the existing controller lifecycle; unrelated +core runtimes without this receipt are unchanged. +The recorded Deployment incarnation is checked before **both CREATE and UPDATE**, +including completed v4 scopes. If it disappears or is replaced, no replacement +is adopted or created; explicit operator recovery is required. Ordinary core +creation and existing v3 scope handling do not acquire this v4 identity fence. + +Re-preview after a CAS conflict or lost response resumes the recorded attempt, +original intent and epoch; it cannot adopt changed templates/specifications or +invent missing retirement evidence. Failure preserves suspension and recovery +records. Task, Sandbox, namespace, source bundles, projections, agent keys and +persistent volumes are not deleted or replaced. **Pod-local ephemeral state, +including `emptyDir`, is restarted**, as preview explains; this is not a backup +or live migration facility. Already-enrolled observer/App runtimes require their +separate explicit rotation workflow. Later executable/source changes also require +an explicit review rather than silent acceptance by this retirement receipt. + +**Deliberate bounds:** other additional scopes with existing private consumers +still require owner-specific retirement/rotation; shared enrollment never pauses +the root or adopts arbitrary consumers. Existing marked templates and consuming +Pod/Job instances also require explicit recovery. Changed shared consumers, deleted original evidence namespaces, changed root/profile/bundle/template/budget identities or keys, and missing/tampered retirement evidence fail explicitly. A completed budget qualification can be shared only with its exact already-qualified key and Secret From 6342d22d6141e077d62907879cddc6f905564d96 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 04:03:41 +0200 Subject: [PATCH 053/111] Require real retired-observer identity and old-key rejection in native acceptance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../native-credentials/observation_cases.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py index 18b293c96..808f70655 100644 --- a/bridge/tests/native-credentials/observation_cases.py +++ b/bridge/tests/native-credentials/observation_cases.py @@ -59,6 +59,77 @@ def public(self): target = self.target() return self.bff.call("GET", f"/api/namespaces/{CORE}/tasks/{target['task']}/egress/learned") + def late_runtime_before(self): + target = self.target() + value, deployment, pod = running(self.setup, CORE, target["sandbox"]) + namespace = f"kars-{target['sandbox']}" + agent = next(container for container in pod["spec"]["containers"] if container["name"] == "openclaw") + projection = next(entry["secretRef"]["name"] for entry in agent["envFrom"] + if entry.get("secretRef", {}).get("optional") is False) + paths = [core(CORE, "secrets", SOURCE), core(CORE, "secrets", value["spec"]["credentialsRef"]["name"]), + core(namespace, "secrets", projection)] + stored = [(path, self.setup.admin.get(path)) for path in paths] + root = self.setup.admin.get("/api/v1/namespaces/" + CORE) + return { + "sandbox": value, "deployment": deployment, + "task": self.setup.admin.get(resource(CORE, "karstasks", target["task"])), + "namespace": self.setup.admin.get("/api/v1/namespaces/" + namespace), + "pods": {uid(entry) for entry in self.setup.admin.get(core(namespace, "pods"))["items"]}, + "admin": self.setup.admin.get(core(namespace, "secrets", "router-services-admin")), + "stored": stored, + "root": {key: value for key, value in root["metadata"].get("annotations", {}).items() + if key.startswith("kars.azure.com/private-")}, + "rootDeployment": self.setup.admin.get(resource(CORE, "deployments", "kars-controller", "/apis/apps/v1")), + } + + def late_runtime_after(self, before): + target = self.target() + namespace = f"kars-{target['sandbox']}" + value, deployment, pod = running(self.setup, CORE, target["sandbox"]) + task = self.setup.admin.get(resource(CORE, "karstasks", target["task"])) + current_namespace = self.setup.admin.get("/api/v1/namespaces/" + namespace) + require(uid(value) == uid(before["sandbox"]) and value["spec"] == before["sandbox"]["spec"] + and value["metadata"].get("ownerReferences") == before["sandbox"]["metadata"].get("ownerReferences") + and uid(task) == uid(before["task"]) and task["spec"] == before["task"]["spec"] + and uid(deployment) == uid(before["deployment"]) + and uid(current_namespace) == uid(before["namespace"]), + "Late operator enrollment replaced runtime identities or changed customer intent") + current_pods = {uid(entry) for entry in self.setup.admin.get(core(namespace, "pods"))["items"]} + require(not current_pods.intersection(before["pods"]), "Old late-enrollment Pod UID survived retirement") + receipt = json.loads(current_namespace["metadata"]["annotations"]["kars.azure.com/private-root-retirement"]) + require(receipt.get("version") == 4 and receipt.get("phase") == "Qualified" + and before["pods"].issubset(set(receipt.get("captured", []))), + "The real operator did not complete the captured late-runtime retirement") + for path, previous in before["stored"]: + current = self.setup.admin.get(path) + require(uid(current) == uid(previous) and current.get("data") == previous.get("data"), + "Late enrollment replaced or changed source bundle/projection/customer credentials") + root = self.setup.admin.get("/api/v1/namespaces/" + CORE) + require({key: value for key, value in root["metadata"].get("annotations", {}).items() + if key.startswith("kars.azure.com/private-")} == before["root"], + "Late enrollment changed the shared root epoch or retirement proof") + root_deployment = self.setup.admin.get(resource(CORE, "deployments", "kars-controller", "/apis/apps/v1")) + require(uid(root_deployment) == uid(before["rootDeployment"]) + and root_deployment["spec"] == before["rootDeployment"]["spec"] + and root_deployment["metadata"]["generation"] == before["rootDeployment"]["metadata"]["generation"], + "Late enrollment restarted or changed the shared root Deployment") + admin = self.setup.admin.get(core(namespace, "secrets", "router-services-admin")) + require(uid(admin) == uid(before["admin"]) + and admin["data"]["control-token"] != before["admin"]["data"]["control-token"], + "Late enrollment did not rotate the actual existing admin authentication key") + with forward(namespace, f"pod/{pod['metadata']['name']}", 19443, 8443): + for secret, expected in [(before["admin"], 401), (admin, 200)]: + token = base64.b64decode(secret["data"]["control-token"]).decode("ascii") + connection = http.client.HTTPConnection("127.0.0.1", 19443, timeout=20) + try: + connection.request("GET", "/internal/access-requests", headers={"Authorization": f"Bearer {token}"}) + response = connection.getresponse() + response.read(65536) + require(response.status == expected, + "Actual restarted router did not reject old admin authority and accept only the new key") + finally: + connection.close() + def enable(self): self.prepare_target() target = self.target() @@ -109,10 +180,12 @@ def enable(self): until("BFF retains API connectivity under existing Cilium isolation", self.bff.ready, 30) grant = self.setup.ready_grant(CORE) writer = self.setup.admin.get(core(BRIDGE, "serviceaccounts", WRITER)) + before = self.late_runtime_before() enroll(self.setup, CORE, writer, grant["spec"]["agentKeys"], previous=grant, observations=[ {"kind": "KarsSandbox", "namespace": CORE, "name": target["sandbox"], "uid": uid(value)}, ]) self.ready() + self.late_runtime_after(before) until("real BFF-to-observer9447 and router-to-verifier9448", lambda: self.public().get("available") is True, 240) From 3c5a0b8c6c935d24dde785db9f4028a68e57fce7 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 04:59:24 +0200 Subject: [PATCH 054/111] Snapshot the anchored v2 Task bundle in native observer enrollment Reproduce the absent legacy credentialsRef failure while preserving source, bundle and projection UID/data checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../native-credentials/observation_cases.py | 40 +++++-- .../test_late_observation_snapshot.py | 113 ++++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 bridge/tests/native-credentials/test_late_observation_snapshot.py diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py index 808f70655..22910f69b 100644 --- a/bridge/tests/native-credentials/observation_cases.py +++ b/bridge/tests/native-credentials/observation_cases.py @@ -63,16 +63,42 @@ def late_runtime_before(self): target = self.target() value, deployment, pod = running(self.setup, CORE, target["sandbox"]) namespace = f"kars-{target['sandbox']}" - agent = next(container for container in pod["spec"]["containers"] if container["name"] == "openclaw") - projection = next(entry["secretRef"]["name"] for entry in agent["envFrom"] - if entry.get("secretRef", {}).get("optional") is False) - paths = [core(CORE, "secrets", SOURCE), core(CORE, "secrets", value["spec"]["credentialsRef"]["name"]), - core(namespace, "secrets", projection)] - stored = [(path, self.setup.admin.get(path)) for path in paths] + agents = [container for container in pod["spec"]["containers"] if container["name"] == "openclaw"] + require(len(agents) == 1, "Late observation fixture requires its single OpenClaw consumer") + projections = [entry["secretRef"]["name"] for entry in agents[0].get("envFrom", []) + if entry.get("secretRef", {}).get("optional") is False] + require(len(projections) == 1, "Late observation fixture requires its exact governed projection") + task = self.setup.admin.get(resource(CORE, "karstasks", target["task"])) + owners = [owner for owner in value["metadata"].get("ownerReferences", []) + if owner.get("controller") is True] + require(len(owners) == 1 and owners[0].get("apiVersion") == "kars.azure.com/v1alpha1" + and owners[0].get("kind") == "KarsTask" and owners[0].get("name") == target["task"] + and owners[0].get("uid") == uid(task), + "Late observation Sandbox does not belong to the current Task") + bundle_path = core(CORE, "secrets", "kars-credential-bundle-karstask-" + target["task"]) + bundle = self.setup.admin.get(bundle_path) + bundle_annotations = bundle["metadata"].get("annotations", {}) + bundle_owners = [owner for owner in bundle["metadata"].get("ownerReferences", []) + if owner.get("controller") is True] + require(task["metadata"].get("annotations", {}).get("kars.azure.com/credential-bundle-uid") == uid(bundle) + and bundle_annotations.get("kars.azure.com/credential-purpose") == "agent-bundle-v2" + and bundle_annotations.get("kars.azure.com/credential-target-kind") == "KarsTask" + and bundle_annotations.get("kars.azure.com/credential-target-uid") == uid(task) + and len(bundle_owners) == 1 and bundle_owners[0].get("apiVersion") == "kars.azure.com/v1alpha1" + and bundle_owners[0].get("kind") == "KarsTask" and bundle_owners[0].get("name") == target["task"] + and bundle_owners[0].get("uid") == uid(task), + "Late observation bundle does not match its current Task anchor and owner") + projection_path = core(namespace, "secrets", projections[0]) + projection = self.setup.admin.get(projection_path) + require(projection["metadata"].get("annotations", {}).get("kars.azure.com/credential-source-uid") == uid(bundle), + "Late observation projection does not consume the anchored Task bundle") + source_path = core(CORE, "secrets", SOURCE) + stored = [(source_path, self.setup.admin.get(source_path)), (bundle_path, bundle), + (projection_path, projection)] root = self.setup.admin.get("/api/v1/namespaces/" + CORE) return { "sandbox": value, "deployment": deployment, - "task": self.setup.admin.get(resource(CORE, "karstasks", target["task"])), + "task": task, "namespace": self.setup.admin.get("/api/v1/namespaces/" + namespace), "pods": {uid(entry) for entry in self.setup.admin.get(core(namespace, "pods"))["items"]}, "admin": self.setup.admin.get(core(namespace, "secrets", "router-services-admin")), diff --git a/bridge/tests/native-credentials/test_late_observation_snapshot.py b/bridge/tests/native-credentials/test_late_observation_snapshot.py new file mode 100644 index 000000000..32f063d6a --- /dev/null +++ b/bridge/tests/native-credentials/test_late_observation_snapshot.py @@ -0,0 +1,113 @@ +"""Retain the actual governed Task bundle, not an absent legacy credentialsRef.""" + +import copy +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import observation_cases as observation +from credential_cases import SOURCE +from native_api import CORE, Failure, core, resource + + +class LateObservationSnapshotTests(unittest.TestCase): + def setUp(self): + self.name = "native-observation-task" + namespace = "kars-" + self.name + self.owner = {"apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsTask", + "name": self.name, "uid": "task-uid", "controller": True} + self.sandbox = { + "metadata": {"name": self.name, "namespace": CORE, "uid": "sandbox-uid", + "ownerReferences": [copy.deepcopy(self.owner)]}, + "spec": {"credentialBindings": {"grant": {"name": "workspace", "uid": "grant-uid"}}}, + } + self.deployment = {"metadata": {"name": self.name, "uid": "deployment-uid"}, "spec": {}} + self.pod = {"metadata": {"name": "pod", "uid": "pod-uid"}, "spec": {"containers": [ + {"name": "openclaw", "envFrom": [{"secretRef": {"name": "projection", "optional": False}}]}, + {"name": "inference-router"}, + ]}} + self.task_path = resource(CORE, "karstasks", self.name) + self.bundle_path = core(CORE, "secrets", "kars-credential-bundle-karstask-" + self.name) + self.projection_path = core(namespace, "secrets", "projection") + self.source_path = core(CORE, "secrets", SOURCE) + self.objects = { + self.task_path: {"metadata": {"name": self.name, "uid": "task-uid", "annotations": { + "kars.azure.com/credential-bundle-uid": "bundle-uid"}}, "spec": {}}, + self.bundle_path: {"metadata": {"uid": "bundle-uid", "ownerReferences": [copy.deepcopy(self.owner)], + "annotations": {"kars.azure.com/credential-purpose": "agent-bundle-v2", + "kars.azure.com/credential-target-kind": "KarsTask", + "kars.azure.com/credential-target-uid": "task-uid"}}, + "data": {"SLACK_BOT_TOKEN": "cHJpdmF0ZS1maXh0dXJl"}}, + self.projection_path: {"metadata": {"uid": "projection-uid", "annotations": { + "kars.azure.com/credential-source-uid": "bundle-uid"}}, + "data": {"SLACK_BOT_TOKEN": "cHJpdmF0ZS1maXh0dXJl"}}, + self.source_path: {"metadata": {"uid": "source-uid"}, + "data": {"SLACK_BOT_TOKEN": "cHJpdmF0ZS1maXh0dXJl"}}, + "/api/v1/namespaces/" + CORE: {"metadata": {"uid": "root-uid", "annotations": { + "kars.azure.com/private-epoch": "root-epoch"}}}, + "/api/v1/namespaces/" + namespace: {"metadata": {"uid": "namespace-uid"}}, + core(namespace, "pods"): {"items": [self.pod]}, + core(namespace, "secrets", "router-services-admin"): {"metadata": {"uid": "admin-uid"}}, + resource(CORE, "deployments", "kars-controller", "/apis/apps/v1"): { + "metadata": {"uid": "controller-uid", "generation": 1}, "spec": {}}, + } + self.reads = [] + + def get(path): + self.reads.append(path) + return copy.deepcopy(self.objects[path]) + + self.setup = SimpleNamespace(admin=SimpleNamespace(get=get)) + self.cases = observation.ObservationCases(self.setup, None, None) + self.cases.observer_target = {"task": self.name, "sandbox": self.name, + "workspace": CORE, "uid": "sandbox-uid"} + + def snapshot(self): + with patch.object(observation, "running", return_value=( + self.sandbox, self.deployment, self.pod)): + return self.cases.late_runtime_before() + + def test_v2_without_legacy_reference_retains_source_task_bundle_and_projection(self): + self.assertNotIn("credentialsRef", self.sandbox["spec"]) + result = self.snapshot() + self.assertEqual([path for path, _ in result["stored"]], + [self.source_path, self.bundle_path, self.projection_path]) + for path, saved in result["stored"]: + self.assertEqual(saved, self.objects[path]) + self.assertEqual(result["task"], self.objects[self.task_path]) + self.assertEqual(result["pods"], {"pod-uid"}) + + def test_replaced_or_unanchored_bundle_and_projection_are_refused(self): + for changed in ("task-uid", "missing-anchor", "bundle-uid", "bundle-owner", + "bundle-purpose", "bundle-kind", "bundle-target", "projection-source"): + with self.subTest(changed=changed): + self.setUp() + if changed == "task-uid": + self.objects[self.task_path]["metadata"]["uid"] = "replacement" + elif changed == "missing-anchor": + self.objects[self.task_path]["metadata"]["annotations"] = {} + elif changed == "bundle-uid": + self.objects[self.bundle_path]["metadata"]["uid"] = "replacement" + elif changed == "bundle-owner": + self.objects[self.bundle_path]["metadata"]["ownerReferences"][0]["uid"] = "replacement" + elif changed == "projection-source": + self.objects[self.projection_path]["metadata"]["annotations"]["kars.azure.com/credential-source-uid"] = "replacement" + else: + key = {"bundle-purpose": "purpose", "bundle-kind": "target-kind", + "bundle-target": "target-uid"}[changed] + self.objects[self.bundle_path]["metadata"]["annotations"]["kars.azure.com/credential-" + key] = "replacement" + with self.assertRaises(Failure): + self.snapshot() + + def test_missing_or_ambiguous_projection_is_explicitly_rejected(self): + for sources in ([], [{"secretRef": {"name": "optional", "optional": True}}], + [{"secretRef": {"name": "one", "optional": False}}, + {"secretRef": {"name": "two", "optional": False}}]): + with self.subTest(sources=sources): + self.pod["spec"]["containers"][0]["envFrom"] = sources + with self.assertRaises(Failure): + self.snapshot() + + +if __name__ == "__main__": + unittest.main() From cfd2f85ab4fedb643a8665610c7f72771bd7c591 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 05:10:52 +0200 Subject: [PATCH 055/111] Keep migration profile fixtures distinct on evaluator-v2 source trees Validate the actual rendered schema before deriving either exact target; preserve both profile assertions without changing migration permissions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/lib/sre-migration.test-support.ts | 9 ++++++++- cli/src/lib/sre-schema-migration.test.ts | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/src/lib/sre-migration.test-support.ts b/cli/src/lib/sre-migration.test-support.ts index f81808c93..ae7a5ba73 100644 --- a/cli/src/lib/sre-migration.test-support.ts +++ b/cli/src/lib/sre-migration.test-support.ts @@ -15,7 +15,14 @@ export function canonicalMigrationSchemas(evalV2 = false): { before: ObjectMap[] const after = schemaDocuments(execFileSync("helm", ["template", "kars", chart, "--namespace", "kars-system", "--set", "sre.enabled=false", "--dry-run=client"], { encoding: "utf8" })) .filter(object => object.kind === "CustomResourceDefinition"); - const evalSchema = after.find(object => object.spec.names.kind === "KarsEval")!.spec.versions[0].schema.openAPIV3Schema; + const evalCrd = after.find(object => object.spec.names.kind === "KarsEval")!; + if (!CANONICAL_SCHEMAS[evalCrd.metadata.name].after.includes(schemaDigest(normalizedCrd(evalCrd)))) { + throw new Error("Current evaluator schema is not a qualified fixture input"); + } + const evalSchema = evalCrd.spec.versions[0].schema.openAPIV3Schema; + for (const key of ["reportConfigMapRef", "reportConfigMapUid", "reportEvidenceDigest"]) { + delete evalSchema.properties.status.properties[key]; + } if (evalV2) Object.assign(evalSchema.properties.status.properties, { reportConfigMapRef: { description: "Bounded, exclusively owned latest per-case report and attribution.", nullable: true, properties: { name: { type: "string" } }, required: ["name"], type: "object" }, diff --git a/cli/src/lib/sre-schema-migration.test.ts b/cli/src/lib/sre-schema-migration.test.ts index 85b8ef346..d37e973b9 100644 --- a/cli/src/lib/sre-schema-migration.test.ts +++ b/cli/src/lib/sre-schema-migration.test.ts @@ -17,7 +17,10 @@ describe("closed BASE365 SRE schema migration", () => { expect(after).toHaveLength(21); for (const object of before) expect(schemaDigest(normalizedCrd(object))).toBe(CANONICAL_SCHEMAS[object.metadata.name].before); for (const object of after) expect(CANONICAL_SCHEMAS[object.metadata.name].after).toContain(schemaDigest(normalizedCrd(object))); - if (evalV2) expect(schemaDigest(normalizedCrd(after.find(object => object.spec.names.kind === "KarsEval")!))).toBe(EVALUATOR_V2); + const evalCrd = after.find(object => object.spec.names.kind === "KarsEval")!; + const legacy = CANONICAL_SCHEMAS[evalCrd.metadata.name].after.filter(value => value !== EVALUATOR_V2); + expect(legacy).toHaveLength(1); + expect(schemaDigest(normalizedCrd(evalCrd))).toBe(evalV2 ? EVALUATOR_V2 : legacy[0]); }); it.each([false, true])("preserves object data/UID/RV while applying only the qualified target (v2=%s)", async evalV2 => { From c3e8e911f0cf25a188e2469ac03093596980635a Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 05:37:58 +0200 Subject: [PATCH 056/111] Retain bounded late-observer preview failure locations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../native-credentials/operator_diagnostics.py | 11 +++++++++++ .../native-credentials/test_operator_diagnostics.py | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index afbd881b7..d3f5ca306 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -15,12 +15,23 @@ "Consumer execution differs from the reviewed controller template; preserve it for explicit Pod review": "consumer-execution-drift", "Private activation staging requires the existing cluster-scoped credential operator authority": "operator-authority", "Explicit credential-grant operator permission is required": "operator-authority", + "Late private runtime retirement changed or is unsupported; preserve the runtime and re-preview its original review": "late-runtime-review", + "Task authorization must retain its exact production sha256: digest": "late-task-authorization", + "Customized admin credential mount requires explicit recovery": "late-admin-mount", + "Late enrollment only retires the reviewed runtime's controller-owned admin token, not host access, privileged tokens or other private authority": "late-authority-unsupported", + "Unreviewed late private consumer preserved": "late-pod-lineage", + "Existing observer, TLS or App private material requires its owner-specific rotation; late admin-only enrollment preserved it": "late-existing-private-material", + "Late private runtime requires its existing controller-owned admin credential; missing material was not adopted": "late-admin-missing", + "Late private credential provenance is missing or conflicting": "late-admin-provenance", + "Reviewed runtime has not consumed its current controller-owned admin credential version": "late-admin-version", + "Customized or missing late private credential keys require explicit operator recovery": "late-admin-keys", } MODULES = ( "commands/credential-grants", "lib/private-activation", "lib/private-activation-retirement", "lib/kube-bootstrap", "lib/kube-context", "lib/private-activation-continuity", "lib/private-activation-guard-retirement", + "lib/private-activation-late-scope", "commands/schemas", "lib/core-helm-schemas", "lib/schema-stage", "lib/schema-documents", "lib/schema-discovery", "lib/repo-assets", diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 340047fd8..3eac37933 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -43,6 +43,19 @@ def test_error_bodies_cannot_create_arbitrary_categories_or_paths(self): f" at object (/cli/dist/lib/private-activation.js:1:2){PRIVATE}"): self.assertEqual(source_location(line), "unavailable") + def test_late_scope_leaf_is_retained_instead_of_only_its_awaiting_caller(self): + stderr = ( + f"{PRIVATE}\n" + "Error: Customized admin credential mount requires explicit recovery\n" + f" at supportedTemplate (/private/{PRIVATE}/cli/dist/lib/private-activation-late-scope.js:285:19)\n" + " at scopePlan (/cli/dist/lib/private-activation-continuity.js:231:22)\n" + ) + self.assertEqual(category(stderr), "late-admin-mount") + self.assertEqual(source_location(stderr), "lib/private-activation-late-scope:285") + self.assertNotIn(PRIVATE, category(stderr) + source_location(stderr)) + for module in ("private-activation-late-scope-private", "private-activation-late-scope/unknown"): + self.assertEqual(source_location(f" at function (/cli/dist/lib/{module}.js:1:2)"), "unavailable") + def test_success_and_unknown_stage_do_not_change_authority(self): with tempfile.TemporaryDirectory(prefix="native-operator-success-") as directory, \ patch.object(native_api, "ROOT", Path(directory)): From 9b99e6b81241813f4e349d4a998b729770ece8c3 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 05:52:12 +0200 Subject: [PATCH 057/111] Use supported metadata-only kubectl projection for private credential review Replace unsupported Go-template json calls in late enrollment and budget TLS identity reads. Validate the actual printer with an offline kubectl contract test and preserve absence/error and UID/RV fences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/lib/private-activation-fixtures.ts | 3 +- .../lib/private-activation-late-scope.test.ts | 2 +- cli/src/lib/private-activation-late-scope.ts | 8 ++-- ...private-activation-secret-metadata.test.ts | 46 +++++++++++++++++++ cli/src/lib/private-activation.ts | 19 ++++++-- docs/how-to/governed-credential-grants.md | 5 ++ 6 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 cli/src/lib/private-activation-secret-metadata.test.ts diff --git a/cli/src/lib/private-activation-fixtures.ts b/cli/src/lib/private-activation-fixtures.ts index f2fa9bc2d..d7096e633 100644 --- a/cli/src/lib/private-activation-fixtures.ts +++ b/cli/src/lib/private-activation-fixtures.ts @@ -71,9 +71,10 @@ export function fixture() { if (!value) throw new Error("fixture object unavailable"); if (args[0] === "get" && args[1] === "secret") { const format = args[args.indexOf("-o") + 1]; - if (format === "go-template={{json .metadata}}") return JSON.stringify(value.metadata); + if (format === "jsonpath-as-json={.metadata}") return JSON.stringify([value.metadata]); if (format === "go-template={{.type}}") return value.type; if (format === 'go-template={{index .data "tls.crt"}}') return value.data["tls.crt"]; + if (format !== "json") throw new Error("Unsupported fixture Secret printer"); } if (args[0] === "get") return JSON.stringify(value); if (args[0] !== "patch") throw new Error("Unexpected fixture mutation"); diff --git a/cli/src/lib/private-activation-late-scope.test.ts b/cli/src/lib/private-activation-late-scope.test.ts index ab42291ac..e0c38ed9d 100644 --- a/cli/src/lib/private-activation-late-scope.test.ts +++ b/cli/src/lib/private-activation-late-scope.test.ts @@ -135,7 +135,7 @@ describe("reviewed late runtime private enrollment", () => { const review = await f.document(); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("controller admin-key rotation")); expect(f.calls.every(args => args[0] === "get")).toBe(true); - expect(f.calls.filter(args => args[1] === "secret").every(args => args.includes("go-template={{json .metadata}}"))).toBe(true); + expect(f.calls.filter(args => args[1] === "secret").every(args => args.includes("jsonpath-as-json={.metadata}"))).toBe(true); const oldKey = f.secret.data["control-token"]; await applyReviewedGrant(f.execute, review); expect(f.preserved()).toEqual(before); diff --git a/cli/src/lib/private-activation-late-scope.ts b/cli/src/lib/private-activation-late-scope.ts index 287d7cc0b..76d419d03 100644 --- a/cli/src/lib/private-activation-late-scope.ts +++ b/cli/src/lib/private-activation-late-scope.ts @@ -4,7 +4,7 @@ import { randomBytes } from "node:crypto"; import { annotations, at, bundleDefinition, canonical, consumesPrivateAuthority, digest, patchNamespace, - PRIVATE_PREFIX as P, read, record, reviewed, reviewedOwner, template, templateDigest, + PRIVATE_PREFIX as P, read, readSecretMetadata, record, reviewed, reviewedOwner, template, templateDigest, type Execute, type Json, type NamespaceReview, type PrivateActivation, type ReviewedObject, } from "./private-activation.js"; import { replicaIntent } from "./private-activation-retirement.js"; @@ -228,10 +228,8 @@ async function inventory(execute: Execute, scope: NamespaceReview): Promise<Json } async function secretMetadata(execute: Execute, scope: NamespaceReview, name: string): Promise<ReturnType<typeof record> | undefined> { - const raw = await execute(["get", "secret", name, "-n", scope.namespace.name, "--ignore-not-found", - "-o", "go-template={{json .metadata}}"]); - if (!raw.trim()) return undefined; - return record({ metadata: JSON.parse(raw) }); + const metadata = await readSecretMetadata(execute, name, scope.namespace.name, true); + return metadata === undefined ? undefined : record({ metadata }); } function ownedMaterial(secret: unknown, scope: NamespaceReview, runtime: Runtime): ReviewedObject { const identity = reviewed(secret); diff --git a/cli/src/lib/private-activation-secret-metadata.test.ts b/cli/src/lib/private-activation-secret-metadata.test.ts new file mode 100644 index 000000000..13ecd84f0 --- /dev/null +++ b/cli/src/lib/private-activation-secret-metadata.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from "node:child_process"; +import { devNull } from "node:os"; +import { describe, expect, it, vi } from "vitest"; +import { readSecretMetadata, type Execute } from "./private-activation.js"; + +describe("private Secret metadata projection", () => { + it("uses the real kubectl printer without returning Secret values or contacting a cluster", async () => { + const execute: Execute = async args => { + expect(args.slice(0, 5)).toEqual(["get", "secret", "metadata-fixture", "-n", "reviewed"]); + const format = args[args.indexOf("-o") + 1]!; + return execFileSync("kubectl", [ + "--kubeconfig", devNull, "--server", "http://127.0.0.1:1", "--request-timeout=2s", + "create", "secret", "generic", "metadata-fixture", "--namespace", "reviewed", + "--from-literal=fixture=public-test-value", "--dry-run=client", "-o", format, + ], { encoding: "utf8", timeout: 10_000, windowsHide: true }); + }; + const metadata = await readSecretMetadata(execute, "metadata-fixture", "reviewed"); + expect(metadata.name).toBe("metadata-fixture"); + expect(metadata.namespace).toBe("reviewed"); + expect(metadata).not.toHaveProperty("data"); + expect(JSON.stringify(metadata)).not.toContain("public-test-value"); + }, 15_000); + + it.each([{}, null, "metadata", [], [null], [[]], ["metadata"], [{}, {}]])( + "rejects malformed or ambiguous metadata projection %j", async value => { + await expect(readSecretMetadata(async () => JSON.stringify(value), "secret", "namespace")).rejects.toThrow(); + }, + ); + + it("permits absence only for explicitly optional lookups", async () => { + const execute = vi.fn(async (_args: readonly string[]) => ""); + await expect(readSecretMetadata(execute, "secret", "namespace")).rejects.toThrow("missing"); + expect(execute.mock.calls[0]?.[0]).not.toContain("--ignore-not-found"); + await expect(readSecretMetadata(execute, "secret", "namespace", true)).resolves.toBeUndefined(); + expect(execute.mock.calls[1]?.[0]).toContain("--ignore-not-found"); + }); + + it("does not disguise authorization or transport errors as absent optional material", async () => { + const failure = new Error("fixture API denied"); + const execute: Execute = async () => { throw failure; }; + await expect(readSecretMetadata(execute, "secret", "namespace", true)).rejects.toBe(failure); + }); +}); diff --git a/cli/src/lib/private-activation.ts b/cli/src/lib/private-activation.ts index bbfd2152d..3f5d47805 100644 --- a/cli/src/lib/private-activation.ts +++ b/cli/src/lib/private-activation.ts @@ -121,6 +121,20 @@ function rootEnvironment(deployment: unknown, name: string): string | undefined return value; } +export function readSecretMetadata(execute: Execute, name: string, namespace: string): Promise<RecordValue>; +export function readSecretMetadata(execute: Execute, name: string, namespace: string, optional: true): Promise<RecordValue | undefined>; +export async function readSecretMetadata(execute: Execute, name: string, namespace: string, optional = false): Promise<RecordValue | undefined> { + const raw = await execute(["get", "secret", name, "-n", namespace, ...(optional ? ["--ignore-not-found"] : []), + "-o", "jsonpath-as-json={.metadata}"]); + if (!raw.trim()) { + if (optional) return undefined; + throw new Error("Private Secret metadata projection is missing"); + } + const projected = list(JSON.parse(raw)); + if (projected.length !== 1) throw new Error("Private Secret metadata projection must contain exactly one object"); + return record(projected[0]); +} + export async function reviewBudgetTls(execute: Execute, deployment: unknown, rootNamespace: string): Promise<BudgetTlsReview | undefined> { const enabled = rootEnvironment(deployment, "KARS_INFERENCE_BUDGET_ENABLED"); if (enabled === undefined || enabled === "" || enabled === "false") return undefined; @@ -137,7 +151,7 @@ export async function reviewBudgetTls(execute: Execute, deployment: unknown, roo if (podNamespace.length && value === undefined && downward !== "metadata.namespace") throw new Error("Root Pod namespace input requires explicit review"); const namespace = configured || (typeof value === "string" ? value.trim() : downward ? rootNamespace : "") || "kars-system"; const ns = reviewed(await read(execute, "namespace", namespace)); - const metadata = JSON.parse(await execute(["get", "secret", name, "-n", namespace, "-o", "go-template={{json .metadata}}"])); + const metadata = await readSecretMetadata(execute, name, namespace); const secret = reviewed({ metadata }); if (at(metadata, "annotations", "kars.azure.com/inference-budget-tls") !== "v1") { throw new Error("Budget TLS Secret is not the reviewed budget identity"); @@ -148,8 +162,7 @@ export async function reviewBudgetTls(execute: Execute, deployment: unknown, roo const certificate = await execute(["get", "secret", name, "-n", namespace, "-o", 'go-template={{index .data "tls.crt"}}']); const publicKey = new X509Certificate(Buffer.from(certificate.trim(), "base64")).publicKey .export({ format: "der", type: "spki" }); - const after = reviewed({ metadata: JSON.parse(await execute(["get", "secret", name, "-n", namespace, - "-o", "go-template={{json .metadata}}"])) }); + const after = reviewed({ metadata: await readSecretMetadata(execute, name, namespace) }); if (canonical(after) !== canonical(secret) || reviewed(await read(execute, "namespace", namespace)).uid !== ns.uid) { throw new Error("Budget TLS identity changed during public-key review"); } diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 83fad3fc8..eb8cab150 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -306,6 +306,11 @@ digest. Only metadata and `tls.crt` are read for this review, never `tls.key`. The namespace fence protects that exact configured Secret name, rather than guessing a default name or making every TLS Secret private. +Metadata review uses kubectl's JSON metadata projection and accepts exactly one +object. Optional absence does not hide authorization or transport errors. Budget +TLS review rereads the same Secret identity after reading the public certificate; +the CLI receives metadata and `tls.crt`, not the private `tls.key`. + The review includes `root.replicaIntent`, including an explicit zero. Before pausing the root, apply persists this intent and an attempt bound to the reviewed namespace, ServiceAccount, Deployment, template, consumers, and bundle in From 010fd77d81d94087d026e2298b3b72691abe6efe Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 06:47:38 +0200 Subject: [PATCH 058/111] Retain only strict late-preview readiness comparison facts Reject duplicate, extended, malformed and ambiguous diagnostic records while preserving failed-command behavior and secret redaction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../operator_diagnostics.py | 32 ++++++++++++++++++- .../test_operator_diagnostics.py | 32 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index d3f5ca306..c20f58ed7 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -1,5 +1,6 @@ """Project only fixed categories and allowlisted source locations from CLI errors.""" +import json import re from native_api import CommandFailure, Failure, command @@ -36,6 +37,13 @@ "lib/schema-documents", "lib/schema-discovery", "lib/repo-assets", ) +CHECK_PREFIX = "KARS_PRIVATE_LATE_SANDBOX_CHECKS " +CHECK_FIELDS = ( + ("resourceVersionMatch", "rv"), + ("observedGenerationMatch", "generation"), + ("phaseRunningMatch", "running"), + ("readyConditionMatch", "ready"), +) def category(stderr): @@ -55,13 +63,35 @@ def source_location(stderr): return "unavailable" +def sandbox_checks(stderr): + lines = [line for line in stderr.splitlines() if line.startswith(CHECK_PREFIX)] + if not lines: + return "" + if len(lines) != 1 or len(lines[0]) > 512: + return "unavailable" + try: + # Preserve duplicate keys so ambiguous facts cannot silently overwrite each other. + pairs = json.loads(lines[0][len(CHECK_PREFIX):], object_pairs_hook=lambda values: values) + except json.JSONDecodeError: + return "unavailable" + if (not isinstance(pairs, list) or len(pairs) != len(CHECK_FIELDS) + or not all(isinstance(pair, tuple) and len(pair) == 2 + and isinstance(pair[0], str) and isinstance(pair[1], bool) for pair in pairs) + or {key for key, _ in pairs} != {key for key, _ in CHECK_FIELDS}): + return "unavailable" + values = dict(pairs) + return ",".join(f"{label}={str(values[key]).lower()}" for key, label in CHECK_FIELDS) + + def operator_command(stage, *args, timeout): if stage not in ("preview", "apply", "schemas"): raise Failure("Unknown native operator enrollment stage") try: return command(*args, timeout=timeout) except CommandFailure as error: + checks = sandbox_checks(error.stderr) + details = f" (sandbox-checks={checks})" if checks else "" raise Failure( f"Native operator {stage} failed: {category(error.stderr)} " - f"(source={source_location(error.stderr)})" + f"(source={source_location(error.stderr)}){details}" ) from None diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 3eac37933..536ce8ae7 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -1,6 +1,7 @@ """Run real subprocess failures and prove no CLI body is published.""" import io +import json from contextlib import redirect_stderr, redirect_stdout from pathlib import Path import sys @@ -10,7 +11,7 @@ import native_api from native_api import Failure -from operator_diagnostics import ERRORS, category, operator_command, source_location +from operator_diagnostics import CHECK_PREFIX, ERRORS, category, operator_command, sandbox_checks, source_location PRIVATE = "DO-NOT-EMIT-TOKENS-OR-PRIVATE-API-BODIES" @@ -56,6 +57,35 @@ def test_late_scope_leaf_is_retained_instead_of_only_its_awaiting_caller(self): for module in ("private-activation-late-scope-private", "private-activation-late-scope/unknown"): self.assertEqual(source_location(f" at function (/cli/dist/lib/{module}.js:1:2)"), "unavailable") + def test_actual_failure_retains_only_the_four_boolean_snapshot_checks(self): + facts = {"resourceVersionMatch": False, "observedGenerationMatch": True, + "phaseRunningMatch": True, "readyConditionMatch": True} + stderr = f"{PRIVATE}\n{CHECK_PREFIX}{json.dumps(facts)}\n{PRIVATE}" + output = io.StringIO() + with tempfile.TemporaryDirectory(prefix="native-operator-checks-") as directory, \ + patch.object(native_api, "ROOT", Path(directory)), redirect_stdout(output), redirect_stderr(output): + with self.assertRaises(Failure) as failure: + operator_command("preview", sys.executable, "-c", + "import sys; print(sys.argv[1],file=sys.stderr); sys.exit(1)", + stderr, timeout=5) + self.assertIn("(sandbox-checks=rv=false,generation=true,running=true,ready=true)", str(failure.exception)) + self.assertNotIn(PRIVATE, str(failure.exception)) + self.assertEqual(output.getvalue(), "") + + def test_malformed_ambiguous_or_extended_snapshot_checks_remain_unavailable(self): + valid = {"resourceVersionMatch": False, "observedGenerationMatch": True, + "phaseRunningMatch": True, "readyConditionMatch": True} + for value in ({}, None, list(valid.items()), {**valid, "private": PRIVATE}, + {**valid, "resourceVersionMatch": 0}, {**valid, "phaseRunningMatch": PRIVATE}): + with self.subTest(value=value): + self.assertEqual(sandbox_checks(CHECK_PREFIX + json.dumps(value)), "unavailable") + line = CHECK_PREFIX + json.dumps(valid) + for value in (line + PRIVATE, line + "\n" + line, CHECK_PREFIX + " " * 512, + CHECK_PREFIX + '{"resourceVersionMatch":false,"resourceVersionMatch":true,' + '"phaseRunningMatch":true,"readyConditionMatch":true}'): + self.assertEqual(sandbox_checks(value), "unavailable") + self.assertEqual(sandbox_checks(PRIVATE), "") + def test_success_and_unknown_stage_do_not_change_authority(self): with tempfile.TemporaryDirectory(prefix="native-operator-success-") as directory, \ patch.object(native_api, "ROOT", Path(directory)): From 38be1389919717aba45ee4ba294f7ee3cbfeb525 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 06:53:05 +0200 Subject: [PATCH 059/111] Require coherent late-observer snapshots without rejecting RV-only repeats Preserve all identity, metadata, status and authority comparisons except the revision-only equality; retain original reviewed versions and unchanged pre-write CAS. Emit only fixed readiness match booleans on failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../lib/private-activation-late-scope.test.ts | 220 +++++++++++++++++- cli/src/lib/private-activation-late-scope.ts | 49 +++- 2 files changed, 258 insertions(+), 11 deletions(-) diff --git a/cli/src/lib/private-activation-late-scope.test.ts b/cli/src/lib/private-activation-late-scope.test.ts index e0c38ed9d..40cc5c011 100644 --- a/cli/src/lib/private-activation-late-scope.test.ts +++ b/cli/src/lib/private-activation-late-scope.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { applyReviewedGrant } from "../commands/credential-grants.js"; +import { applyReviewedGrant, credentialGrantsCommand } from "../commands/credential-grants.js"; import { continuityFixture, privateAuthoritySnapshot } from "./private-activation-fixtures.js"; import { PRIVATE_PREFIX as P, canonical, type Execute } from "./private-activation.js"; @@ -13,6 +13,9 @@ const SOURCE = "kars.azure.com/sandbox-uid"; const NS = "kars.azure.com/namespace-uid"; const consumer = "kars-late/Deployment/late"; const AUTHORIZATION = `sha256:${"a".repeat(64)}`; +const SNAPSHOT_MARKER = "KARS_PRIVATE_LATE_SANDBOX_CHECKS "; +const cliProcess = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("execa", () => ({ execa: cliProcess.execute })); async function setup(suspended: boolean | null = null) { const f = continuityFixture(); @@ -126,9 +129,222 @@ async function setup(suspended: boolean | null = null) { } describe("reviewed late runtime private enrollment", () => { - beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); + beforeEach(() => { + cliProcess.execute.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); afterEach(() => { vi.restoreAllMocks(); }); + it("accepts an identical current Sandbox after an intervening status PATCH advances only resourceVersion", async () => { + const f = await setup(); + let updated = false; + const statusUpdate: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (!updated && args[0] === "get" && args[1] === "karstask") { + updated = true; + f.sandbox.status = structuredClone(f.sandbox.status); + f.sandbox.metadata.resourceVersion = "2"; + } + return result; + }; + const before = f.preserved(); + const review = await f.document(statusUpdate); + expect(updated).toBe(true); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.calls.filter(args => args[0] === "get" && args[1] === "karssandbox")).toHaveLength(3); + expect(f.calls.filter(args => args[0] === "get" && args[1] === "karstask")).toHaveLength(2); + expect(vi.mocked(console.error).mock.calls.some(([value]) => String(value).startsWith(SNAPSHOT_MARKER))).toBe(false); + await applyReviewedGrant(f.execute, review); + expect(f.preserved()).toEqual(before); + expect(f.sandbox.spec.suspended).toBeUndefined(); + }); + + it.each(["same-status", "changed-spec", "stale-ready"])( + "exercises the shipped --observe preview and validation command with concurrent %s", async fault => { + const f = await setup(); + const output = vi.spyOn(console, "log").mockImplementation(() => {}); + let updated = false; + cliProcess.execute.mockImplementation(async (program: string, args: string[], options: { input?: string }) => { + expect(program).toBe("kubectl"); + const stdout = await f.execute(args, options.input); + if (!updated && args[0] === "get" && args[1] === "karstask") { + updated = true; + f.sandbox.metadata.resourceVersion = "2"; + if (fault === "same-status") f.sandbox.status = structuredClone(f.sandbox.status); + if (fault === "changed-spec") f.sandbox.spec.credentialsRef.uid = "unreviewed-source"; + if (fault === "stale-ready") f.sandbox.status.conditions[0].observedGeneration = 0; + } + return { stdout }; + }); + const preview = credentialGrantsCommand().parseAsync([ + "preview", "--namespace", "work", "--writer", "reader/bff", "--observe", "late", + "--private-root", "core", "--private-controller-profile", "kcm-certificate", + "--private-consumer", consumer, + ], { from: "user" }); + if (fault === "same-status") { + await preview; + expect(output).toHaveBeenCalledTimes(1); + const review = JSON.parse(String(output.mock.calls[0]![0])); + expect(review.spec.observationTargets).toEqual([{ kind: "KarsSandbox", namespace: "work", name: "late", uid: "sandbox" }]); + expect(review.spec.privateActivation.phase).toBe("reviewed"); + expect(JSON.stringify(review)).not.toContain("control-token"); + expect(JSON.stringify(review)).not.toContain("conditions"); + } else { + await expect(preview).rejects.toThrow(); + expect(output).not.toHaveBeenCalled(); + } + expect(updated).toBe(true); + expect(f.calls.every(args => ["get", "auth"].includes(args[0]!))).toBe(true); + expect(f.namespace.metadata.annotations[HISTORY]).toBeUndefined(); + }); + + it.each(["uid", "spec", "generation", "owner", "labels", "annotations", "finalizers", "managed-fields", + "namespace-binding", "observation-authority", "status-content"])( + "does not treat concurrent Sandbox %s drift as a harmless resourceVersion update", async fault => { + const f = await setup(); + let changed = false; + const race: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (!changed && args[0] === "get" && args[1] === "karstask") { + changed = true; + f.sandbox.metadata.resourceVersion = "2"; + if (fault === "uid") f.sandbox.metadata.uid = "must-not-be-logged"; + if (fault === "spec") f.sandbox.spec.credentialsRef.uid = "changed-source"; + if (fault === "generation") f.sandbox.metadata.generation = 2; + if (fault === "owner") f.sandbox.metadata.ownerReferences[0].uid = "changed-task"; + if (fault === "labels") f.sandbox.metadata.labels = { changed: "must-not-be-logged" }; + if (fault === "annotations") f.sandbox.metadata.annotations.unreviewed = "must-not-be-logged"; + if (fault === "finalizers") f.sandbox.metadata.finalizers = ["changed-finalizer"]; + if (fault === "managed-fields") f.sandbox.metadata.managedFields = [{ manager: "changed-manager" }]; + if (fault === "namespace-binding") f.sandbox.metadata.annotations[NS] = "changed-namespace"; + if (fault === "observation-authority") f.sandbox.status.serviceObservation = { phase: "Prepared" }; + if (fault === "status-content") f.sandbox.status.conditions[0].message = "changed-status"; + } + return result; + }; + await expect(f.document(race)).rejects.toThrow(); + expect(changed).toBe(true); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + const markers = vi.mocked(console.error).mock.calls.map(([value]) => String(value)) + .filter(value => value.startsWith(SNAPSHOT_MARKER)); + expect(markers).toEqual([`${SNAPSHOT_MARKER}{"resourceVersionMatch":false,"observedGenerationMatch":true,"phaseRunningMatch":true,"readyConditionMatch":true}`]); + expect(markers.join("")).not.toContain("must-not-be-logged"); + expect(f.namespace.metadata.annotations[HISTORY]).toBeUndefined(); + }); + + it.each(["observed-generation", "phase", "missing-ready", "false-ready", "stale-ready", "missing-ready-generation", "malformed-conditions"])( + "reports only fixed readiness match booleans for %s without a fallback", async fault => { + const f = await setup(); + if (fault === "observed-generation") f.sandbox.status.observedGeneration = 0; + if (fault === "phase") f.sandbox.status.phase = "Degraded"; + if (fault === "missing-ready") f.sandbox.status.conditions = []; + if (fault === "false-ready") f.sandbox.status.conditions[0].status = "False"; + if (fault === "stale-ready") f.sandbox.status.conditions[0].observedGeneration = 0; + if (fault === "missing-ready-generation") delete f.sandbox.status.conditions[0].observedGeneration; + if (fault === "malformed-conditions") f.sandbox.status.conditions = {}; + await expect(f.document()).rejects.toThrow(); + const markers = vi.mocked(console.error).mock.calls.map(([value]) => String(value)) + .filter(value => value.startsWith(SNAPSHOT_MARKER)); + expect(markers).toHaveLength(1); + expect(JSON.parse(markers[0]!.slice(SNAPSHOT_MARKER.length))).toEqual({ + resourceVersionMatch: true, observedGenerationMatch: fault !== "observed-generation", + phaseRunningMatch: fault !== "phase", + readyConditionMatch: ["observed-generation", "phase"].includes(fault), + }); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it.each(["task-spec", "task-owner", "task-uid", "task-authorization", "task-metadata", + "deployment-env", "deployment-metadata", "namespace"])( + "rechecks the bounded read set and preserves authority on concurrent %s drift", async fault => { + const f = await setup(true); + let changed = false; + const race: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (!changed && args[0] === "get" && args[1] === "karstask") { + changed = true; + if (fault === "task-spec") f.task.spec.objective = "changed"; + if (fault === "task-owner") f.task.metadata.ownerReferences = [{ uid: "changed-team" }]; + if (fault === "task-uid") f.task.metadata.uid = "changed-task"; + if (fault === "task-authorization") f.task.status.envelopeDigest = `sha256:${"b".repeat(64)}`; + if (fault === "task-metadata") f.task.metadata.labels = { changed: "authority" }; + if (fault === "deployment-env") f.deployment.spec.template.spec.containers[0].env[0].value = "{}"; + if (fault === "deployment-metadata") f.deployment.metadata.labels.changed = "authority"; + if (fault === "namespace") f.namespace.metadata.annotations[SOURCE] = "changed-sandbox"; + } + return result; + }; + await expect(f.document(race)).rejects.toThrow(); + expect(changed).toBe(true); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.namespace.metadata.annotations[HISTORY]).toBeUndefined(); + expect(f.sandbox.spec.suspended).toBe(true); + }); + + it("accepts an identical Task status snapshot without rebasing its authority", async () => { + const f = await setup(); + let updated = false; + const race: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (!updated && args[0] === "get" && args[1] === "karstask") { + updated = true; + f.task.metadata.resourceVersion = "2"; + } + return result; + }; + const review = await f.document(race); + expect(updated).toBe(true); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + await applyReviewedGrant(f.execute, review); + expect(JSON.parse(f.namespace.metadata.annotations[HISTORY]).runtime.task.authorization).toBe(AUTHORIZATION); + }); + + it("does not promote a first-read stale Ready condition when the second read becomes current", async () => { + const f = await setup(); + f.sandbox.status.conditions[0].observedGeneration = 0; + const race: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] === "get" && args[1] === "karstask") { + f.sandbox.status.conditions[0].observedGeneration = 1; + f.sandbox.metadata.resourceVersion = "2"; + } + return result; + }; + await expect(f.document(race)).rejects.toThrow(); + expect(console.error).toHaveBeenCalledWith(`${SNAPSHOT_MARKER}{"resourceVersionMatch":false,"observedGenerationMatch":true,"phaseRunningMatch":true,"readyConditionMatch":true}`); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it("revalidates the same snapshot constraints during apply before any mutation", async () => { + const f = await setup(); + const review = await f.document(); + f.calls.length = 0; + let changed = false; + const race: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (!changed && args[0] === "get" && args[1] === "karstask") { + changed = true; + f.sandbox.metadata.resourceVersion = "2"; + f.sandbox.spec.credentialsRef.uid = "changed-before-apply"; + } + return result; + }; + await expect(applyReviewedGrant(race, review)).rejects.toThrow(); + expect(changed).toBe(true); + expect(f.calls.every(args => ["get", "auth"].includes(args[0]!))).toBe(true); + expect(f.namespace.metadata.annotations[HISTORY]).toBeUndefined(); + }); + + it("still rejects changed shared-root authority after preview before any mutation", async () => { + const f = await setup(); + const review = await f.document(); + f.objects.get(f.key("deployment", "kars-controller", "core")).spec.template.spec.containers[0].image = "changed-root"; + f.calls.length = 0; + await expect(applyReviewedGrant(f.execute, review)).rejects.toThrow(); + expect(f.calls.every(args => ["get", "auth"].includes(args[0]!))).toBe(true); + expect(f.namespace.metadata.annotations[HISTORY]).toBeUndefined(); + }); + it.each([null, false, true])("retires real owned Pod UIDs, verifies token rotation and restores suspension %s without touching shared authority", async original => { const f = await setup(original); const before = f.preserved(); diff --git a/cli/src/lib/private-activation-late-scope.ts b/cli/src/lib/private-activation-late-scope.ts index 76d419d03..b9d750dcc 100644 --- a/cli/src/lib/private-activation-late-scope.ts +++ b/cli/src/lib/private-activation-late-scope.ts @@ -26,6 +26,7 @@ interface Runtime { suspended: boolean | null; task?: { object: ReviewedObject; spec: string; generation: number; authorization: string }; } +interface RuntimeSnapshot { runtime: Runtime; sandbox: Json; task?: Json } interface Material { object: ReviewedObject; key: string } interface Receipt { version: 4; @@ -145,7 +146,18 @@ async function namespaceFor(execute: Execute, scope: NamespaceReview): Promise<R return namespace; } -async function runtimeFor(execute: Execute, scope: NamespaceReview, namespace: unknown, deployment: unknown): Promise<Runtime> { +function sameReadSnapshot(before: Json, after: Json): boolean { + const body = (value: Json) => { + const copy = structuredClone(record(value)); + delete record(copy.metadata).resourceVersion; + return canonical(copy); + }; + // An identical status PATCH can advance only this opaque revision. Keep the + // original reviewed identity; all status, metadata and authority remain exact. + return body(before) === body(after); +} + +async function runtimeFor(execute: Execute, scope: NamespaceReview, namespace: unknown, deployment: unknown): Promise<RuntimeSnapshot> { const fields = record(at(namespace, "metadata", "annotations")); const name = text(fields["kars.azure.com/sandbox-name"]); const workspace = text(fields["kars.azure.com/sandbox-namespace"]); @@ -175,6 +187,7 @@ async function runtimeFor(execute: Execute, scope: NamespaceReview, namespace: u || at(sandbox, "status", "serviceObservation") != null) throw new Error(failure); const owners = items(at(sandbox, "metadata", "ownerReferences") ?? []); let task: Runtime["task"]; + let taskSnapshot: Json | undefined; if (owners.length) { const owner = record(owners[0]); if (owners.length !== 1 || owner.apiVersion !== "kars.azure.com/v1alpha1" @@ -197,9 +210,12 @@ async function runtimeFor(execute: Execute, scope: NamespaceReview, namespace: u || !items(at(current, "status", "conditions") ?? []).some(c => at(c, "type") === "Ready" && at(c, "status") === "True")) throw new Error(failure); task = { object: taskIdentity, spec: digest({ spec: current.spec, owners: at(current, "metadata", "ownerReferences") ?? [] }), generation: currentGeneration, authorization }; + taskSnapshot = current; } - return { sandbox: identity, workspace, spec: sandboxSpec(sandbox), owners: digest(owners), - generation: generation(sandbox), suspended: suspended(sandbox), ...(task ? { task } : {}) }; + return { sandbox, ...(taskSnapshot ? { task: taskSnapshot } : {}), runtime: { + sandbox: identity, workspace, spec: sandboxSpec(sandbox), owners: digest(owners), + generation: generation(sandbox), suspended: suspended(sandbox), ...(task ? { task } : {}), + } }; } function sameRuntime(current: Runtime, original: Runtime, phase: Phase): void { @@ -308,7 +324,8 @@ async function current( const namespace = await namespaceFor(execute, scope); const deployment = await read(execute, "deployments.apps", consumer.object.name, scope.namespace.name); if (reviewed(deployment).uid !== consumer.object.uid) throw new Error(failure); - const runtime = await runtimeFor(execute, scope, namespace, deployment); + const snapshot = await runtimeFor(execute, scope, namespace, deployment); + const runtime = snapshot.runtime; if (state) { sameRuntime(runtime, state.runtime, state.phase); if (state.root !== root || state.deployment.uid !== consumer.object.uid || structure(deployment) !== state.structure @@ -331,12 +348,26 @@ async function current( || replicaIntent(deployment) !== (runtime.suspended === true ? 0 : 1) || at(template(deployment), "metadata", "annotations", `${P}epoch`) !== undefined) throw new Error(failure); const sandbox = await read(execute, "karssandbox", runtime.sandbox.name, runtime.workspace); - if (reviewed(sandbox).resourceVersion !== runtime.sandbox.resourceVersion - || at(sandbox, "status", "observedGeneration") !== runtime.generation - || at(sandbox, "status", "phase") !== "Running" - || !items(at(sandbox, "status", "conditions") ?? []).some(condition => + const conditions = at(sandbox, "status", "conditions"); + const checks = { + resourceVersionMatch: reviewed(sandbox).resourceVersion === runtime.sandbox.resourceVersion, + observedGenerationMatch: at(sandbox, "status", "observedGeneration") === runtime.generation, + phaseRunningMatch: at(sandbox, "status", "phase") === "Running", + readyConditionMatch: Array.isArray(conditions) && conditions.some(condition => at(condition, "type") === "Ready" && at(condition, "status") === "True" - && at(condition, "observedGeneration") === runtime.generation)) throw new Error(failure); + && at(condition, "observedGeneration") === runtime.generation), + }; + if (!sameReadSnapshot(snapshot.sandbox, sandbox) + || !checks.observedGenerationMatch || !checks.phaseRunningMatch || !checks.readyConditionMatch) { + console.error(`KARS_PRIVATE_LATE_SANDBOX_CHECKS ${JSON.stringify(checks)}`); + throw new Error(failure); + } + if (runtime.task && (!snapshot.task || !sameReadSnapshot(snapshot.task, + await read(execute, "karstask", runtime.task.object.name, runtime.workspace)))) throw new Error(failure); + if (canonical(await namespaceFor(execute, scope)) !== canonical(namespace) + || canonical(await read(execute, "deployments.apps", consumer.object.name, scope.namespace.name)) !== canonical(deployment)) { + throw new Error(failure); + } } supportedTemplate(deployment, scope, activation); const secret = await materialInventory(execute, scope, runtime); From 0c6f5d3e3a842372461e6e25b2d4c07642950991 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 07:03:52 +0200 Subject: [PATCH 060/111] Validate unchanged migration seeds against the early historical API gate Require strict server dry-runs and unchanged stored identities/data before seeding. Retain only fixed kind/status/field diagnostics without weakening validation or changing migration permissions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../e2e/sre_authority/canonical_migration.py | 30 +-- .../sre_authority/canonical_migration_test.py | 49 +++- tests/e2e/sre_authority/canonical_seed.py | 211 ++++++++++++++++++ tests/e2e/sre_authority/legacy_crd_probe.py | 6 + tests/e2e/sre_authority/legacy_crds_test.py | 194 ++++++++++++++++ 5 files changed, 462 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/sre_authority/canonical_seed.py diff --git a/tests/e2e/sre_authority/canonical_migration.py b/tests/e2e/sre_authority/canonical_migration.py index 2e2da57f2..f2b8bef33 100644 --- a/tests/e2e/sre_authority/canonical_migration.py +++ b/tests/e2e/sre_authority/canonical_migration.py @@ -12,6 +12,7 @@ import json from .common import SYSTEM, require +from .canonical_seed import dry_run_seed_data, request_seed, seed_definitions CRDS = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions" STAGE = ("authority", "stage", "--controller-image", "kars-controller:e2e", @@ -35,32 +36,11 @@ def seed_data(h): controller = h.get("deployment", "kars-controller", SYSTEM) require(controller["spec"].get("replicas") == 0, "Migration fixture must keep the real controller paused while measuring data") - envelope = {"tier": 1, "authorityCeiling": 1, - "budget": {"tokens": 20, "usdMicros": 0}} - definitions = [ - ("karstask", "KarsTask", {"objective": "Inert migration data", - "envelope": envelope, "execution": {"launch": False}}), - ("karsteam", "KarsTeam", {"charter": "Inert migration data", - "envelope": envelope, "roster": []}), - ("mcpserver", "McpServer", {"url": "https://migration-fixture.invalid/", - "productionMode": False}), - ("karseval", "KarsEval", {"corpus": {"builtin": "sre"}, - "targetSandboxRef": {"name": "sre"}}), - ("karssreaction", "KarsSREAction", { - "action": {"type": "ScaleDeployment", "params": { - "namespace": SYSTEM, "name": "kars-controller", "replicas": 0, - "opaque": {"nested": [1, "retained", True]}, - }}, - "approval": {"state": "Rejected"}, - }), - ] + dry_run_seed_data(h) fixtures = [] - for resource, kind, spec in definitions: - name = f"e2e-migration-{resource}" - obj = h.create({"apiVersion": "kars.azure.com/v1alpha1", "kind": kind, - "metadata": {"name": name, "namespace": SYSTEM}, - "spec": copy.deepcopy(spec)}) - fixtures.append({"resource": resource, "name": name, "before": data_snapshot(obj)}) + for resource, obj in seed_definitions(): + created = request_seed(h, resource, obj, dry_run=False) + fixtures.append({"resource": resource, "name": created["metadata"]["name"], "before": data_snapshot(created)}) h.passed("Native canonical migration fixture data created without launching workloads") return fixtures diff --git a/tests/e2e/sre_authority/canonical_migration_test.py b/tests/e2e/sre_authority/canonical_migration_test.py index 9c1303608..7b0011650 100644 --- a/tests/e2e/sre_authority/canonical_migration_test.py +++ b/tests/e2e/sre_authority/canonical_migration_test.py @@ -4,18 +4,29 @@ """Pure checks of the native fixture; no cluster or controller execution.""" import copy +from pathlib import Path from types import SimpleNamespace import unittest +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit from sre_authority.canonical_migration import ( CRDS, STAGE, assert_data_unchanged, deny_late_conflicts, finish_data_proof, seed_data, ) +from sre_authority.canonical_seed import SEEDS, WORKLOADS, collection_path, seed_definitions class FakeHarness: + """Transport orchestration only; this is not Kubernetes schema validation.""" + def __init__(self): + self.root = Path("unused-fixture-report-root") self.objects = { - ("deployment", "kars-controller"): {"spec": {"replicas": 0}}, + ("deployment", "kars-controller"): { + "apiVersion": "apps/v1", "kind": "Deployment", + "metadata": {"name": "kars-controller", "namespace": "kars-system", + "uid": "controller", "resourceVersion": "1"}, + "spec": {"replicas": 0}}, ("clusterrolebinding", "kars-sre-reader"): { "metadata": {"uid": "binding"}, "subjects": [{"name": "legacy"}, {"name": "unrelated"}]}, } @@ -37,8 +48,32 @@ def create(self, obj): self.objects[(result["kind"].lower(), result["metadata"]["name"])] = result return copy.deepcopy(result) - def api(self, method, path, *, body, status): + def api(self, method, path, *, body=None, status=None): self.calls.append((method, path, copy.deepcopy(body))) + parsed = urlsplit(path) + if method == "GET": + assert status == 200 and parse_qs(parsed.query) == {"limit": ["513"]} + if parsed.path in WORKLOADS: + items = [self.get("deployment", "kars-controller")] if parsed.path.endswith("/deployments") else [] + else: + resource = next(resource for resource, _plural, _kind in SEEDS + if collection_path(resource) == parsed.path) + items = [copy.deepcopy(obj) for (kind, _name), obj in self.objects.items() if kind == resource] + result = {"kind": "List", "metadata": {}, "items": items} + return SimpleNamespace(status_code=200, json=lambda: result) + if method == "POST": + resource = next(resource for resource, _plural, _kind in SEEDS + if collection_path(resource) == parsed.path) + query = parse_qs(parsed.query) + assert query in ({"fieldManager": ["kubectl-create"], "fieldValidation": ["Strict"]}, + {"fieldManager": ["kubectl-create"], "fieldValidation": ["Strict"], "dryRun": ["All"]}) + assert body == dict(seed_definitions())[resource] + if "dryRun" in query: + result = copy.deepcopy(body) + result["metadata"]["uid"] = "ephemeral-dry-run" + else: + result = self.create(body) + return SimpleNamespace(status_code=201, json=lambda: result) if method == "PATCH": current = self.objects[("crd", path.rsplit("/", 1)[1])] assert body["metadata"]["uid"] == current["metadata"]["uid"] @@ -67,9 +102,15 @@ def passed(self, _message): class CanonicalMigrationFixtureTests(unittest.TestCase): - def test_seed_uses_nonexecuting_valid_action_and_real_data_preservation_assertions(self): + def setUp(self): + reporter = patch("sre_authority.canonical_seed.write_report") + self.reporter = reporter.start() + self.addCleanup(reporter.stop) + + def test_seed_uses_typed_inert_action_and_real_data_preservation_assertions(self): h = FakeHarness() fixtures = seed_data(h) + h.calls.clear() action = h.get("karssreaction", "e2e-migration-karssreaction") self.assertEqual(action["spec"]["approval"]["state"], "Rejected") self.assertEqual(action["spec"]["action"]["type"], "ScaleDeployment") @@ -82,6 +123,7 @@ def test_seed_uses_nonexecuting_valid_action_and_real_data_preservation_assertio def test_negative_fixtures_use_the_public_cli_and_restore_only_exact_uid_rv_owned_schema(self): h = FakeHarness() fixtures = seed_data(h) + h.calls.clear() before = copy.deepcopy(h.objects[("crd", "karstasks.kars.azure.com")]["spec"]) action = copy.deepcopy(h.objects[("crd", "karssreactions.kars.azure.com")]) subjects = copy.deepcopy(h.objects[("clusterrolebinding", "kars-sre-reader")]["subjects"]) @@ -97,6 +139,7 @@ def test_negative_fixtures_use_the_public_cli_and_restore_only_exact_uid_rv_owne def test_cleanup_is_limited_to_measured_disposable_crs_with_exact_uid_rv(self): h = FakeHarness() fixtures = seed_data(h) + h.calls.clear() finish_data_proof(h, fixtures) self.assertEqual(len(h.calls), 5) self.assertTrue(all(method == "DELETE" and "/customresourcedefinitions/" not in path diff --git a/tests/e2e/sre_authority/canonical_seed.py b/tests/e2e/sre_authority/canonical_seed.py new file mode 100644 index 000000000..9cda16611 --- /dev/null +++ b/tests/e2e/sre_authority/canonical_seed.py @@ -0,0 +1,211 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Shared historical seed bodies and strict API probes, not schema migration.""" + +import copy +import json +import re + +from .common import SYSTEM, require +from .registration_schema import write_report + +SEEDS = ( + ("karstask", "karstasks", "KarsTask"), + ("karsteam", "karsteams", "KarsTeam"), + ("mcpserver", "mcpservers", "McpServer"), + ("karseval", "karsevals", "KarsEval"), + ("karssreaction", "karssreactions", "KarsSREAction"), +) +WORKLOADS = ( + "/api/v1/pods", "/api/v1/replicationcontrollers", + "/apis/apps/v1/deployments", "/apis/apps/v1/replicasets", + "/apis/apps/v1/statefulsets", "/apis/apps/v1/daemonsets", + "/apis/batch/v1/jobs", "/apis/batch/v1/cronjobs", +) + + +def seed_definitions(): + envelope = {"tier": 1, "authorityCeiling": 1, + "budget": {"tokens": 20, "usdMicros": 0}} + specs = [ + {"objective": "Inert migration data", "envelope": envelope, "execution": {"launch": False}}, + {"charter": "Inert migration data", "envelope": envelope, "roster": []}, + {"url": "https://migration-fixture.invalid/", "productionMode": False}, + {"corpus": {"builtin": "sre"}, "targetSandboxRef": {"name": "sre"}}, + {"action": {"type": "ScaleDeployment", "params": { + "namespace": SYSTEM, "name": "kars-controller", "replicas": 0, + "opaque": {"nested": [1, "retained", True]}, + }}, "approval": {"state": "Rejected"}}, + ] + return [(resource, {"apiVersion": "kars.azure.com/v1alpha1", "kind": kind, + "metadata": {"name": f"e2e-migration-{resource}", "namespace": SYSTEM}, + "spec": copy.deepcopy(spec)}) + for (resource, _plural, kind), spec in zip(SEEDS, specs)] + + +def collection_path(resource): + plural = next((plural for singular, plural, _kind in SEEDS if singular == resource), None) + require(plural is not None, "Unrecognized historical migration seed") + return f"/apis/kars.azure.com/v1alpha1/namespaces/{SYSTEM}/{plural}" + + +def _field_paths(value, path=""): + result = {path} if path else set() + if isinstance(value, dict): + for key, item in value.items(): + result |= _field_paths(item, f"{path}.{key}" if path else key) + elif isinstance(value, list): + for index, item in enumerate(value): + result |= _field_paths(item, f"{path}[{index}]") + return result + + +def seed_status(resource, code, body): + expected = dict(seed_definitions())[resource] + allowed = _field_paths(expected) + report = {"kind": expected["kind"], "httpStatus": code, "category": "unexpected-response", + "fields": [], "validation": []} + if not isinstance(body, dict) or body.get("kind") != "Status": + return report + reasons = {"Invalid", "Forbidden", "Unauthorized", "NotFound", "AlreadyExists", + "Conflict", "BadRequest", "InternalError", "ServiceUnavailable"} + if isinstance(body.get("reason"), str) and body["reason"] in reasons: + report["category"] = body["reason"] + fields, validation = set(), set() + details = body.get("details") + causes = details.get("causes", []) if isinstance(details, dict) else [] + if isinstance(causes, list): + for cause in causes[:32]: + if not isinstance(cause, dict): + continue + field = cause.get("field") + if isinstance(field, str) and field in allowed: + fields.add(field) + category = {"FieldValueRequired": "required-field", "FieldValueInvalid": "invalid-field", + "FieldValueNotSupported": "unsupported-field"}.get( + cause["reason"]) if isinstance(cause.get("reason"), str) else None + if category: + validation.add(category) + message = body.get("message") + if isinstance(message, str): + # BadRequest strict-decoding errors often have no structured causes. + # Only exact field paths in our fixed public bodies may leave this parser. + for field in re.findall(r'unknown field "([^"\r\n]{1,256})"', message[:16384]): + if field in allowed: + fields.add(field) + for needle, category in (("unknown field", "unknown-field"), ("strict decoding error", "strict-decoding"), + ("cannot unmarshal", "type-mismatch")): + if needle in message[:16384]: + validation.add(category) + report["fields"], report["validation"] = sorted(fields), sorted(validation) + return report + + +def _retains_input(expected, actual): + if isinstance(expected, dict): + return isinstance(actual, dict) and all(key in actual and _retains_input(value, actual[key]) + for key, value in expected.items()) + if isinstance(expected, list): + return isinstance(actual, list) and len(expected) == len(actual) and all( + _retains_input(left, right) for left, right in zip(expected, actual)) + return type(expected) is type(actual) and expected == actual + + +class SeedRejected(AssertionError): + pass + + +def request_seed(h, resource, obj, *, dry_run): + expected = dict(seed_definitions()).get(resource) + require(expected is not None and json.dumps(obj, sort_keys=True) == json.dumps(expected, sort_keys=True), + "Only the exact public historical seed body may be submitted") + mode = "server-dry-run" if dry_run else "create" + filename = f"migration-seed-{resource}-{mode}.json" + write_report(h.root, filename, {"kind": expected["kind"], "mode": mode, + "httpStatus": None, "category": "requesting"}) + path = (collection_path(resource) + "?fieldManager=kubectl-create&fieldValidation=Strict" + + ("&dryRun=All" if dry_run else "")) + response = h.api("POST", path, body=obj) + try: + body = response.json() + except (ValueError, TypeError): + body = None + report = seed_status(resource, response.status_code, body) + report["mode"] = mode + if response.status_code == 201 and isinstance(body, dict) and body.get("kind") == expected["kind"]: + meta = body.get("metadata") + identity = isinstance(meta, dict) and all(meta.get(key) == expected["metadata"][key] + for key in ("name", "namespace")) + if not dry_run: + identity = identity and all(isinstance(meta.get(key), str) and meta[key] for key in ("uid", "resourceVersion")) + if (identity and body.get("apiVersion") == expected["apiVersion"] + and _retains_input(expected["spec"], body.get("spec")) and not body.get("status")): + report["category"] = "accepted" + else: + report["category"] = "identity-or-data-round-trip" + write_report(h.root, filename, report) + if report["category"] != "accepted": + raise SeedRejected(f"Historical seed {expected['kind']} {mode} rejected: " + f"HTTP {response.status_code}; category={report['category']}") + return body + + +def _inventory(h, path): + response = h.api("GET", path + "?limit=513", status=200) + body = response.json() + require(isinstance(body, dict) and isinstance(body.get("items"), list) + and isinstance(body.get("metadata", {}), dict) + and not body.get("metadata", {}).get("continue") and len(body["items"]) <= 512, + "Historical seed inventory must be complete and bounded") + items = body["items"] + require(all(isinstance(item, dict) and isinstance(item.get("metadata"), dict) + and all(isinstance(item["metadata"].get(key), str) and item["metadata"][key] + for key in ("name", "uid", "resourceVersion")) for item in items), + "Historical seed inventory lacks real API identities") + require(len({item["metadata"]["uid"] for item in items}) == len(items), + "Historical seed inventory contains duplicate identities") + return sorted(items, key=lambda item: item["metadata"]["uid"]) + + +def _snapshot(h): + controller = h.get("deployment", "kars-controller", SYSTEM) + require(controller and controller.get("spec", {}).get("replicas") == 0 + and all(controller.get("status", {}).get(key, 0) == 0 + for key in ("replicas", "readyReplicas", "availableReplicas", "updatedReplicas")) + and all(controller.get("metadata", {}).get(key) for key in ("uid", "resourceVersion")), + "Historical seed dry-runs require the actual controller paused with a stable identity") + state = {"controller": controller} + for resource, _plural, _kind in SEEDS: + state[resource] = _inventory(h, collection_path(resource)) + require(not any(obj["metadata"]["name"] == f"e2e-migration-{resource}" for obj in state[resource]), + "Historical seed already exists; no collision or adoption is permitted") + for path in WORKLOADS: + objects = _inventory(h, path) + if path == "/api/v1/pods": + require(not any(obj["metadata"].get("namespace") == SYSTEM + and obj.get("spec", {}).get("serviceAccountName") == "kars-controller" for obj in objects), + "Controller Pods remain during historical seed dry-runs") + # Kubelet/controller status updates are unrelated to dry-run persistence. + # Pin every workload UID, desired spec and non-server-managed metadata. + state[path] = [{**{key: obj.get(key) for key in ("apiVersion", "kind", "spec")}, + "metadata": {key: value for key, value in obj["metadata"].items() + if key not in ("resourceVersion", "managedFields")}} for obj in objects] + encoded = json.dumps(state, sort_keys=True, separators=(",", ":")) + require(len(encoded.encode()) <= 8 * 1024 * 1024, "Historical seed inventory exceeds its 8 MiB bound") + return encoded + + +def dry_run_seed_data(h): + before = _snapshot(h) + rejected = 0 + try: + for resource, obj in seed_definitions(): + try: + request_seed(h, resource, obj, dry_run=True) + except SeedRejected: + rejected += 1 + finally: + require(_snapshot(h) == before, "Historical seed dry-runs changed stored data, identity or workload intent") + require(rejected == 0, f"{rejected} historical seed bodies failed strict server dry-run; see fixed kind/field diagnostics") + h.passed("All five historical seed bodies passed strict server dry-run without persistence or workload changes") diff --git a/tests/e2e/sre_authority/legacy_crd_probe.py b/tests/e2e/sre_authority/legacy_crd_probe.py index c5fc5589f..31681b6e7 100644 --- a/tests/e2e/sre_authority/legacy_crd_probe.py +++ b/tests/e2e/sre_authority/legacy_crd_probe.py @@ -4,11 +4,13 @@ """Same-Kind historical Helm wait/upgrade proof without controller execution.""" from pathlib import Path +import re import time import types from sre_authority.bootstrap_probe import converted_objects from sre_authority.common import CONTEXT, Harness, SYSTEM, require +from sre_authority.canonical_seed import dry_run_seed_data from sre_authority.fixtures import LEGACY_COMMIT, install_historical_chart from sre_authority.registration_schema import ( create_registration_crd, kind_proxy, request, write_report, @@ -21,6 +23,8 @@ def exercise(root): h.work.mkdir(mode=0o700) h.deadline, h.phase = time.monotonic() + 300, "legacy-helm-proof" with kind_proxy(root) as (port, version): + require(re.fullmatch(r"v1\.31\.\d+(?:[-+].*)?", version.get("gitVersion", "")) is not None, + "Historical seed API proof requires the pinned Kubernetes 1.31 server") def api(method, path, *, body=None, status=None): code, obj = request(port, method, path, body) if status is not None: @@ -29,6 +33,7 @@ def api(method, path, *, body=None, status=None): return types.SimpleNamespace(status_code=code, json=lambda: obj) h.api = api install_historical_chart(h) + dry_run_seed_data(h) rendered = h.run(["helm", "template", "kars", str(root / "deploy/helm/kars"), "--namespace", SYSTEM, "--show-only", "templates/crd-karssreregistration.yaml"]) objects = converted_objects(h.k("create", "--dry-run=client", "--validate=strict", @@ -47,6 +52,7 @@ def api(method, path, *, body=None, status=None): write_report(root, "legacy-helm-readiness.json", { "apiServer": version, "legacyCommit": LEGACY_COMMIT, "historicalInstallAndPostInstallHook": "passed", "currentAuthorityServerDryRun": "passed", + "historicalSeedStrictServerDryRuns": 5, "historicalSeedPersistence": "unchanged", "controllerReplicas": 0, "legacyCRDs": 18, "crdCreation": "native-Helm-only"}) diff --git a/tests/e2e/sre_authority/legacy_crds_test.py b/tests/e2e/sre_authority/legacy_crds_test.py index 41ec4bffc..09f0cf287 100644 --- a/tests/e2e/sre_authority/legacy_crds_test.py +++ b/tests/e2e/sre_authority/legacy_crds_test.py @@ -4,7 +4,10 @@ """Pure historical fixture checks, not a substitute for hosted Kind acceptance.""" import copy +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json from pathlib import Path +import threading import types import unittest from unittest.mock import Mock, patch @@ -13,6 +16,12 @@ CRDS, IDENTITIES, preflight_legacy_crds, render_legacy_crds, validate_rendered_crds, ) from sre_authority.registration_schema import CRD_NAME +from sre_authority.registration_schema import request +from sre_authority.canonical_migration import seed_data +from sre_authority.canonical_migration_test import FakeHarness +from sre_authority.canonical_seed import ( + SEEDS, SeedRejected, collection_path, dry_run_seed_data, request_seed, seed_definitions, seed_status, +) def historical_objects(): @@ -140,5 +149,190 @@ def test_initial_helm_uses_existing_versioned_waiter_without_custom_creation(sel self.assertNotIn(bypass, fixture) +class CanonicalSeedProbeTests(unittest.TestCase): + """Contract/privacy/transport tests; only the hosted API can accept a body.""" + + def setUp(self): + reporter = patch("sre_authority.canonical_seed.write_report") + self.reporter = reporter.start() + self.addCleanup(reporter.stop) + + def test_historical_typed_fields_and_every_original_case_are_preserved(self): + definitions = dict(seed_definitions()) + self.assertEqual(list(definitions), [row[0] for row in SEEDS]) + for resource, _plural, kind in SEEDS: + obj = definitions[resource] + self.assertEqual(obj["kind"], kind) + self.assertEqual(obj["apiVersion"], "kars.azure.com/v1alpha1") + self.assertEqual(obj["metadata"], {"name": f"e2e-migration-{resource}", "namespace": "kars-system"}) + self.assertEqual(set(obj), {"apiVersion", "kind", "metadata", "spec"}) + for resource in ("karstask", "karsteam"): + envelope = definitions[resource]["spec"]["envelope"] + self.assertEqual(envelope, {"tier": 1, "authorityCeiling": 1, "budget": {"tokens": 20, "usdMicros": 0}}) + self.assertTrue(all(type(value) is int for value in + [envelope["tier"], envelope["authorityCeiling"], *envelope["budget"].values()])) + self.assertEqual(definitions["karstask"]["spec"]["execution"], {"launch": False}) + self.assertIs(definitions["karstask"]["spec"]["execution"]["launch"], False) + self.assertEqual(definitions["karsteam"]["spec"]["roster"], []) + self.assertEqual(definitions["mcpserver"]["spec"], + {"url": "https://migration-fixture.invalid/", "productionMode": False}) + self.assertIs(definitions["mcpserver"]["spec"]["productionMode"], False) + self.assertEqual(definitions["karseval"]["spec"], + {"corpus": {"builtin": "sre"}, "targetSandboxRef": {"name": "sre"}}) + self.assertEqual(definitions["karssreaction"]["spec"], { + "action": {"type": "ScaleDeployment", "params": { + "namespace": "kars-system", "name": "kars-controller", "replicas": 0, + "opaque": {"nested": [1, "retained", True]}}}, + "approval": {"state": "Rejected"}}) + definitions["karstask"]["spec"]["envelope"]["tier"] = 9 + self.assertEqual(dict(seed_definitions())["karstask"]["spec"]["envelope"]["tier"], 1) + self.assertEqual(definitions["karsteam"]["spec"]["envelope"]["tier"], 1) + + def test_bad_request_diagnostics_only_expose_fixed_kind_categories_and_paths(self): + message = ('Secret-value cannot unmarshal; strict decoding error: ' + 'unknown field "spec.action.params.opaque.nested", unknown field "secret-value"') + report = seed_status("karssreaction", 400, {"kind": "Status", "reason": "BadRequest", + "message": message, "details": {"name": "secret-value", "causes": [ + {"field": "spec.approval.state", "reason": "FieldValueInvalid", "message": "secret-value"}, + {"field": "spec.secret-value", "reason": "secret-value"}, + {"field": ["secret-value"], "reason": ["secret-value"]}, + ]}}) + self.assertEqual(report, {"kind": "KarsSREAction", "httpStatus": 400, "category": "BadRequest", + "fields": ["spec.action.params.opaque.nested", "spec.approval.state"], + "validation": ["invalid-field", "strict-decoding", "type-mismatch", "unknown-field"]}) + self.assertNotIn("secret-value", json.dumps(report).lower()) + for body in (None, [], {"kind": "Secret"}, {"kind": "Status", "reason": [], "details": []}): + self.assertEqual(seed_status("karstask", 400, body)["category"], "unexpected-response") + + def test_real_http_transport_uses_exact_resource_json_media_and_strict_server_dry_run(self): + seen = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + seen.append((self.path, self.headers["Content-Type"], + json.loads(self.rfile.read(int(self.headers["Content-Length"]))))) + body = json.dumps({"kind": "Status", "reason": "BadRequest", + "message": 'unknown field "spec.execution.launch"'}).encode() + self.send_response(400) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + def api(method, path, *, body): + code, result = request(server.server_port, method, path, body) + return types.SimpleNamespace(status_code=code, json=lambda: result) + h = types.SimpleNamespace(root=Path("unused"), api=api) + obj = dict(seed_definitions())["karstask"] + with self.assertRaisesRegex(SeedRejected, "KarsTask server-dry-run.*HTTP 400"): + request_seed(h, "karstask", obj, dry_run=True) + self.assertEqual(seen, [(collection_path("karstask") + "?fieldManager=kubectl-create&fieldValidation=Strict&dryRun=All", + "application/json", obj)]) + self.assertEqual(self.reporter.call_args.args[2]["fields"], ["spec.execution.launch"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + + def test_all_five_dry_runs_leave_no_ephemeral_uid_data_or_workload_persisted(self): + h = FakeHarness() + before = copy.deepcopy(h.objects) + dry_run_seed_data(h) + self.assertEqual(h.objects, before) + posts = [(path, body) for method, path, body in h.calls if method == "POST"] + self.assertEqual(posts, [(collection_path(resource) + "?fieldManager=kubectl-create&fieldValidation=Strict&dryRun=All", obj) + for resource, obj in seed_definitions()]) + self.assertTrue(all(method in ("GET", "POST") for method, _path, _body in h.calls)) + self.assertNotIn("ephemeral-dry-run", json.dumps(list(h.objects.values()))) + + def test_all_failed_bodies_are_identified_before_any_real_seed_creation(self): + h = FakeHarness() + original = h.api + def api(method, path, **kwargs): + if method == "POST": + original(method, path, **kwargs) + return types.SimpleNamespace(status_code=400, json=lambda: { + "kind": "Status", "reason": "BadRequest", "message": "private-unretained-message"}) + return original(method, path, **kwargs) + h.api = api + before = copy.deepcopy(h.objects) + with self.assertRaisesRegex(AssertionError, "5 historical seed bodies"): + seed_data(h) + self.assertEqual(h.objects, before) + reports = [call.args[2] for call in self.reporter.call_args_list + if call.args[2]["category"] != "requesting"] + self.assertEqual([report["kind"] for report in reports], [row[2] for row in SEEDS]) + self.assertTrue(all(report["httpStatus"] == 400 for report in reports)) + self.assertNotIn("private-unretained-message", json.dumps(reports)) + self.assertTrue(all("dryRun=All" in path for method, path, _body in h.calls if method == "POST")) + + def test_successful_http_status_cannot_hide_pruning_type_change_ready_or_wrong_identity(self): + obj = dict(seed_definitions())["karstask"] + changes = ( + lambda result: result["spec"].pop("execution"), + lambda result: result["spec"]["execution"].update(launch=0), + lambda result: result.update(status={"phase": "Ready"}), + lambda result: result["metadata"].update(name="another"), + lambda result: result["metadata"].pop("resourceVersion"), + ) + for change in changes: + result = copy.deepcopy(obj) + result["metadata"].update(uid="real-fixture", resourceVersion="1") + change(result) + h = types.SimpleNamespace(root=Path("unused"), api=lambda *_args, **_kwargs: + types.SimpleNamespace(status_code=201, json=lambda: result)) + with self.subTest(result=result), self.assertRaisesRegex(SeedRejected, "identity-or-data-round-trip"): + request_seed(h, "karstask", obj, dry_run=False) + + def test_live_data_uid_rv_and_workload_mutations_during_dry_run_fail(self): + for fault in ("data", "uid", "resourceVersion", "workload", "new-seed"): + h = FakeHarness() + prior = dict(seed_definitions())["karstask"] + prior["metadata"]["name"] = "existing-task" + h.create(prior) + original = h.api + def api(method, path, **kwargs): + result = original(method, path, **kwargs) + if method == "POST": + item = h.objects[("karstask", "existing-task")] + if fault == "data": + item["spec"]["objective"] = "changed" + elif fault in ("uid", "resourceVersion"): + item["metadata"][fault] = "changed" + elif fault == "workload": + h.objects[("deployment", "kars-controller")]["spec"]["template"] = {"changed": True} + elif fault == "new-seed": + h.create(dict(seed_definitions())["karseval"]) + return result + h.api = api + with self.subTest(fault=fault), self.assertRaisesRegex(AssertionError, "changed|already exists"): + dry_run_seed_data(h) + + def test_paged_missing_identity_and_oversized_inventory_stop_before_any_post(self): + for items, metadata in (([], {"continue": "opaque"}), ([{}], {}), ([{}] * 513, {})): + h = FakeHarness() + h.api = Mock(return_value=types.SimpleNamespace(status_code=200, json=lambda: { + "kind": "List", "metadata": metadata, "items": items})) + with self.subTest(metadata=metadata), self.assertRaisesRegex(AssertionError, "inventory"): + dry_run_seed_data(h) + self.assertTrue(all(call.args[0] == "GET" for call in h.api.call_args_list)) + + def test_early_existing_legacy_probe_uses_shared_bodies_before_current_schema_or_helm_stage(self): + source = Path(__file__).with_name("legacy_crd_probe.py").read_text() + self.assertIn("from sre_authority.canonical_seed import dry_run_seed_data", source) + self.assertIn(r'r"v1\.31\.\d+(?:[-+].*)?"', source) + self.assertLess(source.index("install_historical_chart(h)"), source.index("dry_run_seed_data(h)")) + self.assertLess(source.index("dry_run_seed_data(h)"), source.index("create_registration_crd(h, obj)")) + self.assertLess(source.index("dry_run_seed_data(h)"), source.index('"--dry-run=server"')) + self.assertIn('"historicalSeedStrictServerDryRuns": 5', source) + self.assertNotIn("--validate=false", source) + + if __name__ == "__main__": unittest.main() From 20928442193d3ea806e6ef11f2cd6448c824a8d0 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 07:32:56 +0200 Subject: [PATCH 061/111] Retain bounded migration seed reports in schema qualification artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f686ce3..c6aed5f2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,6 +460,7 @@ jobs: path: | e2e-sre-schema-diag/versions.json e2e-sre-schema-diag/legacy-helm-readiness.json + e2e-sre-schema-diag/migration-seed-*.json e2e-sre-schema-diag/validation.json e2e-sre-schema-diag/validation-instances.json e2e-sre-schema-diag/credential-namespace-uid.json From 06ba4a636cb252316a066c8404ac454f786544c6 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 07:44:31 +0200 Subject: [PATCH 062/111] Test historical scalar action data and strict nested migration boundaries Keep the observed nested-field BadRequest as an explicit pre-migration negative; require lossless nonexecuting nested acceptance only after real schema migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../e2e/sre_authority/canonical_migration.py | 4 +- .../sre_authority/canonical_migration_test.py | 50 +++++- tests/e2e/sre_authority/canonical_seed.py | 99 ++++++++++-- tests/e2e/sre_authority/legacy_crd_probe.py | 1 + tests/e2e/sre_authority/legacy_crds_test.py | 148 +++++++++++++++++- 5 files changed, 279 insertions(+), 23 deletions(-) diff --git a/tests/e2e/sre_authority/canonical_migration.py b/tests/e2e/sre_authority/canonical_migration.py index f2b8bef33..a2bff5176 100644 --- a/tests/e2e/sre_authority/canonical_migration.py +++ b/tests/e2e/sre_authority/canonical_migration.py @@ -12,7 +12,7 @@ import json from .common import SYSTEM, require -from .canonical_seed import dry_run_seed_data, request_seed, seed_definitions +from .canonical_seed import dry_run_seed_data, prove_nested_params_support, request_seed, seed_definitions CRDS = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions" STAGE = ("authority", "stage", "--controller-image", "kars-controller:e2e", @@ -93,6 +93,8 @@ def deny_late_conflicts(h, fixtures): def finish_data_proof(h, fixtures): + assert_data_unchanged(h, fixtures) + prove_nested_params_support(h) assert_data_unchanged(h, fixtures) h.passed("Native BASE365-to-current schema migration preserved all fixture data/UIDs/resourceVersions") for fixture in fixtures: diff --git a/tests/e2e/sre_authority/canonical_migration_test.py b/tests/e2e/sre_authority/canonical_migration_test.py index 7b0011650..30ff25ce8 100644 --- a/tests/e2e/sre_authority/canonical_migration_test.py +++ b/tests/e2e/sre_authority/canonical_migration_test.py @@ -13,7 +13,7 @@ from sre_authority.canonical_migration import ( CRDS, STAGE, assert_data_unchanged, deny_late_conflicts, finish_data_proof, seed_data, ) -from sre_authority.canonical_seed import SEEDS, WORKLOADS, collection_path, seed_definitions +from sre_authority.canonical_seed import SEEDS, WORKLOADS, collection_path, nested_action_definition, seed_definitions class FakeHarness: @@ -37,6 +37,18 @@ def __init__(self): self.calls = [] self.rejections = [] self.serial = 1 + action = self.objects[("crd", "karssreactions.kars.azure.com")] + action["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"] = { + "spec": {"properties": {"action": {"properties": { + "params": {"type": "object", "additionalProperties": True, + "description": "Public action params documentation"}}}}}} + + def migrate_action_schema(self): + action = self.objects[("crd", "karssreactions.kars.azure.com")] + params = action["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]["properties"]["action"]["properties"]["params"] + assert params.pop("additionalProperties") is True + params["x-kubernetes-preserve-unknown-fields"] = True + action["metadata"]["resourceVersion"] = str(int(action["metadata"]["resourceVersion"]) + 1) def get(self, kind, name, *_args): return copy.deepcopy(self.objects.get((kind, name))) @@ -67,7 +79,21 @@ def api(self, method, path, *, body=None, status=None): query = parse_qs(parsed.query) assert query in ({"fieldManager": ["kubectl-create"], "fieldValidation": ["Strict"]}, {"fieldManager": ["kubectl-create"], "fieldValidation": ["Strict"], "dryRun": ["All"]}) - assert body == dict(seed_definitions())[resource] + nested = resource == "karssreaction" and body in ( + nested_action_definition(after_migration=False), nested_action_definition(after_migration=True)) + if nested: + assert query["dryRun"] == ["All"] + crd = self.objects[("crd", "karssreactions.kars.azure.com")] + params = crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]["properties"]["action"]["properties"]["params"] + params = {key: value for key, value in params.items() if key != "description"} + if params == {"type": "object", "additionalProperties": True}: + # The precise native BASE365 rejection, not a generic validator. + result = {"kind": "Status", "reason": "BadRequest", "message": + 'strict decoding error: unknown field "spec.action.params.opaque.nested"'} + return SimpleNamespace(status_code=400, json=lambda: result) + assert params == {"type": "object", "x-kubernetes-preserve-unknown-fields": True} + else: + assert body == dict(seed_definitions())[resource] if "dryRun" in query: result = copy.deepcopy(body) result["metadata"]["uid"] = "ephemeral-dry-run" @@ -114,6 +140,7 @@ def test_seed_uses_typed_inert_action_and_real_data_preservation_assertions(self action = h.get("karssreaction", "e2e-migration-karssreaction") self.assertEqual(action["spec"]["approval"]["state"], "Rejected") self.assertEqual(action["spec"]["action"]["type"], "ScaleDeployment") + self.assertEqual(action["spec"]["action"]["params"]["opaque"], "retained") self.assertFalse(h.get("karstask", "e2e-migration-karstask")["spec"]["execution"]["launch"]) assert_data_unchanged(h, fixtures) h.objects[("mcpserver", "e2e-migration-mcpserver")]["spec"]["url"] = "changed" @@ -139,13 +166,30 @@ def test_negative_fixtures_use_the_public_cli_and_restore_only_exact_uid_rv_owne def test_cleanup_is_limited_to_measured_disposable_crs_with_exact_uid_rv(self): h = FakeHarness() fixtures = seed_data(h) + h.migrate_action_schema() h.calls.clear() finish_data_proof(h, fixtures) - self.assertEqual(len(h.calls), 5) + deletes = [(method, path, body) for method, path, body in h.calls if method == "DELETE"] + self.assertEqual(len(deletes), 5) self.assertTrue(all(method == "DELETE" and "/customresourcedefinitions/" not in path + for method, path, _body in deletes)) + self.assertTrue(all(method in ("GET", "DELETE") or method == "POST" and "dryRun=All" in path for method, path, _body in h.calls)) + nested = [(index, body) for index, (method, _path, body) in enumerate(h.calls) if method == "POST"] + self.assertEqual([body for _index, body in nested], [nested_action_definition(after_migration=True)]) + self.assertLess(nested[0][0], next(index for index, call in enumerate(h.calls) if call[0] == "DELETE")) self.assertIsNotNone(h.get("crd", "karstasks.kars.azure.com")) + def test_post_migration_proof_refuses_an_unmigrated_schema_without_deleting_preserved_data(self): + h = FakeHarness() + fixtures = seed_data(h) + h.calls.clear() + before = copy.deepcopy(h.objects) + with self.assertRaisesRegex(AssertionError, "wrong side"): + finish_data_proof(h, fixtures) + self.assertEqual(h.objects, before) + self.assertTrue(all(method == "GET" for method, _path, _body in h.calls)) + def test_controller_must_remain_paused_for_native_data_measurement(self): h = FakeHarness() h.objects[("deployment", "kars-controller")]["spec"]["replicas"] = 1 diff --git a/tests/e2e/sre_authority/canonical_seed.py b/tests/e2e/sre_authority/canonical_seed.py index 9cda16611..43e5e093d 100644 --- a/tests/e2e/sre_authority/canonical_seed.py +++ b/tests/e2e/sre_authority/canonical_seed.py @@ -23,6 +23,7 @@ "/apis/apps/v1/statefulsets", "/apis/apps/v1/daemonsets", "/apis/batch/v1/jobs", "/apis/batch/v1/cronjobs", ) +NESTED_FIELD = "spec.action.params.opaque.nested" def seed_definitions(): @@ -35,7 +36,7 @@ def seed_definitions(): {"corpus": {"builtin": "sre"}, "targetSandboxRef": {"name": "sre"}}, {"action": {"type": "ScaleDeployment", "params": { "namespace": SYSTEM, "name": "kars-controller", "replicas": 0, - "opaque": {"nested": [1, "retained", True]}, + "opaque": "retained", }}, "approval": {"state": "Rejected"}}, ] return [(resource, {"apiVersion": "kars.azure.com/v1alpha1", "kind": kind, @@ -44,6 +45,14 @@ def seed_definitions(): for (resource, _plural, kind), spec in zip(SEEDS, specs)] +def nested_action_definition(*, after_migration): + obj = dict(seed_definitions())["karssreaction"] + suffix = "after" if after_migration else "before" + obj["metadata"]["name"] += f"-nested-{suffix}" + obj["spec"]["action"]["params"]["opaque"] = {"nested": [1, "retained", True]} + return obj + + def collection_path(resource): plural = next((plural for singular, plural, _kind in SEEDS if singular == resource), None) require(plural is not None, "Unrecognized historical migration seed") @@ -64,6 +73,8 @@ def _field_paths(value, path=""): def seed_status(resource, code, body): expected = dict(seed_definitions())[resource] allowed = _field_paths(expected) + if resource == "karssreaction": + allowed |= _field_paths(nested_action_definition(after_migration=False)) report = {"kind": expected["kind"], "httpStatus": code, "category": "unexpected-response", "fields": [], "validation": []} if not isinstance(body, dict) or body.get("kind") != "Status": @@ -116,23 +127,22 @@ class SeedRejected(AssertionError): pass -def request_seed(h, resource, obj, *, dry_run): - expected = dict(seed_definitions()).get(resource) - require(expected is not None and json.dumps(obj, sort_keys=True) == json.dumps(expected, sort_keys=True), - "Only the exact public historical seed body may be submitted") - mode = "server-dry-run" if dry_run else "create" - filename = f"migration-seed-{resource}-{mode}.json" - write_report(h.root, filename, {"kind": expected["kind"], "mode": mode, - "httpStatus": None, "category": "requesting"}) +def _write_seed_report(h, resource, mode, report): + report["mode"] = mode + write_report(h.root, f"migration-seed-{resource}-{mode}.json", report) + + +def _submit_seed(h, resource, expected, *, dry_run, mode): + _write_seed_report(h, resource, mode, {"kind": expected["kind"], + "httpStatus": None, "category": "requesting"}) path = (collection_path(resource) + "?fieldManager=kubectl-create&fieldValidation=Strict" + ("&dryRun=All" if dry_run else "")) - response = h.api("POST", path, body=obj) + response = h.api("POST", path, body=expected) try: body = response.json() except (ValueError, TypeError): body = None report = seed_status(resource, response.status_code, body) - report["mode"] = mode if response.status_code == 201 and isinstance(body, dict) and body.get("kind") == expected["kind"]: meta = body.get("metadata") identity = isinstance(meta, dict) and all(meta.get(key) == expected["metadata"][key] @@ -144,13 +154,51 @@ def request_seed(h, resource, obj, *, dry_run): report["category"] = "accepted" else: report["category"] = "identity-or-data-round-trip" - write_report(h.root, filename, report) + return body, report + + +def request_seed(h, resource, obj, *, dry_run): + expected = dict(seed_definitions()).get(resource) + require(expected is not None and json.dumps(obj, sort_keys=True) == json.dumps(expected, sort_keys=True), + "Only the exact public historical seed body may be submitted") + mode = "server-dry-run" if dry_run else "create" + body, report = _submit_seed(h, resource, expected, dry_run=dry_run, mode=mode) + _write_seed_report(h, resource, mode, report) if report["category"] != "accepted": raise SeedRejected(f"Historical seed {expected['kind']} {mode} rejected: " - f"HTTP {response.status_code}; category={report['category']}") + f"HTTP {report['httpStatus']}; category={report['category']}") return body +def _request_nested_params(h, *, after_migration): + crd = h.get("crd", "karssreactions.kars.azure.com") + versions = crd.get("spec", {}).get("versions", []) if isinstance(crd, dict) else [] + require(len(versions) == 1, "Nested params probe requires the single reviewed action API version") + params = (versions[0].get("schema", {}).get("openAPIV3Schema", {}).get("properties", {}).get("spec", {}) + .get("properties", {}).get("action", {}).get("properties", {}).get("params")) + expected_schema = ({"type": "object", "x-kubernetes-preserve-unknown-fields": True} if after_migration + else {"type": "object", "additionalProperties": True}) + require(isinstance(params, dict) and {key: value for key, value in params.items() if key != "description"} == expected_schema, + "Nested params probe is on the wrong side of the actual schema migration") + expected = nested_action_definition(after_migration=after_migration) + mode = "nested-after-server-dry-run" if after_migration else "nested-before-server-dry-run" + body, report = _submit_seed(h, "karssreaction", expected, dry_run=True, mode=mode) + if after_migration: + matched = (report["category"] == "accepted" + and json.dumps(body["spec"]["action"]["params"], sort_keys=True) + == json.dumps(expected["spec"]["action"]["params"], sort_keys=True)) + else: + message = body.get("message") if isinstance(body, dict) else None + matched = (report["httpStatus"] == 400 and report["category"] == "BadRequest" + and report["fields"] == [NESTED_FIELD] + and report["validation"] == ["strict-decoding", "unknown-field"] + and isinstance(message, str) and len(message) <= 16384 + and re.findall(r'unknown field "([^"\r\n]*)"', message) == [NESTED_FIELD]) + report.update(expectedHttpStatus=201 if after_migration else 400, matched=bool(matched)) + _write_seed_report(h, "karssreaction", mode, report) + require(matched, f"Nested action params {mode} did not satisfy the exact expected API result") + + def _inventory(h, path): response = h.api("GET", path + "?limit=513", status=200) body = response.json() @@ -168,7 +216,7 @@ def _inventory(h, path): return sorted(items, key=lambda item: item["metadata"]["uid"]) -def _snapshot(h): +def _snapshot(h, *, after_migration=False): controller = h.get("deployment", "kars-controller", SYSTEM) require(controller and controller.get("spec", {}).get("replicas") == 0 and all(controller.get("status", {}).get(key, 0) == 0 @@ -176,9 +224,16 @@ def _snapshot(h): and all(controller.get("metadata", {}).get(key) for key in ("uid", "resourceVersion")), "Historical seed dry-runs require the actual controller paused with a stable identity") state = {"controller": controller} + action_crd = h.get("crd", "karssreactions.kars.azure.com") + require(action_crd and all(action_crd.get("metadata", {}).get(key) for key in ("uid", "resourceVersion")), + "Nested params probe requires the real action CRD identity") + state["actionSchema"] = action_crd for resource, _plural, _kind in SEEDS: state[resource] = _inventory(h, collection_path(resource)) - require(not any(obj["metadata"]["name"] == f"e2e-migration-{resource}" for obj in state[resource]), + absent = {f"e2e-migration-{resource}"} if not after_migration else set() + if resource == "karssreaction": + absent |= {nested_action_definition(after_migration=phase)["metadata"]["name"] for phase in (False, True)} + require(not any(obj["metadata"]["name"] in absent for obj in state[resource]), "Historical seed already exists; no collision or adoption is permitted") for path in WORKLOADS: objects = _inventory(h, path) @@ -205,7 +260,19 @@ def dry_run_seed_data(h): request_seed(h, resource, obj, dry_run=True) except SeedRejected: rejected += 1 + require(rejected == 0, f"{rejected} historical seed bodies failed strict server dry-run; see fixed kind/field diagnostics") + _request_nested_params(h, after_migration=False) finally: require(_snapshot(h) == before, "Historical seed dry-runs changed stored data, identity or workload intent") - require(rejected == 0, f"{rejected} historical seed bodies failed strict server dry-run; see fixed kind/field diagnostics") h.passed("All five historical seed bodies passed strict server dry-run without persistence or workload changes") + h.passed("Historical nested action params rejected at the exact observed unknown field without persistence") + + +def prove_nested_params_support(h): + before = _snapshot(h, after_migration=True) + try: + _request_nested_params(h, after_migration=True) + finally: + require(_snapshot(h, after_migration=True) == before, + "Post-migration nested params dry-run changed stored data, identity or workload intent") + h.passed("Migrated action API retained nested params unchanged in a nonexecuting, nonpersistent server dry-run") diff --git a/tests/e2e/sre_authority/legacy_crd_probe.py b/tests/e2e/sre_authority/legacy_crd_probe.py index 31681b6e7..c97a58973 100644 --- a/tests/e2e/sre_authority/legacy_crd_probe.py +++ b/tests/e2e/sre_authority/legacy_crd_probe.py @@ -53,6 +53,7 @@ def api(method, path, *, body=None, status=None): "apiServer": version, "legacyCommit": LEGACY_COMMIT, "historicalInstallAndPostInstallHook": "passed", "currentAuthorityServerDryRun": "passed", "historicalSeedStrictServerDryRuns": 5, "historicalSeedPersistence": "unchanged", + "historicalNestedParamsRejection": "passed", "controllerReplicas": 0, "legacyCRDs": 18, "crdCreation": "native-Helm-only"}) diff --git a/tests/e2e/sre_authority/legacy_crds_test.py b/tests/e2e/sre_authority/legacy_crds_test.py index 09f0cf287..899c1818b 100644 --- a/tests/e2e/sre_authority/legacy_crds_test.py +++ b/tests/e2e/sre_authority/legacy_crds_test.py @@ -20,7 +20,8 @@ from sre_authority.canonical_migration import seed_data from sre_authority.canonical_migration_test import FakeHarness from sre_authority.canonical_seed import ( - SEEDS, SeedRejected, collection_path, dry_run_seed_data, request_seed, seed_definitions, seed_status, + SEEDS, SeedRejected, collection_path, dry_run_seed_data, nested_action_definition, + prove_nested_params_support, request_seed, seed_definitions, seed_status, ) @@ -182,12 +183,25 @@ def test_historical_typed_fields_and_every_original_case_are_preserved(self): self.assertEqual(definitions["karssreaction"]["spec"], { "action": {"type": "ScaleDeployment", "params": { "namespace": "kars-system", "name": "kars-controller", "replicas": 0, - "opaque": {"nested": [1, "retained", True]}}}, + "opaque": "retained"}}, "approval": {"state": "Rejected"}}) definitions["karstask"]["spec"]["envelope"]["tier"] = 9 self.assertEqual(dict(seed_definitions())["karstask"]["spec"]["envelope"]["tier"], 1) self.assertEqual(definitions["karsteam"]["spec"]["envelope"]["tier"], 1) + def test_original_nested_shape_is_preserved_on_both_correct_schema_sides_with_distinct_names(self): + baseline = dict(seed_definitions())["karssreaction"] + before = nested_action_definition(after_migration=False) + after = nested_action_definition(after_migration=True) + self.assertEqual(before["spec"], after["spec"]) + for obj in (before, after): + self.assertEqual(obj["spec"]["action"]["params"]["opaque"], {"nested": [1, "retained", True]}) + scalar = copy.deepcopy(obj) + scalar["metadata"]["name"] = baseline["metadata"]["name"] + scalar["spec"]["action"]["params"]["opaque"] = "retained" + self.assertEqual(scalar, baseline) + self.assertEqual(len({obj["metadata"]["name"] for obj in (baseline, before, after)}), 3) + def test_bad_request_diagnostics_only_expose_fixed_kind_categories_and_paths(self): message = ('Secret-value cannot unmarshal; strict decoding error: ' 'unknown field "spec.action.params.opaque.nested", unknown field "secret-value"') @@ -246,10 +260,137 @@ def test_all_five_dry_runs_leave_no_ephemeral_uid_data_or_workload_persisted(sel dry_run_seed_data(h) self.assertEqual(h.objects, before) posts = [(path, body) for method, path, body in h.calls if method == "POST"] + expected = seed_definitions() + [("karssreaction", nested_action_definition(after_migration=False))] self.assertEqual(posts, [(collection_path(resource) + "?fieldManager=kubectl-create&fieldValidation=Strict&dryRun=All", obj) - for resource, obj in seed_definitions()]) + for resource, obj in expected]) self.assertTrue(all(method in ("GET", "POST") for method, _path, _body in h.calls)) self.assertNotIn("ephemeral-dry-run", json.dumps(list(h.objects.values()))) + self.assertEqual(self.reporter.call_args.args[2], { + "kind": "KarsSREAction", "httpStatus": 400, "category": "BadRequest", + "fields": ["spec.action.params.opaque.nested"], "validation": ["strict-decoding", "unknown-field"], + "expectedHttpStatus": 400, "matched": True, "mode": "nested-before-server-dry-run"}) + + def test_historical_negative_requires_the_exact_native_rejection_not_any_failure(self): + faults = ( + (403, {"kind": "Status", "reason": "Forbidden"}), + (400, {"kind": "Status", "reason": "BadRequest", + "message": 'strict decoding error: unknown field "spec.action.params.name"'}), + (400, {"kind": "Status", "reason": "BadRequest", + "message": 'strict decoding error: unknown field "spec.action.params.opaque.nested", unknown field "unreviewed"'}), + (400, {"kind": "Status", "reason": "BadRequest", + "message": 'unknown field "spec.action.params.opaque.nested"'}), + (422, {"kind": "Status", "reason": "Invalid"}), + (201, nested_action_definition(after_migration=False)), + ) + for code, body in faults: + h = FakeHarness() + original = h.api + def api(method, path, **kwargs): + result = original(method, path, **kwargs) + if method == "POST" and kwargs["body"] == nested_action_definition(after_migration=False): + return types.SimpleNamespace(status_code=code, json=lambda: body) + return result + h.api = api + before = copy.deepcopy(h.objects) + with self.subTest(code=code, body=body), self.assertRaisesRegex(AssertionError, "exact expected API result"): + dry_run_seed_data(h) + self.assertEqual(h.objects, before) + + def test_post_migration_nested_acceptance_retains_scalar_data_and_all_identities_without_persistence(self): + h = FakeHarness() + fixtures = seed_data(h) + h.migrate_action_schema() + before = copy.deepcopy(h.objects) + h.calls.clear() + prove_nested_params_support(h) + self.assertEqual(h.objects, before) + for fixture in fixtures: + self.assertEqual(h.get(fixture["resource"], fixture["name"])["metadata"]["uid"], fixture["before"]["uid"]) + self.assertEqual([body for method, _path, body in h.calls if method == "POST"], + [nested_action_definition(after_migration=True)]) + self.assertTrue(all(method == "GET" or method == "POST" and "dryRun=All" in path + for method, path, _body in h.calls)) + self.assertTrue(self.reporter.call_args.args[2]["matched"]) + self.assertEqual(self.reporter.call_args.args[2]["expectedHttpStatus"], 201) + + def test_post_migration_acceptance_cannot_prune_change_or_add_nested_values_or_forge_ready(self): + changes = ( + lambda body: body["spec"]["action"]["params"]["opaque"].pop("nested"), + lambda body: body["spec"]["action"]["params"]["opaque"].update(nested=[1, "changed", True]), + lambda body: body["spec"]["action"]["params"]["opaque"].update(nested=[1, "retained", 1]), + lambda body: body["spec"]["action"]["params"]["opaque"].update(extra="unreviewed"), + lambda body: body.update(status={"phase": "Ready"}), + ) + for change in changes: + h = FakeHarness() + h.migrate_action_schema() + original = h.api + def api(method, path, **kwargs): + result = original(method, path, **kwargs) + if method == "POST": + body = result.json() + change(body) + return types.SimpleNamespace(status_code=201, json=lambda: body) + return result + h.api = api + before = copy.deepcopy(h.objects) + with self.subTest(change=change), self.assertRaisesRegex(AssertionError, "exact expected API result"): + prove_nested_params_support(h) + self.assertEqual(h.objects, before) + + def test_post_migration_snapshot_rejects_schema_cas_data_and_workload_drift(self): + for fault in ("uid", "resourceVersion", "schema", "data", "workload", "persisted-probe"): + h = FakeHarness() + seed_data(h) + h.migrate_action_schema() + original = h.api + def api(method, path, **kwargs): + result = original(method, path, **kwargs) + if method == "POST": + crd = h.objects[("crd", "karssreactions.kars.azure.com")] + if fault in ("uid", "resourceVersion"): + crd["metadata"][fault] = "changed" + elif fault == "schema": + crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] = "changed" + elif fault == "data": + h.objects[("karssreaction", "e2e-migration-karssreaction")]["spec"]["approval"]["state"] = "Pending" + elif fault == "workload": + h.objects[("deployment", "kars-controller")]["spec"]["template"] = {"changed": True} + else: + h.create(kwargs["body"]) + return result + h.api = api + with self.subTest(fault=fault), self.assertRaisesRegex(AssertionError, "changed|already exists"): + prove_nested_params_support(h) + + def test_post_migration_probe_requires_paused_controller_and_no_same_name_object(self): + for fault in ("controller", "collision"): + h = FakeHarness() + h.migrate_action_schema() + if fault == "controller": + h.objects[("deployment", "kars-controller")]["spec"]["replicas"] = 1 + else: + h.create(nested_action_definition(after_migration=True)) + with self.subTest(fault=fault), self.assertRaisesRegex(AssertionError, "paused|already exists"): + prove_nested_params_support(h) + self.assertTrue(all(method == "GET" for method, _path, _body in h.calls)) + + def test_documented_params_schema_is_accepted_but_extra_validation_is_not_ignored(self): + for phase in (False, True): + for constraint in ({"maxProperties": 1}, {"properties": {"opaque": {"type": "string"}}}, + {"additionalProperties": False}): + h = FakeHarness() + if phase: + h.migrate_action_schema() + crd = h.objects[("crd", "karssreactions.kars.azure.com")] + params = crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]["properties"]["action"]["properties"]["params"] + self.assertIn("description", params) + params.update(constraint) + probe = prove_nested_params_support if phase else dry_run_seed_data + with self.subTest(phase=phase, constraint=constraint), self.assertRaisesRegex(AssertionError, "wrong side"): + probe(h) + self.assertFalse(any(method == "POST" and body == nested_action_definition(after_migration=phase) + for method, _path, body in h.calls)) def test_all_failed_bodies_are_identified_before_any_real_seed_creation(self): h = FakeHarness() @@ -331,6 +472,7 @@ def test_early_existing_legacy_probe_uses_shared_bodies_before_current_schema_or self.assertLess(source.index("dry_run_seed_data(h)"), source.index("create_registration_crd(h, obj)")) self.assertLess(source.index("dry_run_seed_data(h)"), source.index('"--dry-run=server"')) self.assertIn('"historicalSeedStrictServerDryRuns": 5', source) + self.assertIn('"historicalNestedParamsRejection": "passed"', source) self.assertNotIn("--validate=false", source) From 2c8f1e314aea5e431d4643e08ea7b908533e59c3 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 07:48:10 +0200 Subject: [PATCH 063/111] Preserve and converge Sandbox condition generation evidence Add the missing optional condition schema field, backfill through authoritative reconciliation without timestamp churn, and prove old pruning/new retention with non-authorizing native schema fixtures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 5 +- cli/src/lib/sre-migration-catalog.ts | 3 +- cli/src/lib/sre-migration.test-support.ts | 1 + cli/src/lib/sre-schema-migration.test.ts | 37 + controller/src/helm_drift.rs | 50 ++ controller/src/status/convergence_tests.rs | 257 +++++++ controller/src/status/mod.rs | 811 +++------------------ controller/src/status/tests.rs | 644 ++++++++++++++++ deploy/helm/kars/templates/crd.yaml | 3 + docs/api/conditions.md | 20 + tests/e2e/sandbox_condition_schema.py | 286 ++++++++ tests/e2e/sandbox_condition_schema_test.py | 105 +++ 12 files changed, 1518 insertions(+), 704 deletions(-) create mode 100644 controller/src/status/convergence_tests.rs create mode 100644 controller/src/status/tests.rs create mode 100644 tests/e2e/sandbox_condition_schema.py create mode 100644 tests/e2e/sandbox_condition_schema_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6aed5f2b..4e6302e76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -424,9 +424,11 @@ jobs: version: v1.30.5 - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 - name: Check public-schema diagnostic privacy - run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test governed_services_test + run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test sandbox_condition_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test governed_services_test - name: Create the same disposable API server as the real harness run: kind create cluster --name kars-e2e --config tests/e2e/kind-config.yaml --kubeconfig "$KUBECONFIG" + - name: Prove native Sandbox condition generation pruning, retention and type validation + run: PYTHONPATH=tests/e2e python3 -m sandbox_condition_schema - name: Prove native historical Helm wait orders CRDs before hooks and permits schema upgrades run: PYTHONPATH=tests/e2e python3 -m sre_authority.legacy_crd_probe - name: Reset disposable cluster after historical Helm proof @@ -465,6 +467,7 @@ jobs: e2e-sre-schema-diag/validation-instances.json e2e-sre-schema-diag/credential-namespace-uid.json e2e-sre-schema-diag/credential-policy-typechecking.json + e2e-sre-schema-diag/sandbox-condition-generation.json e2e-sre-schema-diag/namespace-accessor-candidate.json e2e-sre-schema-diag/namespace-accessor-candidate-instances.json e2e-sre-schema-diag/bootstrap-*.json diff --git a/cli/src/lib/sre-migration-catalog.ts b/cli/src/lib/sre-migration-catalog.ts index 5444641c8..0190d1b58 100644 --- a/cli/src/lib/sre-migration-catalog.ts +++ b/cli/src/lib/sre-migration-catalog.ts @@ -3,6 +3,7 @@ // Complete normalized CRD specs: BASE365 8b206065608593667a40665b3f48225ef9ce278d // -> 470773c2 plus the independently approved b5ad6791 evaluator-v2 additions. +// Also qualifies the optional Sandbox Condition observedGeneration addition. // Metadata/Helm retention is not part of these schema fingerprints. export const BASE365 = "8b206065608593667a40665b3f48225ef9ce278d"; export const MIGRATION = "kars.azure.com/sre-base365-schema/v1"; @@ -25,7 +26,7 @@ export const CANONICAL_SCHEMAS: Readonly<Record<string, { before?: string; after "mcpservers.kars.azure.com": { before: "67f2913e504a28d92ed2cc773f75efe4d132de4dbe304330cccca222e93264fa", after: ["4f2b2d1c8e2b01235d48d1adc5fe8f45ad2362c623e519a64788106293430f1f"] }, "toolpolicies.kars.azure.com": { before: "f594f5d274bb23e18e6a6f34227bb28a7dd20c7ebcfae8aa7975e3edea6c90f3", after: ["f594f5d274bb23e18e6a6f34227bb28a7dd20c7ebcfae8aa7975e3edea6c90f3"] }, "trustgraphs.kars.azure.com": { before: "354d1f2405b0dd99fd963a49b2ec7e2a2dc702abe68a9fdc9b9088d3df412bf2", after: ["354d1f2405b0dd99fd963a49b2ec7e2a2dc702abe68a9fdc9b9088d3df412bf2"] }, - "karssandboxes.kars.azure.com": { before: "d7ddb2d69dc654e3a457a4455c7de7e3f44ec42a9384a39e16816f012646e7da", after: ["da674a84c19c8ac64a1d96d04f79435c6899601426e25931feaca483f139b920"] }, + "karssandboxes.kars.azure.com": { before: "d7ddb2d69dc654e3a457a4455c7de7e3f44ec42a9384a39e16816f012646e7da", after: ["da674a84c19c8ac64a1d96d04f79435c6899601426e25931feaca483f139b920", "5d495b8cfe5e4526741a673161cbae0492812e2650c2f2d08c5e522a3bda946f"] }, "karspairings.kars.azure.com": { before: "18dd892fc268f575d67e44456a3031885645561c6b9ec1ae995faa659c8b2920", after: ["18dd892fc268f575d67e44456a3031885645561c6b9ec1ae995faa659c8b2920"] }, "karsbudgetaccounts.kars.azure.com": { after: ["0706c8eb2b31308de59f6b388cf9744a0989ccdd7cc6173ef42c3eb331d91135"] }, "karscredentialgrants.kars.azure.com": { after: ["5427f9dd6735d79b069abb24398161650c0dc56eed9dfc07dc92c094b4976b95"] }, diff --git a/cli/src/lib/sre-migration.test-support.ts b/cli/src/lib/sre-migration.test-support.ts index ae7a5ba73..83b0b7279 100644 --- a/cli/src/lib/sre-migration.test-support.ts +++ b/cli/src/lib/sre-migration.test-support.ts @@ -79,6 +79,7 @@ export function canonicalMigrationSchemas(evalV2 = false): { before: ObjectMap[] removeBindings(spec); delete spec.properties.inferenceBudgetRef; delete root.properties.status.properties.serviceObservation; + delete root.properties.status.properties.conditions.items.properties.observedGeneration; } if (kind === "KarsSREAction") { const params = spec.properties.action.properties.params; diff --git a/cli/src/lib/sre-schema-migration.test.ts b/cli/src/lib/sre-schema-migration.test.ts index d37e973b9..e74775375 100644 --- a/cli/src/lib/sre-schema-migration.test.ts +++ b/cli/src/lib/sre-schema-migration.test.ts @@ -11,6 +11,43 @@ import { CANONICAL_SCHEMAS, EVALUATOR_V2, MIGRATION } from "./sre-migration-cata import { planCoreHelmSchemas } from "./core-helm-schemas.js"; describe("closed BASE365 SRE schema migration", () => { + it("qualifies only the exact optional Sandbox condition generation addition", async () => { + const f = migrationFixture(); + const target = f.after.find(object => object.spec.names.kind === "KarsSandbox")!; + const previous = structuredClone(target); + const condition = previous.spec.versions[0].schema.openAPIV3Schema.properties.status.properties.conditions.items; + expect(condition.properties.observedGeneration).toEqual({ type: "integer", format: "int64" }); + expect(condition.required ?? []).not.toContain("observedGeneration"); + delete condition.properties.observedGeneration; + expect(schemaDigest(normalizedCrd(previous))).toBe("da674a84c19c8ac64a1d96d04f79435c6899601426e25931feaca483f139b920"); + expect(schemaDigest(normalizedCrd(target))).toBe("5d495b8cfe5e4526741a673161cbae0492812e2650c2f2d08c5e522a3bda946f"); + expect(CANONICAL_SCHEMAS[target.metadata.name].after).toContain(schemaDigest(normalizedCrd(previous))); + expect(() => assertSchemaCompatibility(previous, target)).not.toThrow(); + expect(await qualifySreSchemaMigration(f.execute, f.after, f.owner)).toBeDefined(); + condition.properties.observedGeneration = { type: "string" }; + expect(() => assertSchemaCompatibility(target, previous)).toThrow(); + f.after[f.after.indexOf(target)] = previous; + await expect(qualifySreSchemaMigration(f.execute, f.after, f.owner)).rejects.toThrow(); + expect(f.writes).toEqual([]); + }); + + it("upgrades a previously qualified Sandbox target on the strict ordinary path", async () => { + const f = migrationFixture(); + for (const target of f.after) f.install(target); + const sandbox = f.objects.get("karssandboxes.kars.azure.com")!; + delete sandbox.spec.versions[0].schema.openAPIV3Schema.properties.status.properties.conditions.items.properties.observedGeneration; + f.objects.get("kars-controller")!.spec.replicas = 1; + const manifest = f.after.map(object => object.metadata.name === sandbox.metadata.name ? sandbox : object) + .map(object => JSON.stringify(object)).join("\n---\n"); + const execute: typeof f.execute = async (file, args, options) => { + if (file === "helm" && args[0] === "get" && args[1] === "manifest") return { stdout: manifest }; + return f.execute(file, args, options); + }; + await stageCoreSchemaDocuments(execute, f.after, { ...f.owner, ...f.wait }); + expect(f.writes).toHaveLength(1); + expect(f.writes[0].metadata.name).toBe(sandbox.metadata.name); + }); + it.each([false, true])("pins complete before/after schemas including evaluator-v2=%s", evalV2 => { const { before, after } = canonicalMigrationSchemas(evalV2); expect(before).toHaveLength(18); diff --git a/controller/src/helm_drift.rs b/controller/src/helm_drift.rs index f2a4dffea..f0943390f 100644 --- a/controller/src/helm_drift.rs +++ b/controller/src/helm_drift.rs @@ -138,6 +138,56 @@ fn canonical_form(value: &serde_json::Value) -> serde_json::Value { mod tests { use super::*; + #[test] + fn helm_sandbox_retains_standard_condition_observed_generation() { + use kube::CustomResourceExt; + use serde::Deserialize; + + let chart = concat!(env!("CARGO_MANIFEST_DIR"), "/../deploy/helm/kars"); + let output = std::process::Command::new("helm") + .args([ + "template", + "kars", + chart, + "--namespace", + "kars-system", + "--show-only", + "templates/crd.yaml", + ]) + .output() + .expect("Helm is required for Sandbox condition drift detection"); + assert!(output.status.success(), "Helm failed to render Sandbox CRD"); + let documents: Vec<serde_json::Value> = + serde_yaml::Deserializer::from_slice(&output.stdout) + .map(|document| { + serde_json::Value::deserialize(document).expect("rendered CRD YAML") + }) + .collect(); + let helm = documents + .iter() + .find(|document| document["metadata"]["name"] == "karssandboxes.kars.azure.com") + .expect("rendered Sandbox CRD"); + let rust = serde_json::to_value(crate::crd::KarsSandbox::crd()).unwrap(); + let path = + "/spec/versions/0/schema/openAPIV3Schema/properties/status/properties/conditions/items"; + let rust_condition = rust.pointer(path).expect("generated standard Condition"); + let helm_condition = helm.pointer(path).expect("Helm Condition"); + for condition in [rust_condition, helm_condition] { + let field = &condition["properties"]["observedGeneration"]; + assert_eq!(field["type"], "integer"); + assert_eq!(field["format"], "int64"); + assert!(field.get("default").is_none()); + assert!(!condition["required"].as_array().is_some_and(|required| { + required.iter().any(|field| field == "observedGeneration") + })); + } + assert!( + helm_condition + .get("x-kubernetes-preserve-unknown-fields") + .is_none() + ); + } + /// One-shot dumper. Run via: /// /// DUMP_MCP_CRD_YAML=1 cargo test --bin kars-controller \ diff --git a/controller/src/status/convergence_tests.rs b/controller/src/status/convergence_tests.rs new file mode 100644 index 000000000..3980f5833 --- /dev/null +++ b/controller/src/status/convergence_tests.rs @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::crd::{KarsSandboxSpec, KarsSandboxStatus}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + +fn sandbox() -> KarsSandbox { + KarsSandbox { + metadata: ObjectMeta { + name: Some("demo".into()), + namespace: Some("kars-demo".into()), + generation: Some(7), + uid: Some("owned-uid".into()), + resource_version: Some("42".into()), + ..Default::default() + }, + spec: KarsSandboxSpec::default(), + status: Some(KarsSandboxStatus { + foundry_agent_id: Some("agent-to-preserve".into()), + ..Default::default() + }), + } +} + +fn timestamp() -> Time { + serde_json::from_value(json!("2026-01-01T00:00:00Z")).unwrap() +} + +fn extra() -> Condition { + let mut condition = conditions::new_condition( + conditions::TYPE_ALLOWLIST_AUTHORITATIVE, + conditions::status::FALSE, + conditions::reason::INLINE, + "inline endpoints", + Some(7), + ); + condition.last_transition_time = timestamp(); + condition +} + +fn apply_status(sb: &mut KarsSandbox, patch: Value) { + assert_eq!(patch.as_object().unwrap().len(), 1); + let mut status = serde_json::to_value(sb.status.as_ref().unwrap()).unwrap(); + for (key, value) in patch["status"].as_object().unwrap() { + status[key] = value.clone(); + } + sb.status = Some(serde_json::from_value(status).unwrap()); +} + +fn settled_running() -> KarsSandbox { + let mut sb = sandbox(); + let patch = build_running_status_patch_with_extras(&sb, "kars-demo", "OpenClaw", &[extra()]); + apply_status(&mut sb, patch); + for condition in &mut sb.status.as_mut().unwrap().conditions { + condition.last_transition_time = timestamp(); + } + let mut unrelated = extra(); + unrelated.type_ = "ExternalObservation".into(); + unrelated.observed_generation = None; + sb.status.as_mut().unwrap().conditions.push(unrelated); + sb +} + +fn reconcile_running(sb: &mut KarsSandbox, extras: &[Condition]) -> bool { + if running_status_matches_with_extras(sb, "kars-demo", "OpenClaw", extras) { + return false; + } + let patch = build_running_status_patch_with_extras(sb, "kars-demo", "OpenClaw", extras); + apply_status(sb, patch); + true +} + +#[test] +fn running_backfills_each_missing_or_stale_condition_generation_once() { + for type_ in [ + conditions::TYPE_READY, + conditions::TYPE_PROGRESSING, + conditions::TYPE_RUNTIME_READY, + conditions::TYPE_ALLOWLIST_AUTHORITATIVE, + ] { + for generation in [None, Some(6), Some(8)] { + let mut sb = settled_running(); + let current = serde_json::to_value(&sb).unwrap(); + let condition = sb + .status + .as_mut() + .unwrap() + .conditions + .iter_mut() + .find(|c| c.type_ == type_) + .unwrap(); + condition.observed_generation = generation; + assert!( + reconcile_running(&mut sb, &[extra()]), + "{type_}: {generation:?}" + ); + assert_eq!( + serde_json::to_value(&sb).unwrap(), + current, + "only the controller-owned generation should be backfilled" + ); + assert!(!reconcile_running(&mut sb, &[extra()])); + assert_eq!(serde_json::to_value(&sb).unwrap(), current); + } + } +} + +#[test] +fn running_correct_status_is_untouched_including_messages_and_timestamps() { + let mut sb = settled_running(); + let before = serde_json::to_value(&sb).unwrap(); + let mut desired = extra(); + desired.message = "new diagnostic text".into(); + desired.last_transition_time = + conditions::new_condition("Ignored", "Unknown", "Ignored", "", None).last_transition_time; + for _ in 0..3 { + assert!(!reconcile_running(&mut sb, &[desired.clone()])); + assert_eq!(serde_json::to_value(&sb).unwrap(), before); + } +} + +#[test] +fn running_repairs_the_api_pruned_shape_despite_current_top_level_generation() { + let mut sb = settled_running(); + let expected = serde_json::to_value(&sb).unwrap(); + for condition in &mut sb.status.as_mut().unwrap().conditions { + condition.observed_generation = None; + } + assert_eq!(sb.status.as_ref().unwrap().observed_generation, Some(7)); + assert!(reconcile_running(&mut sb, &[extra()])); + assert_eq!(serde_json::to_value(&sb).unwrap(), expected); + assert!(!reconcile_running(&mut sb, &[extra()])); +} + +#[test] +fn running_generation_backfill_does_not_churn_extra_timestamps() { + let mut sb = settled_running(); + let before = serde_json::to_value(&sb).unwrap(); + sb.status.as_mut().unwrap().conditions[0].observed_generation = None; + let mut desired = extra(); + desired.last_transition_time = + conditions::new_condition("Ignored", "Unknown", "Ignored", "", None).last_transition_time; + assert!(reconcile_running(&mut sb, &[desired.clone()])); + assert_eq!(serde_json::to_value(&sb).unwrap(), before); + assert!(!reconcile_running(&mut sb, &[desired])); +} + +#[test] +fn extras_keep_authoritative_generations_and_last_writer_semantics() { + let mut sb = settled_running(); + let mut earlier = extra(); + earlier.status = "True".into(); + let mut desired = extra(); + desired.observed_generation = Some(6); + assert!(reconcile_running( + &mut sb, + &[earlier.clone(), desired.clone()] + )); + assert!(!reconcile_running(&mut sb, &[earlier, desired])); + let condition = conditions::find( + &sb.status.as_ref().unwrap().conditions, + conditions::TYPE_ALLOWLIST_AUTHORITATIVE, + ) + .unwrap(); + assert_eq!( + condition.observed_generation, + Some(6), + "do not invent extra freshness" + ); +} + +#[test] +fn explicit_standard_condition_overrides_do_not_create_a_reconcile_loop() { + let mut sb = settled_running(); + let mut desired = extra(); + desired.type_ = conditions::TYPE_READY.into(); + desired.status = conditions::status::FALSE.into(); + desired.observed_generation = Some(6); + assert!(reconcile_running(&mut sb, &[extra(), desired.clone()])); + assert!(!reconcile_running(&mut sb, &[extra(), desired])); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn real_extra_transition_is_preserved_and_then_settles() { + let mut sb = settled_running(); + let mut desired = extra(); + desired.status = "True".into(); + desired.reason = conditions::reason::VERIFIED.into(); + desired.last_transition_time = + conditions::new_condition("Ignored", "Unknown", "Ignored", "", None).last_transition_time; + assert!(reconcile_running(&mut sb, &[desired.clone()])); + let condition = conditions::find( + &sb.status.as_ref().unwrap().conditions, + conditions::TYPE_ALLOWLIST_AUTHORITATIVE, + ) + .unwrap(); + assert_eq!(condition.last_transition_time, desired.last_transition_time); + assert!(!reconcile_running(&mut sb, &[desired])); +} + +#[test] +fn recovery_retires_controller_degraded_condition_not_external_observations() { + let mut sb = settled_running(); + sb.status + .as_mut() + .unwrap() + .conditions + .push(conditions::new_condition( + conditions::TYPE_DEGRADED, + "True", + "SpecInvalid", + "prior failure", + Some(6), + )); + sb.status.as_mut().unwrap().conditions[0].observed_generation = None; + assert!(reconcile_running(&mut sb, &[extra()])); + let conditions = &sb.status.as_ref().unwrap().conditions; + assert!(conditions::find(conditions, conditions::TYPE_DEGRADED).is_none()); + assert!(conditions::find(conditions, "ExternalObservation").is_some()); +} + +#[test] +fn overlay_and_unsupported_generation_repairs_preserve_transitions_and_settle() { + for overlay in [true, false] { + let build = |sb: &KarsSandbox| { + if overlay { + build_overlay_status_patch(sb, "kars-demo", "upstream", "OpenClaw") + } else { + build_runtime_unsupported_status_patch(sb, "BYO", "adapter unavailable") + } + }; + let matches = |sb: &KarsSandbox| { + if overlay { + overlay_status_matches(sb, "kars-demo", "upstream", "OpenClaw") + } else { + runtime_unsupported_status_matches(sb, "BYO") + } + }; + for index in 0..4 { + for generation in [None, Some(6)] { + let mut sb = settled_running(); + let patch = build(&sb); + apply_status(&mut sb, patch); + assert!(matches(&sb)); + let before = serde_json::to_value(&sb).unwrap(); + sb.status.as_mut().unwrap().conditions[index].observed_generation = generation; + assert!(!matches(&sb)); + let patch = build(&sb); + apply_status(&mut sb, patch); + assert!(matches(&sb)); + assert_eq!(serde_json::to_value(&sb).unwrap(), before); + } + } + } +} diff --git a/controller/src/status/mod.rs b/controller/src/status/mod.rs index 1ed66ea97..db9a0ffe9 100644 --- a/controller/src/status/mod.rs +++ b/controller/src/status/mod.rs @@ -15,9 +15,33 @@ pub mod router_confirmation; pub mod router_confirmation_io; use crate::crd::KarsSandbox; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; use kube::ResourceExt; use serde_json::{Value, json}; +// Merge patches replace the conditions array. Keep other writers' conditions, +// but retire our own obsolete outcomes (e.g. Degraded on recovery). +fn retain_unrelated_conditions(prior: &[Condition], desired: &mut Vec<Condition>) { + use conditions::*; + for condition in prior { + if !matches!( + condition.type_.as_str(), + TYPE_READY + | TYPE_PROGRESSING + | TYPE_RUNTIME_READY + | TYPE_DEGRADED + | TYPE_SUSPENDED + | TYPE_ALLOWLIST_VERIFIED + | TYPE_ALLOWLIST_AUTHORITATIVE + | TYPE_ALLOWLIST_DRIFT + | "CredentialsReady" + ) && !desired.iter().any(|c| c.type_ == condition.type_) + { + desired.push(condition.clone()); + } + } +} + /// Build the `status` patch for a `KarsSandbox` that has reached the /// Running phase. Includes `observedGeneration` (per KEP-1623 status /// semantics) and a Ready=True condition whose `lastTransitionTime` is @@ -106,12 +130,19 @@ pub fn build_running_status_patch_with_extras( let mut conditions_vec = vec![ready, progressing, runtime_ready]; for extra in extra_conditions { + let mut extra = extra.clone(); + if let Some(prior) = conditions::find(prior_conditions, &extra.type_) + && prior.status == extra.status + { + extra.last_transition_time = prior.last_transition_time.clone(); + } if let Some(slot) = conditions_vec.iter_mut().find(|c| c.type_ == extra.type_) { - *slot = extra.clone(); + *slot = extra; } else { - conditions_vec.push(extra.clone()); + conditions_vec.push(extra); } } + retain_unrelated_conditions(prior_conditions, &mut conditions_vec); let mut status_obj = json!({ "status": { @@ -159,7 +190,7 @@ pub fn running_status_matches(sandbox: &KarsSandbox, sandbox_ns: &str, runtime_k /// As [`running_status_matches`], but additionally requires that the /// existing CR status carries every condition in `extra_conditions` -/// with the same `type_`/`status`/`reason` (message changes alone do +/// with the same `type_`/`status`/`reason`/`observedGeneration` (message changes alone do /// **not** force a re-patch — they ride along on the next genuine /// transition). Used by the reconciler to keep the `AllowlistVerified` /// Condition stable across same-result reconciles without churning @@ -190,43 +221,44 @@ pub fn running_status_matches_with_extras( if status.runtime_kind.as_deref() != Some(runtime_kind) { return false; } - let ready_ok = status - .conditions - .iter() - .find(|c| c.type_ == TYPE_READY) - .is_some_and(|c| c.status == STATUS_TRUE); - if !ready_ok { - return false; - } // Phase 2 S7.B: the running shape now stamps Progressing=False // alongside Ready=True; verifying it here prevents an upgrade-time // status flap where a pre-S7.B controller's Ready-only status would // otherwise be considered a no-op match and the Progressing field // would never get back-filled. - let progressing_ok = status - .conditions - .iter() - .find(|c| c.type_ == TYPE_PROGRESSING) - .is_some_and(|c| c.status == STATUS_FALSE); - if !progressing_ok { - return false; - } - let runtime_ready_ok = status - .conditions - .iter() - .find(|c| c.type_ == TYPE_RUNTIME_READY) - .is_some_and(|c| c.status == STATUS_TRUE); - if !runtime_ready_ok { - return false; + for (type_, expected) in [ + (TYPE_READY, STATUS_TRUE), + (TYPE_PROGRESSING, STATUS_FALSE), + (TYPE_RUNTIME_READY, STATUS_TRUE), + ] { + // The builder upserts caller overrides last; compare those below + // rather than also demanding the superseded default outcome. + if extra_conditions.iter().any(|c| c.type_ == type_) { + continue; + } + if !conditions::find(&status.conditions, type_).is_some_and(|c| { + c.status == expected && c.observed_generation == sandbox.metadata.generation + }) { + return false; + } } - // S12.b: `AllowlistVerified` must match in (type,status,reason) so + // S12.b: `AllowlistVerified` must match in (type,status,reason,generation) so // a transient → verified flip triggers a re-patch. We deliberately // ignore `message` because the verifier rewrites the digest / // generation summary on every successful pass and we don't want // that to defeat the idempotency guard. - for extra in extra_conditions { + for (index, extra) in extra_conditions.iter().enumerate() { + if extra_conditions[index + 1..] + .iter() + .any(|c| c.type_ == extra.type_) + { + continue; + } let matched = status.conditions.iter().any(|c| { - c.type_ == extra.type_ && c.status == extra.status && c.reason == extra.reason + c.type_ == extra.type_ + && c.status == extra.status + && c.reason == extra.reason + && c.observed_generation == extra.observed_generation }); if !matched { return false; @@ -305,6 +337,8 @@ pub fn build_overlay_status_patch( &format!("runtime `{runtime_kind}` not driven by kars in overlay mode"), generation, ); + let mut conditions_vec = vec![ready, progressing, suspended, runtime_ready]; + retain_unrelated_conditions(prior_conditions, &mut conditions_vec); json!({ "status": { "phase": "Overlay", @@ -312,7 +346,7 @@ pub fn build_overlay_status_patch( "sandboxPod": format!("upstream/{upstream_ref}"), "observedGeneration": generation, "runtimeKind": runtime_kind, - "conditions": [ready, progressing, suspended, runtime_ready], + "conditions": conditions_vec, } }) } @@ -328,7 +362,10 @@ pub fn overlay_status_matches( upstream_ref: &str, runtime_kind: &str, ) -> bool { - use crate::status::conditions::{TYPE_READY, status::TRUE as STATUS_TRUE}; + use crate::status::conditions::{ + TYPE_PROGRESSING, TYPE_READY, TYPE_RUNTIME_READY, TYPE_SUSPENDED, + status::{FALSE as STATUS_FALSE, TRUE as STATUS_TRUE}, + }; let Some(status) = sandbox.status.as_ref() else { return false; @@ -349,15 +386,18 @@ pub fn overlay_status_matches( if status.sandbox_pod.as_deref() != Some(expected_pod.as_str()) { return false; } - let ready_ok = status - .conditions - .iter() - .find(|c| c.type_ == TYPE_READY) - .is_some_and(|c| c.status == STATUS_TRUE); - if !ready_ok { - return false; - } - true + [ + (TYPE_READY, STATUS_TRUE), + (TYPE_PROGRESSING, STATUS_FALSE), + (TYPE_SUSPENDED, STATUS_TRUE), + (TYPE_RUNTIME_READY, STATUS_FALSE), + ] + .iter() + .all(|(type_, expected)| { + conditions::find(&status.conditions, type_).is_some_and(|c| { + c.status == *expected && c.observed_generation == sandbox.metadata.generation + }) + }) } /// and a `Degraded=True` / `Ready=False` condition pair so `kubectl wait @@ -409,11 +449,13 @@ pub fn build_degraded_status_patch( message, generation, ); + let mut conditions_vec = vec![degraded, not_ready, not_progressing]; + retain_unrelated_conditions(prior_conditions, &mut conditions_vec); json!({ "status": { "phase": "Degraded", "observedGeneration": generation, - "conditions": [degraded, not_ready, not_progressing], + "conditions": conditions_vec, } }) } @@ -502,12 +544,14 @@ pub fn build_runtime_unsupported_status_patch( message, generation, ); + let mut conditions_vec = vec![degraded, not_ready, runtime_not_ready, not_progressing]; + retain_unrelated_conditions(prior_conditions, &mut conditions_vec); json!({ "status": { "phase": "Degraded", "observedGeneration": generation, "runtimeKind": runtime_kind, - "conditions": [degraded, not_ready, runtime_not_ready, not_progressing], + "conditions": conditions_vec, } }) } @@ -537,17 +581,29 @@ pub fn runtime_unsupported_status_matches(sandbox: &KarsSandbox, runtime_kind: & .conditions .iter() .find(|c| c.type_ == TYPE_DEGRADED) - .is_some_and(|c| c.status == STATUS_TRUE && c.reason == ADAPTER_MISSING); + .is_some_and(|c| { + c.status == STATUS_TRUE + && c.reason == ADAPTER_MISSING + && c.observed_generation == sandbox.metadata.generation + }); let ready_ok = status .conditions .iter() .find(|c| c.type_ == TYPE_READY) - .is_some_and(|c| c.status == STATUS_FALSE && c.reason == ADAPTER_MISSING); + .is_some_and(|c| { + c.status == STATUS_FALSE + && c.reason == ADAPTER_MISSING + && c.observed_generation == sandbox.metadata.generation + }); let runtime_ready_ok = status .conditions .iter() .find(|c| c.type_ == TYPE_RUNTIME_READY) - .is_some_and(|c| c.status == STATUS_FALSE && c.reason == ADAPTER_MISSING); + .is_some_and(|c| { + c.status == STATUS_FALSE + && c.reason == ADAPTER_MISSING + && c.observed_generation == sandbox.metadata.generation + }); // Phase 2 S7.B: also verify Progressing=False so a pre-S7.B // status (no Progressing field) is treated as stale and gets // back-filled on the next reconcile rather than masked. @@ -555,7 +611,11 @@ pub fn runtime_unsupported_status_matches(sandbox: &KarsSandbox, runtime_kind: & .conditions .iter() .find(|c| c.type_ == TYPE_PROGRESSING) - .is_some_and(|c| c.status == STATUS_FALSE && c.reason == ADAPTER_MISSING); + .is_some_and(|c| { + c.status == STATUS_FALSE + && c.reason == ADAPTER_MISSING + && c.observed_generation == sandbox.metadata.generation + }); degraded_ok && ready_ok && runtime_ready_ok && progressing_ok } @@ -588,660 +648,7 @@ pub async fn stamp_runtime_unsupported( } #[cfg(test)] -mod tests { - use super::*; - use crate::crd::{KarsSandbox, KarsSandboxSpec, KarsSandboxStatus}; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; - - fn new_sandbox(generation: Option<i64>, status: Option<KarsSandboxStatus>) -> KarsSandbox { - KarsSandbox { - metadata: ObjectMeta { - name: Some("demo".into()), - namespace: Some("kars-demo".into()), - generation, - ..Default::default() - }, - spec: KarsSandboxSpec::default(), - status, - } - } - - #[test] - fn running_patch_emits_generation_and_ready_condition() { - let sb = new_sandbox(Some(7), None); - let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); - let st = &patch["status"]; - assert_eq!(st["phase"], "Running"); - assert_eq!(st["observedGeneration"], 7); - assert_eq!(st["runtimeKind"], "OpenClaw"); - let conds = st["conditions"].as_array().expect("conditions array"); - assert_eq!( - conds.len(), - 3, - "expected Ready + Progressing + RuntimeReady" - ); - let ready = conds.iter().find(|c| c["type"] == "Ready").expect("Ready"); - assert_eq!(ready["status"], "True"); - assert_eq!(ready["reason"], "Reconciled"); - assert_eq!(ready["observedGeneration"], 7); - let progressing = conds - .iter() - .find(|c| c["type"] == "Progressing") - .expect("Progressing"); - assert_eq!(progressing["status"], "False"); - assert_eq!(progressing["reason"], "Reconciled"); - assert_eq!(progressing["observedGeneration"], 7); - let runtime_ready = conds - .iter() - .find(|c| c["type"] == "RuntimeReady") - .expect("RuntimeReady"); - assert_eq!(runtime_ready["status"], "True"); - assert_eq!(runtime_ready["reason"], "Reconciled"); - assert!( - runtime_ready["message"] - .as_str() - .unwrap_or_default() - .contains("OpenClaw"), - "RuntimeReady message must reference the runtime kind" - ); - } - - #[test] - fn running_patch_preserves_foundry_agent_id() { - let prior = KarsSandboxStatus { - foundry_agent_id: Some("asst-abc".into()), - ..Default::default() - }; - let sb = new_sandbox(Some(3), Some(prior)); - let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); - assert_eq!(patch["status"]["foundryAgentId"], "asst-abc"); - } - - #[test] - fn running_patch_reuses_ready_transition_time() { - let existing_ready = conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - ); - let prior_ts = existing_ready.last_transition_time.clone(); - let prior = KarsSandboxStatus { - conditions: vec![existing_ready], - ..Default::default() - }; - std::thread::sleep(std::time::Duration::from_millis(5)); - let sb = new_sandbox(Some(2), Some(prior)); - let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); - let emitted_ts = patch["status"]["conditions"][0]["lastTransitionTime"] - .as_str() - .expect("timestamp must be stringified"); - // Timestamps serialize as RFC3339; ensure format unchanged == preserved. - let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); - assert_eq!(emitted_ts, prior_ts_str.as_str().unwrap()); - } - - #[test] - fn running_patch_emits_null_observed_generation_when_metadata_missing() { - let sb = new_sandbox(None, None); - let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); - assert!(patch["status"]["observedGeneration"].is_null()); - } +mod tests; - #[test] - fn running_status_matches_returns_false_when_status_missing() { - let sb = new_sandbox(Some(1), None); - assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn running_status_matches_returns_false_when_phase_differs() { - let prior = KarsSandboxStatus { - phase: Some("Pending".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn running_status_matches_returns_false_when_namespace_differs() { - let prior = KarsSandboxStatus { - phase: Some("Running".into()), - namespace: Some("kars-other".into()), - observed_generation: Some(1), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn running_status_matches_returns_false_when_generation_stale() { - let prior = KarsSandboxStatus { - phase: Some("Running".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(2), Some(prior)); - assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn running_status_matches_returns_false_when_ready_false() { - let prior = KarsSandboxStatus { - phase: Some("Running".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::FALSE, - conditions::reason::FAILED, - "boom", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn running_status_matches_returns_true_for_settled_status() { - let prior = KarsSandboxStatus { - phase: Some("Running".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - runtime_kind: Some("OpenClaw".into()), - conditions: vec![ - conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_PROGRESSING, - conditions::status::FALSE, - conditions::reason::RECONCILED, - "ok", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_RUNTIME_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - ), - ], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn running_status_matches_returns_false_when_progressing_missing() { - // Phase 2 S7.B regression: pre-S7.B controllers wrote - // [Ready=True, RuntimeReady=True] without Progressing. After - // upgrade, that prior shape must be considered stale so the - // first reconcile back-fills the new Progressing condition - // instead of being short-circuited as a no-op. - let prior = KarsSandboxStatus { - phase: Some("Running".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - runtime_kind: Some("OpenClaw".into()), - conditions: vec![ - conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_RUNTIME_READY, - conditions::status::TRUE, - conditions::reason::RECONCILED, - "ok", - Some(1), - ), - ], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); - } - - #[test] - fn degraded_patch_stamps_degraded_true_and_ready_false() { - let sb = new_sandbox(Some(9), None); - let patch = build_degraded_status_patch( - &sb, - conditions::reason::SPEC_INVALID, - "empty inference.model", - ); - let st = &patch["status"]; - assert_eq!(st["phase"], "Degraded"); - assert_eq!(st["observedGeneration"], 9); - let conds = st["conditions"].as_array().expect("conditions array"); - assert_eq!(conds.len(), 3); - let degraded = conds - .iter() - .find(|c| c["type"] == "Degraded") - .expect("Degraded cond"); - assert_eq!(degraded["status"], "True"); - assert_eq!(degraded["reason"], "SpecInvalid"); - assert_eq!(degraded["observedGeneration"], 9); - let ready = conds - .iter() - .find(|c| c["type"] == "Ready") - .expect("Ready cond"); - assert_eq!(ready["status"], "False"); - assert_eq!(ready["reason"], "SpecInvalid"); - assert_eq!(ready["observedGeneration"], 9); - let progressing = conds - .iter() - .find(|c| c["type"] == "Progressing") - .expect("Progressing cond"); - assert_eq!(progressing["status"], "False"); - assert_eq!(progressing["reason"], "SpecInvalid"); - assert_eq!(progressing["observedGeneration"], 9); - } - - #[test] - fn degraded_patch_preserves_transition_time_on_repeat() { - let prior_degraded = conditions::new_condition( - conditions::TYPE_DEGRADED, - conditions::status::TRUE, - conditions::reason::SPEC_INVALID, - "bad spec", - Some(1), - ); - let prior_ts = prior_degraded.last_transition_time.clone(); - let prior = KarsSandboxStatus { - conditions: vec![prior_degraded], - ..Default::default() - }; - std::thread::sleep(std::time::Duration::from_millis(5)); - let sb = new_sandbox(Some(2), Some(prior)); - let patch = - build_degraded_status_patch(&sb, conditions::reason::SPEC_INVALID, "still bad spec"); - let degraded_ts = patch["status"]["conditions"] - .as_array() - .unwrap() - .iter() - .find(|c| c["type"] == "Degraded") - .unwrap()["lastTransitionTime"] - .as_str() - .unwrap() - .to_string(); - let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); - assert_eq!(degraded_ts, prior_ts_str.as_str().unwrap()); - } - - #[test] - fn degraded_patch_handles_missing_generation() { - let sb = new_sandbox(None, None); - let patch = - build_degraded_status_patch(&sb, conditions::reason::SPEC_INVALID, "no generation"); - assert!(patch["status"]["observedGeneration"].is_null()); - let degraded = patch["status"]["conditions"][0].clone(); - assert!(degraded["observedGeneration"].is_null()); - } - - // ── OverlayMode (Phase 2 S8) status helpers ── - - #[test] - fn overlay_patch_emits_overlay_phase_and_three_conditions() { - let sb = new_sandbox(Some(4), None); - let patch = build_overlay_status_patch(&sb, "kars-demo", "upstream-1", "OpenClaw"); - let st = &patch["status"]; - assert_eq!(st["phase"], "Overlay"); - assert_eq!(st["namespace"], "kars-demo"); - assert_eq!(st["sandboxPod"], "upstream/upstream-1"); - assert_eq!(st["observedGeneration"], 4); - assert_eq!(st["runtimeKind"], "OpenClaw"); - let conds = st["conditions"].as_array().expect("conditions array"); - assert_eq!( - conds.len(), - 4, - "expected Ready+Progressing+Suspended+RuntimeReady" - ); - let ready = conds.iter().find(|c| c["type"] == "Ready").expect("Ready"); - assert_eq!(ready["status"], "True"); - assert_eq!(ready["reason"], "OverlayMode"); - let progressing = conds - .iter() - .find(|c| c["type"] == "Progressing") - .expect("Progressing"); - assert_eq!(progressing["status"], "False"); - assert_eq!(progressing["reason"], "OverlayMode"); - let suspended = conds - .iter() - .find(|c| c["type"] == "Suspended") - .expect("Suspended"); - assert_eq!(suspended["status"], "True"); - assert_eq!(suspended["reason"], "OverlayMode"); - assert!( - suspended["message"] - .as_str() - .unwrap_or_default() - .contains("upstream-1"), - "Suspended message must reference the upstream CR name" - ); - let runtime_ready = conds - .iter() - .find(|c| c["type"] == "RuntimeReady") - .expect("RuntimeReady"); - assert_eq!(runtime_ready["status"], "False"); - assert_eq!(runtime_ready["reason"], "OverlayMode"); - } - - #[test] - fn overlay_status_matches_rejects_when_status_missing() { - let sb = new_sandbox(Some(1), None); - assert!(!overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); - } - - #[test] - fn overlay_status_matches_rejects_when_phase_is_running() { - let prior = KarsSandboxStatus { - phase: Some("Running".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - sandbox_pod: Some("upstream/u1".into()), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::OVERLAY_MODE, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); - } - - #[test] - fn overlay_status_matches_rejects_when_upstream_ref_differs() { - let prior = KarsSandboxStatus { - phase: Some("Overlay".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - sandbox_pod: Some("upstream/old-name".into()), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::OVERLAY_MODE, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!overlay_status_matches( - &sb, - "kars-demo", - "new-name", - "OpenClaw" - )); - } - - #[test] - fn overlay_status_matches_rejects_when_generation_stale() { - let prior = KarsSandboxStatus { - phase: Some("Overlay".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - sandbox_pod: Some("upstream/u1".into()), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::OVERLAY_MODE, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(2), Some(prior)); - assert!(!overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); - } - - #[test] - fn overlay_status_matches_returns_true_for_settled_overlay_status() { - let prior = KarsSandboxStatus { - phase: Some("Overlay".into()), - namespace: Some("kars-demo".into()), - observed_generation: Some(1), - sandbox_pod: Some("upstream/u1".into()), - runtime_kind: Some("OpenClaw".into()), - conditions: vec![conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::OVERLAY_MODE, - "ok", - Some(1), - )], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); - } - - #[test] - fn overlay_patch_preserves_ready_transition_time_on_repeat() { - let existing_ready = conditions::new_condition( - conditions::TYPE_READY, - conditions::status::TRUE, - conditions::reason::OVERLAY_MODE, - "overlay", - Some(1), - ); - let prior_ts = existing_ready.last_transition_time.clone(); - let prior = KarsSandboxStatus { - conditions: vec![existing_ready], - ..Default::default() - }; - std::thread::sleep(std::time::Duration::from_millis(5)); - let sb = new_sandbox(Some(2), Some(prior)); - let patch = build_overlay_status_patch(&sb, "kars-demo", "u1", "OpenClaw"); - let ready = patch["status"]["conditions"] - .as_array() - .unwrap() - .iter() - .find(|c| c["type"] == "Ready") - .unwrap() - .clone(); - let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); - assert_eq!( - ready["lastTransitionTime"].as_str().unwrap(), - prior_ts_str.as_str().unwrap() - ); - } - - // ── S10.A1: AdapterMissing (runtime unsupported) status helpers ── - - #[test] - fn runtime_unsupported_patch_stamps_three_conditions_and_runtime_kind() { - let sb = new_sandbox(Some(5), None); - let patch = build_runtime_unsupported_status_patch( - &sb, - "OpenAIAgents", - "no adapter wired in this build", - ); - let st = &patch["status"]; - assert_eq!(st["phase"], "Degraded"); - assert_eq!(st["observedGeneration"], 5); - assert_eq!(st["runtimeKind"], "OpenAIAgents"); - let conds = st["conditions"].as_array().expect("conditions array"); - assert_eq!( - conds.len(), - 4, - "expected Degraded+Ready+RuntimeReady+Progressing" - ); - let degraded = conds - .iter() - .find(|c| c["type"] == "Degraded") - .expect("Degraded"); - assert_eq!(degraded["status"], "True"); - assert_eq!(degraded["reason"], "AdapterMissing"); - let ready = conds.iter().find(|c| c["type"] == "Ready").expect("Ready"); - assert_eq!(ready["status"], "False"); - assert_eq!(ready["reason"], "AdapterMissing"); - let runtime_ready = conds - .iter() - .find(|c| c["type"] == "RuntimeReady") - .expect("RuntimeReady"); - assert_eq!(runtime_ready["status"], "False"); - assert_eq!(runtime_ready["reason"], "AdapterMissing"); - let progressing = conds - .iter() - .find(|c| c["type"] == "Progressing") - .expect("Progressing"); - assert_eq!(progressing["status"], "False"); - assert_eq!(progressing["reason"], "AdapterMissing"); - } - - #[test] - fn runtime_unsupported_status_matches_rejects_when_status_missing() { - let sb = new_sandbox(Some(1), None); - assert!(!runtime_unsupported_status_matches(&sb, "OpenAIAgents")); - } - - #[test] - fn runtime_unsupported_status_matches_rejects_when_runtime_kind_differs() { - let prior = KarsSandboxStatus { - phase: Some("Degraded".into()), - observed_generation: Some(1), - runtime_kind: Some("MicrosoftAgentFramework".into()), - conditions: vec![ - conditions::new_condition( - conditions::TYPE_DEGRADED, - conditions::status::TRUE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_READY, - conditions::status::FALSE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_RUNTIME_READY, - conditions::status::FALSE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - ], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(!runtime_unsupported_status_matches(&sb, "OpenAIAgents")); - } - - #[test] - fn runtime_unsupported_status_matches_returns_true_for_settled_status() { - let prior = KarsSandboxStatus { - phase: Some("Degraded".into()), - observed_generation: Some(1), - runtime_kind: Some("OpenAIAgents".into()), - conditions: vec![ - conditions::new_condition( - conditions::TYPE_DEGRADED, - conditions::status::TRUE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_READY, - conditions::status::FALSE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_RUNTIME_READY, - conditions::status::FALSE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - conditions::new_condition( - conditions::TYPE_PROGRESSING, - conditions::status::FALSE, - conditions::reason::ADAPTER_MISSING, - "x", - Some(1), - ), - ], - ..Default::default() - }; - let sb = new_sandbox(Some(1), Some(prior)); - assert!(runtime_unsupported_status_matches(&sb, "OpenAIAgents")); - } - - #[test] - fn runtime_unsupported_patch_preserves_transition_time_on_repeat() { - let prior_degraded = conditions::new_condition( - conditions::TYPE_DEGRADED, - conditions::status::TRUE, - conditions::reason::ADAPTER_MISSING, - "no adapter", - Some(1), - ); - let prior_ts = prior_degraded.last_transition_time.clone(); - let prior = KarsSandboxStatus { - conditions: vec![prior_degraded], - ..Default::default() - }; - std::thread::sleep(std::time::Duration::from_millis(5)); - let sb = new_sandbox(Some(2), Some(prior)); - let patch = build_runtime_unsupported_status_patch(&sb, "OpenAIAgents", "still no adapter"); - let degraded_ts = patch["status"]["conditions"] - .as_array() - .unwrap() - .iter() - .find(|c| c["type"] == "Degraded") - .unwrap()["lastTransitionTime"] - .as_str() - .unwrap() - .to_string(); - let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); - assert_eq!(degraded_ts, prior_ts_str.as_str().unwrap()); - } -} +#[cfg(test)] +mod convergence_tests; diff --git a/controller/src/status/tests.rs b/controller/src/status/tests.rs new file mode 100644 index 000000000..b501aa9fb --- /dev/null +++ b/controller/src/status/tests.rs @@ -0,0 +1,644 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::crd::{KarsSandbox, KarsSandboxSpec, KarsSandboxStatus}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + +fn new_sandbox(generation: Option<i64>, status: Option<KarsSandboxStatus>) -> KarsSandbox { + KarsSandbox { + metadata: ObjectMeta { + name: Some("demo".into()), + namespace: Some("kars-demo".into()), + generation, + ..Default::default() + }, + spec: KarsSandboxSpec::default(), + status, + } +} + +#[test] +fn running_patch_emits_generation_and_ready_condition() { + let sb = new_sandbox(Some(7), None); + let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); + let st = &patch["status"]; + assert_eq!(st["phase"], "Running"); + assert_eq!(st["observedGeneration"], 7); + assert_eq!(st["runtimeKind"], "OpenClaw"); + let conds = st["conditions"].as_array().expect("conditions array"); + assert_eq!( + conds.len(), + 3, + "expected Ready + Progressing + RuntimeReady" + ); + let ready = conds.iter().find(|c| c["type"] == "Ready").expect("Ready"); + assert_eq!(ready["status"], "True"); + assert_eq!(ready["reason"], "Reconciled"); + assert_eq!(ready["observedGeneration"], 7); + let progressing = conds + .iter() + .find(|c| c["type"] == "Progressing") + .expect("Progressing"); + assert_eq!(progressing["status"], "False"); + assert_eq!(progressing["reason"], "Reconciled"); + assert_eq!(progressing["observedGeneration"], 7); + let runtime_ready = conds + .iter() + .find(|c| c["type"] == "RuntimeReady") + .expect("RuntimeReady"); + assert_eq!(runtime_ready["status"], "True"); + assert_eq!(runtime_ready["reason"], "Reconciled"); + assert!( + runtime_ready["message"] + .as_str() + .unwrap_or_default() + .contains("OpenClaw"), + "RuntimeReady message must reference the runtime kind" + ); +} + +#[test] +fn running_patch_preserves_foundry_agent_id() { + let prior = KarsSandboxStatus { + foundry_agent_id: Some("asst-abc".into()), + ..Default::default() + }; + let sb = new_sandbox(Some(3), Some(prior)); + let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); + assert_eq!(patch["status"]["foundryAgentId"], "asst-abc"); +} + +#[test] +fn running_patch_reuses_ready_transition_time() { + let existing_ready = conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + ); + let prior_ts = existing_ready.last_transition_time.clone(); + let prior = KarsSandboxStatus { + conditions: vec![existing_ready], + ..Default::default() + }; + std::thread::sleep(std::time::Duration::from_millis(5)); + let sb = new_sandbox(Some(2), Some(prior)); + let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); + let emitted_ts = patch["status"]["conditions"][0]["lastTransitionTime"] + .as_str() + .expect("timestamp must be stringified"); + // Timestamps serialize as RFC3339; ensure format unchanged == preserved. + let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); + assert_eq!(emitted_ts, prior_ts_str.as_str().unwrap()); +} + +#[test] +fn running_patch_emits_null_observed_generation_when_metadata_missing() { + let sb = new_sandbox(None, None); + let patch = build_running_status_patch(&sb, "kars-demo", "OpenClaw"); + assert!(patch["status"]["observedGeneration"].is_null()); +} + +#[test] +fn running_status_matches_returns_false_when_status_missing() { + let sb = new_sandbox(Some(1), None); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn running_status_matches_returns_false_when_phase_differs() { + let prior = KarsSandboxStatus { + phase: Some("Pending".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn running_status_matches_returns_false_when_namespace_differs() { + let prior = KarsSandboxStatus { + phase: Some("Running".into()), + namespace: Some("kars-other".into()), + observed_generation: Some(1), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn running_status_matches_returns_false_when_generation_stale() { + let prior = KarsSandboxStatus { + phase: Some("Running".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(2), Some(prior)); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn running_status_matches_returns_false_when_ready_false() { + let prior = KarsSandboxStatus { + phase: Some("Running".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::FALSE, + conditions::reason::FAILED, + "boom", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn running_status_matches_returns_true_for_settled_status() { + let prior = KarsSandboxStatus { + phase: Some("Running".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + runtime_kind: Some("OpenClaw".into()), + conditions: vec![ + conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_PROGRESSING, + conditions::status::FALSE, + conditions::reason::RECONCILED, + "ok", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_RUNTIME_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + ), + ], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn running_status_matches_returns_false_when_progressing_missing() { + // Phase 2 S7.B regression: pre-S7.B controllers wrote + // [Ready=True, RuntimeReady=True] without Progressing. After + // upgrade, that prior shape must be considered stale so the + // first reconcile back-fills the new Progressing condition + // instead of being short-circuited as a no-op. + let prior = KarsSandboxStatus { + phase: Some("Running".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + runtime_kind: Some("OpenClaw".into()), + conditions: vec![ + conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_RUNTIME_READY, + conditions::status::TRUE, + conditions::reason::RECONCILED, + "ok", + Some(1), + ), + ], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!running_status_matches(&sb, "kars-demo", "OpenClaw")); +} + +#[test] +fn degraded_patch_stamps_degraded_true_and_ready_false() { + let sb = new_sandbox(Some(9), None); + let patch = build_degraded_status_patch( + &sb, + conditions::reason::SPEC_INVALID, + "empty inference.model", + ); + let st = &patch["status"]; + assert_eq!(st["phase"], "Degraded"); + assert_eq!(st["observedGeneration"], 9); + let conds = st["conditions"].as_array().expect("conditions array"); + assert_eq!(conds.len(), 3); + let degraded = conds + .iter() + .find(|c| c["type"] == "Degraded") + .expect("Degraded cond"); + assert_eq!(degraded["status"], "True"); + assert_eq!(degraded["reason"], "SpecInvalid"); + assert_eq!(degraded["observedGeneration"], 9); + let ready = conds + .iter() + .find(|c| c["type"] == "Ready") + .expect("Ready cond"); + assert_eq!(ready["status"], "False"); + assert_eq!(ready["reason"], "SpecInvalid"); + assert_eq!(ready["observedGeneration"], 9); + let progressing = conds + .iter() + .find(|c| c["type"] == "Progressing") + .expect("Progressing cond"); + assert_eq!(progressing["status"], "False"); + assert_eq!(progressing["reason"], "SpecInvalid"); + assert_eq!(progressing["observedGeneration"], 9); +} + +#[test] +fn degraded_patch_preserves_transition_time_on_repeat() { + let prior_degraded = conditions::new_condition( + conditions::TYPE_DEGRADED, + conditions::status::TRUE, + conditions::reason::SPEC_INVALID, + "bad spec", + Some(1), + ); + let prior_ts = prior_degraded.last_transition_time.clone(); + let prior = KarsSandboxStatus { + conditions: vec![prior_degraded], + ..Default::default() + }; + std::thread::sleep(std::time::Duration::from_millis(5)); + let sb = new_sandbox(Some(2), Some(prior)); + let patch = + build_degraded_status_patch(&sb, conditions::reason::SPEC_INVALID, "still bad spec"); + let degraded_ts = patch["status"]["conditions"] + .as_array() + .unwrap() + .iter() + .find(|c| c["type"] == "Degraded") + .unwrap()["lastTransitionTime"] + .as_str() + .unwrap() + .to_string(); + let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); + assert_eq!(degraded_ts, prior_ts_str.as_str().unwrap()); +} + +#[test] +fn degraded_patch_handles_missing_generation() { + let sb = new_sandbox(None, None); + let patch = build_degraded_status_patch(&sb, conditions::reason::SPEC_INVALID, "no generation"); + assert!(patch["status"]["observedGeneration"].is_null()); + let degraded = patch["status"]["conditions"][0].clone(); + assert!(degraded["observedGeneration"].is_null()); +} + +// ── OverlayMode (Phase 2 S8) status helpers ── + +#[test] +fn overlay_patch_emits_overlay_phase_and_three_conditions() { + let sb = new_sandbox(Some(4), None); + let patch = build_overlay_status_patch(&sb, "kars-demo", "upstream-1", "OpenClaw"); + let st = &patch["status"]; + assert_eq!(st["phase"], "Overlay"); + assert_eq!(st["namespace"], "kars-demo"); + assert_eq!(st["sandboxPod"], "upstream/upstream-1"); + assert_eq!(st["observedGeneration"], 4); + assert_eq!(st["runtimeKind"], "OpenClaw"); + let conds = st["conditions"].as_array().expect("conditions array"); + assert_eq!( + conds.len(), + 4, + "expected Ready+Progressing+Suspended+RuntimeReady" + ); + let ready = conds.iter().find(|c| c["type"] == "Ready").expect("Ready"); + assert_eq!(ready["status"], "True"); + assert_eq!(ready["reason"], "OverlayMode"); + let progressing = conds + .iter() + .find(|c| c["type"] == "Progressing") + .expect("Progressing"); + assert_eq!(progressing["status"], "False"); + assert_eq!(progressing["reason"], "OverlayMode"); + let suspended = conds + .iter() + .find(|c| c["type"] == "Suspended") + .expect("Suspended"); + assert_eq!(suspended["status"], "True"); + assert_eq!(suspended["reason"], "OverlayMode"); + assert!( + suspended["message"] + .as_str() + .unwrap_or_default() + .contains("upstream-1"), + "Suspended message must reference the upstream CR name" + ); + let runtime_ready = conds + .iter() + .find(|c| c["type"] == "RuntimeReady") + .expect("RuntimeReady"); + assert_eq!(runtime_ready["status"], "False"); + assert_eq!(runtime_ready["reason"], "OverlayMode"); +} + +#[test] +fn overlay_status_matches_rejects_when_status_missing() { + let sb = new_sandbox(Some(1), None); + assert!(!overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); +} + +#[test] +fn overlay_status_matches_rejects_when_phase_is_running() { + let prior = KarsSandboxStatus { + phase: Some("Running".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + sandbox_pod: Some("upstream/u1".into()), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::OVERLAY_MODE, + "ok", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); +} + +#[test] +fn overlay_status_matches_rejects_when_upstream_ref_differs() { + let prior = KarsSandboxStatus { + phase: Some("Overlay".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + sandbox_pod: Some("upstream/old-name".into()), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::OVERLAY_MODE, + "ok", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!overlay_status_matches( + &sb, + "kars-demo", + "new-name", + "OpenClaw" + )); +} + +#[test] +fn overlay_status_matches_rejects_when_generation_stale() { + let prior = KarsSandboxStatus { + phase: Some("Overlay".into()), + namespace: Some("kars-demo".into()), + observed_generation: Some(1), + sandbox_pod: Some("upstream/u1".into()), + conditions: vec![conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::OVERLAY_MODE, + "ok", + Some(1), + )], + ..Default::default() + }; + let sb = new_sandbox(Some(2), Some(prior)); + assert!(!overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); +} + +#[test] +fn overlay_status_matches_returns_true_for_settled_overlay_status() { + let mut sb = new_sandbox(Some(1), None); + let patch = build_overlay_status_patch(&sb, "kars-demo", "u1", "OpenClaw"); + sb.status = Some(serde_json::from_value(patch["status"].clone()).unwrap()); + assert!(overlay_status_matches(&sb, "kars-demo", "u1", "OpenClaw")); +} + +#[test] +fn overlay_patch_preserves_ready_transition_time_on_repeat() { + let existing_ready = conditions::new_condition( + conditions::TYPE_READY, + conditions::status::TRUE, + conditions::reason::OVERLAY_MODE, + "overlay", + Some(1), + ); + let prior_ts = existing_ready.last_transition_time.clone(); + let prior = KarsSandboxStatus { + conditions: vec![existing_ready], + ..Default::default() + }; + std::thread::sleep(std::time::Duration::from_millis(5)); + let sb = new_sandbox(Some(2), Some(prior)); + let patch = build_overlay_status_patch(&sb, "kars-demo", "u1", "OpenClaw"); + let ready = patch["status"]["conditions"] + .as_array() + .unwrap() + .iter() + .find(|c| c["type"] == "Ready") + .unwrap() + .clone(); + let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); + assert_eq!( + ready["lastTransitionTime"].as_str().unwrap(), + prior_ts_str.as_str().unwrap() + ); +} + +// ── S10.A1: AdapterMissing (runtime unsupported) status helpers ── + +#[test] +fn runtime_unsupported_patch_stamps_three_conditions_and_runtime_kind() { + let sb = new_sandbox(Some(5), None); + let patch = build_runtime_unsupported_status_patch( + &sb, + "OpenAIAgents", + "no adapter wired in this build", + ); + let st = &patch["status"]; + assert_eq!(st["phase"], "Degraded"); + assert_eq!(st["observedGeneration"], 5); + assert_eq!(st["runtimeKind"], "OpenAIAgents"); + let conds = st["conditions"].as_array().expect("conditions array"); + assert_eq!( + conds.len(), + 4, + "expected Degraded+Ready+RuntimeReady+Progressing" + ); + let degraded = conds + .iter() + .find(|c| c["type"] == "Degraded") + .expect("Degraded"); + assert_eq!(degraded["status"], "True"); + assert_eq!(degraded["reason"], "AdapterMissing"); + let ready = conds.iter().find(|c| c["type"] == "Ready").expect("Ready"); + assert_eq!(ready["status"], "False"); + assert_eq!(ready["reason"], "AdapterMissing"); + let runtime_ready = conds + .iter() + .find(|c| c["type"] == "RuntimeReady") + .expect("RuntimeReady"); + assert_eq!(runtime_ready["status"], "False"); + assert_eq!(runtime_ready["reason"], "AdapterMissing"); + let progressing = conds + .iter() + .find(|c| c["type"] == "Progressing") + .expect("Progressing"); + assert_eq!(progressing["status"], "False"); + assert_eq!(progressing["reason"], "AdapterMissing"); +} + +#[test] +fn runtime_unsupported_status_matches_rejects_when_status_missing() { + let sb = new_sandbox(Some(1), None); + assert!(!runtime_unsupported_status_matches(&sb, "OpenAIAgents")); +} + +#[test] +fn runtime_unsupported_status_matches_rejects_when_runtime_kind_differs() { + let prior = KarsSandboxStatus { + phase: Some("Degraded".into()), + observed_generation: Some(1), + runtime_kind: Some("MicrosoftAgentFramework".into()), + conditions: vec![ + conditions::new_condition( + conditions::TYPE_DEGRADED, + conditions::status::TRUE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_READY, + conditions::status::FALSE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_RUNTIME_READY, + conditions::status::FALSE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + ], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(!runtime_unsupported_status_matches(&sb, "OpenAIAgents")); +} + +#[test] +fn runtime_unsupported_status_matches_returns_true_for_settled_status() { + let prior = KarsSandboxStatus { + phase: Some("Degraded".into()), + observed_generation: Some(1), + runtime_kind: Some("OpenAIAgents".into()), + conditions: vec![ + conditions::new_condition( + conditions::TYPE_DEGRADED, + conditions::status::TRUE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_READY, + conditions::status::FALSE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_RUNTIME_READY, + conditions::status::FALSE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + conditions::new_condition( + conditions::TYPE_PROGRESSING, + conditions::status::FALSE, + conditions::reason::ADAPTER_MISSING, + "x", + Some(1), + ), + ], + ..Default::default() + }; + let sb = new_sandbox(Some(1), Some(prior)); + assert!(runtime_unsupported_status_matches(&sb, "OpenAIAgents")); +} + +#[test] +fn runtime_unsupported_patch_preserves_transition_time_on_repeat() { + let prior_degraded = conditions::new_condition( + conditions::TYPE_DEGRADED, + conditions::status::TRUE, + conditions::reason::ADAPTER_MISSING, + "no adapter", + Some(1), + ); + let prior_ts = prior_degraded.last_transition_time.clone(); + let prior = KarsSandboxStatus { + conditions: vec![prior_degraded], + ..Default::default() + }; + std::thread::sleep(std::time::Duration::from_millis(5)); + let sb = new_sandbox(Some(2), Some(prior)); + let patch = build_runtime_unsupported_status_patch(&sb, "OpenAIAgents", "still no adapter"); + let degraded_ts = patch["status"]["conditions"] + .as_array() + .unwrap() + .iter() + .find(|c| c["type"] == "Degraded") + .unwrap()["lastTransitionTime"] + .as_str() + .unwrap() + .to_string(); + let prior_ts_str = serde_json::to_value(&prior_ts).unwrap(); + assert_eq!(degraded_ts, prior_ts_str.as_str().unwrap()); +} diff --git a/deploy/helm/kars/templates/crd.yaml b/deploy/helm/kars/templates/crd.yaml index 46ffd13a7..9f1c4b044 100644 --- a/deploy/helm/kars/templates/crd.yaml +++ b/deploy/helm/kars/templates/crd.yaml @@ -786,6 +786,9 @@ spec: lastTransitionTime: type: string format: date-time + observedGeneration: + type: integer + format: int64 reason: type: string message: diff --git a/docs/api/conditions.md b/docs/api/conditions.md index 6b253c8e9..c90cebf4b 100644 --- a/docs/api/conditions.md +++ b/docs/api/conditions.md @@ -43,6 +43,26 @@ means the object **is** degraded; for `Ready`, that it **is** ready. The sandbox carries a richer condition set because it owns the end-to-end runtime. +`conditions[].observedGeneration` is an optional integer (`int64`) in the +Helm schema, distinct from `status.observedGeneration`. Older Sandbox schemas +omitted the per-condition property, so the API server pruned the generation +the controller wrote. A top-level current generation alone does not establish +current readiness. + +After installing the additive schema and updated controller, normal +authoritative reconciliation backfills missing or stale generations on the +conditions it computes. Same-status repairs retain transition timestamps, +unrelated conditions and caller-supplied condition outcomes; the next identical +reconcile is a no-op. Do not patch `Ready`, change customer intent to force a +generation bump, or bypass the CLI's condition-generation check. + +The existing SRE CRD API CI lane runs +`tests/e2e/sandbox_condition_schema.py` against its disposable API server. +It owns a suspended fixture and writes only `SchemaProbe=Unknown`, proving the +exact old schema prunes the field, the new schema retains an integer after a +status write plus GET, and a wrong type is rejected at that field. This is +schema evidence, not controller readiness or runtime qualification. + | Type | `status` | Reasons emitted | |---|---|---| | `Ready` | True/False | `Created`, `Reconciled`, `SuspendedBySpec`, `Failed`, `AdapterMissing`, `OverlayMode`, `InferencePolicyNotFound`, `ToolPolicyNotFound`, `AwaitingFoundryProvisioning`, `AwaitingRouterEnforcement`, `RouterEnforcing` | diff --git a/tests/e2e/sandbox_condition_schema.py b/tests/e2e/sandbox_condition_schema.py new file mode 100644 index 000000000..c960944c9 --- /dev/null +++ b/tests/e2e/sandbox_condition_schema.py @@ -0,0 +1,286 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Hosted, nonexecuting proof of Sandbox Condition schema pruning/retention. + +Own the CRD exclusively in the early disposable API lane. Upgrade the exact +pre-fix schema on that same UID; never touch an existing CRD or customer object. +SchemaProbe=Unknown is deliberately not readiness or controller authority. +""" + +import copy +import hashlib +import json +import os +from pathlib import Path +import time +import uuid + +from credential_schema import decode_documents +from sre_authority.registration_schema import ( + CONTEXT, CRD_PATH, command, crd_established, kind_proxy, request, +) + +NAME = "karssandboxes.kars.azure.com" +GROUP = "/apis/kars.azure.com/v1alpha1" +LABEL = "kars.azure.com/condition-schema-proof" +OLD_DIGEST = "da674a84c19c8ac64a1d96d04f79435c6899601426e25931feaca483f139b920" +NEW_DIGEST = "5d495b8cfe5e4526741a673161cbae0492812e2650c2f2d08c5e522a3bda946f" +FIELD = "status.conditions[0].observedGeneration" +CASES = {"render", "create", "established", "old-pruned", "upgrade", "new-retained", + "new-type-denied", "new-optional", "cleanup", "complete"} + + +class Failure(RuntimeError): + def __init__(self, case, code=0): + self.case = case if case in CASES else "complete" + self.code = code if type(code) is int and 100 <= code <= 599 else 0 + super().__init__("Sandbox condition schema proof failed") + + +def require(value, case, code=0): + if not value: + raise Failure(case, code) + + +def condition_schema(crd): + return crd["spec"]["versions"][0]["schema"]["openAPIV3Schema"][ + "properties"]["status"]["properties"]["conditions"]["items"] + + +def spec_digest(crd): + # Match CLI normalizedCrd: API-assigned conventional defaults are not drift. + spec = copy.deepcopy(crd["spec"]) + for version in spec["versions"]: + if version.get("deprecated") is False: + del version["deprecated"] + for column in version.get("additionalPrinterColumns", []): + if column.get("priority") == 0: + del column["priority"] + names = spec["names"] + for key in ("categories", "shortNames"): + if names.get(key) == []: + del names[key] + if names.get("listKind") == names["kind"] + "List": + del names["listKind"] + if names.get("singular") == names["kind"].lower(): + del names["singular"] + if spec.get("conversion") == {"strategy": "None"}: + del spec["conversion"] + if spec.get("preserveUnknownFields") is False: + del spec["preserveUnknownFields"] + raw = json.dumps(spec, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(raw.encode()).hexdigest() + + +def exact_schemas(current): + require(current.get("metadata", {}).get("name") == NAME + and spec_digest(current) == NEW_DIGEST, "render") + item = condition_schema(current) + require(item["properties"]["observedGeneration"] == {"type": "integer", "format": "int64"} + and "observedGeneration" not in item.get("required", []) + and "x-kubernetes-preserve-unknown-fields" not in item, "render") + old = copy.deepcopy(current) + del condition_schema(old)["properties"]["observedGeneration"] + require(spec_digest(old) == OLD_DIGEST, "render") + return old, copy.deepcopy(current) + + +def fixture(namespace, token): + return { + "apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsSandbox", + "metadata": {"name": "schema-probe", "namespace": namespace, "labels": {LABEL: token}}, + "spec": {"runtime": {"kind": "OpenClaw", "openclaw": {}}, + "sandbox": {"isolation": "enhanced"}, + "inferenceRef": {"name": "nonexecuting-schema-probe"}, "suspended": True}, + } + + +def probe_status(generation, *, include_generation=True): + condition = {"type": "SchemaProbe", "status": "Unknown", "reason": "SchemaRetentionProbe", + "message": "Non-authorizing schema fixture", + "lastTransitionTime": "2026-01-01T00:00:00Z"} + if include_generation: + condition["observedGeneration"] = generation + return {"observedGeneration": 1, "conditions": [condition]} + + +def intended_type_denial(code, body, name): + if (code != 422 or not isinstance(body, dict) or body.get("kind") != "Status" + or body.get("status") != "Failure" or body.get("reason") != "Invalid"): + return False + details = body.get("details", {}) + return (isinstance(details, dict) and details.get("name") == name + and details.get("group") == "kars.azure.com" + and isinstance(details.get("causes"), list) + and any(isinstance(cause, dict) and cause.get("field") == FIELD + and cause.get("reason") in ("FieldValueInvalid", "FieldValueTypeInvalid") + for cause in details["causes"])) + + +def wait_for(probe, case, seconds=40): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if probe(): + return + time.sleep(0.25) + raise Failure(case) + + +class Owned: + def __init__(self, port, token): + self.port, self.token, self.resources = port, token, [] + + def read(self, path, uid, case): + code, body = request(self.port, "GET", path) + require(code == 200 and isinstance(body, dict), case, code) + meta = body.get("metadata", {}) + require(meta.get("uid") == uid and meta.get("resourceVersion") + and meta.get("labels", {}).get(LABEL) == self.token + and not meta.get("deletionTimestamp"), case, code) + return body + + def create(self, path, body): + body = copy.deepcopy(body) + body["metadata"].setdefault("labels", {})[LABEL] = self.token + code, created = request(self.port, "POST", path, body) + require(code == 201 and isinstance(created, dict), "create", code) + metadata = created.get("metadata", {}) + require(metadata.get("name") == body["metadata"]["name"] + and metadata.get("namespace") == body["metadata"].get("namespace") + and metadata.get("uid") and metadata.get("resourceVersion") + and metadata.get("labels", {}).get(LABEL) == self.token, "create", code) + owned_path = path + "/" + metadata["name"] + self.resources.append((owned_path, metadata["uid"])) + return self.read(owned_path, metadata["uid"], "create") + + def cleanup(self): + failures = [] + for path, uid in reversed(self.resources): + try: + current = self.read(path, uid, "cleanup") + code, _ = request(self.port, "DELETE", path, { + "apiVersion": "v1", "kind": "DeleteOptions", + "preconditions": {"uid": uid, + "resourceVersion": current["metadata"]["resourceVersion"]}, + "propagationPolicy": "Background", + }) + require(code in (200, 202), "cleanup", code) + wait_for(lambda: request(self.port, "GET", path)[0] == 404, "cleanup") + except (Failure, OSError): + failures.append(path) + require(not failures, "cleanup") + + +def patch_status(owned, path, original, status, *, query="", case): + current = owned.read(path, original["metadata"]["uid"], case) + require(current["metadata"]["generation"] == original["metadata"]["generation"] + and current["spec"] == original["spec"], case) + return request(owned.port, "PATCH", path + "/status" + query, { + "metadata": {"uid": current["metadata"]["uid"], + "resourceVersion": current["metadata"]["resourceVersion"]}, + "status": status, + }) + + +def assert_roundtrip(owned, path, original, response, status, case): + current = owned.read(path, original["metadata"]["uid"], case) + require(current["metadata"]["generation"] == original["metadata"]["generation"] + and current["spec"] == original["spec"] + and current.get("status") == status + and current["metadata"]["resourceVersion"] == response["metadata"]["resourceVersion"], + case) + return current + + +def exercise(port, current, evidence): + old, new = exact_schemas(current) + token = uuid.uuid4().hex + namespace = "kars-condition-schema-" + token + owned = Owned(port, token) + try: + crd = owned.create(CRD_PATH, old) + crd_path = CRD_PATH + "/" + NAME + wait_for(lambda: crd_established(*request(port, "GET", crd_path)), "established") + owned.create("/api/v1/namespaces", { + "apiVersion": "v1", "kind": "Namespace", "metadata": {"name": namespace}, + }) + collection = GROUP + "/namespaces/" + namespace + "/karssandboxes" + original = owned.create(collection, fixture(namespace, token)) + path = collection + "/" + original["metadata"]["name"] + require(original["metadata"]["generation"] == 1 and original["spec"]["suspended"] is True, + "create") + status = probe_status(original["metadata"]["generation"]) + code, result = patch_status(owned, path, original, status, case="old-pruned") + require(code == 200, "old-pruned", code) + assert_roundtrip(owned, path, original, result, + probe_status(1, include_generation=False), "old-pruned") + evidence["markers"].append("old-exact-schema-prunes-condition-generation") + + latest = owned.read(crd_path, crd["metadata"]["uid"], "upgrade") + require(spec_digest(latest) == OLD_DIGEST, "upgrade") + latest["spec"] = new["spec"] + code, _ = request(port, "PUT", crd_path, latest) + require(code == 200, "upgrade", code) + require(spec_digest(owned.read(crd_path, crd["metadata"]["uid"], "upgrade")) == NEW_DIGEST, + "upgrade") + + def serving_new_schema(): + code, body = patch_status(owned, path, original, status, + query="?dryRun=All", case="upgrade") + return code == 200 and body.get("status") == status + wait_for(serving_new_schema, "upgrade") + code, result = patch_status(owned, path, original, status, case="new-retained") + require(code == 200, "new-retained", code) + stable = assert_roundtrip(owned, path, original, result, status, "new-retained") + evidence["markers"].append("new-schema-retains-integer-after-status-write-and-get") + code, body = patch_status(owned, path, original, probe_status("not-an-integer"), + case="new-type-denied") + require(intended_type_denial(code, body, original["metadata"]["name"]), + "new-type-denied", code) + require(owned.read(path, original["metadata"]["uid"], "new-type-denied") == stable, + "new-type-denied") + evidence["markers"].append("new-schema-rejects-wrong-type-at-condition-field") + optional = probe_status(1, include_generation=False) + code, result = patch_status(owned, path, original, optional, case="new-optional") + require(code == 200, "new-optional", code) + assert_roundtrip(owned, path, original, result, optional, "new-optional") + evidence["markers"].append("condition-generation-remains-optional-without-default") + finally: + owned.cleanup() + evidence["markers"].append("owned-fixtures-uid-resource-version-cleanup") + + +def main(): + root = Path(__file__).resolve().parents[2] + evidence = {"markers": [], "oldDigest": OLD_DIGEST, "newDigest": NEW_DIGEST, + "controllerReadinessQualified": False, "result": "failed"} + try: + require(os.environ.get("GITHUB_ACTIONS") == "true", "complete") + with kind_proxy(root) as (port, version): + evidence["apiVersion"] = version + yaml = command("sandbox-render", [ + "helm", "template", "condition-schema-proof", str(root / "deploy/helm/kars"), + "--namespace", "kars-system", "--show-only", "templates/crd.yaml", + ], root=root) + raw = command("sandbox-convert", [ + "kubectl", "--context", CONTEXT, "create", "--dry-run=client", + "--validate=false", "-f", "-", "-o", "json", + ], root=root, data=yaml) + current = decode_documents(raw).get(("CustomResourceDefinition", NAME), {}) + exercise(port, current, evidence) + evidence["result"] = "passed" + except Failure as error: + evidence["failure"] = {"case": error.case, "httpStatus": error.code} + except Exception: + evidence["failure"] = {"case": "complete", "httpStatus": 0} + directory = root / "e2e-sre-schema-diag" + directory.mkdir(exist_ok=True) + (directory / "sandbox-condition-generation.json").write_text( + json.dumps(evidence, indent=2) + "\n") + print("SANDBOX-CONDITION-SCHEMA " + json.dumps(evidence, sort_keys=True), flush=True) + return 0 if evidence["result"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/e2e/sandbox_condition_schema_test.py b/tests/e2e/sandbox_condition_schema_test.py new file mode 100644 index 000000000..ab516b22c --- /dev/null +++ b/tests/e2e/sandbox_condition_schema_test.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Local helper/diagnostic tests; these do not qualify API schema behavior.""" + +import copy +import unittest +from unittest.mock import patch + +import sandbox_condition_schema as probe + + +class SandboxConditionSchemaTests(unittest.TestCase): + def test_fixture_cannot_execute_and_does_not_assert_readiness(self): + sandbox = probe.fixture("owned", "token") + self.assertIs(sandbox["spec"]["suspended"], True) + self.assertNotIn("status", sandbox) + for value in (1, "not-an-integer"): + condition = probe.probe_status(value)["conditions"][0] + self.assertEqual(condition["type"], "SchemaProbe") + self.assertEqual(condition["status"], "Unknown") + self.assertEqual(condition["observedGeneration"], value) + self.assertNotIn("observedGeneration", + probe.probe_status(1, include_generation=False)["conditions"][0]) + + def test_denial_must_be_native_type_validation_at_the_exact_condition_field(self): + body = {"kind": "Status", "status": "Failure", "reason": "Invalid", "details": { + "name": "schema-probe", "group": "kars.azure.com", + "causes": [{"field": probe.FIELD, "reason": "FieldValueTypeInvalid"}], + }} + self.assertTrue(probe.intended_type_denial(422, body, "schema-probe")) + for field in ("spec.suspended", "status.observedGeneration", + "status.conditions[0].status", "status.conditions[1].observedGeneration"): + changed = copy.deepcopy(body) + changed["details"]["causes"][0]["field"] = field + self.assertFalse(probe.intended_type_denial(422, changed, "schema-probe")) + for code in (200, 400, 403, 409, 500): + self.assertFalse(probe.intended_type_denial(code, body, "schema-probe")) + self.assertFalse(probe.intended_type_denial(422, body, "another-object")) + changed = copy.deepcopy(body) + changed["details"]["causes"] = [{"field": probe.FIELD, "reason": "Forbidden"}] + self.assertFalse(probe.intended_type_denial(422, changed, "schema-probe")) + + def test_cleanup_is_fenced_by_owned_uid_label_and_latest_resource_version(self): + owned = probe.Owned(1234, "proof") + owned.resources.append(("/owned/fixture", "owned-uid")) + current = {"metadata": {"uid": "owned-uid", "resourceVersion": "43", + "labels": {probe.LABEL: "proof"}}} + with patch.object(probe, "request", side_effect=[ + (200, current), (200, {}), (404, {}), + ]) as request: + owned.cleanup() + deletion = request.call_args_list[1].args + self.assertEqual(deletion[1:3], ("DELETE", "/owned/fixture")) + self.assertEqual(deletion[3]["preconditions"], + {"uid": "owned-uid", "resourceVersion": "43"}) + for change in ({"uid": "replacement"}, {"labels": {probe.LABEL: "foreign"}}): + changed = copy.deepcopy(current) + changed["metadata"].update(change) + with patch.object(probe, "request", return_value=(200, changed)) as request: + with self.assertRaises(probe.Failure): + owned.cleanup() + self.assertEqual(request.call_count, 1) + + def test_status_patch_does_not_change_intent_generation_or_unowned_identity(self): + owned = probe.Owned(1234, "proof") + original = probe.fixture("owned", "proof") + original["metadata"].update(uid="owned-uid", resourceVersion="12", generation=1) + status = probe.probe_status(1) + with patch.object(probe, "request", side_effect=[ + (200, original), (200, {}), + ]) as request: + probe.patch_status(owned, "/owned/fixture", original, status, case="new-retained") + body = request.call_args_list[1].args[3] + self.assertEqual(body, {"metadata": {"uid": "owned-uid", "resourceVersion": "12"}, + "status": status}) + for part, key, value in (("metadata", "uid", "foreign"), + ("metadata", "generation", 2), + ("spec", "suspended", False)): + changed = copy.deepcopy(original) + changed[part][key] = value + with patch.object(probe, "request", return_value=(200, changed)) as request: + with self.assertRaises(probe.Failure): + probe.patch_status(owned, "/owned/fixture", original, status, + case="new-retained") + self.assertEqual(request.call_count, 1) + + def test_failed_create_never_adopts_or_deletes_an_existing_object(self): + owned = probe.Owned(1234, "proof") + with patch.object(probe, "request", return_value=(409, {})) as request: + with self.assertRaises(probe.Failure): + owned.create("/fixtures", probe.fixture("owned", "proof")) + owned.cleanup() + self.assertEqual(request.call_count, 1) + self.assertEqual(owned.resources, []) + + def test_diagnostics_reject_unbounded_cases_and_non_http_statuses(self): + failure = probe.Failure("private body", "private code") + self.assertEqual(failure.case, "complete") + self.assertEqual(failure.code, 0) + self.assertNotIn("private", str(failure)) + + +if __name__ == "__main__": + unittest.main() From 620f6920994d3ad78bd11b857e06d8ae6ab66610 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 08:31:27 +0200 Subject: [PATCH 064/111] Assert condition transition timestamps at their exact Kubernetes wire precision Keep a deterministic distinct fractional fixture and require its precise serialized timestamp after status round-trip; production status handling is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/status/convergence_tests.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/controller/src/status/convergence_tests.rs b/controller/src/status/convergence_tests.rs index 3980f5833..f606aa17c 100644 --- a/controller/src/status/convergence_tests.rs +++ b/controller/src/status/convergence_tests.rs @@ -113,7 +113,12 @@ fn running_correct_status_is_untouched_including_messages_and_timestamps() { let mut desired = extra(); desired.message = "new diagnostic text".into(); desired.last_transition_time = - conditions::new_condition("Ignored", "Unknown", "Ignored", "", None).last_transition_time; + serde_json::from_value(json!("2026-01-02T00:00:00.123456789Z")).unwrap(); + let expected_wire_time = json!("2026-01-02T00:00:00Z"); + assert_eq!( + serde_json::to_value(&desired.last_transition_time).unwrap(), + expected_wire_time + ); for _ in 0..3 { assert!(!reconcile_running(&mut sb, &[desired.clone()])); assert_eq!(serde_json::to_value(&sb).unwrap(), before); @@ -196,7 +201,10 @@ fn real_extra_transition_is_preserved_and_then_settles() { conditions::TYPE_ALLOWLIST_AUTHORITATIVE, ) .unwrap(); - assert_eq!(condition.last_transition_time, desired.last_transition_time); + assert_eq!( + serde_json::to_value(&condition.last_transition_time).unwrap(), + expected_wire_time + ); assert!(!reconcile_running(&mut sb, &[desired])); } From ba5b92d4bb029856543a6275db081f5781bab5b6 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 08:37:12 +0200 Subject: [PATCH 065/111] Keep wire timestamp fixture inside its transition test scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/status/convergence_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/controller/src/status/convergence_tests.rs b/controller/src/status/convergence_tests.rs index f606aa17c..508beaeda 100644 --- a/controller/src/status/convergence_tests.rs +++ b/controller/src/status/convergence_tests.rs @@ -113,12 +113,7 @@ fn running_correct_status_is_untouched_including_messages_and_timestamps() { let mut desired = extra(); desired.message = "new diagnostic text".into(); desired.last_transition_time = - serde_json::from_value(json!("2026-01-02T00:00:00.123456789Z")).unwrap(); - let expected_wire_time = json!("2026-01-02T00:00:00Z"); - assert_eq!( - serde_json::to_value(&desired.last_transition_time).unwrap(), - expected_wire_time - ); + conditions::new_condition("Ignored", "Unknown", "Ignored", "", None).last_transition_time; for _ in 0..3 { assert!(!reconcile_running(&mut sb, &[desired.clone()])); assert_eq!(serde_json::to_value(&sb).unwrap(), before); @@ -194,7 +189,12 @@ fn real_extra_transition_is_preserved_and_then_settles() { desired.status = "True".into(); desired.reason = conditions::reason::VERIFIED.into(); desired.last_transition_time = - conditions::new_condition("Ignored", "Unknown", "Ignored", "", None).last_transition_time; + serde_json::from_value(json!("2026-01-02T00:00:00.123456789Z")).unwrap(); + let expected_wire_time = json!("2026-01-02T00:00:00Z"); + assert_eq!( + serde_json::to_value(&desired.last_transition_time).unwrap(), + expected_wire_time + ); assert!(reconcile_running(&mut sb, &[desired.clone()])); let condition = conditions::find( &sb.status.as_ref().unwrap().conditions, From 3d6f3982fb240f4361fd0f385f99b3bd28c06b33 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 08:53:44 +0200 Subject: [PATCH 066/111] Preserve the exact writer-settling diagnostic leaf Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../tests/native-credentials/operator_diagnostics.py | 1 + .../native-credentials/test_operator_diagnostics.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index c20f58ed7..31a9f3d2c 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -33,6 +33,7 @@ "lib/private-activation-continuity", "lib/private-activation-guard-retirement", "lib/private-activation-late-scope", + "lib/private-activation-writer-settle", "commands/schemas", "lib/core-helm-schemas", "lib/schema-stage", "lib/schema-documents", "lib/schema-discovery", "lib/repo-assets", diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 536ce8ae7..e1f99b0f1 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -72,6 +72,17 @@ def test_actual_failure_retains_only_the_four_boolean_snapshot_checks(self): self.assertNotIn(PRIVATE, str(failure.exception)) self.assertEqual(output.getvalue(), "") + def test_writer_settling_reports_only_its_exact_module_location(self): + stderr = ( + f"{PRIVATE}\n" + f" at settled (/private/{PRIVATE}/cli/dist/lib/private-activation-writer-settle.js:222:10)\n" + " at applyReviewedGrant (/cli/dist/commands/credential-grants.js:170:20)\n" + ) + self.assertEqual(source_location(stderr), "lib/private-activation-writer-settle:222") + self.assertNotIn(PRIVATE, source_location(stderr)) + self.assertEqual(source_location( + " at function (/cli/dist/lib/private-activation-writer-settle-private.js:1:2)"), "unavailable") + def test_malformed_ambiguous_or_extended_snapshot_checks_remain_unavailable(self): valid = {"resourceVersionMatch": False, "observedGenerationMatch": True, "phaseRunningMatch": True, "readyConditionMatch": True} From 3ac41532413a1324a93940a13f7adf81731e7ee2 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 09:22:17 +0200 Subject: [PATCH 067/111] Settle witnessed Task credential transitions before late private enrollment Retain writer retirement and original review authority; require fresh Task attestation and post-revocation projection consumption while preserving qualified scopes and UID/RV fences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/commands/credential-grants.ts | 6 +- cli/src/lib/private-activation-continuity.ts | 4 +- .../private-activation-guard-retirement.ts | 6 +- cli/src/lib/private-activation-late-scope.ts | 17 +- .../private-activation-writer-settle.test.ts | 353 ++++++++++++++++++ .../lib/private-activation-writer-settle.ts | 321 ++++++++++++++++ docs/how-to/governed-credential-grants.md | 16 +- 7 files changed, 716 insertions(+), 7 deletions(-) create mode 100644 cli/src/lib/private-activation-writer-settle.test.ts create mode 100644 cli/src/lib/private-activation-writer-settle.ts diff --git a/cli/src/commands/credential-grants.ts b/cli/src/commands/credential-grants.ts index ac2116353..65d373f9c 100644 --- a/cli/src/commands/credential-grants.ts +++ b/cli/src/commands/credential-grants.ts @@ -11,6 +11,7 @@ import { verifyOwnedRuntimeNamespace, } from "../lib/private-activation.js"; import { captureGuardRetirement, refreshGuardRetirement } from "../lib/private-activation-guard-retirement.js"; +import { captureWriterSettlement, observeWriterSettlement, settleWriterRetirement } from "../lib/private-activation-writer-settle.js"; type Execute=(args:string[],input?:string)=>Promise<string>; const resource="karscredentialgrants.kars.azure.com"; @@ -138,6 +139,7 @@ export async function applyReviewedGrant(run:Execute,document:any):Promise<void> if(document.spec.enabled!==false&&document.spec.writers.length){ if(existing&&existing.spec.writers.length){ const guardReview=await captureGuardRetirement(run,stagedSpec.privateActivation,existing); + const settlement=await captureWriterSettlement(run,stagedSpec.privateActivation,existing); quiescentSpec={...existing.spec,writers:[]}; await run(["patch",resource,"workspace","-n",document.metadata.namespace,"--type=merge","-p",JSON.stringify({ metadata:{uid:existing.metadata.uid,resourceVersion:existing.metadata.resourceVersion},spec:quiescentSpec, @@ -147,6 +149,7 @@ export async function applyReviewedGrant(run:Execute,document:any):Promise<void> const current=await get(run,resource,"workspace",document.metadata.namespace); if(!current||current.metadata.uid!==existing.metadata.uid||canonical(current.spec)!==canonical(quiescentSpec)) throw new Error("Grant changed while retiring prior private writer authority"); + if(settlement)await observeWriterSettlement(run,stagedSpec.privateActivation,settlement); if(current.status?.observedGeneration===current.metadata.generation &¤t.status?.conditions?.some((c:any)=>c.type==="WriterReady"&&c.status==="False")){ const inventory=JSON.parse(await run(["get","roles,rolebindings,clusterroles,clusterrolebindings", @@ -155,7 +158,8 @@ export async function applyReviewedGrant(run:Execute,document:any):Promise<void> throw new Error("Private authority retirement inventory is incomplete"); if(!inventory.items.some((object:any)=> object.metadata?.annotations?.["kars.azure.com/credential-grant-owner"]===existing.metadata.uid)){ - const refreshed=await refreshGuardRetirement(run,guardReview); + const refreshed=await refreshGuardRetirement(run,guardReview,settlement + ?activation=>settleWriterRetirement(run,activation,settlement):undefined); if(refreshed){ stagedSpec.privateActivation=refreshed; existing=current;break; diff --git a/cli/src/lib/private-activation-continuity.ts b/cli/src/lib/private-activation-continuity.ts index 1ec30f1c5..a093b9303 100644 --- a/cli/src/lib/private-activation-continuity.ts +++ b/cli/src/lib/private-activation-continuity.ts @@ -36,6 +36,7 @@ export interface PrivateContinuity { proof: RootQualification; state: RootRetirement; sealed: boolean; + lateScopes?: string[]; } function encoded(value: unknown): string { @@ -208,9 +209,10 @@ export async function reviewPrivateContinuity( if (!sealed && !rootReady(deployment, state) && retirementBinding(activation) !== state.binding) { throw new Error("Original private root restore is incomplete; resume its exact review before adding another workspace"); } - const continuity = { proof, state, sealed }; + const continuity = { proof, state, sealed, lateScopes: [] as string[] }; for (const scope of activation.namespaces) { const plan = await scopePlan(execute, activation, scope, continuity); + if (plan === "Late") continuity.lateScopes.push(scope.namespace.uid); if (recoverIntent && plan === "Late") { console.error(`Private enrollment of ${scope.namespace.name} requires reviewed runtime suspension, retirement of all old Pod UIDs, ` + "controller admin-key rotation and restoration of the original suspension/replica intent. " diff --git a/cli/src/lib/private-activation-guard-retirement.ts b/cli/src/lib/private-activation-guard-retirement.ts index c1978bf12..e5b09502e 100644 --- a/cli/src/lib/private-activation-guard-retirement.ts +++ b/cli/src/lib/private-activation-guard-retirement.ts @@ -82,6 +82,7 @@ function released(snapshot: NamespaceSnapshot, key: string): Record<string, Json /** Called only after the selected grant's acknowledgement and owned-role absence checks. */ export async function refreshGuardRetirement( execute: Execute, review: GuardRetirementReview, + settle?: (activation: PrivateActivation) => Promise<PrivateActivation>, ): Promise<PrivateActivation | undefined> { const activation = structuredClone(review.activation); let pending = false; @@ -99,6 +100,7 @@ export async function refreshGuardRetirement( if (scope) scope.namespace.resourceVersion = identity.resourceVersion; } if (pending) return undefined; - await validatePrivateActivation(execute, activation); - return activation; + const settled = settle ? await settle(activation) : activation; + await validatePrivateActivation(execute, settled); + return settled; } diff --git a/cli/src/lib/private-activation-late-scope.ts b/cli/src/lib/private-activation-late-scope.ts index b9d750dcc..bc59f5853 100644 --- a/cli/src/lib/private-activation-late-scope.ts +++ b/cli/src/lib/private-activation-late-scope.ts @@ -318,7 +318,7 @@ function supportedTemplate(deployment: unknown, scope: NamespaceReview, activati async function current( execute: Execute, activation: PrivateActivation, scope: NamespaceReview, root: string, state?: Receipt, -): Promise<{ runtime: Runtime; deployment: ReturnType<typeof record>; pods: Json[]; namespace: ReturnType<typeof record> }> { +): Promise<{ runtime: Runtime; snapshot: RuntimeSnapshot; deployment: ReturnType<typeof record>; pods: Json[]; namespace: ReturnType<typeof record> }> { if (scope.consumers.length !== 1 || scope.consumers[0]?.kind !== "Deployment") throw new Error(failure); const consumer = scope.consumers[0]; const namespace = await namespaceFor(execute, scope); @@ -384,7 +384,7 @@ async function current( if (state && ["Qualified", "Restoring"].includes(state.phase) && pods.some(pod => state.captured.includes(reviewed(pod, true).uid) || at(pod, "metadata", "annotations", `${P}epoch`) !== state.epoch || at(pod, "metadata", "annotations", VERSION) !== `${state.qualified!.material.object.uid}:${state.qualified!.material.object.resourceVersion}`)) throw new Error(failure); - return { runtime, deployment, pods, namespace }; + return { runtime, snapshot, deployment, pods, namespace }; } /** Read-only. Public activation JSON remains v1; recovery lives only in the existing operator-only namespace field. */ @@ -408,6 +408,19 @@ export async function reviewLateScope( return "Late"; } +/** Apply-only witness, captured while the original Task authority is current. */ +export async function captureLateWriterScope(execute: Execute, activation: PrivateActivation, scope: NamespaceReview) { + if (scope.consumers.length !== 1 || scope.consumers[0]?.kind !== "Deployment") return undefined; + const namespace = await namespaceFor(execute, scope); + if (at(namespace, "metadata", "annotations", HISTORY) !== undefined + || at(namespace, "metadata", "annotations", "kars.azure.com/sandbox-name") === undefined) return undefined; + const live = await current(execute, activation, scope, ""); + const snapshot = live.snapshot; + if (!snapshot.task || !at(snapshot.task, "spec", "blueprint", "credentialBindings")) return undefined; + return { scope: structuredClone(scope), namespace: live.namespace, deployment: live.deployment, pods: live.pods, + sandbox: snapshot.sandbox, task: snapshot.task, admin: await material(execute, scope, live.runtime) }; +} + export async function stageLateScope( execute: Execute, activation: PrivateActivation, scope: NamespaceReview, root: string, assertRoot: () => Promise<void>, ): Promise<void> { diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts new file mode 100644 index 000000000..e7d4949dc --- /dev/null +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { applyReviewedGrant } from "../commands/credential-grants.js"; +import { continuityFixture, privateAuthoritySnapshot } from "./private-activation-fixtures.js"; +import { canonical, PRIVATE_PREFIX as P, type Execute } from "./private-activation.js"; +import { captureGuardRetirement, refreshGuardRetirement } from "./private-activation-guard-retirement.js"; +import { captureWriterSettlement } from "./private-activation-writer-settle.js"; + +const RESOURCE = "karscredentialgrants.kars.azure.com"; +const C = "kars.azure.com/credential-"; +const VERSION = `${C}projection-version`; +const INPUTS = `${C}input-state`; +const REVISION = "deployment.kubernetes.io/revision"; +const AUTH = `sha256:${"a".repeat(64)}`; +const consumer = "kars-late/Deployment/late"; +const data = { SLACK_BOT_TOKEN: Buffer.from("original-customer-token").toString("base64") }; + +async function setup(originalRuntime = false) { + const f = continuityFixture(); + if (originalRuntime) { + await applyReviewedGrant(f.execute, { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsCredentialGrant", + metadata: { name: "workspace", namespace: "work" }, + spec: { workspaceUid: "work-uid", enabled: true, writers: [] } }); + } else { + await applyReviewedGrant(f.execute, await f.document()); + await applyReviewedGrant(f.execute, await f.document("second")); + } + f.grant().status.phase = "Ready"; + const grantUid = f.grant().metadata.uid; + const owner = (kind: string, name: string, uid: string) => ({ + apiVersion: kind === "Namespace" ? "v1" : "kars.azure.com/v1alpha1", + kind, name, uid, controller: true, blockOwnerDeletion: false, + }); + const bindings = { grant: { name: "workspace", uid: grantUid }, sources: [ + { scope: "workspace", source: { name: "kars-credential-input-workspace", uid: "input-uid" }, keys: ["SLACK_BOT_TOKEN"] }, + ] }; + const task: any = { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsTask", + metadata: { name: "late", namespace: "work", uid: "task-uid", resourceVersion: "1", generation: 1, + annotations: { [`${C}bundle-uid`]: "bundle-uid" } }, + spec: { objective: "Existing native observer", execution: { launch: true }, blueprint: { credentialBindings: bindings } }, + status: { phase: "Ready", observedGeneration: 1, envelopeDigest: AUTH, sandboxRef: { name: "late" }, + conditions: [{ type: "Ready", status: "True", observedGeneration: 1, reason: "Reconciled" }] } }; + const sandbox: any = { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsSandbox", + metadata: { name: "late", namespace: "work", uid: "sandbox-uid", resourceVersion: "1", generation: 1, + annotations: { "kars.azure.com/namespace-uid": "runtime-uid" }, ownerReferences: [owner("KarsTask", "late", "task-uid")] }, + spec: { credentialBindings: bindings }, + status: { phase: "Running", observedGeneration: 1, conditions: [{ type: "Ready", status: "True", observedGeneration: 1 }] } }; + const namespace: any = { kind: "Namespace", metadata: { name: "kars-late", uid: "runtime-uid", resourceVersion: "1", + annotations: { "kars.azure.com/namespace-claim-version": "v1", "kars.azure.com/sandbox-name": "late", + "kars.azure.com/sandbox-namespace": "work", "kars.azure.com/sandbox-uid": "sandbox-uid" } } }; + const input: any = { kind: "Secret", type: "Opaque", metadata: { name: "kars-credential-input-workspace", namespace: "work", + uid: "input-uid", resourceVersion: "1", annotations: { [`${C}purpose`]: "agent-input-v2", [`${C}grant-uid`]: grantUid } }, data }; + const inputs = { grantUid, grantGeneration: f.grant().metadata.generation, + target: { kind: "KarsTask", namespace: "work", name: "late", uid: "task-uid" }, bindings, + sources: [{ name: input.metadata.name, uid: "input-uid", resourceVersion: "1", keys: ["SLACK_BOT_TOKEN"], scope: "workspace" }] }; + const bundle: any = { kind: "Secret", type: "Opaque", metadata: { name: "kars-credential-bundle-karstask-late", namespace: "work", + uid: "bundle-uid", resourceVersion: "1", ownerReferences: [owner("KarsTask", "late", "task-uid")], + annotations: { [`${C}purpose`]: "agent-bundle-v2", [`${C}grant-uid`]: grantUid, [`${C}target-uid`]: "task-uid", [INPUTS]: JSON.stringify(inputs) } }, data }; + const projection: any = { kind: "Secret", type: "Opaque", metadata: { name: "late-credential-projection", namespace: "kars-late", + uid: "projection-uid", resourceVersion: "1", ownerReferences: [owner("Namespace", "kars-late", "runtime-uid")], + annotations: { [`${C}purpose`]: "agent-projection-v1", [`${C}sandbox-uid`]: "sandbox-uid", + [`${C}namespace-uid`]: "runtime-uid", [`${C}projection-uid`]: "projection-uid", [`${C}source-uid`]: "bundle-uid" } }, data }; + const admin: any = { kind: "Secret", type: "Opaque", metadata: { name: "router-services-admin", namespace: "kars-late", + uid: "admin-uid", resourceVersion: "1", labels: { "app.kubernetes.io/managed-by": "kars-controller" }, + annotations: { "kars.azure.com/sandbox-uid": "sandbox-uid", "kars.azure.com/namespace-uid": "runtime-uid" } }, + data: { "control-token": Buffer.from("A".repeat(64)).toString("base64") } }; + const deployment: any = { kind: "Deployment", metadata: { name: "late", namespace: "kars-late", uid: "deployment-uid", + resourceVersion: "1", generation: 1, labels: { "kars.azure.com/sandbox": "late", "kars.azure.com/component": "sandbox" }, + annotations: { [`${C}sandbox-uid`]: "sandbox-uid", [`${C}namespace-uid`]: "runtime-uid", [REVISION]: "1" } }, + spec: { replicas: 1, strategy: { type: "Recreate" }, selector: { matchLabels: { app: "late" } }, + template: { metadata: { annotations: { [VERSION]: "projection-uid:1", "kars.azure.com/services-credential-version": "admin-uid:1" } }, + spec: { automountServiceAccountToken: false, volumes: [{ name: "governed-services-control", + secret: { secretName: "router-services-admin", items: [{ key: "control-token", path: "control-token" }] } }], + containers: [{ name: "inference-router", image: "fixture", volumeMounts: [ + { name: "governed-services-control", mountPath: "/etc/kars/services", readOnly: true }], + env: [{ name: "KARS_SERVICE_IDENTITY_JSON", value: JSON.stringify({ + task: { uid: "task-uid", name: "late", namespace: "work" }, task_authorization: AUTH, task_generation: 1, + }) }] }] } } }, + status: { observedGeneration: 1, updatedReplicas: 1, availableReplicas: 1 } }; + for (const [kind, object, ns] of [ + ["namespace", namespace, ""], ["karstask", task, "work"], ["karssandbox", sandbox, "work"], + ["deployments.apps", deployment, "kars-late"], ["secret", input, "work"], ["secret", bundle, "work"], + ["secret", projection, "kars-late"], ["secret", admin, "kars-late"], + ] as const) f.objects.set(f.key(kind, object.metadata.name, ns), object); + const pod = (uid: string) => { + f.objects.set(f.key("replicasets.apps", "rs", "kars-late"), { kind: "ReplicaSet", + metadata: { name: "rs", uid: "rs-uid", resourceVersion: "1", ownerReferences: [ + { apiVersion: "apps/v1", kind: "Deployment", name: "late", uid: "deployment-uid", controller: true }] }, + spec: { template: structuredClone(deployment.spec.template) } }); + return { kind: "Pod", metadata: { name: uid, uid, resourceVersion: "1", + annotations: structuredClone(deployment.spec.template.metadata.annotations), ownerReferences: [ + { apiVersion: "apps/v1", kind: "ReplicaSet", name: "rs", uid: "rs-uid", controller: true }] }, + spec: structuredClone(deployment.spec.template.spec) }; + }; + f.pods.set("kars-late", [pod("original-pod")]); + const bump = (object: any) => { object.metadata.resourceVersion = String(Number(object.metadata.resourceVersion) + 1); }; + const deploymentStatus = () => { deployment.status = { observedGeneration: deployment.metadata.generation, + updatedReplicas: deployment.spec.replicas, availableReplicas: deployment.spec.replicas }; }; + let retired = false; + let restored = false; + let allowRestore = true; + let restoreAt = 2; + let emptyReads = 0; + let fault: ((stage: string) => void) | undefined; + const restore = () => { + restored = true; + task.status = { phase: "Ready", observedGeneration: 1, envelopeDigest: AUTH, sandboxRef: { name: "late" }, + conditions: [{ type: "Ready", status: "True", observedGeneration: 1, reason: "Reconciled" }] }; + bump(task); + sandbox.status = { phase: "Running", observedGeneration: 1, conditions: [{ type: "Ready", status: "True", observedGeneration: 1 }] }; + bump(sandbox); + bundle.metadata.annotations[INPUTS] = JSON.stringify({ ...inputs, grantGeneration: f.grant().metadata.generation }); + bump(bundle); + projection.data = structuredClone(data); bump(projection); + deployment.spec.replicas = 1; + deployment.spec.template.metadata.annotations[VERSION] = `projection-uid:${projection.metadata.resourceVersion}`; + deployment.metadata.annotations[REVISION] = "2"; + deployment.metadata.generation++; bump(deployment); deploymentStatus(); + f.pods.set("kars-late", [pod("reattested-pod")]); + fault?.("restored"); + }; + const execute: Execute = async (args, inputValue) => { + const result = await f.execute(args, inputValue); + if (allowRestore && retired && !restored && args[0] === "get" && args[1] === "secret" && args[2] === projection.metadata.name) { + if (++emptyReads === restoreAt) restore(); + } + if (args[0] !== "patch") return result; + const patch = JSON.parse(args[args.indexOf("-p") + 1]!); + if (args[1] === RESOURCE && patch.spec.writers.length === 0) { + retired = true; + f.grant().status.phase = "Ready"; + task.status = { phase: "Degraded", observedGeneration: 1, envelopeDigest: null, sandboxRef: { name: "late" }, + conditions: [{ type: "Ready", status: "False", observedGeneration: 1, reason: "CredentialAuthorityUnavailable" }] }; + bump(task); + sandbox.status = { phase: "Degraded", observedGeneration: 1, conditions: [ + { type: "Ready", status: "False", observedGeneration: 1, reason: "CredentialSourceUnavailable" }] }; + bump(sandbox); + deployment.spec.replicas = 0; deployment.metadata.generation++; bump(deployment); deploymentStatus(); + projection.data = {}; bump(projection); + f.pods.set("kars-late", []); + fault?.("retired"); + } + if (args[1] === "karssandbox") { + sandbox.metadata.generation++; + if (patch.spec.suspended === null) delete sandbox.spec.suspended; + if (sandbox.spec.suspended !== true) { + deployment.spec.replicas = 1; deployment.metadata.generation++; bump(deployment); deploymentStatus(); + f.pods.set("kars-late", [pod("qualified-pod")]); + } + } + if (args[1] === "deployments.apps" && patch.spec.replicas === 0) f.pods.set("kars-late", []); + if (args[1] === "namespace" && args[2] === "kars-late" + && namespace.metadata.annotations[`${P}root-retirement`] + && JSON.parse(namespace.metadata.annotations[`${P}root-retirement`]).phase === "Rotating") { + admin.data["control-token"] = Buffer.from("B".repeat(64)).toString("base64"); bump(admin); + admin.metadata.annotations[`${P}epoch`] = namespace.metadata.annotations[`${P}epoch`]; + deployment.spec.template.metadata.annotations[`${P}epoch`] = namespace.metadata.annotations[`${P}epoch`]; + deployment.spec.template.metadata.annotations["kars.azure.com/services-credential-version"] = `admin-uid:${admin.metadata.resourceVersion}`; + deployment.metadata.generation++; bump(deployment); deploymentStatus(); + } + return result; + }; + const document = () => f.document("work", [consumer], execute); + if (originalRuntime) { + await applyReviewedGrant(execute, await document()); + await f.execute(["patch", "deployments.apps", "late", "-n", "kars-late", "--type=merge", "-p", JSON.stringify({ + metadata: { uid: deployment.metadata.uid, resourceVersion: deployment.metadata.resourceVersion }, spec: { replicas: 1 }, + })]); + f.pods.set("kars-late", [pod("original-qualified-pod")]); + await applyReviewedGrant(f.execute, await f.document("second")); + } + const preserved = () => structuredClone({ root: f.objects.get(f.key("namespace", "core")), + otherGrant: f.grant("second"), reader: privateAuthoritySnapshot(f.objects.get(f.key("namespace", "reader"))), + rootDeployment: f.deployment, rootPods: f.pods.get("core"), input, taskSpec: task.spec, sandboxSpec: sandbox.spec }); + return { ...f, execute, document, passiveExecute: f.execute, preserved, task, sandbox, namespace, deployment, bundle, projection, input, admin, + fault: (callback: (stage: string) => void) => { fault = callback; }, restore, wasRestored: () => restored, + neverRestore: () => { allowRestore = false; }, delayRestore: () => { restoreAt = 8; } }; +} + +describe("late runtime authority across selected writer retirement", () => { + beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it("reproduces the rejected null attestation in the original immediate post-retirement validation", async () => { + const f = await setup(); + const review = await f.document(); + const grant = structuredClone(f.grant()); + const guard = await captureGuardRetirement(f.execute, review.spec.privateActivation, grant); + await f.execute(["patch", RESOURCE, "workspace", "-n", "work", "--type=merge", "-p", JSON.stringify({ + metadata: { uid: grant.metadata.uid, resourceVersion: grant.metadata.resourceVersion }, + spec: { ...grant.spec, writers: [] }, + })]); + expect(f.task.status.envelopeDigest).toBeNull(); + await expect(refreshGuardRetirement(f.execute, guard)).rejects.toThrow("Task authorization"); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + }); + + it("updates an active grant whose runtime was qualified in the shared v2 proof, without a local scope receipt", async () => { + const f = await setup(true); + const root = f.objects.get(f.key("namespace", "core")); + const proof = JSON.parse(root.metadata.annotations[`${P}root-retirement`]); + expect(proof.version).toBe(2); + expect(proof.activation.namespaces.some((scope: any) => scope.namespace.uid === f.namespace.metadata.uid)).toBe(true); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + expect(f.deployment.spec.replicas).toBe(1); + expect(f.pods.get("kars-late")).toHaveLength(1); + const review = await f.document(); + const before = structuredClone({ root, namespace: f.namespace, deployment: f.deployment, otherGrant: f.grant("second") }); + expect(await captureWriterSettlement(f.passiveExecute, review.spec.privateActivation, f.grant())).toBeUndefined(); + f.calls.length = 0; + await applyReviewedGrant(f.passiveExecute, review); + expect({ root, namespace: f.namespace, deployment: f.deployment, otherGrant: f.grant("second") }).toEqual(before); + expect(f.calls.filter(args => args[0] === "patch").every(args => args[1] === RESOURCE)).toBe(true); + }); + + it.each(["epoch", "state"])("does not skip an unproven private %s marker during settlement classification", async marker => { + const f = await setup(); + const review = await f.document(); + f.namespace.metadata.annotations[`${P}${marker}`] = marker === "epoch" ? "a".repeat(64) : "Qualified"; + f.calls.length = 0; + await expect(captureWriterSettlement(f.execute, review.spec.privateActivation, f.grant())).rejects.toThrow("unproven private lifecycle"); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it("waits through real withdrawal, owned pause and exact projection refill before private qualification", async () => { + const f = await setup(); + const review = await f.document(); + const original = structuredClone(review); + const before = f.preserved(); + f.delayRestore(); + const waits = vi.spyOn(globalThis, "setTimeout"); + f.calls.length = 0; + await applyReviewedGrant(f.execute, review); + expect(f.wasRestored()).toBe(true); + expect(review).toEqual(original); + expect(f.preserved()).toEqual(before); + expect(f.task.status.envelopeDigest).toBe(AUTH); + expect(f.projection.data).toEqual(data); + expect(f.bundle.data).toEqual(data); + expect(f.admin.data["control-token"]).toBe(Buffer.from("B".repeat(64)).toString("base64")); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); + const recorded = JSON.parse(f.namespace.metadata.annotations[`${P}root-retirement`]); + expect(recorded.captured).toEqual(["reattested-pod"]); + expect(recorded.deployment.uid).toBe("deployment-uid"); + expect(recorded.runtime.task.authorization).toBe(AUTH); + expect(f.calls.filter(args => args[0] === "patch" && args[1] === RESOURCE)).toHaveLength(2); + expect(waits.mock.calls.some(([, delay]) => delay === 500)).toBe(true); + }); + + it.each(["disabled", "keys", "grant-uid", "source", "source-uid", "task-spec", "task-uid", "task-owner", + "task-generation", "sandbox-spec", "sandbox-uid", "sandbox-owner", "template", "private-key", "projection-key", "bundle-anchor", + "additional-private", "projection-uid", "bundle-data", "namespace", "deployment-uid", "deployment-generation"])( + "does not settle changed %s authority", async fault => { + const f = await setup(); + const review = await f.document(); + f.neverRestore(); + f.fault(stage => { + if (stage !== "retired") return; + if (fault === "disabled") f.grant().spec.enabled = false; + if (fault === "keys") f.grant().spec.agentKeys = ["UNREVIEWED_TOKEN"]; + if (fault === "grant-uid") f.grant().metadata.uid = "different"; + if (fault === "source") f.input.data = { SLACK_BOT_TOKEN: "changed" }; + if (fault === "source-uid") f.input.metadata.uid = "different"; + if (fault === "task-spec") f.task.spec.objective = "different"; + if (fault === "task-uid") f.task.metadata.uid = "different"; + if (fault === "task-owner") f.task.metadata.ownerReferences = [{ uid: "different" }]; + if (fault === "task-generation") f.task.metadata.generation++; + if (fault === "sandbox-spec") f.sandbox.spec.suspended = true; + if (fault === "sandbox-uid") f.sandbox.metadata.uid = "different"; + if (fault === "sandbox-owner") f.sandbox.metadata.ownerReferences[0].uid = "different"; + if (fault === "template") f.deployment.spec.template.spec.containers[0].image = "different"; + if (fault === "private-key") f.admin.data["control-token"] = Buffer.from("C".repeat(64)).toString("base64"); + if (fault === "projection-key") f.projection.data = { SLACK_BOT_TOKEN: "different" }; + if (fault === "bundle-anchor") f.task.metadata.annotations[`${C}bundle-uid`] = "different"; + if (fault === "additional-private") f.objects.set(f.key("secret", "router-services-observer", "kars-late"), + { metadata: { name: "router-services-observer", uid: "foreign", resourceVersion: "1" } }); + if (fault === "projection-uid") f.projection.metadata.uid = "different"; + if (fault === "bundle-data") f.bundle.data = { SLACK_BOT_TOKEN: "new-key" }; + if (fault === "namespace") f.namespace.metadata.annotations.unreviewed = "changed"; + if (fault === "deployment-uid") f.deployment.metadata.uid = "different"; + if (fault === "deployment-generation") f.deployment.metadata.generation += 4; + }); + f.calls.length = 0; + await expect(applyReviewedGrant(f.execute, review)).rejects.toThrow(); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + expect(f.calls.filter(args => args[0] === "patch").every(args => args[1] === RESOURCE)).toBe(true); + }); + + it("times out without substituting the captured digest when fresh authority never returns", async () => { + const f = await setup(); + const review = await f.document(); + f.neverRestore(); + f.fault(stage => { + if (stage === "retired") { + const elapsed = Date.now() + 121_000; + vi.spyOn(Date, "now").mockReturnValue(elapsed); + } + }); + await expect(applyReviewedGrant(f.execute, review)).rejects.toThrow("awaiting fresh Task attestation"); + expect(f.task.status.envelopeDigest).toBeNull(); + expect(f.grant().spec.writers).toEqual([]); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + }); + + it("rejects extra template changes even when a fresh current Task re-attests", async () => { + const f = await setup(); + const review = await f.document(); + f.fault(stage => { + if (stage === "restored") f.deployment.spec.template.spec.containers[0].env.push({ name: "UNREVIEWED", value: "changed" }); + }); + await expect(applyReviewedGrant(f.execute, review)).rejects.toThrow(); + expect(f.wasRestored()).toBe(true); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + expect(canonical(f.grant().spec.writers)).toBe("[]"); + }); + + it.each(["stale-task-version", "changed-task-digest", "stale-projection-version", "original-projection-version", "unconsumed-refill"])( + "does not accept %s as authentic restoration", async fault => { + const f = await setup(); + const review = await f.document(); + f.fault(stage => { + if (stage !== "restored") return; + if (fault === "stale-task-version") f.task.metadata.resourceVersion = "1"; + if (fault === "changed-task-digest") f.task.status.envelopeDigest = `sha256:${"b".repeat(64)}`; + if (fault === "stale-projection-version") { + f.projection.metadata.resourceVersion = "2"; + f.deployment.spec.template.metadata.annotations[VERSION] = "projection-uid:2"; + } + if (fault === "original-projection-version") { + f.projection.metadata.resourceVersion = "1"; + f.deployment.spec.template.metadata.annotations[VERSION] = "projection-uid:1"; + f.deployment.metadata.annotations[REVISION] = "1"; + for (const pod of f.pods.get("kars-late")!) pod.metadata.annotations[VERSION] = "projection-uid:1"; + } + if (fault === "unconsumed-refill") { + f.deployment.spec.template.metadata.annotations[VERSION] = "projection-uid:1"; + f.deployment.metadata.annotations[REVISION] = "1"; + const elapsed = Date.now() + 121_000; + vi.spyOn(Date, "now").mockReturnValue(elapsed); + } + }); + const result = applyReviewedGrant(f.execute, review); + if (["stale-projection-version", "original-projection-version"].includes(fault)) { + await expect(result).rejects.toThrow("witnessed fresh revoke/refill"); + } else { + await expect(result).rejects.toThrow(); + } + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + }); +}); diff --git a/cli/src/lib/private-activation-writer-settle.ts b/cli/src/lib/private-activation-writer-settle.ts new file mode 100644 index 000000000..a93d982ae --- /dev/null +++ b/cli/src/lib/private-activation-writer-settle.ts @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + at, canonical, digest, read, readSecretMetadata, record, reviewed, reviewedOwner, template, templateDigest, + type Execute, type Json, type PrivateActivation, +} from "./private-activation.js"; +import { captureLateWriterScope } from "./private-activation-late-scope.js"; +import { reviewPrivateContinuity } from "./private-activation-continuity.js"; +import { replicaIntent } from "./private-activation-retirement.js"; + +const GRANTS = "karscredentialgrants.kars.azure.com"; +const CREDENTIAL = "kars.azure.com/credential-"; +const PROJECTION = `${CREDENTIAL}projection-version`; +const INPUTS = `${CREDENTIAL}input-state`; +const REVISION = "deployment.kubernetes.io/revision"; +const ERROR = "Writer retirement changed the captured runtime authority; preserve quiescence and obtain explicit operator recovery"; +type ObjectValue = ReturnType<typeof record>; +type Captured = NonNullable<Awaited<ReturnType<typeof captureLateWriterScope>>>; +interface RuntimeReview { + captured: Captured; + bundle: ObjectValue; + projection: ObjectValue; + sources: ObjectValue[]; + inputs: ObjectValue; + admin: ObjectValue; + pauseSeen: boolean; + withdrawnVersion?: string; + emptyVersion?: string; + restored?: ObjectValue; +} +export interface WriterSettlement { + grant: ObjectValue; + quiescentSpec: Json; + runtimes: RuntimeReview[]; + deadline: number; +} + +function array(value: unknown): Json[] { + if (!Array.isArray(value)) throw new Error(ERROR); + return value as Json[]; +} +function field(object: unknown, key: string): Json | undefined { + return at(object, "metadata", "annotations", `${CREDENTIAL}${key}`); +} +function gen(object: unknown): number { + const value = at(object, "metadata", "generation"); + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new Error(ERROR); + return value; +} +function sameBody(a: Json, b: Json, status = false, generation = false): boolean { + const normalized = (value: Json) => { + const result = structuredClone(record(value)); + const metadata = record(result.metadata); + delete metadata.resourceVersion; + if (generation) delete metadata.generation; + if (status) delete result.status; + return canonical(result); + }; + return normalized(a) === normalized(b); +} +function unchangedSecretMetadata(current: ObjectValue, before: ObjectValue, inputs = false): boolean { + const comparable = (value: ObjectValue) => { + const copy = structuredClone(value); + delete copy.data; + if (inputs) delete record(at(copy, "metadata", "annotations"))[INPUTS]; + return copy; + }; + return current.type === "Opaque" && sameBody(comparable(current), comparable(before)); +} +function data(secret: ObjectValue): ObjectValue { return record(secret.data ?? {}); } +function readyTask(task: ObjectValue, original: Json): boolean { + const condition = array(at(task, "status", "conditions") ?? []); + return at(task, "status", "phase") === "Ready" + && at(task, "status", "observedGeneration") === gen(original) + && at(task, "status", "envelopeDigest") === at(original, "status", "envelopeDigest") + && condition.some(value => at(value, "type") === "Ready" && at(value, "status") === "True"); +} +function withdrawn(task: ObjectValue, original: Json): boolean { + return at(task, "status", "phase") === "Degraded" + && at(task, "status", "observedGeneration") === gen(original) + && at(task, "status", "envelopeDigest") == null + && array(at(task, "status", "conditions") ?? []).some(value => + at(value, "type") === "Ready" && at(value, "status") === "False" + && at(value, "reason") === "CredentialAuthorityUnavailable"); +} +function bodySpec(deployment: ObjectValue, replicas: number, revision: string): ObjectValue { + const result = structuredClone(deployment); + record(result.spec).replicas = replicas; + record(at(template(result), "metadata", "annotations"))[PROJECTION] = revision; + if (revision !== at(template(deployment), "metadata", "annotations", PROJECTION)) { + const original = at(deployment, "metadata", "annotations", REVISION); + if (typeof original !== "string" || !/^[1-9][0-9]*$/.test(original) + || !Number.isSafeInteger(Number(original) + 1)) throw new Error(ERROR); + record(at(result, "metadata", "annotations"))[REVISION] = String(Number(original) + 1); + } + return result; +} +function possibleTransition(current: ObjectValue, runtime: RuntimeReview): boolean { + const before = runtime.captured.deployment; + const revision = at(template(current), "metadata", "annotations", PROJECTION); + if (typeof revision !== "string" || !revision.startsWith(`${reviewed(runtime.projection).uid}:`) + || revision.endsWith(":") || ![0, replicaIntent(before)].includes(replicaIntent(current)) + || gen(current) < gen(before) || gen(current) > gen(before) + 2) return false; + const expected = bodySpec(before, replicaIntent(current), revision); + if (sameBody(current, expected, true, true)) return true; + record(at(expected, "metadata", "annotations"))[REVISION] = at(before, "metadata", "annotations", REVISION)!; + return at(current, "status", "observedGeneration") !== gen(current) && sameBody(current, expected, true, true); +} + +export async function captureWriterSettlement( + execute: Execute, activation: PrivateActivation, grant: unknown, +): Promise<WriterSettlement | undefined> { + const original = structuredClone(record(grant)); + const grantId = reviewed(original); + const runtimes: RuntimeReview[] = []; + const continuity = await reviewPrivateContinuity(execute, activation); + const lateScopes = new Set(continuity?.lateScopes ?? []); + for (const scope of activation.namespaces) { + if (!lateScopes.has(scope.namespace.uid)) continue; + const captured = await captureLateWriterScope(execute, activation, scope); + if (!captured) continue; + const bindings = record(at(captured.task, "spec", "blueprint", "credentialBindings")); + if (at(bindings, "grant", "uid") !== grantId.uid) continue; + const task = reviewed(captured.task); + const sandbox = reviewed(captured.sandbox); + const workspace = String(at(captured.task, "metadata", "namespace")); + if (task.name !== sandbox.name || at(bindings, "grant", "name") !== "workspace" + || workspace !== at(original, "metadata", "namespace") + || canonical(at(captured.sandbox, "spec", "credentialBindings")) !== canonical(bindings) + || at(captured.deployment, "spec", "strategy", "type") !== "Recreate" + || array(at(original, "spec", "writers")).some(writer => at(writer, "namespace") === scope.namespace.name)) { + throw new Error("Writer settling requires the exact declared v2 Task-owned runtime and its Recreate policy"); + } + const bundle = await read(execute, "secret", `kars-credential-bundle-karstask-${task.name}`, workspace); + const projection = await read(execute, "secret", `${sandbox.name}-credential-projection`, scope.namespace.name); + const owner = [{ apiVersion: "kars.azure.com/v1alpha1", kind: "KarsTask", name: task.name, uid: task.uid, controller: true, blockOwnerDeletion: false }]; + if (field(captured.task, "bundle-uid") !== reviewed(bundle).uid + || field(bundle, "purpose") !== "agent-bundle-v2" + || field(bundle, "grant-uid") !== grantId.uid || field(bundle, "target-uid") !== task.uid + || canonical(at(bundle, "metadata", "ownerReferences")) !== canonical(owner) + || field(projection, "purpose") !== "agent-projection-v1" + || field(projection, "sandbox-uid") !== sandbox.uid || field(projection, "namespace-uid") !== scope.namespace.uid + || field(projection, "projection-uid") !== reviewed(projection).uid + || field(projection, "source-uid") !== reviewed(bundle).uid + || canonical(at(projection, "metadata", "ownerReferences")) !== canonical([ + { apiVersion: "v1", kind: "Namespace", name: scope.namespace.name, uid: scope.namespace.uid, controller: true, blockOwnerDeletion: false }, + ]) || bundle.type !== "Opaque" || projection.type !== "Opaque" + || canonical(data(bundle)) !== canonical(data(projection)) + || at(template(captured.deployment), "metadata", "annotations", PROJECTION) !== `${reviewed(projection).uid}:${reviewed(projection).resourceVersion}`) throw new Error(ERROR); + const inputs = record(JSON.parse(String(field(bundle, "input-state")))); + if (inputs.grantUid !== grantId.uid || inputs.grantGeneration !== gen(original) + || canonical(inputs.bindings) !== canonical(bindings) + || canonical(inputs.target) !== canonical({ kind: "KarsTask", namespace: workspace, name: task.name, uid: task.uid })) throw new Error(ERROR); + const sources: ObjectValue[] = []; + const values: ObjectValue = {}; + const selections = array(bindings.sources); + if (selections.length > 16 || array(inputs.sources).length !== selections.length) throw new Error(ERROR); + for (const [index, selection] of selections.entries()) { + const source = await read(execute, "secret", String(at(selection, "source", "name")), workspace); + const id = reviewed(source); + const input = array(inputs.sources)[index]; + if (id.uid !== at(selection, "source", "uid") || id.uid !== at(input, "uid") + || id.name !== at(input, "name") || id.resourceVersion !== at(input, "resourceVersion") + || source.type !== "Opaque" || field(source, "purpose") !== "agent-input-v2" + || field(source, "grant-uid") !== grantId.uid + || canonical(at(selection, "keys")) !== canonical(at(input, "keys")) + || at(selection, "scope") !== at(input, "scope")) throw new Error(ERROR); + for (const key of array(at(selection, "keys"))) { + if (typeof key !== "string") throw new Error(ERROR); + if (data(source)[key] === undefined) delete values[key]; else values[key] = data(source)[key]!; + } + sources.push(source); + } + if (canonical(values) !== canonical(data(bundle))) throw new Error(ERROR); + const admin = await read(execute, "secret", "router-services-admin", scope.namespace.name); + if (reviewed(admin).uid !== captured.admin.object.uid || reviewed(admin).resourceVersion !== captured.admin.object.resourceVersion + || digest(String(data(admin)["control-token"])) !== captured.admin.key) throw new Error(ERROR); + runtimes.push({ captured, bundle, projection, sources, inputs, admin, pauseSeen: replicaIntent(captured.deployment) === 0 }); + } + return runtimes.length ? { grant: original, quiescentSpec: { ...record(original.spec), writers: [] }, + runtimes, deadline: Date.now() + 120_000 } : undefined; +} + +/** Observations never publish attestation, restore replicas, or replace Secrets. */ +export async function observeWriterSettlement( + execute: Execute, activation: PrivateActivation, review: WriterSettlement, +): Promise<boolean> { + const workspace = String(at(review.grant, "metadata", "namespace")); + const grant = await read(execute, GRANTS, "workspace", workspace); + if (reviewed(grant).uid !== reviewed(review.grant).uid || gen(grant) !== gen(review.grant) + 1 + || canonical(grant.spec) !== canonical(review.quiescentSpec)) throw new Error(ERROR); + const names = new Set(review.runtimes.map(value => value.captured.scope.namespace.name)); + const rootReview = structuredClone(activation); + rootReview.namespaces = rootReview.namespaces.filter(scope => !names.has(scope.namespace.name)); + if (!await reviewPrivateContinuity(execute, rootReview)) throw new Error(ERROR); + const grantReady = at(grant, "status", "phase") === "Ready" && at(grant, "status", "observedGeneration") === gen(grant); + let allReady = grantReady; + for (const runtime of review.runtimes) { + const before = runtime.captured; + const ns = before.scope.namespace.name; + const task = await read(execute, "karstask", reviewed(before.task).name, workspace); + const sandbox = await read(execute, "karssandbox", reviewed(before.sandbox).name, workspace); + const namespace = await read(execute, "namespace", ns); + const deployment = await read(execute, "deployments.apps", reviewed(before.deployment).name, ns); + if (!sameBody(task, before.task, true) || !sameBody(sandbox, before.sandbox, true) + || canonical(namespace) !== canonical(before.namespace)) throw new Error(ERROR); + const taskReady = readyTask(task, before.task); + if (!taskReady) { + if (!withdrawn(task, before.task)) throw new Error("Task lost authority for an unreviewed reason during writer retirement"); + runtime.withdrawnVersion = reviewed(task).resourceVersion; + } else if (runtime.withdrawnVersion && [runtime.withdrawnVersion, reviewed(before.task).resourceVersion] + .includes(reviewed(task).resourceVersion)) throw new Error("Stale Task attestation cannot settle writer retirement"); + for (const source of runtime.sources) { + if (canonical(await read(execute, "secret", reviewed(source).name, workspace)) !== canonical(source)) throw new Error("Captured credential source/key changed during writer retirement"); + } + if (canonical(await read(execute, "secret", "router-services-admin", ns)) !== canonical(runtime.admin)) throw new Error("Unreviewed private material changed during writer retirement"); + for (const name of ["router-services-observer", "router-services-observer-identity", "router-github-app", "kars-observation-privacy-tls", "sre-api-router-identity"]) { + if (await readSecretMetadata(execute, name, ns, true) !== undefined) throw new Error("Additional private material appeared during writer retirement"); + } + const bundle = await read(execute, "secret", reviewed(runtime.bundle).name, workspace); + const projection = await read(execute, "secret", reviewed(runtime.projection).name, ns); + if (!unchangedSecretMetadata(bundle, runtime.bundle, true) || canonical(data(bundle)) !== canonical(data(runtime.bundle)) + || !unchangedSecretMetadata(projection, runtime.projection)) throw new Error(ERROR); + const inputs = record(JSON.parse(String(field(bundle, "input-state")))); + const expectedInputs = { ...runtime.inputs, grantGeneration: gen(grant) }; + if (canonical(inputs) !== canonical(runtime.inputs) && canonical(inputs) !== canonical(expectedInputs)) throw new Error(ERROR); + const oldRevision = `${reviewed(runtime.projection).uid}:${reviewed(runtime.projection).resourceVersion}`; + const revision = `${reviewed(projection).uid}:${reviewed(projection).resourceVersion}`; + const projectionSame = canonical(data(projection)) === canonical(data(runtime.projection)); + const replicas = replicaIntent(deployment); + const initialReplicas = replicaIntent(before.deployment); + if (replicas !== 0 && replicas !== initialReplicas) throw new Error(ERROR); + const paused = bodySpec(before.deployment, 0, oldRevision); + const unchanged = sameBody(deployment, before.deployment, true, true); + const isPause = sameBody(deployment, paused, true, true) && replicas === 0; + if (isPause) { + if (gen(deployment) !== gen(before.deployment) + initialReplicas) throw new Error(ERROR); + runtime.pauseSeen = true; + } + if (!projectionSame) { + if (!isPause || !runtime.withdrawnVersion || Object.keys(data(projection)).length) throw new Error("Projection changed without the captured authority withdrawal and owned pause"); + runtime.emptyVersion = reviewed(projection).resourceVersion; + } + const restored = bodySpec(before.deployment, initialReplicas, revision); + const restoredShape = sameBody(deployment, restored, true, true); + const changedRevision = revision !== oldRevision; + const awaitingRevision = structuredClone(restored); + record(at(awaitingRevision, "metadata", "annotations"))[REVISION] = at(before.deployment, "metadata", "annotations", REVISION)!; + const restoringShape = restoredShape || (at(deployment, "status", "observedGeneration") !== gen(deployment) + && sameBody(deployment, awaitingRevision, true, true)); + if (projectionSame && ((changedRevision && !runtime.emptyVersion) + || (runtime.emptyVersion && [runtime.emptyVersion, reviewed(runtime.projection).resourceVersion] + .includes(reviewed(projection).resourceVersion)))) { + throw new Error("Projection revision advanced without witnessed fresh revoke/refill; exact review preserved"); + } + const restoredGeneration = gen(before.deployment) + (initialReplicas ? 2 : Number(changedRevision)); + if (!unchanged && !isPause && (!restoringShape || !runtime.pauseSeen || gen(deployment) !== restoredGeneration)) { + throw new Error("Unreviewed template or controller pause/restore generation changed"); + } + if (unchanged && gen(deployment) !== gen(before.deployment) && (!runtime.pauseSeen || gen(deployment) !== restoredGeneration)) throw new Error(ERROR); + const projectionAfter = await readSecretMetadata(execute, reviewed(projection).name, ns); + const deploymentAfter = await read(execute, "deployments.apps", reviewed(deployment).name, ns); + if (!projectionAfter || !unchangedSecretMetadata({ metadata: projectionAfter, type: "Opaque" }, + { metadata: runtime.projection.metadata!, type: "Opaque" }) || !possibleTransition(deploymentAfter, runtime)) throw new Error(ERROR); + if (reviewed({ metadata: projectionAfter }).resourceVersion !== reviewed(projection).resourceVersion + || reviewed(deploymentAfter).resourceVersion !== reviewed(deployment).resourceVersion) { + allReady = false; + continue; + } + const list = record(JSON.parse(await execute(["get", "pods", "-n", ns, "--chunk-size=0", "-o", "json"]))); + if (at(list, "metadata", "continue")) throw new Error(ERROR); + const pods = array(list.items); + const liveScope = { ...before.scope, consumers: [{ ...before.scope.consumers[0]!, templateDigest: templateDigest(deployment) }] }; + for (const pod of pods) if (!await reviewedOwner(execute, pod, liveScope)) throw new Error("Unreviewed consumer appeared during writer retirement"); + const oldGone = pods.every(pod => !before.pods.some(old => reviewed(old, true).uid === reviewed(pod, true).uid)); + const sandboxReady = at(sandbox, "status", "phase") === "Running" + && at(sandbox, "status", "observedGeneration") === gen(before.sandbox) + && array(at(sandbox, "status", "conditions") ?? []).some(condition => + at(condition, "type") === "Ready" && at(condition, "status") === "True" + && at(condition, "observedGeneration") === gen(before.sandbox)); + const deploymentReady = replicas === initialReplicas && at(deployment, "status", "observedGeneration") === gen(deployment) + && (!initialReplicas || (at(deployment, "status", "availableReplicas") === initialReplicas + && at(deployment, "status", "updatedReplicas") === initialReplicas)); + const consumed = canonical(inputs) === canonical(expectedInputs); + const changedDeployment = reviewed(deployment).resourceVersion !== reviewed(before.deployment).resourceVersion; + if (grantReady && taskReady && sandboxReady && deploymentReady && consumed && changedDeployment && !runtime.pauseSeen) { + throw new Error("Consumer revision advanced without a witnessed owned pause; exact review preserved"); + } + const complete = grantReady && taskReady && sandboxReady && deploymentReady && projectionSame && consumed + && restoredShape && (!runtime.withdrawnVersion || reviewed(task).resourceVersion !== reviewed(before.task).resourceVersion) + && (!changedDeployment || (runtime.pauseSeen && oldGone)); + runtime.restored = complete ? deployment : undefined; + allReady &&= complete; + } + return allReady; +} + +export async function settleWriterRetirement( + execute: Execute, activation: PrivateActivation, review: WriterSettlement, +): Promise<PrivateActivation> { + for (;;) { + const ready = await observeWriterSettlement(execute, activation, review); + if (ready) { + const roles = record(JSON.parse(await execute(["get", "roles,rolebindings,clusterroles,clusterrolebindings", + "--all-namespaces", "--chunk-size=0", "-o", "json"]))); + if (at(roles, "metadata", "continue") || array(roles.items).some(value => + at(value, "metadata", "annotations", "kars.azure.com/credential-grant-owner") === reviewed(review.grant).uid)) throw new Error(ERROR); + const settled = structuredClone(activation); + for (const runtime of review.runtimes) { + const scope = settled.namespaces.find(value => value.namespace.uid === runtime.captured.scope.namespace.uid); + if (!scope || !runtime.restored) throw new Error(ERROR); + scope.consumers[0]!.object = reviewed(runtime.restored); + scope.consumers[0]!.templateDigest = templateDigest(runtime.restored); + } + return settled; + } + if (Date.now() >= review.deadline) throw new Error("Writer retirement is still awaiting fresh Task attestation and the captured owned runtime; no stale authority or new activation was published"); + await new Promise(resolve => setTimeout(resolve, 500)); + } +} diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index eb8cab150..ecd74fe21 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -250,7 +250,21 @@ Unrelated non-consuming Pods are preserved. Apply rechecks the complete enforcing policy/binding specifications and their current type-check/observation status. When updating a grant, that grant's existing writer authority is retired first, including absence checks for its owned read -Roles/Bindings; another workspace's grant is not reset. For first qualification, +Roles/Bindings; another workspace's grant is not reset. + +For a verified late-enrollment v2 Task runtime, retirement may temporarily +withdraw Task authorization while the controller observes the new grant +generation. Apply waits up to 120 seconds for genuine re-attestation under the +exact quiescent grant. Task/Sandbox identity and intent, source data, private +material and executable templates remain pinned. An observed projection +revocation requires a fresh refill revision distinct from both the original +and empty revisions, consumed by the owned Deployment. Only those proven +controller metadata transitions can advance; this is not a new user review, +stale-digest reuse or an arbitrary revision refresh. Already-qualified scopes +retain their independently verified path. Missing witnesses or other drift +preserve retirement and require explicit recovery; no new authority is published. + +For first qualification, namespace protection is then enabled in `Pending`, identities/templates are rechecked, and only approved authority-consuming controller replicas are paused. This includes private material, privileged ServiceAccount automount/projected tokens, From b1c9fe052ee332a21eefeb23d080cc6029d5e507 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 09:49:17 +0200 Subject: [PATCH 068/111] Validate unpersisted CRD previews without inventing storage revisions Keep live/update/publication identity and CAS checks strict, and retain bounded child-step diagnostics plus native preview non-persistence proof. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/lib/core-helm-schemas.ts | 8 ++ cli/src/lib/schema-documents.ts | 16 +++ cli/src/lib/schema-preview-identity.test.ts | 53 +++++++++ cli/src/lib/schema-stage.ts | 18 ++- cli/src/lib/sre-migration-data.ts | 21 +++- cli/src/lib/sre-migration-wire.test.ts | 50 ++++++++ cli/src/lib/sre-migration.test-support.ts | 2 +- cli/src/lib/sre-schema-diagnostics.test.ts | 59 ++++++++++ cli/src/lib/sre-schema-diagnostics.ts | 110 ++++++++++++++++++ cli/src/lib/sre-schema-migration.test.ts | 21 ++++ cli/src/lib/sre-schema-migration.ts | 13 +++ cli/src/lib/sre-stage.test.ts | 12 +- cli/src/lib/sre-stage.ts | 6 +- docs/how-to/helm-installation.md | 6 + tests/e2e/sre_authority/common.py | 5 + tests/e2e/sre_authority/harness_test.py | 41 +++++++ tests/e2e/sre_authority/legacy_crd_probe.py | 31 ++++- tests/e2e/sre_authority/legacy_crds_test.py | 29 +++++ .../schema_preparation_diagnostics.py | 69 +++++++++++ 19 files changed, 557 insertions(+), 13 deletions(-) create mode 100644 cli/src/lib/schema-preview-identity.test.ts create mode 100644 cli/src/lib/sre-migration-wire.test.ts create mode 100644 cli/src/lib/sre-schema-diagnostics.test.ts create mode 100644 cli/src/lib/sre-schema-diagnostics.ts create mode 100644 tests/e2e/sre_authority/schema_preparation_diagnostics.py diff --git a/cli/src/lib/core-helm-schemas.ts b/cli/src/lib/core-helm-schemas.ts index 9dbdf3a29..fd95c04dd 100644 --- a/cli/src/lib/core-helm-schemas.ts +++ b/cli/src/lib/core-helm-schemas.ts @@ -8,6 +8,7 @@ import { canonicalSchema, normalizedCrd } from "./schema-documents.js"; import { assertNoCrdRemoval, assertRollbackCompatibility } from "./schema-compatibility.js"; import { enabledHelmFlag, prepareHelmFailureSafety, serverSchemaRenderFlags } from "./schema-helm-safety.js"; import { qualifySreSchemaMigration, sreMigrationSummary } from "./sre-schema-migration.js"; +import { schemaStep } from "./sre-schema-diagnostics.js"; export interface CoreSchemaPreparation extends Partial<SchemaStageOptions> { base365SreMigration?: boolean } @@ -88,13 +89,18 @@ export async function planCoreHelmSchemas( if (options.base365SreMigration && ["--atomic", "--rollback-on-failure"].some(flag => enabledHelmFlag(args, flag))) { throw new Error("The reviewed BASE365 SRE schema migration is explicitly non-atomic; no rollback flag may be dropped"); } + schemaStep("helm-render"); const { run, documents, release, namespace, upgrading, serverRender } = await renderCoreSchemaChart(execute, args); + schemaStep("helm-rollback-review"); const safety = await prepareHelmFailureSafety(run, args, documents, release, namespace, upgrading); + schemaStep("schema-qualification"); const reviewedSreMigration = options.base365SreMigration ? await qualifySreSchemaMigration(run, documents, { release, namespace, ownership: "helm" }) : undefined; const stageOptions = { ...options, release, namespace, ownership: options.ownership ?? "helm", rollbackDocuments: safety.rollbackDocuments, beforeWrite: safety.recheck, reviewedSreMigration }; + schemaStep("schema-plan"); const applySchemas = await planCoreSchemaDocuments(run, documents, stageOptions); + schemaStep("helm-history-recheck"); await safety.recheck?.(); if (reviewedSreMigration) console.log(`SRE-SCHEMA-MIGRATION ${JSON.stringify({ ...sreMigrationSummary(reviewedSreMigration), state: "qualified" })}`); return async () => { @@ -102,6 +108,7 @@ export async function planCoreHelmSchemas( // Render/apply is not a Helm install: Helm's server-side ownership import // check would reject the deliberately template-owned CRDs. if (stageOptions.ownership === "template") return prepared; + schemaStep("helm-render"); const actual = await serverRender(); const crds = (items: ObjectMap[]) => items.filter(object => object.kind === "CustomResourceDefinition") .map(object => ({ name: object.metadata.name, spec: normalizedCrd(object), metadata: object.metadata })) @@ -109,6 +116,7 @@ export async function planCoreHelmSchemas( if (canonicalSchema(crds(actual)) !== canonicalSchema(crds(documents))) { throw new Error("Server-aware chart CRDs differ from the bootstrap schema plan; explicit review is required"); } + schemaStep("helm-history-recheck"); await safety.recheck?.(); const result = await stageCoreSchemaDocuments(run, actual, { ...stageOptions, checkOnly: true }); if (reviewedSreMigration) console.log(`SRE-SCHEMA-MIGRATION ${JSON.stringify({ ...sreMigrationSummary(reviewedSreMigration), state: "applied" })}`); diff --git a/cli/src/lib/schema-documents.ts b/cli/src/lib/schema-documents.ts index 56a647dd3..ad72fefee 100644 --- a/cli/src/lib/schema-documents.ts +++ b/cli/src/lib/schema-documents.ts @@ -89,6 +89,22 @@ export function schemaOwnerFields(owner: SchemaOwner): { labels: ObjectMap; anno export function verifySchemaOwner(object: ObjectMap, owner: SchemaOwner): void { schemaIdentity(object); + verifyOwnerMetadata(object, owner); +} + +/** Kubernetes dry-run CREATE has an ephemeral UID but no storage revision. + * Never use this check for reads, updates, publication or a real create result. */ +export function verifyNewSchemaPreviewOwner(object: ObjectMap, owner: SchemaOwner): void { + normalizedCrd(object); + const metadata = object.metadata; + if (typeof metadata.uid !== "string" || !metadata.uid || metadata.namespace || metadata.deletionTimestamp + || (metadata.resourceVersion !== undefined && metadata.resourceVersion !== "")) { + throw new Error("New CRD preview must have an ephemeral UID and no persisted resourceVersion"); + } + verifyOwnerMetadata(object, owner); +} + +function verifyOwnerMetadata(object: ObjectMap, owner: SchemaOwner): void { const annotations = object.metadata.annotations ?? {}; const manager = object.metadata.labels?.["app.kubernetes.io/managed-by"]; const helm = owner.ownership === "helm" && manager === "Helm" diff --git a/cli/src/lib/schema-preview-identity.test.ts b/cli/src/lib/schema-preview-identity.test.ts new file mode 100644 index 000000000..b243ee0ed --- /dev/null +++ b/cli/src/lib/schema-preview-identity.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { schemaIdentity, schemaOwnerFields, verifyNewSchemaPreviewOwner, verifySchemaOwner } from "./schema-documents.js"; +import { crd } from "./schema-stage.test-support.js"; + +const owner = { release: "kars", namespace: "kars-system", ownership: "helm" as const }; +function preview() { + const object = crd("KarsBudgetAccount", "karsbudgetaccounts"); + object.metadata = { ...object.metadata, ...schemaOwnerFields(owner), uid: "ephemeral-preview-uid" }; + return object; +} + +describe("new CRD server-preview identity", () => { + it.each([undefined, ""])("accepts only non-persisted preview RV=%s without inventing a revision", version => { + const object = preview(); + if (version !== undefined) object.metadata.resourceVersion = version; + const before = structuredClone(object); + expect(() => verifyNewSchemaPreviewOwner(object, owner)).not.toThrow(); + expect(object).toEqual(before); + expect(() => schemaIdentity(object)).toThrow("live UID/resourceVersion"); + expect(() => verifySchemaOwner(object, owner)).toThrow("live UID/resourceVersion"); + }); + + it.each(["27", "0", null, 27])("refuses a persisted/invalid preview revision %s", value => { + const object = preview(); + object.metadata.resourceVersion = value; + expect(() => verifyNewSchemaPreviewOwner(object, owner)).toThrow("no persisted resourceVersion"); + }); + + it.each([ + (object: ReturnType<typeof preview>) => { delete object.metadata.uid; }, + (object: ReturnType<typeof preview>) => { object.metadata.uid = ""; }, + (object: ReturnType<typeof preview>) => { object.metadata.namespace = "other"; }, + (object: ReturnType<typeof preview>) => { object.metadata.deletionTimestamp = "2026-09-12T00:00:00Z"; }, + (object: ReturnType<typeof preview>) => { object.metadata.ownerReferences = [{ uid: "foreign" }]; }, + (object: ReturnType<typeof preview>) => { object.metadata.annotations["meta.helm.sh/release-name"] = "foreign"; }, + (object: ReturnType<typeof preview>) => { object.metadata.name = "other.kars.azure.com"; }, + ])("keeps exact schema/ownership checks on previews: %#", change => { + const object = preview(); + change(object); + expect(() => verifyNewSchemaPreviewOwner(object, owner)).toThrow(); + }); + + it("still requires a real revision for reads, updates and publication", () => { + const object = preview(); + object.metadata.resourceVersion = "27"; + expect(() => verifySchemaOwner(object, owner)).not.toThrow(); + expect(schemaIdentity(object)).toEqual({ uid: "ephemeral-preview-uid", resourceVersion: "27" }); + expect(() => verifyNewSchemaPreviewOwner(object, owner)).toThrow(); + }); +}); diff --git a/cli/src/lib/schema-stage.ts b/cli/src/lib/schema-stage.ts index b22904a77..7a7961dcb 100644 --- a/cli/src/lib/schema-stage.ts +++ b/cli/src/lib/schema-stage.ts @@ -3,13 +3,14 @@ import { canonicalSchema, normalizedCrd, readSchemaObject, SCHEMA_DIGEST, schemaDigest, schemaDocuments, - schemaIdentity, schemaOwnerFields, verifySchemaOwner, type ObjectMap, type SchemaExecute, type SchemaOwner, + schemaIdentity, schemaOwnerFields, verifyNewSchemaPreviewOwner, verifySchemaOwner, type ObjectMap, type SchemaExecute, type SchemaOwner, } from "./schema-documents.js"; import { waitForPublishedSchemas, type PublishedType, type SchemaWait } from "./schema-discovery.js"; import { assertRollbackCompatibility, assertSchemaCompatibility, requireCrdRetention } from "./schema-compatibility.js"; import { authorizesSreSchemaMigration, completeSreSchemaMigration, recheckSreSchemaMigration, recordSreSchemaWrite, type QualifiedSreMigration, } from "./sre-schema-migration.js"; +import { schemaStep } from "./sre-schema-diagnostics.js"; interface PlannedSchema { desired: ObjectMap; current?: ObjectMap; uid?: string; change: boolean } export interface SchemaStageOptions extends SchemaOwner, SchemaWait { @@ -92,6 +93,7 @@ export async function waitForInstalledCoreSchemas( export async function planCoreSchemaDocuments( execute: SchemaExecute, documents: ObjectMap[], options: SchemaStageOptions, ): Promise<() => Promise<{ schemas: number; published: true }>> { + schemaStep("schema-plan"); validateOwner(options); documents = structuredClone(documents); if (options.reviewedSreMigration && options.rollbackDocuments) throw new Error("Reviewed SRE schema migration cannot use automatic rollback"); @@ -115,11 +117,14 @@ export async function planCoreSchemaDocuments( throw new Error(`Policy ${policy.metadata.name} parameter schema is absent from the exact chart`); } } + schemaStep("policy-review"); await policySafety(execute, documents); const plans: PlannedSchema[] = []; let priorManifest: ObjectMap[] | undefined; for (const desired of crds) { + schemaStep("schema-plan-identity", desired.spec.names.kind); const current = await readSchemaObject(execute, "customresourcedefinition", desired.metadata.name); + schemaStep("schema-plan-identity", desired.spec.names.kind, current); if (options.reviewedSreMigration && !options.checkOnly && !authorizesSreSchemaMigration(options.reviewedSreMigration, current, desired)) { throw new Error("Schema plan differs from its qualified SRE migration snapshot"); @@ -137,6 +142,7 @@ export async function planCoreSchemaDocuments( if (options.checkOnly) throw new Error(`Schema ${desired.metadata.name} differs from the chart`); const recorded = current.metadata.annotations?.[SCHEMA_DIGEST] === schemaDigest(actual); if (!recorded) { + schemaStep("helm-schema-match", desired.spec.names.kind); if (owner.ownership !== "helm") throw new Error(`Customized or unrecorded schema ${desired.metadata.name}; no overwrite is permitted`); priorManifest ??= schemaDocuments((await execute("helm", ["get", "manifest", owner.release, "-n", owner.namespace], { stdio: "pipe" })).stdout); @@ -173,25 +179,31 @@ export async function planCoreSchemaDocuments( await recheckSreSchemaMigration(options.reviewedSreMigration); for (const plan of plans.filter(plan => plan.change)) { const { object, args } = writeRequest(plan); + schemaStep("schema-server-preview", plan.desired.spec.names.kind); const checked: ObjectMap = JSON.parse((await execute("kubectl", [...args, "--dry-run=server", "--request-timeout=20s"], { stdio: "pipe", input: JSON.stringify(object), timeout: 25_000 })).stdout); + schemaStep("schema-preview-identity", plan.desired.spec.names.kind, checked); if ((plan.uid && schemaIdentity(checked).uid !== plan.uid) || canonicalSchema(normalizedCrd(checked)) !== canonicalSchema(normalizedCrd(plan.desired))) { throw new Error("Migration schema dry-run returned another identity or schema"); } - verifySchemaOwner(checked, owner); + if (plan.current) verifySchemaOwner(checked, owner); + else verifyNewSchemaPreviewOwner(checked, owner); } await recheckSreSchemaMigration(options.reviewedSreMigration); } // Every plan and server dry-run completes before any real schema/action write. return async () => { + schemaStep("schema-plan"); await options.beforeWrite?.(); if (options.reviewedSreMigration) await recheckSreSchemaMigration(options.reviewedSreMigration); for (const plan of plans.filter(plan => plan.change)) { if (options.reviewedSreMigration) await recheckSreSchemaMigration(options.reviewedSreMigration, plan.desired.metadata.name); const { object, args } = writeRequest(plan); + schemaStep("schema-write", plan.desired.spec.names.kind); const applied: ObjectMap = JSON.parse((await execute("kubectl", [...args, "--request-timeout=20s"], { stdio: "pipe", input: JSON.stringify(object), timeout: 25_000 })).stdout); + schemaStep("schema-plan-identity", plan.desired.spec.names.kind, applied); const identity = schemaIdentity(applied); if ((plan.uid && plan.uid !== identity.uid) || applied.metadata.name !== plan.desired.metadata.name || canonicalSchema(normalizedCrd(applied)) !== canonicalSchema(normalizedCrd(plan.desired))) throw new Error("Schema write returned an unreviewed identity/spec"); @@ -202,6 +214,7 @@ export async function planCoreSchemaDocuments( await recheckSreSchemaMigration(options.reviewedSreMigration, plan.desired.metadata.name); } } + schemaStep("schema-publication"); await waitForPublishedSchemas(execute, types, async () => { let established = true; for (const plan of plans) { @@ -216,6 +229,7 @@ export async function planCoreSchemaDocuments( } return established; }, options); + schemaStep("policy-review"); await existingPoliciesObserved(execute, documents, options); if (options.reviewedSreMigration) await completeSreSchemaMigration(options.reviewedSreMigration); return { schemas: crds.length, published: true }; diff --git a/cli/src/lib/sre-migration-data.ts b/cli/src/lib/sre-migration-data.ts index da81498e4..28ac76f6e 100644 --- a/cli/src/lib/sre-migration-data.ts +++ b/cli/src/lib/sre-migration-data.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { canonicalSchema, schemaDigest, schemaIdentity, type ObjectMap, type SchemaExecute } from "./schema-documents.js"; +import { schemaField, schemaStep } from "./sre-schema-diagnostics.js"; export interface MigrationDataSnapshot { crd: ObjectMap; @@ -21,6 +22,7 @@ function addedFieldsAbsent(before: ObjectMap, after: ObjectMap, data: unknown, p for (const [name, next] of Object.entries(after.properties ?? {})) { if (!Object.hasOwn(object, name)) continue; if (!Object.hasOwn(before.properties ?? {}, name)) { + schemaField(`${path}/${name}`.split("/").slice(1).join("/")); throw new Error(`Existing data contains a post-BASE365 field at ${path}/${name}; no authority is grandfathered`); } addedFieldsAbsent(before.properties[name], next as ObjectMap, object[name], `${path}/${name}`); @@ -42,8 +44,10 @@ function endpoint(crd: ObjectMap, object?: ObjectMap): string { } async function inventory(execute: SchemaExecute, crd: ObjectMap): Promise<ObjectMap[]> { + schemaStep("data-inventory", crd.spec.names.kind); const { stdout } = await execute("kubectl", ["get", "--raw", `${endpoint(crd)}?limit=513`, "--request-timeout=20s"], { stdio: "pipe", timeout: 25_000 }); + schemaStep("data-list-shape", crd.spec.names.kind); const result: unknown = JSON.parse(stdout); if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Migration data inventory is malformed"); const list = result as ObjectMap; @@ -52,6 +56,7 @@ async function inventory(execute: SchemaExecute, crd: ObjectMap): Promise<Object } const seen = new Set<string>(); for (const object of list.items) { + schemaStep("data-item-identity", crd.spec.names.kind, object); const { uid } = schemaIdentity(object); if (seen.has(uid) || object.apiVersion !== "kars.azure.com/v1alpha1" || object.kind !== crd.spec.names.kind || (crd.spec.scope === "Namespaced" && (typeof object.metadata.namespace !== "string" || !object.metadata.namespace))) { @@ -70,20 +75,26 @@ export async function qualifyMigrationData( const after = desired.spec.versions[0].schema.openAPIV3Schema; let bytes = 0; for (const object of objects) { + schemaStep("data-fields", current.spec.names.kind); bytes += Buffer.byteLength(canonicalSchema(object)); if (bytes > 8 * 1024 * 1024) throw new Error("Migration data review exceeds its 8 MiB bound"); addedFieldsAbsent(before, after, object, object.kind); if (object.kind === "KarsSandbox" && object.spec?.credentialsRef && (typeof object.spec.credentialsRef.name !== "string" || !/^kars-credential-source-[a-z0-9][a-z0-9-]*$/.test(object.spec.credentialsRef.name))) { + schemaField("spec/credentialsRef"); throw new Error("An existing Sandbox credential reference is not a canonical v1 source; no bundle authority is grandfathered"); } // Server validation uses the still-installed before-schema and unchanged // object/UID/RV. It is a dry-run PUT, never a data migration or status write. + schemaStep("data-server-validation", current.spec.names.kind); const validation = await execute("kubectl", ["replace", "--raw", `${endpoint(current, object)}?dryRun=All`, "-f", "-", "--request-timeout=20s"], { stdio: "pipe", input: JSON.stringify(object), timeout: 25_000 }); const checked: ObjectMap = JSON.parse(validation.stdout); - if (schemaIdentity(checked).uid !== object.metadata.uid || checked.metadata.name !== object.metadata.name + schemaStep("data-returned-identity", current.spec.names.kind, checked); + const identity = schemaIdentity(checked); + schemaStep("data-round-trip", current.spec.names.kind); + if (identity.uid !== object.metadata.uid || checked.metadata.name !== object.metadata.name || checked.metadata.namespace !== object.metadata.namespace || ["labels", "annotations", "ownerReferences", "finalizers"].some(key => canonicalSchema(checked.metadata[key] ?? null) !== canonicalSchema(object.metadata[key] ?? null)) @@ -96,13 +107,17 @@ export async function qualifyMigrationData( } export async function recheckMigrationData(execute: SchemaExecute, snapshot: MigrationDataSnapshot): Promise<void> { - if (schemaDigest(await inventory(execute, snapshot.crd)) !== snapshot.digest) { + const actual = await inventory(execute, snapshot.crd); + schemaStep("data-recheck", snapshot.crd.spec.names.kind); + if (schemaDigest(actual) !== snapshot.digest) { throw new Error("Custom-resource data/UID/resourceVersion changed during migration qualification; no unchecked writes may continue"); } } export async function requireNoNewAuthorities(execute: SchemaExecute, crd: ObjectMap): Promise<void> { - if ((await inventory(execute, crd)).length) { + const objects = await inventory(execute, crd); + schemaStep("new-authorities", crd.spec.names.kind); + if (objects.length) { throw new Error(`BASE365 migration cannot grandfather existing post-baseline authority objects for ${crd.spec.names.kind}`); } } diff --git a/cli/src/lib/sre-migration-wire.test.ts b/cli/src/lib/sre-migration-wire.test.ts new file mode 100644 index 000000000..0990fa669 --- /dev/null +++ b/cli/src/lib/sre-migration-wire.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import http from "node:http"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; +import { qualifyMigrationData } from "./sre-migration-data.js"; +import { canonicalMigrationSchemas } from "./sre-migration.test-support.js"; +import type { SchemaExecute } from "./schema-documents.js"; + +describe("actual kubectl migration raw transport (not Kubernetes validation)", () => { + it("sends the UID/RV-bound stored Task unchanged through a dry-run PUT and reads raw JSON", async () => { + const { before, after } = canonicalMigrationSchemas(true); + const current = before.find(object => object.spec.names.kind === "KarsTask")!; + const desired = after.find(object => object.spec.names.kind === "KarsTask")!; + const object = { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsTask", + metadata: { name: "migration-contract", namespace: "kars-system", uid: "fixture-uid", resourceVersion: "19", + creationTimestamp: "2026-09-12T00:00:00Z" }, + spec: { objective: "Inert migration data", envelope: { tier: 1, authorityCeiling: 1, + budget: { tokens: 20, usdMicros: 0 }, delegationDepth: 0 }, execution: { launch: false } } }; + const requests: { method?: string; url: string; body: string; authorization?: string }[] = []; + const server = http.createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + requests.push({ method: request.method, url: request.url!, body, authorization: request.headers.authorization }); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(request.method === "GET" ? { metadata: {}, items: [object] } : object)); + }); + await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Loopback fixture did not bind a TCP port"); + try { + const execute: SchemaExecute = (file, args, options) => execa(file, + [...args, "--server", `http://127.0.0.1:${address.port}`, "--kubeconfig=/dev/null"], options); + const snapshot = await qualifyMigrationData(execute, current, desired); + expect(snapshot.count).toBe(1); + expect(requests.map(request => request.method)).toEqual(["GET", "PUT"]); + const read = new URL(requests[0].url, "http://localhost"); + expect(read.pathname).toBe("/apis/kars.azure.com/v1alpha1/karstasks"); + expect(read.searchParams.get("limit")).toBe("513"); + const write = new URL(requests[1].url, "http://localhost"); + expect(write.pathname).toBe("/apis/kars.azure.com/v1alpha1/namespaces/kars-system/karstasks/migration-contract"); + expect(write.searchParams.get("dryRun")).toBe("All"); + expect(JSON.parse(requests[1].body)).toEqual(object); + expect(requests.every(request => request.authorization === undefined)).toBe(true); + } finally { + await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + } + }, 30_000); +}); diff --git a/cli/src/lib/sre-migration.test-support.ts b/cli/src/lib/sre-migration.test-support.ts index 83b0b7279..96b0f4e0e 100644 --- a/cli/src/lib/sre-migration.test-support.ts +++ b/cli/src/lib/sre-migration.test-support.ts @@ -127,7 +127,7 @@ export function migrationFixture(evalV2 = false) { const object = JSON.parse(options.input!); onDryRun(object); return { stdout: JSON.stringify({ ...object, metadata: { ...object.metadata, - uid: object.metadata.uid ?? "dry-run-uid", resourceVersion: object.metadata.resourceVersion ?? "dry-run-version" } }) }; + uid: object.metadata.uid ?? "dry-run-uid" } }) }; } return base.execute(file, args, options); }; diff --git a/cli/src/lib/sre-schema-diagnostics.test.ts b/cli/src/lib/sre-schema-diagnostics.test.ts new file mode 100644 index 000000000..685acce80 --- /dev/null +++ b/cli/src/lib/sre-schema-diagnostics.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { schemaField, schemaStep, withSchemaPreparationDiagnostics } from "./sre-schema-diagnostics.js"; + +afterEach(() => vi.restoreAllMocks()); + +describe("SRE schema preparation diagnostic boundary", () => { + it("exposes only fixed child/source/shape facts, not values or original error causes", async () => { + const output = vi.spyOn(console, "error").mockImplementation(() => {}); + const secret = "PRIVATE-CR-TOKEN-CERTIFICATE"; + const original = Object.assign(new Error(secret), { + stderr: `Error from server (BadRequest): ${secret}`, stdout: secret, cause: { token: secret }, + }); + await expect(withSchemaPreparationDiagnostics(async () => { + schemaStep("schema-preview-identity", "KarsBudgetAccount", { + apiVersion: secret, kind: secret, metadata: { uid: secret }, + }); + schemaField(secret); + throw original; + })).rejects.toThrow("schema-preview-identity: BadRequest"); + const report = JSON.parse(String(output.mock.calls[0][0]).replace("SRE-SCHEMA-PREPARATION ", "")); + expect(report).toEqual({ + step: "schema-preview-identity", source: "cli/src/lib/schema-stage.ts", kind: "KarsBudgetAccount", + field: "unrecognized", shape: { uid: "string", resourceVersion: "missing", kind: "string", apiVersion: "string" }, + category: "api-rejection", reason: "BadRequest", + }); + expect(JSON.stringify(output.mock.calls)).not.toContain(secret); + }); + + it("reports a missing live item TypeMeta without logging the item or namespace", async () => { + const output = vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(withSchemaPreparationDiagnostics(async () => { + schemaStep("data-item-identity", "KarsTask", { metadata: { uid: "private", resourceVersion: "private", namespace: "private" } }); + throw new Error("private"); + })).rejects.toThrow("data-item-identity: local-check"); + const facts = JSON.parse(String(output.mock.calls[0][0]).replace("SRE-SCHEMA-PREPARATION ", "")); + expect(facts.shape).toEqual({ uid: "string", resourceVersion: "string", kind: "missing", apiVersion: "missing" }); + expect(JSON.stringify(output.mock.calls)).not.toContain("private"); + }); + + it("isolates concurrent preparation traces and does not change successful return values", async () => { + const output = vi.spyOn(console, "error").mockImplementation(() => {}); + const results = await Promise.allSettled(["KarsTask", "KarsTeam"].map(kind => withSchemaPreparationDiagnostics(async () => { + schemaStep("data-server-validation", kind); + await Promise.resolve(); + throw new Error("not retained"); + }))); + expect(results.every(result => result.status === "rejected")).toBe(true); + const kinds = output.mock.calls.map(call => JSON.parse(String(call[0]).replace("SRE-SCHEMA-PREPARATION ", "")).kind); + expect(kinds.sort()).toEqual(["KarsTask", "KarsTeam"]); + output.mockClear(); + schemaStep("data-inventory", "untrusted-kind"); + const result = { retained: true }; + expect(await withSchemaPreparationDiagnostics(async () => result)).toBe(result); + expect(output).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/lib/sre-schema-diagnostics.ts b/cli/src/lib/sre-schema-diagnostics.ts new file mode 100644 index 000000000..ea9e21848 --- /dev/null +++ b/cli/src/lib/sre-schema-diagnostics.ts @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AsyncLocalStorage } from "node:async_hooks"; + +const sources = { + "helm-render": "core-helm-schemas", + "helm-rollback-review": "core-helm-schemas", + "helm-history-recheck": "core-helm-schemas", + "schema-qualification": "sre-schema-migration", + "registrar": "sre-schema-migration", + "schema-inventory": "sre-schema-migration", + "controller-quiescence": "sre-schema-migration", + "schema-retention": "sre-schema-migration", + "canonical-target": "sre-schema-migration", + "canonical-before": "sre-schema-migration", + "schema-owner": "sre-schema-migration", + "stored-versions": "sre-schema-migration", + "migration-recheck": "sre-schema-migration", + "data-inventory": "sre-migration-data", + "data-list-shape": "sre-migration-data", + "data-item-identity": "sre-migration-data", + "data-fields": "sre-migration-data", + "data-server-validation": "sre-migration-data", + "data-returned-identity": "sre-migration-data", + "data-round-trip": "sre-migration-data", + "data-recheck": "sre-migration-data", + "new-authorities": "sre-migration-data", + "schema-plan": "schema-stage", + "policy-review": "schema-stage", + "schema-plan-identity": "schema-stage", + "helm-schema-match": "schema-stage", + "schema-server-preview": "schema-stage", + "schema-preview-identity": "schema-stage", + "schema-write": "schema-stage", + "schema-publication": "schema-stage", +} as const; +type Step = keyof typeof sources; +const kinds = new Set(["A2AAgent", "EgressApproval", "InferencePolicy", "KarsApproval", "KarsAuthConfig", + "KarsEval", "KarsMemory", "KarsProfile", "KarsReceipt", "KarsSkill", "KarsSREAction", "KarsTask", + "KarsTeam", "McpServer", "ToolPolicy", "TrustGraph", "KarsSandbox", "KarsPairing", + "KarsBudgetAccount", "KarsCredentialGrant", "KarsSRERegistration", "Deployment"]); +const fields = new Set(["metadata/uid", "metadata/resourceVersion", "metadata/name", "metadata/namespace", + "metadata/labels", "metadata/annotations", "metadata/ownerReferences", "metadata/finalizers", "spec", "status", + "spec/envelope/budget/scope", "spec/defaultEnvelope/budget/scope", "spec/blueprint/credentialBindings", + "spec/blueprint/githubBinding", "spec/roster/*/blueprint/credentialBindings", "spec/roster/*/blueprint/githubBinding", + "spec/roster/*/envelope/budget/scope", "spec/managed", "spec/credentialsRef", "spec/credentialBindings", + "spec/githubBinding", "spec/inferenceBudgetRef", "status/serviceObservation", + "status/conditions/*/observedGeneration", "status/reportConfigMapRef", "status/reportConfigMapUid", + "status/reportEvidenceDigest"]); +type Shape = "missing" | "empty" | "string" | "other"; +interface Facts { + step: Step; + source: string; + kind?: string; + field?: string; + shape?: Record<"uid" | "resourceVersion" | "kind" | "apiVersion", Shape>; +} +const traces = new AsyncLocalStorage<{ current: Facts }>(); + +function record(value: unknown): Record<string, unknown> | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record<string, unknown> : undefined; +} + +function shape(value: unknown): Shape { + return value === undefined ? "missing" : value === "" ? "empty" : typeof value === "string" ? "string" : "other"; +} + +export function schemaStep(step: Step, kind?: unknown, object?: unknown): void { + const trace = traces.getStore(); + if (!trace) return; + const value = record(object); + const metadata = record(value?.metadata); + trace.current = { + step, source: `cli/src/lib/${sources[step]}.ts`, + ...(typeof kind === "string" && kinds.has(kind) ? { kind } : {}), + ...(value ? { shape: { uid: shape(metadata?.uid), resourceVersion: shape(metadata?.resourceVersion), + kind: shape(value.kind), apiVersion: shape(value.apiVersion) } } : {}), + }; +} + +export function schemaField(field: string): void { + const trace = traces.getStore(); + if (trace) trace.current.field = fields.has(field) ? field : "unrecognized"; +} + +function failureCategory(error: unknown): { category: string; reason?: string } { + const value = record(error); + const stderr = typeof value?.stderr === "string" ? value.stderr.slice(0, 16384) : ""; + const reason = /^Error from server \((Forbidden|Unauthorized|Invalid|NotFound|AlreadyExists|Conflict|BadRequest|InternalError|ServiceUnavailable)\):/m.exec(stderr)?.[1]; + if (reason) return { category: "api-rejection", reason }; + if (value?.timedOut === true) return { category: "transport-timeout" }; + if (value?.code === "ENOENT") return { category: "missing-command" }; + if (typeof value?.exitCode === "number") return { category: "command-failure" }; + if (error instanceof SyntaxError) return { category: "invalid-json" }; + return { category: "local-check" }; +} + +/** Diagnostic scope only: no raw error/cause survives the CLI output boundary. */ +export async function withSchemaPreparationDiagnostics<T>(run: () => Promise<T>): Promise<T> { + const trace: { current: Facts } = { current: { step: "helm-render", source: "cli/src/lib/core-helm-schemas.ts" } }; + try { + return await traces.run(trace, run); + } catch (error) { + const facts = { ...trace.current, ...failureCategory(error) }; + console.error(`SRE-SCHEMA-PREPARATION ${JSON.stringify(facts)}`); + throw new Error(`SRE schema preparation failed at ${facts.step}: ${facts.reason ?? facts.category}`); + } +} diff --git a/cli/src/lib/sre-schema-migration.test.ts b/cli/src/lib/sre-schema-migration.test.ts index e74775375..b32c9c21a 100644 --- a/cli/src/lib/sre-schema-migration.test.ts +++ b/cli/src/lib/sre-schema-migration.test.ts @@ -11,6 +11,27 @@ import { CANONICAL_SCHEMAS, EVALUATOR_V2, MIGRATION } from "./sre-migration-cata import { planCoreHelmSchemas } from "./core-helm-schemas.js"; describe("closed BASE365 SRE schema migration", () => { + it("accepts a new-CRD server CREATE preview without inventing a persisted resourceVersion", async () => { + const f = migrationFixture(true); + const execute: typeof f.execute = async (file, args, options) => { + const result = await f.execute(file, args, options); + if (file === "kubectl" && args[0] === "create" && args.includes("--dry-run=server")) { + const preview = JSON.parse(result.stdout); + delete preview.metadata.resourceVersion; + return { stdout: JSON.stringify(preview) }; + } + return result; + }; + const permit = await qualifySreSchemaMigration(execute, f.after, f.owner); + const apply = await planCoreSchemaDocuments(execute, f.after, { ...f.owner, ...f.wait, reviewedSreMigration: permit }); + expect(f.writes).toEqual([]); + await apply(); + for (const object of f.objects.values()) { + expect(object.metadata.resourceVersion).toBeTruthy(); + expect(object.metadata.uid).not.toBe("dry-run-uid"); + } + }); + it("qualifies only the exact optional Sandbox condition generation addition", async () => { const f = migrationFixture(); const target = f.after.find(object => object.spec.names.kind === "KarsSandbox")!; diff --git a/cli/src/lib/sre-schema-migration.ts b/cli/src/lib/sre-schema-migration.ts index 77cdb00a6..b93889c8e 100644 --- a/cli/src/lib/sre-schema-migration.ts +++ b/cli/src/lib/sre-schema-migration.ts @@ -9,6 +9,7 @@ import { import { requireCrdRetention } from "./schema-compatibility.js"; import { get, requireRegistrar } from "./sre-authority.js"; import { qualifyMigrationData, recheckMigrationData, requireNoNewAuthorities, type MigrationDataSnapshot } from "./sre-migration-data.js"; +import { schemaStep } from "./sre-schema-diagnostics.js"; export interface QualifiedSreMigration { readonly id: typeof MIGRATION } interface Review { @@ -26,7 +27,9 @@ const requiresReviewedMigration = new Set([ ]); async function quiescentController(execute: SchemaExecute, owner: SchemaOwner, expected?: ObjectMap): Promise<ObjectMap> { + schemaStep("controller-quiescence", "Deployment"); const controller = await get(execute, "deployment", "kars-controller", owner.namespace); + schemaStep("controller-quiescence", "Deployment", controller); if (!controller || controller.metadata.name !== "kars-controller" || controller.metadata.namespace !== owner.namespace || controller.spec?.replicas !== 0 || controller.spec?.template?.spec?.serviceAccountName !== "kars-controller" @@ -50,24 +53,29 @@ export async function qualifySreSchemaMigration( execute: SchemaExecute, documents: ObjectMap[], owner: SchemaOwner, ): Promise<QualifiedSreMigration | undefined> { if (owner.ownership !== "helm") throw new Error("The canonical BASE365 migration requires its exact Helm owner"); + schemaStep("registrar"); await requireRegistrar(execute); const crds = documents.filter(object => object.kind === "CustomResourceDefinition"); const schemas: Review["schemas"] = new Map(); let needed = false; for (const desired of crds) { + schemaStep("schema-inventory", desired.spec?.names?.kind); const current = await readSchemaObject(execute, "customresourcedefinition", desired.metadata.name); if (current && requiresReviewedMigration.has(desired.metadata.name) && schemaDigest(normalizedCrd(current)) !== schemaDigest(normalizedCrd(desired))) needed = true; schemas.set(desired.metadata.name, { current, desired: structuredClone(desired), written: false }); } if (!needed) return undefined; + schemaStep("schema-inventory"); if (canonicalSchema([...schemas.keys()].sort()) !== canonicalSchema(Object.keys(CANONICAL_SCHEMAS).sort())) { throw new Error("BASE365 migration requires the complete canonical core CRD inventory"); } const controller = await quiescentController(execute, owner); + schemaStep("schema-retention"); requireCrdRetention(crds); const data: MigrationDataSnapshot[] = []; for (const [name, entry] of schemas) { + schemaStep("canonical-target", entry.desired.spec.names.kind); const allowed = CANONICAL_SCHEMAS[name]; const after = schemaDigest(normalizedCrd(entry.desired)); if (!allowed.after.includes(after)) throw new Error(`Unreviewed target schema in BASE365 migration: ${name}`); @@ -75,15 +83,19 @@ export async function qualifySreSchemaMigration( if (allowed.before) throw new Error(`Historical BASE365 CRD is missing: ${name}`); continue; } + schemaStep("schema-owner", entry.desired.spec.names.kind, entry.current); verifySchemaOwner(entry.current, owner); + schemaStep("canonical-before", entry.desired.spec.names.kind); const before = schemaDigest(normalizedCrd(entry.current)); if (before !== after && before !== allowed.before) throw new Error(`Live schema is not the exact BASE365 or qualified target: ${name}`); + schemaStep("stored-versions", entry.desired.spec.names.kind); if ((entry.current.status?.storedVersions ?? []).some((version: string) => version !== "v1alpha1")) { throw new Error("Canonical SRE migration cannot migrate another stored API version"); } if (!allowed.before) await requireNoNewAuthorities(execute, entry.desired); if (before !== after) data.push(await qualifyMigrationData(execute, entry.current, entry.desired)); } + schemaStep("schema-qualification"); if (data.reduce((count, item) => count + item.count, 0) > 512 || data.reduce((bytes, item) => bytes + item.bytes, 0) > 8 * 1024 * 1024) throw new Error("Complete migration data inventory exceeds its bound"); const evalSchema = schemas.get("karsevals.kars.azure.com")!.desired; @@ -108,6 +120,7 @@ export async function recheckSreSchemaMigration(plan: QualifiedSreMigration, nam await quiescentController(review.execute, review.owner, review.controller); for (const [key, entry] of review.schemas) { if (name && key !== name) continue; + schemaStep("migration-recheck", entry.desired.spec.names.kind); const current = await readSchemaObject(review.execute, "customresourcedefinition", key); if (!entry.current) { if (current) throw new Error("A new CRD appeared after migration review"); diff --git a/cli/src/lib/sre-stage.test.ts b/cli/src/lib/sre-stage.test.ts index 5c157af44..143c10ae2 100644 --- a/cli/src/lib/sre-stage.test.ts +++ b/cli/src/lib/sre-stage.test.ts @@ -201,11 +201,12 @@ describe("existing action API prerequisite compatibility", () => { const f = fixture(helm); const execute = vi.fn<Execute>(async (file, args, options) => { if (args[0] === "wait" && args.includes(`crd/${ACTION_CRD}`) - || (helm&&args.includes("/openapi/v3"))) throw new Error("Established timeout"); + || (helm&&args.includes("/openapi/v3"))) throw Object.assign(new Error("Established timeout"), { timedOut: true }); return f.execute(file, args, options); }); - await expect(f.run(false, execute)).rejects.toThrow("Established timeout"); + await expect(f.run(false, execute)).rejects.toThrow( + helm ? "schema-publication: transport-timeout" : "Established timeout"); expect(execute.mock.calls.some(([, args, options]) => (args[0]==="upgrade"&&!args.includes("--dry-run=server")) || (args[0]==="create"&&JSON.parse(options.input!).kind!=="CustomResourceDefinition"))).toBe(false); }); @@ -214,8 +215,11 @@ describe("existing action API prerequisite compatibility", () => { const f = fixture(helm); const execute: Execute = (file, args, options) => (args[0] === "patch" && args[2] === ACTION_CRD) || (args[0] === "apply" && JSON.parse(options.input!).metadata.name === ACTION_CRD) - ? Promise.reject(new Error("Forbidden action API update")) : f.execute(file, args, options); - await expect(f.run(false, execute)).rejects.toThrow("Forbidden action API update"); + ? Promise.reject(Object.assign(new Error("Forbidden action API update"), { + stderr: "Error from server (Forbidden): private response body", + })) : f.execute(file, args, options); + await expect(f.run(false, execute)).rejects.toThrow( + helm ? "schema-server-preview: Forbidden" : "Forbidden action API update"); expect(f.execute.mock.calls.some(([, args]) => ["create", "upgrade"].includes(args[0]) && !args.includes("--dry-run=server"))).toBe(false); }); diff --git a/cli/src/lib/sre-stage.ts b/cli/src/lib/sre-stage.ts index a7c7e8abf..ebc2ee01e 100644 --- a/cli/src/lib/sre-stage.ts +++ b/cli/src/lib/sre-stage.ts @@ -8,6 +8,7 @@ import { ACTION_CRD, planActionCrd } from "./sre-action-crd.js"; import { planCoreHelmSchemas } from "./core-helm-schemas.js"; import { waitForInstalledCoreSchemas } from "./schema-stage.js"; import { planTemplateAuthoritySchemas } from "./sre-template-schema-plan.js"; +import { withSchemaPreparationDiagnostics } from "./sre-schema-diagnostics.js"; type StagePhase = "registrar" | "controller-review" | "release-inventory" | "prerequisite-chart-render" | "action-schema-review" | "helm-compatibility" | "action-schema-migration" | "core-schema-preparation" @@ -85,12 +86,13 @@ async function stageAuthorityChecked( // Qualify the complete schema/data plan before even the action-params // conversion. The ordinary comparator remains strict outside this command. mark("core-schema-preparation"); - const applySchemas = await planCoreHelmSchemas(execute,args,{base365SreMigration:true}); + const applySchemas = await withSchemaPreparationDiagnostics( + () => planCoreHelmSchemas(execute,args,{base365SreMigration:true})); mark("helm-server-dry-run"); await execute("helm",[...baseArgs,"--dry-run=server"],{stdio:"pipe"}); if(!dryRun) { mark("core-schema-preparation"); - await applySchemas(); + await withSchemaPreparationDiagnostics(applySchemas); mark("helm-upgrade"); await execute("helm",args,{stdio:"pipe"}); } diff --git a/docs/how-to/helm-installation.md b/docs/how-to/helm-installation.md index 20ea03c86..b49c9d5af 100644 --- a/docs/how-to/helm-installation.md +++ b/docs/how-to/helm-installation.md @@ -132,6 +132,12 @@ Before any real action/schema write, the CLI qualifies all schemas and a complet bounded inventory of affected objects, validates unchanged UID/RV-bound objects through **server dry-run PUTs**, and dry-runs every proposed CRD CREATE/SSA update. The full Helm stage is also server-previewed before applying the schema plan. +An unpersisted CRD CREATE preview has an ephemeral UID but no storage +resourceVersion. It is checked only as a preview of the exact proposed schema +and ownership; its identity is never copied into the real CREATE. Existing +objects, update previews and real publication still require their strict UID/RV +identities. Fixed child-step diagnostics distinguish these checks without +printing CR contents or raw API error bodies. Foreign ownership, customized/unknown before or after schemas, forbidden reads or dry-runs, incomplete inventories and late data/UID/RV changes stop the operation. The bound is 512 affected objects and 8 MiB total reviewed data; larger or actively diff --git a/tests/e2e/sre_authority/common.py b/tests/e2e/sre_authority/common.py index cea1657c4..42ef16a9e 100644 --- a/tests/e2e/sre_authority/common.py +++ b/tests/e2e/sre_authority/common.py @@ -15,6 +15,7 @@ import ssl import subprocess import time +from .schema_preparation_diagnostics import schema_preparation_failure CONTEXT = "kind-kars-e2e" SYSTEM = "kars-system" @@ -253,6 +254,10 @@ def run(self, args, *, data=None, user="admin", timeout=35, expected=0): raise AssertionError(f"Command exceeded its bounded timeout at {command_site()}") from None result = subprocess.CompletedProcess(args, process.returncode, stdout, stderr) if expected is not None: + if result.returncode != expected: + facts = schema_preparation_failure(stderr) + if facts: + print("SRE-SCHEMA-PREPARATION-FACTS " + json.dumps(facts, sort_keys=True), flush=True) # Never echo command output or argv: token/Secret reads are captured. require(result.returncode == expected, f"Command failed during {self.phase} at {command_site()}; " diff --git a/tests/e2e/sre_authority/harness_test.py b/tests/e2e/sre_authority/harness_test.py index bb2d57d10..35a7bb96c 100644 --- a/tests/e2e/sre_authority/harness_test.py +++ b/tests/e2e/sre_authority/harness_test.py @@ -35,6 +35,47 @@ def json(self): class HarnessTests(unittest.TestCase): + def test_real_failed_command_relays_sanitized_preparation_facts_without_its_output(self): + facts = {"step": "data-returned-identity", "source": "cli/src/lib/sre-migration-data.ts", + "kind": "KarsTask", "category": "local-check"} + stderr = "PRIVATE-RESPONSE\nSRE-SCHEMA-PREPARATION " + json.dumps(facts) + "\n" + with tempfile.TemporaryDirectory() as temporary: + h = Harness.__new__(Harness) + h.work = h.root = Path(temporary) + h.deadline, h.phase = time.monotonic() + 20, "prepare" + with patch("builtins.print") as printed, self.assertRaisesRegex(AssertionError, "Command failed"): + h.run(["python3", "-c", f"import sys; sys.stderr.write({stderr!r}); sys.exit(1)"]) + output = " ".join(str(call) for call in printed.call_args_list) + self.assertIn("SRE-SCHEMA-PREPARATION-FACTS", output) + self.assertIn("data-returned-identity", output) + self.assertNotIn("PRIVATE-RESPONSE", output) + + def test_schema_preparation_diagnostics_preserve_only_fixed_child_source_and_shape(self): + from sre_authority.schema_preparation_diagnostics import schema_preparation_failure + facts = {"step": "schema-preview-identity", "source": "cli/src/lib/schema-stage.ts", + "kind": "KarsBudgetAccount", "category": "local-check", + "shape": {"uid": "string", "resourceVersion": "missing", "kind": "string", "apiVersion": "string"}} + prefix = "SRE-SCHEMA-PREPARATION " + self.assertEqual(schema_preparation_failure("PRIVATE\n" + prefix + json.dumps(facts) + "\nPRIVATE"), facts) + for key, value in (("step", "PRIVATE"), ("source", "PRIVATE"), ("kind", "PRIVATE"), + ("raw", "PRIVATE"), ("field", "spec/PRIVATE"), ("reason", "PRIVATE"), + ("shape", {"uid": "PRIVATE"})): + with self.subTest(key=key): + self.assertIsNone(schema_preparation_failure(prefix + json.dumps({**facts, key: value}))) + self.assertIsNone(schema_preparation_failure(prefix + "{invalid PRIVATE")) + self.assertEqual(schema_preparation_failure((prefix + json.dumps(facts) + "\n") * 2), + {"category": "ambiguous"}) + + def test_schema_preparation_diagnostic_vocabulary_matches_the_cli_source(self): + from sre_authority.schema_preparation_diagnostics import FIELDS, KINDS, SOURCES + root = Path(__file__).resolve().parents[3] + source = (root / "cli/src/lib/sre-schema-diagnostics.ts").read_text() + steps = re.search(r"const sources = \{(.*?)\} as const;", source, re.S).group(1) + self.assertEqual(dict(re.findall(r'"([^"]+)": "([^"]+)"', steps)), SOURCES) + for name, expected in (("kinds", KINDS), ("fields", FIELDS - {"unrecognized"})): + literal = re.search(rf"const {name} = new Set\(\[(.*?)\]\);", source, re.S).group(1) + self.assertEqual(set(re.findall(r'"([^"]+)"', literal)), expected) + def test_sre_stage_diagnostics_keep_only_fixed_known_phase_names(self): from sre_authority.common import command_error_category private = "DO-NOT-EMIT-PRIVATE-DATA" diff --git a/tests/e2e/sre_authority/legacy_crd_probe.py b/tests/e2e/sre_authority/legacy_crd_probe.py index c97a58973..3c0e5c277 100644 --- a/tests/e2e/sre_authority/legacy_crd_probe.py +++ b/tests/e2e/sre_authority/legacy_crd_probe.py @@ -13,10 +13,37 @@ from sre_authority.canonical_seed import dry_run_seed_data from sre_authority.fixtures import LEGACY_COMMIT, install_historical_chart from sre_authority.registration_schema import ( - create_registration_crd, kind_proxy, request, write_report, + CRD_NAME, CRD_PATH, create_registration_crd, kind_proxy, request, write_report, ) +def preview_registration_identity(h, obj): + path = f"{CRD_PATH}/{CRD_NAME}" + absent = h.api("GET", path, status=404).json() + require(absent.get("kind") == "Status" and absent.get("reason") == "NotFound", + "New registration preview requires actual absence; no adoption is permitted") + response = h.api("POST", CRD_PATH + "?dryRun=All&fieldManager=helm&fieldValidation=Strict", body=obj) + body = response.json() + metadata = body.get("metadata") if isinstance(body, dict) else None + metadata = metadata if isinstance(metadata, dict) else {} + valid = (response.status_code == 201 and isinstance(body, dict) + and body.get("kind") == "CustomResourceDefinition" and metadata.get("name") == CRD_NAME + and isinstance(metadata.get("uid"), str) and bool(metadata["uid"]) + and metadata.get("resourceVersion", "") == "") + write_report(h.root, "migration-seed-crd-preview.json", { + "resource": CRD_NAME, "httpStatus": response.status_code, + "category": "accepted" if valid else "unexpected-preview-identity", + "uidPresent": isinstance(metadata.get("uid"), str) and bool(metadata["uid"]), + "resourceVersionPresent": "resourceVersion" in metadata, + }) + require(valid, "New registration server CREATE preview did not have its exact non-persisted identity shape") + after = h.api("GET", path, status=404).json() + require(after.get("kind") == "Status" and after.get("reason") == "NotFound", + "Server CREATE preview persisted a CRD unexpectedly") + require("uid" not in obj["metadata"] and "resourceVersion" not in obj["metadata"], + "An ephemeral preview identity entered the real CREATE request") + + def exercise(root): h = Harness.__new__(Harness) h.root, h.work = root, root / ".e2e-legacy-helm" @@ -43,6 +70,7 @@ def api(method, path, *, body=None, status=None): obj["metadata"].setdefault("labels", {})["app.kubernetes.io/managed-by"] = "Helm" obj["metadata"]["annotations"] = { "meta.helm.sh/release-name": "kars", "meta.helm.sh/release-namespace": SYSTEM} + preview_registration_identity(h, obj) create_registration_crd(h, obj) h.k("wait", "--for=condition=Established", "crd/karssreregistrations.kars.azure.com", "--timeout=60s", timeout=70) @@ -54,6 +82,7 @@ def api(method, path, *, body=None, status=None): "historicalInstallAndPostInstallHook": "passed", "currentAuthorityServerDryRun": "passed", "historicalSeedStrictServerDryRuns": 5, "historicalSeedPersistence": "unchanged", "historicalNestedParamsRejection": "passed", + "newCrdPreviewWithoutPersistedRevision": "passed", "controllerReplicas": 0, "legacyCRDs": 18, "crdCreation": "native-Helm-only"}) diff --git a/tests/e2e/sre_authority/legacy_crds_test.py b/tests/e2e/sre_authority/legacy_crds_test.py index 899c1818b..fe66e5047 100644 --- a/tests/e2e/sre_authority/legacy_crds_test.py +++ b/tests/e2e/sre_authority/legacy_crds_test.py @@ -44,6 +44,35 @@ def flattened(historical): class LegacyCRDTests(unittest.TestCase): + def test_early_new_crd_preview_proves_no_persisted_revision_or_identity_reuse(self): + from sre_authority.legacy_crd_probe import preview_registration_identity + from sre_authority.registration_schema import CRD_PATH + obj = {"apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", + "metadata": {"name": CRD_NAME}, "spec": {"scope": "Cluster"}} + before = copy.deepcopy(obj) + not_found = {"kind": "Status", "reason": "NotFound"} + preview = copy.deepcopy(obj) + preview["metadata"]["uid"] = "ephemeral-private-uid" + h = Mock(root=Path("unused")) + h.api.side_effect = [ + types.SimpleNamespace(json=lambda: not_found), + types.SimpleNamespace(status_code=201, json=lambda: preview), + types.SimpleNamespace(json=lambda: not_found), + ] + with patch("sre_authority.legacy_crd_probe.write_report") as report: + preview_registration_identity(h, obj) + self.assertEqual(obj, before) + self.assertEqual([call.args for call in h.api.call_args_list], [ + ("GET", f"{CRD_PATH}/{CRD_NAME}"), + ("POST", CRD_PATH + "?dryRun=All&fieldManager=helm&fieldValidation=Strict"), + ("GET", f"{CRD_PATH}/{CRD_NAME}"), + ]) + facts = report.call_args.args[2] + self.assertTrue(facts["uidPresent"]) + self.assertFalse(facts["resourceVersionPresent"]) + self.assertNotIn("ephemeral-private-uid", json.dumps(facts)) + h.create.assert_not_called() + def test_render_requires_exact_historical_content_not_just_a_matching_name(self): historical = historical_objects() rendered = flattened(copy.deepcopy(historical)) diff --git a/tests/e2e/sre_authority/schema_preparation_diagnostics.py b/tests/e2e/sre_authority/schema_preparation_diagnostics.py new file mode 100644 index 000000000..1504ac007 --- /dev/null +++ b/tests/e2e/sre_authority/schema_preparation_diagnostics.py @@ -0,0 +1,69 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Closed CLI diagnostic vocabulary; never relay executable output or values.""" + +import json + +SOURCES = { + **{step: "core-helm-schemas" for step in ("helm-render", "helm-rollback-review", "helm-history-recheck")}, + **{step: "sre-schema-migration" for step in ( + "schema-qualification", "registrar", "schema-inventory", "controller-quiescence", + "schema-retention", "canonical-target", "canonical-before", "schema-owner", "stored-versions", "migration-recheck")}, + **{step: "sre-migration-data" for step in ( + "data-inventory", "data-list-shape", "data-item-identity", "data-fields", "data-server-validation", + "data-returned-identity", "data-round-trip", "data-recheck", "new-authorities")}, + **{step: "schema-stage" for step in ( + "schema-plan", "policy-review", "schema-plan-identity", "helm-schema-match", + "schema-server-preview", "schema-preview-identity", "schema-write", "schema-publication")}, +} +KINDS = {"A2AAgent", "EgressApproval", "InferencePolicy", "KarsApproval", "KarsAuthConfig", + "KarsEval", "KarsMemory", "KarsProfile", "KarsReceipt", "KarsSkill", "KarsSREAction", "KarsTask", + "KarsTeam", "McpServer", "ToolPolicy", "TrustGraph", "KarsSandbox", "KarsPairing", + "KarsBudgetAccount", "KarsCredentialGrant", "KarsSRERegistration", "Deployment"} +FIELDS = {"metadata/uid", "metadata/resourceVersion", "metadata/name", "metadata/namespace", + "metadata/labels", "metadata/annotations", "metadata/ownerReferences", "metadata/finalizers", "spec", "status", + "spec/envelope/budget/scope", "spec/defaultEnvelope/budget/scope", "spec/blueprint/credentialBindings", + "spec/blueprint/githubBinding", "spec/roster/*/blueprint/credentialBindings", "spec/roster/*/blueprint/githubBinding", + "spec/roster/*/envelope/budget/scope", "spec/managed", "spec/credentialsRef", "spec/credentialBindings", + "spec/githubBinding", "spec/inferenceBudgetRef", "status/serviceObservation", + "status/conditions/*/observedGeneration", "status/reportConfigMapRef", "status/reportConfigMapUid", + "status/reportEvidenceDigest", "unrecognized"} +REASONS = {"Forbidden", "Unauthorized", "Invalid", "NotFound", "AlreadyExists", "Conflict", + "BadRequest", "InternalError", "ServiceUnavailable"} + + +def schema_preparation_failure(stderr): + records = [] + prefix = "SRE-SCHEMA-PREPARATION " + for line in stderr.splitlines(): + if not line.startswith(prefix) or len(line) > 4096: + continue + try: + value = json.loads(line[len(prefix):]) + except ValueError: + continue + if not isinstance(value, dict) or set(value) - {"step", "source", "kind", "field", "shape", "category", "reason"}: + continue + step, category = value.get("step"), value.get("category") + if (not isinstance(step, str) or step not in SOURCES + or value.get("source") != f"cli/src/lib/{SOURCES[step]}.ts" + or not isinstance(category, str) + or category not in {"local-check", "api-rejection", "transport-timeout", "missing-command", + "command-failure", "invalid-json"}): + continue + if any(key in value and (not isinstance(value[key], str) or value[key] not in allowed) + for key, allowed in (("kind", KINDS), ("field", FIELDS), ("reason", REASONS))): + continue + if ("reason" in value) != (category == "api-rejection"): + continue + if "shape" in value: + shape = value["shape"] + if (not isinstance(shape, dict) or set(shape) != {"uid", "resourceVersion", "kind", "apiVersion"} + or any(not isinstance(item, str) or item not in {"missing", "empty", "string", "other"} + for item in shape.values())): + continue + records.append(value) + if len(records) == 1: + return records[0] + return {"category": "ambiguous"} if records else None From 0643cdc688788de156f80e5da7896e0903f00999 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 10:13:34 +0200 Subject: [PATCH 069/111] Project only fixed writer-recheck booleans from native CLI failures Reuse strict duplicate/shape/boundary checks without exposing metadata or error bodies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../operator_diagnostics.py | 29 +++++++++++++++---- .../test_operator_diagnostics.py | 29 ++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index 31a9f3d2c..efd4162ab 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -45,6 +45,12 @@ ("phaseRunningMatch", "running"), ("readyConditionMatch", "ready"), ) +WRITER_CHECK_PREFIX = "KARS_PRIVATE_WRITER_RECHECK " +WRITER_CHECK_FIELDS = ( + ("projectionMetadataPresent", "metadata-present"), + ("projectionMetadataMatches", "metadata-matches"), + ("deploymentTransitionMatches", "deployment"), +) def category(stderr): @@ -64,24 +70,32 @@ def source_location(stderr): return "unavailable" -def sandbox_checks(stderr): - lines = [line for line in stderr.splitlines() if line.startswith(CHECK_PREFIX)] +def _checks(stderr, prefix, fields): + lines = [line for line in stderr.splitlines() if line.startswith(prefix)] if not lines: return "" if len(lines) != 1 or len(lines[0]) > 512: return "unavailable" try: # Preserve duplicate keys so ambiguous facts cannot silently overwrite each other. - pairs = json.loads(lines[0][len(CHECK_PREFIX):], object_pairs_hook=lambda values: values) + pairs = json.loads(lines[0][len(prefix):], object_pairs_hook=lambda values: values) except json.JSONDecodeError: return "unavailable" - if (not isinstance(pairs, list) or len(pairs) != len(CHECK_FIELDS) + if (not isinstance(pairs, list) or len(pairs) != len(fields) or not all(isinstance(pair, tuple) and len(pair) == 2 and isinstance(pair[0], str) and isinstance(pair[1], bool) for pair in pairs) - or {key for key, _ in pairs} != {key for key, _ in CHECK_FIELDS}): + or {key for key, _ in pairs} != {key for key, _ in fields}): return "unavailable" values = dict(pairs) - return ",".join(f"{label}={str(values[key]).lower()}" for key, label in CHECK_FIELDS) + return ",".join(f"{label}={str(values[key]).lower()}" for key, label in fields) + + +def sandbox_checks(stderr): + return _checks(stderr, CHECK_PREFIX, CHECK_FIELDS) + + +def writer_checks(stderr): + return _checks(stderr, WRITER_CHECK_PREFIX, WRITER_CHECK_FIELDS) def operator_command(stage, *args, timeout): @@ -92,6 +106,9 @@ def operator_command(stage, *args, timeout): except CommandFailure as error: checks = sandbox_checks(error.stderr) details = f" (sandbox-checks={checks})" if checks else "" + recheck = writer_checks(error.stderr) + if recheck: + details += f" (writer-recheck={recheck})" raise Failure( f"Native operator {stage} failed: {category(error.stderr)} " f"(source={source_location(error.stderr)}){details}" diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index e1f99b0f1..c6dadfe6a 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -11,7 +11,10 @@ import native_api from native_api import Failure -from operator_diagnostics import CHECK_PREFIX, ERRORS, category, operator_command, sandbox_checks, source_location +from operator_diagnostics import ( + CHECK_PREFIX, ERRORS, WRITER_CHECK_PREFIX, category, operator_command, + sandbox_checks, source_location, writer_checks, +) PRIVATE = "DO-NOT-EMIT-TOKENS-OR-PRIVATE-API-BODIES" @@ -97,6 +100,30 @@ def test_malformed_ambiguous_or_extended_snapshot_checks_remain_unavailable(self self.assertEqual(sandbox_checks(value), "unavailable") self.assertEqual(sandbox_checks(PRIVATE), "") + def test_writer_recheck_retains_only_its_three_fixed_booleans(self): + facts = {"projectionMetadataPresent": True, "projectionMetadataMatches": False, + "deploymentTransitionMatches": True} + marker = WRITER_CHECK_PREFIX + json.dumps(facts) + stderr = f"{PRIVATE}\n{marker}\n{PRIVATE}" + self.assertEqual(writer_checks(stderr), "metadata-present=true,metadata-matches=false,deployment=true") + output = io.StringIO() + with tempfile.TemporaryDirectory(prefix="native-writer-checks-") as directory, \ + patch.object(native_api, "ROOT", Path(directory)), redirect_stdout(output), redirect_stderr(output): + with self.assertRaises(Failure) as failure: + operator_command("apply", sys.executable, "-c", + "import sys; print(sys.argv[1],file=sys.stderr); sys.exit(1)", + stderr, timeout=5) + self.assertIn("(writer-recheck=metadata-present=true,metadata-matches=false,deployment=true)", str(failure.exception)) + self.assertNotIn(PRIVATE, str(failure.exception)) + self.assertEqual(output.getvalue(), "") + for value in (marker + PRIVATE, marker + "\n" + marker, + WRITER_CHECK_PREFIX + json.dumps({**facts, "private": PRIVATE}), + WRITER_CHECK_PREFIX + json.dumps({**facts, "projectionMetadataMatches": 0}), + WRITER_CHECK_PREFIX + '{"projectionMetadataPresent":true,' + '"projectionMetadataPresent":false,"deploymentTransitionMatches":true}'): + self.assertEqual(writer_checks(value), "unavailable") + self.assertEqual(writer_checks(PRIVATE), "") + def test_success_and_unknown_stage_do_not_change_authority(self): with tempfile.TemporaryDirectory(prefix="native-operator-success-") as directory, \ patch.object(native_api, "ROOT", Path(directory)): From 60b72582c256d17c1062f8d8eeb6e5665ac0b510 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 10:17:21 +0200 Subject: [PATCH 070/111] Align Secret metadata printer views during witnessed writer settling Normalize only uncaptured managedFields at the projection recheck, preserving all authority, data, revision and Deployment checks; cover the actual kubectl wire difference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../private-activation-writer-settle.test.ts | 140 +++++++++++++++++- .../lib/private-activation-writer-settle.ts | 20 ++- docs/how-to/governed-credential-grants.md | 4 + 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts index e7d4949dc..66a9b0c98 100644 --- a/cli/src/lib/private-activation-writer-settle.test.ts +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -2,9 +2,14 @@ // Licensed under the MIT License. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { execFile } from "node:child_process"; +import { createServer } from "node:http"; +import { devNull } from "node:os"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { applyReviewedGrant } from "../commands/credential-grants.js"; import { continuityFixture, privateAuthoritySnapshot } from "./private-activation-fixtures.js"; -import { canonical, PRIVATE_PREFIX as P, type Execute } from "./private-activation.js"; +import { canonical, readSecretMetadata, PRIVATE_PREFIX as P, type Execute } from "./private-activation.js"; import { captureGuardRetirement, refreshGuardRetirement } from "./private-activation-guard-retirement.js"; import { captureWriterSettlement } from "./private-activation-writer-settle.js"; @@ -16,6 +21,43 @@ const REVISION = "deployment.kubernetes.io/revision"; const AUTH = `sha256:${"a".repeat(64)}`; const consumer = "kars-late/Deployment/late"; const data = { SLACK_BOT_TOKEN: Buffer.from("original-customer-token").toString("base64") }; +const managedFields = [{ manager: "kars-controller", operation: "Update", apiVersion: "v1", + fieldsType: "FieldsV1", fieldsV1: { "f:data": { ".": {}, "f:SLACK_BOT_TOKEN": {} } } }]; + +async function projectionWire() { + let secret: any; + const requests: string[] = []; + const server = createServer((request, response) => { + const path = new URL(request.url!, "http://127.0.0.1").pathname; + requests.push(`${request.method} ${path}`); + const objects: Record<string, unknown> = { + "/api": { apiVersion: "v1", kind: "APIVersions", versions: ["v1"], serverAddressByClientCIDRs: [] }, + "/apis": { apiVersion: "v1", kind: "APIGroupList", groups: [] }, + "/api/v1": { apiVersion: "v1", kind: "APIResourceList", groupVersion: "v1", + resources: [{ name: "secrets", singularName: "secret", namespaced: true, kind: "Secret", verbs: ["get", "list"] }] }, + [`/api/v1/namespaces/${secret?.metadata.namespace}/secrets/${secret?.metadata.name}`]: secret, + }; + response.writeHead(path in objects ? 200 : 404, { "Content-Type": "application/json", Connection: "close" }); + response.end(JSON.stringify(objects[path] ?? { apiVersion: "v1", kind: "Status", status: "Failure", reason: "NotFound", code: 404 })); + }); + await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Loopback fixture address missing"); + return { + requests, + get: async (args: string[], value: any) => { + secret = structuredClone({ apiVersion: "v1", ...value }); + // A regular project file is a non-directory cache root: kubectl cannot + // create cache files, and the fixture never touches a user's kubeconfig. + const result = await promisify(execFile)("kubectl", [ + "--kubeconfig", devNull, "--cache-dir", fileURLToPath(new URL("../../package.json", import.meta.url)), + "--server", `http://127.0.0.1:${address.port}`, "--request-timeout=3s", ...args, + ], { encoding: "utf8", timeout: 10_000, windowsHide: true }); + return result.stdout; + }, + close: () => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())), + }; +} async function setup(originalRuntime = false) { const f = continuityFixture(); @@ -103,6 +145,7 @@ async function setup(originalRuntime = false) { let allowRestore = true; let restoreAt = 2; let emptyReads = 0; + let projectionPrinter: ((args: string[], secret: any) => Promise<string>) | undefined; let fault: ((stage: string) => void) | undefined; const restore = () => { restored = true; @@ -122,7 +165,10 @@ async function setup(originalRuntime = false) { fault?.("restored"); }; const execute: Execute = async (args, inputValue) => { - const result = await f.execute(args, inputValue); + let result = await f.execute(args, inputValue); + if (projectionPrinter && args[0] === "get" && args[1] === "secret" && args[2] === projection.metadata.name) { + result = await projectionPrinter(args, structuredClone(projection)); + } if (allowRestore && retired && !restored && args[0] === "get" && args[1] === "secret" && args[2] === projection.metadata.name) { if (++emptyReads === restoreAt) restore(); } @@ -176,13 +222,101 @@ async function setup(originalRuntime = false) { rootDeployment: f.deployment, rootPods: f.pods.get("core"), input, taskSpec: task.spec, sandboxSpec: sandbox.spec }); return { ...f, execute, document, passiveExecute: f.execute, preserved, task, sandbox, namespace, deployment, bundle, projection, input, admin, fault: (callback: (stage: string) => void) => { fault = callback; }, restore, wasRestored: () => restored, - neverRestore: () => { allowRestore = false; }, delayRestore: () => { restoreAt = 8; } }; + neverRestore: () => { allowRestore = false; }, delayRestore: () => { restoreAt = 8; }, + projectionPrinter: (printer: (args: string[], secret: any) => Promise<string>) => { projectionPrinter = printer; } }; } describe("late runtime authority across selected writer retirement", () => { beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); + it("proves the real JSON and metadata JSONPath printer views differ only in managedFields", async () => { + const f = await setup(); + const wire = await projectionWire(); + f.projection.metadata.managedFields = managedFields; + try { + const args = ["get", "secret", f.projection.metadata.name, "-n", "kars-late", "-o", "json"]; + const full = JSON.parse(await wire.get(args, f.projection)); + const metadata = await readSecretMetadata(args => wire.get(args, f.projection), f.projection.metadata.name, "kars-late"); + expect(full.metadata).not.toHaveProperty("managedFields"); + expect(metadata.managedFields).toEqual(managedFields); + const comparable = structuredClone(metadata); + delete comparable.managedFields; + expect(comparable).toEqual(full.metadata); + expect(metadata).not.toHaveProperty("data"); + expect(JSON.stringify(metadata)).not.toContain(data.SLACK_BOT_TOKEN); + expect(wire.requests.every(request => request.startsWith("GET "))).toBe(true); + } finally { await wire.close(); } + }, 20_000); + + it("completes shipped apply with the actual kubectl projection printer views", async () => { + const f = await setup(); + const wire = await projectionWire(); + f.projection.metadata.managedFields = managedFields; + f.projectionPrinter(wire.get); + const before = f.preserved(); + try { + await applyReviewedGrant(f.execute, await f.document()); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); + expect(f.projection.data).toEqual(data); + expect(f.projection.metadata.managedFields).toEqual(managedFields); + expect(f.preserved()).toEqual(before); + expect(wire.requests.some(request => request.endsWith("/secrets/late-credential-projection"))).toBe(true); + expect(wire.requests.every(request => request.startsWith("GET "))).toBe(true); + } finally { await wire.close(); } + }, 30_000); + + it.each(["labels", "annotations", "owner", "uid", "namespace", "captured-managed-fields", "deployment-transition"])( + "preserves the real-wire %s metadata fence", async fault => { + const f = await setup(); + const wire = await projectionWire(); + f.projection.metadata.managedFields = managedFields; + f.projectionPrinter(async (args, value) => { + const metadataOnly = args.includes("jsonpath-as-json={.metadata}"); + const request = !metadataOnly && fault === "captured-managed-fields" ? [...args, "--show-managed-fields=true"] : args; + if (metadataOnly) { + if (fault === "labels") value.metadata.labels = { unreviewed: "must-not-be-logged" }; + if (fault === "annotations") value.metadata.annotations.unreviewed = "must-not-be-logged"; + if (fault === "owner") value.metadata.ownerReferences[0].uid = "changed-owner"; + if (fault === "uid") value.metadata.uid = "changed-projection"; + if (fault === "namespace") value.metadata.annotations[`${C}namespace-uid`] = "changed-namespace"; + if (fault === "captured-managed-fields") value.metadata.managedFields[0].manager = "changed-manager"; + if (fault === "deployment-transition") f.deployment.spec.template.spec.containers[0].image = "unreviewed-image"; + } + return wire.get(request, value); + }); + try { + await expect(applyReviewedGrant(f.execute, await f.document())).rejects.toThrow("captured runtime authority"); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + const markers = vi.mocked(console.error).mock.calls.map(([value]) => String(value)) + .filter(value => value.startsWith("KARS_PRIVATE_WRITER_RECHECK ")); + expect(markers).toEqual([`KARS_PRIVATE_WRITER_RECHECK ${JSON.stringify({ + projectionMetadataPresent: true, projectionMetadataMatches: fault === "deployment-transition", + deploymentTransitionMatches: fault !== "deployment-transition", + })}`]); + expect(markers.join("")).not.toContain("must-not-be-logged"); + } finally { await wire.close(); } + }, 30_000); + + it("does not normalize a real-wire projection resourceVersion mismatch", async () => { + const f = await setup(); + const wire = await projectionWire(); + f.projection.metadata.managedFields = managedFields; + f.neverRestore(); + f.projectionPrinter(async (args, value) => { + if (args.includes("jsonpath-as-json={.metadata}")) { + value.metadata.resourceVersion = "unreviewed-version"; + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 121_000); + } + return wire.get(args, value); + }); + try { + await expect(applyReviewedGrant(f.execute, await f.document())).rejects.toThrow("awaiting fresh Task attestation"); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + expect(f.grant().spec.writers).toEqual([]); + } finally { await wire.close(); } + }, 30_000); + it("reproduces the rejected null attestation in the original immediate post-retirement validation", async () => { const f = await setup(); const review = await f.document(); diff --git a/cli/src/lib/private-activation-writer-settle.ts b/cli/src/lib/private-activation-writer-settle.ts index a93d982ae..9be18e3b7 100644 --- a/cli/src/lib/private-activation-writer-settle.ts +++ b/cli/src/lib/private-activation-writer-settle.ts @@ -68,6 +68,13 @@ function unchangedSecretMetadata(current: ObjectValue, before: ObjectValue, inpu }; return current.type === "Opaque" && sameBody(comparable(current), comparable(before)); } +function projectionMetadataView(metadata: ObjectValue, before: ObjectValue): ObjectValue { + const comparable = structuredClone(metadata); + // Default kubectl JSON omits managedFields, whereas JSONPath retains them. + // Match only that printer difference; a captured field remains authoritative. + if (!Object.hasOwn(record(before.metadata), "managedFields")) delete comparable.managedFields; + return comparable; +} function data(secret: ObjectValue): ObjectValue { return record(secret.data ?? {}); } function readyTask(task: ObjectValue, original: Json): boolean { const condition = array(at(task, "status", "conditions") ?? []); @@ -261,8 +268,17 @@ export async function observeWriterSettlement( if (unchanged && gen(deployment) !== gen(before.deployment) && (!runtime.pauseSeen || gen(deployment) !== restoredGeneration)) throw new Error(ERROR); const projectionAfter = await readSecretMetadata(execute, reviewed(projection).name, ns); const deploymentAfter = await read(execute, "deployments.apps", reviewed(deployment).name, ns); - if (!projectionAfter || !unchangedSecretMetadata({ metadata: projectionAfter, type: "Opaque" }, - { metadata: runtime.projection.metadata!, type: "Opaque" }) || !possibleTransition(deploymentAfter, runtime)) throw new Error(ERROR); + const checks = { + projectionMetadataPresent: projectionAfter !== undefined, + projectionMetadataMatches: projectionAfter !== undefined && unchangedSecretMetadata({ + metadata: projectionMetadataView(projectionAfter, runtime.projection), type: "Opaque", + }, { metadata: runtime.projection.metadata!, type: "Opaque" }), + deploymentTransitionMatches: possibleTransition(deploymentAfter, runtime), + }; + if (!checks.projectionMetadataPresent || !checks.projectionMetadataMatches || !checks.deploymentTransitionMatches) { + console.error(`KARS_PRIVATE_WRITER_RECHECK ${JSON.stringify(checks)}`); + throw new Error(ERROR); + } if (reviewed({ metadata: projectionAfter }).resourceVersion !== reviewed(projection).resourceVersion || reviewed(deploymentAfter).resourceVersion !== reviewed(deployment).resourceVersion) { allReady = false; diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index ecd74fe21..edc797c12 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -263,6 +263,10 @@ controller metadata transitions can advance; this is not a new user review, stale-digest reuse or an arbitrary revision refresh. Already-qualified scopes retain their independently verified path. Missing witnesses or other drift preserve retirement and require explicit recovery; no new authority is published. +The projection recheck aligns kubectl JSON and JSONPath views only for +`managedFields` absent from the captured JSON view. Originally captured +`managedFields` and all other metadata remain compared; this does not grant +ownership or weaken source, value, revision or template checks. For first qualification, namespace protection is then enabled in `Pending`, identities/templates are rechecked, From 52517e1c0fc182354dff4f1b0b532846a8654ca6 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 11:55:26 +0200 Subject: [PATCH 071/111] Probe the exact production Task schema SSA request before full migration Share payload construction and response validation, preserve strict ownership and conflicts, and retain only bounded field-manager diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 8 + cli/src/lib/schema-ssa-conflicts.test.ts | 51 ++++ cli/src/lib/schema-ssa-conflicts.ts | 53 ++++ cli/src/lib/schema-stage.ts | 26 +- cli/src/lib/schema-write-request.test.ts | 54 ++++ cli/src/lib/schema-write-request.ts | 36 +++ cli/src/lib/sre-schema-diagnostics.ts | 5 +- .../e2e/sre_authority/canonical_migration.py | 23 +- .../sre_authority/canonical_migration_test.py | 25 +- tests/e2e/sre_authority/legacy_crd_probe.py | 3 + tests/e2e/sre_authority/legacy_crds_test.py | 1 + .../schema_preparation_diagnostics.py | 5 +- tests/e2e/sre_authority/ssa_diagnostics.py | 72 ++++++ .../sre_authority/task_schema_conflicts.py | 109 +++++++++ .../e2e/sre_authority/task_schema_payload.mjs | 67 +++++ .../e2e/sre_authority/task_schema_preview.py | 70 ++++++ .../sre_authority/task_schema_preview_test.py | 231 ++++++++++++++++++ 17 files changed, 790 insertions(+), 49 deletions(-) create mode 100644 cli/src/lib/schema-ssa-conflicts.test.ts create mode 100644 cli/src/lib/schema-ssa-conflicts.ts create mode 100644 cli/src/lib/schema-write-request.test.ts create mode 100644 cli/src/lib/schema-write-request.ts create mode 100644 tests/e2e/sre_authority/ssa_diagnostics.py create mode 100644 tests/e2e/sre_authority/task_schema_conflicts.py create mode 100644 tests/e2e/sre_authority/task_schema_payload.mjs create mode 100644 tests/e2e/sre_authority/task_schema_preview.py create mode 100644 tests/e2e/sre_authority/task_schema_preview_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e6302e76..52bb19ea3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -423,6 +423,14 @@ jobs: with: version: v1.30.5 - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + - name: Build same-source CLI for schema request parity + working-directory: cli + run: npm ci && npm run build + - name: Require the compiled production schema request helper + run: node tests/e2e/sre_authority/task_schema_payload.mjs check - name: Check public-schema diagnostic privacy run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test sandbox_condition_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test governed_services_test - name: Create the same disposable API server as the real harness diff --git a/cli/src/lib/schema-ssa-conflicts.test.ts b/cli/src/lib/schema-ssa-conflicts.test.ts new file mode 100644 index 000000000..182daf4f7 --- /dev/null +++ b/cli/src/lib/schema-ssa-conflicts.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, vi } from "vitest"; +import { schemaSsaConflict } from "./schema-ssa-conflicts.js"; +import { schemaStep, withSchemaPreparationDiagnostics } from "./sre-schema-diagnostics.js"; + +describe("kubectl SSA conflict diagnostics", () => { + it("distinguishes a witnessed resourceVersion precondition failure from field ownership", () => { + expect(schemaSsaConflict('error: Operation cannot be fulfilled on customresourcedefinitions.apiextensions.k8s.io "PRIVATE-NAME": the object has been modified; please apply your changes to the latest version and try again\n')) + .toEqual({ conflictKind: "resource-version" }); + }); + it("recognizes the pinned single-conflict wrapper without disclosing manager time or raw fields", () => { + const stderr = 'error: Apply failed with 1 conflict: conflict with "python-httpx" using apiextensions.k8s.io/v1 at 2026-09-12T00:00:00Z: .spec.versions\n' + + "Please review the fields above--they currently have other managers. Here\nPRIVATE-BODY"; + expect(schemaSsaConflict(stderr)).toEqual({ conflictKind: "field-manager", conflictCount: 1, + conflictFields: ["spec/versions"], conflictManagers: ["python-httpx"] }); + }); + + it("bounds multi-manager conflicts and maps unreviewed manager/field text to fixed classes", () => { + expect(schemaSsaConflict('error: Apply failed with 2 conflicts: conflicts with "PRIVATE-MANAGER":\n' + + '- .spec.PRIVATE-FIELD\nconflicts with "helm" using apiextensions.k8s.io/v1:\n- .spec.versions\n')) + .toEqual({ conflictKind: "field-manager", conflictCount: 2, conflictFields: ["other", "spec/versions"], + conflictManagers: ["helm", "other"] }); + }); + + it.each([ + 'Error from server (Conflict): resourceVersion changed', + 'error: some OTHER failure mentioning Apply failed with 1 conflict: conflict with "helm": .spec.versions', + 'error: Apply failed with 2 conflicts: conflict with "helm": .spec.versions', + 'error: Apply failed with 33 conflicts: conflict with "helm": .spec.versions', + 'error: Apply failed with 1 conflict: unrecognized PRIVATE format', + ])("does not misclassify unrelated, malformed or unbounded failures", stderr => { + expect(schemaSsaConflict(stderr)).toBeUndefined(); + }); + + it("preserves failure and emits only fixed conflict facts at the existing Task preview step", async () => { + const output = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(withSchemaPreparationDiagnostics(async () => { + schemaStep("schema-server-preview", "KarsTask"); + throw Object.assign(new Error("PRIVATE-STDOUT"), { exitCode: 1, + stderr: 'error: Apply failed with 1 conflict: conflict with "PRIVATE-MANAGER": .spec.versions\n' }); + })).rejects.toThrow("schema-server-preview: Conflict"); + const facts = JSON.parse(String(output.mock.calls[0][0]).replace("SRE-SCHEMA-PREPARATION ", "")); + expect(facts).toMatchObject({ kind: "KarsTask", category: "api-rejection", reason: "Conflict", + conflictKind: "field-manager", conflictFields: ["spec/versions"], conflictManagers: ["other"] }); + expect(JSON.stringify(output.mock.calls)).not.toContain("PRIVATE"); + } finally { output.mockRestore(); } + }); +}); diff --git a/cli/src/lib/schema-ssa-conflicts.ts b/cli/src/lib/schema-ssa-conflicts.ts new file mode 100644 index 000000000..404966716 --- /dev/null +++ b/cli/src/lib/schema-ssa-conflicts.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const managerClasses = new Set(["helm", "python-httpx", "Python-urllib", "kubectl-patch", "kubectl", + "kubectl-client-side-apply", "kars-schema-stage"]); +const conflictPaths: Readonly<Record<string, string>> = { + ".spec.versions": "spec/versions", + ".metadata.annotations.meta.helm.sh/release-name": "metadata/annotations/meta.helm.sh/release-name", + ".metadata.annotations.meta.helm.sh/release-namespace": "metadata/annotations/meta.helm.sh/release-namespace", + ".metadata.annotations.kars.azure.com/core-schema-owner": "metadata/annotations/kars.azure.com/core-schema-owner", + ".metadata.annotations.kars.azure.com/core-schema-spec": "metadata/annotations/kars.azure.com/core-schema-spec", + ".metadata.labels.app.kubernetes.io/managed-by": "metadata/labels/app.kubernetes.io/managed-by", +}; + +export interface SsaConflictFacts { + conflictKind: "field-manager" | "resource-version"; + conflictCount?: number; + conflictFields?: string[]; + conflictManagers?: string[]; +} + +/** kubectl's SSA wrapper loses StatusError's usual "Error from server" prefix. + * Parse only the pinned upstream conflict grammar, never echo manager/field text. */ +export function schemaSsaConflict(stderr: string): SsaConflictFacts | undefined { + if (/^(?:error: )?Operation cannot be fulfilled on customresourcedefinitions(?:\.apiextensions\.k8s\.io)? "[^"\r\n]+": the object has been modified; please apply your changes to the latest version and try again\.?$/m.test(stderr.slice(0, 16384))) { + return { conflictKind: "resource-version" }; + } + const match = /^(?:error: )?Apply failed with ([1-9][0-9]?) conflicts?: /m.exec(stderr.slice(0, 16384)); + if (!match || Number(match[1]) > 32) return undefined; + const text = stderr.slice(match.index + match[0].length, 16384).split("\nPlease review the fields above")[0].trim(); + const fields: string[] = []; + const managers = new Set<string>(); + let managerSeen = false; + for (const line of text.split("\n")) { + const manager = /^conflicts? with ("(?:[^"\\]|\\.)*")/.exec(line); + if (manager) { + let name: unknown; + try { name = JSON.parse(manager[1]); } catch { return undefined; } + managers.add(typeof name === "string" && managerClasses.has(name) ? name : "other"); + managerSeen = true; + const field = /: (\.[^\r\n]+)$/.exec(line)?.[1]; + if (field) fields.push(conflictPaths[field] ?? "other"); + else if (!line.endsWith(":")) return undefined; + } else if (managerSeen && line.startsWith("- .")) { + fields.push(conflictPaths[line.slice(2)] ?? "other"); + } else { + return undefined; + } + } + if (fields.length !== Number(match[1]) || !managers.size || managers.size > fields.length) return undefined; + return { conflictKind: "field-manager", conflictCount: fields.length, + conflictFields: [...new Set(fields)].sort(), conflictManagers: [...managers].sort() }; +} diff --git a/cli/src/lib/schema-stage.ts b/cli/src/lib/schema-stage.ts index 7a7961dcb..7c7d3ee61 100644 --- a/cli/src/lib/schema-stage.ts +++ b/cli/src/lib/schema-stage.ts @@ -3,7 +3,7 @@ import { canonicalSchema, normalizedCrd, readSchemaObject, SCHEMA_DIGEST, schemaDigest, schemaDocuments, - schemaIdentity, schemaOwnerFields, verifyNewSchemaPreviewOwner, verifySchemaOwner, type ObjectMap, type SchemaExecute, type SchemaOwner, + schemaIdentity, verifySchemaOwner, type ObjectMap, type SchemaExecute, type SchemaOwner, } from "./schema-documents.js"; import { waitForPublishedSchemas, type PublishedType, type SchemaWait } from "./schema-discovery.js"; import { assertRollbackCompatibility, assertSchemaCompatibility, requireCrdRetention } from "./schema-compatibility.js"; @@ -11,6 +11,7 @@ import { authorizesSreSchemaMigration, completeSreSchemaMigration, recheckSreSchemaMigration, recordSreSchemaWrite, type QualifiedSreMigration, } from "./sre-schema-migration.js"; import { schemaStep } from "./sre-schema-diagnostics.js"; +import { buildSchemaWriteRequest, verifySchemaWritePreview } from "./schema-write-request.js"; interface PlannedSchema { desired: ObjectMap; current?: ObjectMap; uid?: string; change: boolean } export interface SchemaStageOptions extends SchemaOwner, SchemaWait { @@ -160,21 +161,7 @@ export async function planCoreSchemaDocuments( if (options.rollbackDocuments) assertRollbackCompatibility([current], options.rollbackDocuments); plans.push({ desired, current, uid: schemaIdentity(current).uid, change }); } - const writeRequest = (plan: PlannedSchema) => { - const fields = schemaOwnerFields(owner); - const object = { apiVersion: plan.desired.apiVersion, kind: plan.desired.kind, spec: plan.desired.spec, metadata: { - ...plan.desired.metadata, - ...(plan.current ? schemaIdentity(plan.current) : {}), - labels: { ...plan.desired.metadata.labels, ...fields.labels }, - annotations: { ...plan.desired.metadata.annotations, ...fields.annotations, - [SCHEMA_DIGEST]: schemaDigest(normalizedCrd(plan.desired)) }, - } }; - const manager = owner.ownership === "helm" ? "helm" : "kars-schema-stage"; - const args = plan.current - ? ["apply", "--server-side", `--field-manager=${manager}`, "-f", "-", "-o", "json"] - : ["create", `--field-manager=${manager}`, "-f", "-", "-o", "json"]; - return { object, args }; - }; + const writeRequest = (plan: PlannedSchema) => buildSchemaWriteRequest(plan.desired, plan.current, owner); if (options.reviewedSreMigration && !options.checkOnly) { await recheckSreSchemaMigration(options.reviewedSreMigration); for (const plan of plans.filter(plan => plan.change)) { @@ -183,12 +170,7 @@ export async function planCoreSchemaDocuments( const checked: ObjectMap = JSON.parse((await execute("kubectl", [...args, "--dry-run=server", "--request-timeout=20s"], { stdio: "pipe", input: JSON.stringify(object), timeout: 25_000 })).stdout); schemaStep("schema-preview-identity", plan.desired.spec.names.kind, checked); - if ((plan.uid && schemaIdentity(checked).uid !== plan.uid) - || canonicalSchema(normalizedCrd(checked)) !== canonicalSchema(normalizedCrd(plan.desired))) { - throw new Error("Migration schema dry-run returned another identity or schema"); - } - if (plan.current) verifySchemaOwner(checked, owner); - else verifyNewSchemaPreviewOwner(checked, owner); + verifySchemaWritePreview(checked, plan.desired, owner, plan.uid); } await recheckSreSchemaMigration(options.reviewedSreMigration); } diff --git a/cli/src/lib/schema-write-request.test.ts b/cli/src/lib/schema-write-request.test.ts new file mode 100644 index 000000000..a6aa33c8b --- /dev/null +++ b/cli/src/lib/schema-write-request.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { crd, schemaFixture } from "./schema-stage.test-support.js"; +import { normalizedCrd, SCHEMA_DIGEST, SCHEMA_OWNER, schemaDigest } from "./schema-documents.js"; +import { stageCoreSchemaDocuments } from "./schema-stage.js"; +import { buildSchemaWriteRequest, verifySchemaWritePreview } from "./schema-write-request.js"; + +describe("shared production schema request and preview checks", () => { + it("matches every production request byte, owner annotation and JS digest", async () => { + const desired = crd("KarsTask", "karstasks"); + const f = schemaFixture([desired]); + const current = structuredClone(f.install(desired)); + desired.spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.numeric = { + type: "number", minimum: 1e-7, maximum: 1e21, + }; + const expected = buildSchemaWriteRequest(desired, current, f.owner); + expect(expected.object.metadata.annotations[SCHEMA_OWNER]) + .toBe('{"namespace":"kars-system","ownership":"helm","release":"kars"}'); + expect(expected.object.metadata.annotations[SCHEMA_DIGEST]).toBe(schemaDigest(normalizedCrd(desired))); + expect(expected.object.metadata).toMatchObject({ uid: current.metadata.uid, resourceVersion: current.metadata.resourceVersion }); + await stageCoreSchemaDocuments(f.execute, [desired], { ...f.owner, ...f.wait }); + const writes = f.requests.filter(request => request.args[0] === "apply"); + expect(writes).toHaveLength(1); + expect(writes[0].args).toEqual([...expected.args, "--request-timeout=20s"]); + expect(writes[0].input).toBe(JSON.stringify(expected.object)); + }); + + it.each(["schema-addition", "owner", "uid", "missing-rv"])("does not replace exact preview checks with containment: %s", fault => { + const desired = crd("KarsTask", "karstasks"); + const f = schemaFixture([desired]); + const current = f.install(desired); + const checked = buildSchemaWriteRequest(desired, current, f.owner).object; + if (fault === "schema-addition") checked.spec = structuredClone(checked.spec); + if (fault === "schema-addition") checked.spec.versions[0].schema.openAPIV3Schema.properties.extra = { type: "string" }; + if (fault === "owner") checked.metadata.annotations["meta.helm.sh/release-name"] = "foreign"; + if (fault === "uid") checked.metadata.uid = "replacement"; + if (fault === "missing-rv") delete checked.metadata.resourceVersion; + expect(() => verifySchemaWritePreview(checked, desired, f.owner, current.metadata.uid)).toThrow(); + }); + + it("retains only the existing normalized defaults, not extra validation or fields", () => { + const desired = crd("KarsTask", "karstasks"); + const f = schemaFixture([desired]); + const current = f.install(desired); + const checked = structuredClone(buildSchemaWriteRequest(desired, current, f.owner).object); + checked.spec.names.listKind = "KarsTaskList"; + checked.spec.conversion = { strategy: "None" }; + expect(() => verifySchemaWritePreview(checked, desired, f.owner, current.metadata.uid)).not.toThrow(); + checked.spec.versions[0].schema.openAPIV3Schema.properties.extra = { type: "string" }; + expect(() => verifySchemaWritePreview(checked, desired, f.owner, current.metadata.uid)).toThrow("another identity or schema"); + }); +}); diff --git a/cli/src/lib/schema-write-request.ts b/cli/src/lib/schema-write-request.ts new file mode 100644 index 000000000..7409d7c5b --- /dev/null +++ b/cli/src/lib/schema-write-request.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + canonicalSchema, normalizedCrd, SCHEMA_DIGEST, schemaDigest, schemaIdentity, schemaOwnerFields, + verifyNewSchemaPreviewOwner, verifySchemaOwner, type ObjectMap, type SchemaOwner, +} from "./schema-documents.js"; + +export function buildSchemaWriteRequest(desired: ObjectMap, current: ObjectMap | undefined, owner: SchemaOwner): { + object: ObjectMap; args: string[]; +} { + const fields = schemaOwnerFields(owner); + const object = { apiVersion: desired.apiVersion, kind: desired.kind, spec: desired.spec, metadata: { + ...desired.metadata, + ...(current ? schemaIdentity(current) : {}), + labels: { ...desired.metadata.labels, ...fields.labels }, + annotations: { ...desired.metadata.annotations, ...fields.annotations, + [SCHEMA_DIGEST]: schemaDigest(normalizedCrd(desired)) }, + } }; + const manager = owner.ownership === "helm" ? "helm" : "kars-schema-stage"; + const args = current + ? ["apply", "--server-side", `--field-manager=${manager}`, "-f", "-", "-o", "json"] + : ["create", `--field-manager=${manager}`, "-f", "-", "-o", "json"]; + return { object, args }; +} + +export function verifySchemaWritePreview( + checked: ObjectMap, desired: ObjectMap, owner: SchemaOwner, currentUid?: string, +): void { + if ((currentUid && schemaIdentity(checked).uid !== currentUid) + || canonicalSchema(normalizedCrd(checked)) !== canonicalSchema(normalizedCrd(desired))) { + throw new Error("Migration schema dry-run returned another identity or schema"); + } + if (currentUid) verifySchemaOwner(checked, owner); + else verifyNewSchemaPreviewOwner(checked, owner); +} diff --git a/cli/src/lib/sre-schema-diagnostics.ts b/cli/src/lib/sre-schema-diagnostics.ts index ea9e21848..3aac33a00 100644 --- a/cli/src/lib/sre-schema-diagnostics.ts +++ b/cli/src/lib/sre-schema-diagnostics.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { AsyncLocalStorage } from "node:async_hooks"; +import { schemaSsaConflict, type SsaConflictFacts } from "./schema-ssa-conflicts.js"; const sources = { "helm-render": "core-helm-schemas", @@ -85,9 +86,11 @@ export function schemaField(field: string): void { if (trace) trace.current.field = fields.has(field) ? field : "unrecognized"; } -function failureCategory(error: unknown): { category: string; reason?: string } { +function failureCategory(error: unknown): { category: string; reason?: string } & Partial<SsaConflictFacts> { const value = record(error); const stderr = typeof value?.stderr === "string" ? value.stderr.slice(0, 16384) : ""; + const conflict = schemaSsaConflict(stderr); + if (conflict) return { category: "api-rejection", reason: "Conflict", ...conflict }; const reason = /^Error from server \((Forbidden|Unauthorized|Invalid|NotFound|AlreadyExists|Conflict|BadRequest|InternalError|ServiceUnavailable)\):/m.exec(stderr)?.[1]; if (reason) return { category: "api-rejection", reason }; if (value?.timedOut === true) return { category: "transport-timeout" }; diff --git a/tests/e2e/sre_authority/canonical_migration.py b/tests/e2e/sre_authority/canonical_migration.py index a2bff5176..6af102133 100644 --- a/tests/e2e/sre_authority/canonical_migration.py +++ b/tests/e2e/sre_authority/canonical_migration.py @@ -13,6 +13,7 @@ from .common import SYSTEM, require from .canonical_seed import dry_run_seed_data, prove_nested_params_support, request_seed, seed_definitions +from .task_schema_conflicts import task_schema_conflict CRDS = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions" STAGE = ("authority", "stage", "--controller-image", "kars-controller:e2e", @@ -54,23 +55,12 @@ def assert_data_unchanged(h, fixtures): def deny_late_conflicts(h, fixtures): """A final CRD conflict must prevent even the earlier action conversion.""" - name = "karstasks.kars.azure.com" - original = h.get("crd", name) action = h.get("crd", "karssreactions.kars.azure.com") action_before = {"uid": action["metadata"]["uid"], "spec": copy.deepcopy(action["spec"])} binding = h.get("clusterrolebinding", "kars-sre-reader") subjects = copy.deepcopy(binding["subjects"]) for fault in ("owner", "schema"): - current = h.get("crd", name) - patch = {"metadata": {"uid": current["metadata"]["uid"], - "resourceVersion": current["metadata"]["resourceVersion"]}} - if fault == "owner": - patch["metadata"]["annotations"] = {"meta.helm.sh/release-name": "foreign-fixture"} - else: - patch["spec"] = copy.deepcopy(original["spec"]) - patch["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] = "Unreviewed public fixture description" - h.api("PATCH", f"{CRDS}/{name}", body=patch, status=200) - try: + with task_schema_conflict(h, fault): for mode, flags in (("preview", ("--dry-run",)), ("apply", ())): rejected = h.cli(*STAGE, *flags, expected=None, timeout=180) require(rejected.returncode != 0, "A foreign/custom schema unexpectedly qualified") @@ -81,15 +71,6 @@ def deny_late_conflicts(h, fixtures): "Migration preflight changed an existing subject") assert_data_unchanged(h, fixtures) h.passed(f"Native canonical migration {fault} conflict refused during {mode} before any action/schema conversion") - finally: - live = h.get("crd", name) - restore = {"metadata": {"uid": original["metadata"]["uid"], - "resourceVersion": live["metadata"]["resourceVersion"]}, - "spec": original["spec"]} - if fault == "owner": - restore["metadata"]["annotations"] = { - "meta.helm.sh/release-name": original["metadata"]["annotations"]["meta.helm.sh/release-name"]} - h.api("PATCH", f"{CRDS}/{name}", body=restore, status=200) def finish_data_proof(h, fixtures): diff --git a/tests/e2e/sre_authority/canonical_migration_test.py b/tests/e2e/sre_authority/canonical_migration_test.py index 30ff25ce8..f6675dd32 100644 --- a/tests/e2e/sre_authority/canonical_migration_test.py +++ b/tests/e2e/sre_authority/canonical_migration_test.py @@ -14,6 +14,7 @@ CRDS, STAGE, assert_data_unchanged, deny_late_conflicts, finish_data_proof, seed_data, ) from sre_authority.canonical_seed import SEEDS, WORKLOADS, collection_path, nested_action_definition, seed_definitions +from sre_authority.task_schema_conflicts import TASK_PATH class FakeHarness: @@ -31,9 +32,17 @@ def __init__(self): "metadata": {"uid": "binding"}, "subjects": [{"name": "legacy"}, {"name": "unrelated"}]}, } for name in ("karstasks.kars.azure.com", "karssreactions.kars.azure.com"): - self.objects[("crd", name)] = {"metadata": {"name": name, "uid": name, "resourceVersion": "1", - "annotations": {"meta.helm.sh/release-name": "kars"}}, - "spec": {"versions": [{"schema": {"openAPIV3Schema": {"description": "canonical"}}}]}} + self.objects[("crd", name)] = {"apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", + "metadata": {"name": name, "uid": name, "resourceVersion": "1", + "labels": {"app.kubernetes.io/managed-by": "Helm"}, + "annotations": {"meta.helm.sh/release-name": "kars", "meta.helm.sh/release-namespace": "kars-system"}, + "managedFields": [{"manager": "helm", "operation": "Apply", "fieldsV1": {"f:spec": {"f:versions": {}}}}]}, + "spec": {"group": "kars.azure.com", "scope": "Namespaced", + "names": {"kind": "KarsTask" if name == "karstasks.kars.azure.com" else "KarsSREAction", + "plural": name.split(".")[0]}, + "versions": [{"name": "v1alpha1", "served": True, "storage": True, + "schema": {"openAPIV3Schema": {"type": "object", "description": "canonical", + "properties": {"spec": {"type": "object", "properties": {}}}}}}]}} self.calls = [] self.rejections = [] self.serial = 1 @@ -64,6 +73,10 @@ def api(self, method, path, *, body=None, status=None): self.calls.append((method, path, copy.deepcopy(body))) parsed = urlsplit(path) if method == "GET": + if parsed.path == TASK_PATH: + assert status == 200 and not parsed.query + result = self.get("crd", "karstasks.kars.azure.com") + return SimpleNamespace(status_code=200, json=lambda: result) assert status == 200 and parse_qs(parsed.query) == {"limit": ["513"]} if parsed.path in WORKLOADS: items = [self.get("deployment", "kars-controller")] if parsed.path.endswith("/deployments") else [] @@ -132,6 +145,9 @@ def setUp(self): reporter = patch("sre_authority.canonical_seed.write_report") self.reporter = reporter.start() self.addCleanup(reporter.stop) + managers = patch("sre_authority.task_schema_conflicts.write_report") + managers.start() + self.addCleanup(managers.stop) def test_seed_uses_typed_inert_action_and_real_data_preservation_assertions(self): h = FakeHarness() @@ -160,8 +176,9 @@ def test_negative_fixtures_use_the_public_cli_and_restore_only_exact_uid_rv_owne self.assertEqual(h.objects[("crd", "karstasks.kars.azure.com")]["spec"], before) self.assertEqual(h.objects[("crd", "karssreactions.kars.azure.com")], action) self.assertEqual(h.objects[("clusterrolebinding", "kars-sre-reader")]["subjects"], subjects) - self.assertTrue(all(method == "PATCH" and path == f"{CRDS}/karstasks.kars.azure.com" + self.assertTrue(all(method in ("GET", "PATCH") and path == f"{CRDS}/karstasks.kars.azure.com" for method, path, _body in h.calls)) + self.assertEqual(sum(method == "PATCH" for method, _path, _body in h.calls), 4) def test_cleanup_is_limited_to_measured_disposable_crs_with_exact_uid_rv(self): h = FakeHarness() diff --git a/tests/e2e/sre_authority/legacy_crd_probe.py b/tests/e2e/sre_authority/legacy_crd_probe.py index 3c0e5c277..d77b3d56d 100644 --- a/tests/e2e/sre_authority/legacy_crd_probe.py +++ b/tests/e2e/sre_authority/legacy_crd_probe.py @@ -11,6 +11,7 @@ from sre_authority.bootstrap_probe import converted_objects from sre_authority.common import CONTEXT, Harness, SYSTEM, require from sre_authority.canonical_seed import dry_run_seed_data +from sre_authority.task_schema_preview import exercise_task_restore_preview, require_task_payload_helper from sre_authority.fixtures import LEGACY_COMMIT, install_historical_chart from sre_authority.registration_schema import ( CRD_NAME, CRD_PATH, create_registration_crd, kind_proxy, request, write_report, @@ -49,6 +50,7 @@ def exercise(root): h.root, h.work = root, root / ".e2e-legacy-helm" h.work.mkdir(mode=0o700) h.deadline, h.phase = time.monotonic() + 300, "legacy-helm-proof" + require_task_payload_helper(h) with kind_proxy(root) as (port, version): require(re.fullmatch(r"v1\.31\.\d+(?:[-+].*)?", version.get("gitVersion", "")) is not None, "Historical seed API proof requires the pinned Kubernetes 1.31 server") @@ -61,6 +63,7 @@ def api(method, path, *, body=None, status=None): h.api = api install_historical_chart(h) dry_run_seed_data(h) + exercise_task_restore_preview(h) rendered = h.run(["helm", "template", "kars", str(root / "deploy/helm/kars"), "--namespace", SYSTEM, "--show-only", "templates/crd-karssreregistration.yaml"]) objects = converted_objects(h.k("create", "--dry-run=client", "--validate=strict", diff --git a/tests/e2e/sre_authority/legacy_crds_test.py b/tests/e2e/sre_authority/legacy_crds_test.py index fe66e5047..9af747437 100644 --- a/tests/e2e/sre_authority/legacy_crds_test.py +++ b/tests/e2e/sre_authority/legacy_crds_test.py @@ -16,6 +16,7 @@ CRDS, IDENTITIES, preflight_legacy_crds, render_legacy_crds, validate_rendered_crds, ) from sre_authority.registration_schema import CRD_NAME +from sre_authority.task_schema_preview_test import TaskSchemaPreviewTests from sre_authority.registration_schema import request from sre_authority.canonical_migration import seed_data from sre_authority.canonical_migration_test import FakeHarness diff --git a/tests/e2e/sre_authority/schema_preparation_diagnostics.py b/tests/e2e/sre_authority/schema_preparation_diagnostics.py index 1504ac007..5ea49c2fc 100644 --- a/tests/e2e/sre_authority/schema_preparation_diagnostics.py +++ b/tests/e2e/sre_authority/schema_preparation_diagnostics.py @@ -4,6 +4,7 @@ """Closed CLI diagnostic vocabulary; never relay executable output or values.""" import json +from .ssa_diagnostics import CONFLICT_KEYS, valid_conflict_facts SOURCES = { **{step: "core-helm-schemas" for step in ("helm-render", "helm-rollback-review", "helm-history-recheck")}, @@ -43,7 +44,7 @@ def schema_preparation_failure(stderr): value = json.loads(line[len(prefix):]) except ValueError: continue - if not isinstance(value, dict) or set(value) - {"step", "source", "kind", "field", "shape", "category", "reason"}: + if not isinstance(value, dict) or set(value) - ({"step", "source", "kind", "field", "shape", "category", "reason"} | CONFLICT_KEYS): continue step, category = value.get("step"), value.get("category") if (not isinstance(step, str) or step not in SOURCES @@ -57,6 +58,8 @@ def schema_preparation_failure(stderr): continue if ("reason" in value) != (category == "api-rejection"): continue + if not valid_conflict_facts(value): + continue if "shape" in value: shape = value["shape"] if (not isinstance(shape, dict) or set(shape) != {"uid", "resourceVersion", "kind", "apiVersion"} diff --git a/tests/e2e/sre_authority/ssa_diagnostics.py b/tests/e2e/sre_authority/ssa_diagnostics.py new file mode 100644 index 000000000..3fbab14d0 --- /dev/null +++ b/tests/e2e/sre_authority/ssa_diagnostics.py @@ -0,0 +1,72 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Closed SSA diagnostics shared by the early probe and retained CLI facts.""" + +import json +import re + +MANAGERS = {"helm", "python-httpx", "Python-urllib", "kubectl-patch", "kubectl", + "kubectl-client-side-apply", "kars-schema-stage"} +PATHS = { + ".spec.versions": "spec/versions", + ".metadata.annotations.meta.helm.sh/release-name": "metadata/annotations/meta.helm.sh/release-name", + ".metadata.annotations.meta.helm.sh/release-namespace": "metadata/annotations/meta.helm.sh/release-namespace", + ".metadata.annotations.kars.azure.com/core-schema-owner": "metadata/annotations/kars.azure.com/core-schema-owner", + ".metadata.annotations.kars.azure.com/core-schema-spec": "metadata/annotations/kars.azure.com/core-schema-spec", + ".metadata.labels.app.kubernetes.io/managed-by": "metadata/labels/app.kubernetes.io/managed-by", +} +CONFLICT_KEYS = {"conflictKind", "conflictCount", "conflictFields", "conflictManagers"} + + +def manager_class(value): + return value if isinstance(value, str) and value in MANAGERS else "other" + + +def ssa_conflict(stderr): + if re.search(r'^(?:error: )?Operation cannot be fulfilled on customresourcedefinitions(?:\.apiextensions\.k8s\.io)? ' + r'"[^"\r\n]+": the object has been modified; please apply your changes to the latest version and try again\.?$', + stderr[:16384], re.M): + return {"conflictKind": "resource-version"} + match = re.search(r"^(?:error: )?Apply failed with ([1-9][0-9]?) conflicts?: ", stderr[:16384], re.M) + if not match or int(match[1]) > 32: + return None + text = stderr[match.end():16384].split("\nPlease review the fields above")[0].strip() + fields, managers = [], set() + for line in text.splitlines(): + manager = re.match(r'^conflicts? with ("(?:[^"\\]|\\.)*")', line) + if manager: + try: + managers.add(manager_class(json.loads(manager[1]))) + except ValueError: + return None + field = re.search(r": (\.[^\r\n]+)$", line) + if field: + fields.append(PATHS.get(field[1], "other")) + elif not line.endswith(":"): + return None + elif managers and line.startswith("- ."): + fields.append(PATHS.get(line[2:], "other")) + else: + return None + if len(fields) != int(match[1]) or not managers or len(managers) > len(fields): + return None + return {"conflictKind": "field-manager", "conflictCount": len(fields), + "conflictFields": sorted(set(fields)), "conflictManagers": sorted(managers)} + + +def valid_conflict_facts(value): + if not (set(value) & CONFLICT_KEYS): + return True + if value.get("conflictKind") == "resource-version": + return (set(value) & CONFLICT_KEYS == {"conflictKind"} + and value.get("category") == "api-rejection" and value.get("reason") == "Conflict") + return ( + CONFLICT_KEYS <= set(value) and value.get("conflictKind") == "field-manager" + and value.get("category") == "api-rejection" and value.get("reason") == "Conflict" + and type(value.get("conflictCount")) is int and 1 <= value["conflictCount"] <= 32 + and all(isinstance(value.get(key), list) and 1 <= len(value[key]) <= value["conflictCount"] + and all(isinstance(item, str) and item in allowed for item in value[key]) + for key, allowed in (("conflictFields", set(PATHS.values()) | {"other"}), + ("conflictManagers", MANAGERS | {"other"}))) + ) diff --git a/tests/e2e/sre_authority/task_schema_conflicts.py b/tests/e2e/sre_authority/task_schema_conflicts.py new file mode 100644 index 000000000..7f5c14c20 --- /dev/null +++ b/tests/e2e/sre_authority/task_schema_conflicts.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""The same bounded negative PATCH/restore used by full and early native tests.""" + +from contextlib import contextmanager +import copy +import json + +from .common import SYSTEM, require +from .registration_schema import CRD_PATH, write_report +from .ssa_diagnostics import manager_class + +TASK_NAME = "karstasks.kars.azure.com" +TASK_PATH = f"{CRD_PATH}/{TASK_NAME}" + + +def read_task_schema(h): + obj = h.api("GET", TASK_PATH, status=200).json() + require(isinstance(obj, dict) and obj.get("kind") == "CustomResourceDefinition" + and obj.get("apiVersion") == "apiextensions.k8s.io/v1", + "Task fixture did not read the actual CRD") + meta = obj.get("metadata", {}) + require(meta.get("name") == TASK_NAME and not meta.get("deletionTimestamp") + and all(isinstance(meta.get(key), str) and meta[key] for key in ("uid", "resourceVersion")), + "Task fixture lost its stable CRD identity") + return obj + + +def require_task_owner(obj): + meta = obj["metadata"] + annotations = meta.get("annotations", {}) + require(meta.get("labels", {}).get("app.kubernetes.io/managed-by") == "Helm" + and annotations.get("meta.helm.sh/release-name") == "kars" + and annotations.get("meta.helm.sh/release-namespace") == SYSTEM + and not meta.get("ownerReferences"), "Task fixture refuses foreign CRD ownership") + + +def task_manager_facts(obj): + entries = obj["metadata"].get("managedFields", []) + require(isinstance(entries, list) and len(entries) <= 64, "Task managedFields evidence is unbounded or malformed") + result = [] + for entry in entries: + require(isinstance(entry, dict), "Task managedFields entry is malformed") + fields = entry.get("fieldsV1", {}) + require(isinstance(fields, dict), "Task managedFields field set is malformed") + spec = fields.get("f:spec", {}) + metadata = fields.get("f:metadata", {}) + require(isinstance(spec, dict) and isinstance(metadata, dict), "Task managedFields evidence has invalid field roots") + annotations = metadata.get("f:annotations", {}) + require(isinstance(annotations, dict), "Task managedFields annotations are malformed") + versions = spec.get("f:versions") + require(versions is None or isinstance(versions, dict), "Task version field ownership is malformed") + if versions is not None or "f:meta.helm.sh/release-name" in annotations: + result.append({ + "managerClass": manager_class(entry.get("manager")), + "operation": entry.get("operation") if entry.get("operation") in ("Apply", "Update") else "other", + "subresource": entry.get("subresource", "") if entry.get("subresource", "") in ("", "status") else "other", + "versionsClaim": "absent" if versions is None else "whole" if not versions or "." in versions else "nested", + "releaseNameClaim": "f:meta.helm.sh/release-name" in annotations, + }) + return sorted(result, key=lambda item: json.dumps(item, sort_keys=True)) + + +def _values(obj): + return {"spec": obj["spec"], "metadata": { + key: value for key, value in obj["metadata"].items() + if key not in ("resourceVersion", "managedFields", "generation")}} + + +@contextmanager +def task_schema_conflict(h, fault): + require(fault in ("owner", "schema"), "Unknown Task negative fixture") + original = read_task_schema(h) + require_task_owner(original) + expected = copy.deepcopy(original) + patch = {"metadata": {"uid": original["metadata"]["uid"], + "resourceVersion": original["metadata"]["resourceVersion"]}} + if fault == "owner": + patch["metadata"]["annotations"] = {"meta.helm.sh/release-name": "foreign-fixture"} + expected["metadata"]["annotations"]["meta.helm.sh/release-name"] = "foreign-fixture" + else: + expected["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] = "Unreviewed public fixture description" + patch["spec"] = expected["spec"] + before_managers = task_manager_facts(original) + # Deliberately preserve the original default-manager PATCH contract until + # native evidence establishes whether it changes SSA field ownership. + h.api("PATCH", TASK_PATH, body=patch, status=200) + try: + changed = read_task_schema(h) + require(_values(changed) == _values(expected), "Task negative fixture changed outside its exact intended delta") + yield changed + finally: + live = read_task_schema(h) + require(_values(live) == _values(expected), "Task changed externally; fixture restoration was not issued") + restore = {"metadata": {"uid": original["metadata"]["uid"], + "resourceVersion": live["metadata"]["resourceVersion"]}, + "spec": original["spec"]} + if fault == "owner": + restore["metadata"]["annotations"] = { + "meta.helm.sh/release-name": original["metadata"]["annotations"]["meta.helm.sh/release-name"]} + h.api("PATCH", TASK_PATH, body=restore, status=200) + restored = read_task_schema(h) + require(_values(restored) == _values(original), "Task fixture did not restore its exact original values and UID") + require_task_owner(restored) + write_report(h.root, f"migration-seed-task-{fault}-restore.json", { + "kind": "KarsTask", "case": fault, "valuesAndUidRestored": True, + "beforeManagers": before_managers, "afterManagers": task_manager_facts(restored), + }) diff --git a/tests/e2e/sre_authority/task_schema_payload.mjs b/tests/e2e/sre_authority/task_schema_payload.mjs new file mode 100644 index 000000000..4fec44359 --- /dev/null +++ b/tests/e2e/sre_authority/task_schema_payload.mjs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Internal fixture adapter, not a CLI command. Build and validate with the +// actual compiled production helpers; never reimplement JS hashes in Python. +let phase = "helper-import"; +try { + const { buildSchemaWriteRequest, verifySchemaWritePreview } = await import( + "../../../cli/dist/lib/schema-write-request.js"); + const { normalizedCrd, schemaDocuments, schemaIdentity, verifySchemaOwner } = await import( + "../../../cli/dist/lib/schema-documents.js"); + if (typeof buildSchemaWriteRequest !== "function" || typeof verifySchemaWritePreview !== "function") { + throw new Error("Required production helper exports are missing"); + } + const mode = process.argv[2]; + phase = "mode"; + if (process.argv.length !== 3 || !["check", "build", "validate"].includes(mode)) { + throw new Error("Unsupported internal fixture mode"); + } + if (mode === "check") { + process.stdout.write(JSON.stringify({ ready: true })); + } else { + phase = "input"; + const chunks = []; + let bytes = 0; + for await (const chunk of process.stdin) { + bytes += chunk.length; + if (bytes > 8 * 1024 * 1024) throw new Error("Fixture input exceeds its bound"); + chunks.push(chunk); + } + const input = JSON.parse(Buffer.concat(chunks).toString("utf8")); + phase = "target"; + if (typeof input.rendered !== "string") throw new Error("Missing rendered chart"); + const documents = schemaDocuments(input.rendered); + if (documents.length !== 1) throw new Error("Exactly one Task CRD is required"); + const desired = documents[0]; + normalizedCrd(desired); + if (desired.metadata.name !== "karstasks.kars.azure.com" || desired.spec.names.kind !== "KarsTask" + || ["uid", "resourceVersion", "ownerReferences", "namespace"].some(key => key in desired.metadata)) { + throw new Error("Unreviewed Task chart identity"); + } + const owner = { namespace: "kars-system", release: "kars", ownership: "helm" }; + phase = "current-owner"; + normalizedCrd(input.current); + verifySchemaOwner(input.current, owner); + if (input.current.metadata.name !== desired.metadata.name) throw new Error("Another current CRD"); + const currentIdentity = schemaIdentity(input.current); + if (mode === "build") { + phase = "build"; + const request = buildSchemaWriteRequest(desired, input.current, owner); + // Keep the JSON payload as a string: Python must not reserialize numbers. + process.stdout.write(JSON.stringify({ args: request.args, input: JSON.stringify(request.object) })); + } else { + phase = "validate"; + if (typeof input.returned !== "string") throw new Error("Missing raw API response"); + const checked = JSON.parse(input.returned); + verifySchemaWritePreview(checked, desired, owner, currentIdentity.uid); + if (schemaIdentity(checked).resourceVersion !== currentIdentity.resourceVersion) { + throw new Error("Task preview changed its reviewed resourceVersion"); + } + process.stdout.write(JSON.stringify({ validated: true })); + } + } +} catch { + console.error(`SRE-TASK-PAYLOAD-FAIL ${phase}`); + process.exitCode = 1; +} diff --git a/tests/e2e/sre_authority/task_schema_preview.py b/tests/e2e/sre_authority/task_schema_preview.py new file mode 100644 index 000000000..26487e760 --- /dev/null +++ b/tests/e2e/sre_authority/task_schema_preview.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Early native Task SSA probe; no schema migration or conflict workaround.""" + +import json + +from .canonical_seed import _snapshot +from .common import SYSTEM, command_error_category, require +from .registration_schema import write_report +from .ssa_diagnostics import ssa_conflict +from .task_schema_conflicts import read_task_schema, require_task_owner, task_manager_facts, task_schema_conflict + + +def payload_helper(h, mode, value=None): + args = ["node", str(h.root / "tests/e2e/sre_authority/task_schema_payload.mjs"), mode] + return json.loads(h.run(args, **({"data": json.dumps(value)} if value is not None else {}), timeout=20)) + + +def require_task_payload_helper(h): + require((h.root / "cli/dist/lib/schema-write-request.js").is_file(), + "Early Task SSA requires Node.js 22+ and a CLI build: run npm ci && npm run build in cli before the schema tests") + require(payload_helper(h, "check") == {"ready": True}, "Compiled production schema helper is unavailable") + + +def task_preview(h, rendered, case): + require(case in ("before-negatives", "after-owner-restore", "after-schema-restore"), "Unknown Task SSA case") + current = read_task_schema(h) + require_task_owner(current) + request = payload_helper(h, "build", {"rendered": rendered, "current": current}) + require(request.get("args") == ["apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json"] + and isinstance(request.get("input"), str), "Production helper returned an unexpected Task SSA request") + report = {"kind": "KarsTask", "case": case, "managerFacts": task_manager_facts(current)} + try: + result = h.k(*request["args"], "--dry-run=server", "--request-timeout=20s", + data=request["input"], expected=None, timeout=25) + if result.returncode: + conflict = ssa_conflict(result.stderr) + report.update(category="api-rejection" if conflict else command_error_category(result.stderr)) + if conflict: + report.update(reason="Conflict", **conflict) + write_report(h.root, f"migration-seed-task-ssa-{case}.json", report) + raise AssertionError("Task SSA server-preview failed; see fixed conflict/manager evidence") + require(payload_helper(h, "validate", {"rendered": rendered, "current": current, "returned": result.stdout}) + == {"validated": True}, "Production helper did not validate the exact Task preview") + finally: + require(read_task_schema(h) == current, "Task SSA preview persisted a schema or field-ownership change") + report.update(category="accepted", nonPersistent=True) + write_report(h.root, f"migration-seed-task-ssa-{case}.json", report) + + +def exercise_task_restore_preview(h): + require_task_payload_helper(h) + rendered = h.run(["helm", "template", "kars", str(h.root / "deploy/helm/kars"), + "--namespace", SYSTEM, "--show-only", "templates/crd-karstask.yaml"]) + before = _snapshot(h) + try: + task_preview(h, rendered, "before-negatives") + for fault in ("owner", "schema"): + with task_schema_conflict(h, fault) as changed: + if fault == "owner": + require(changed["metadata"]["annotations"]["meta.helm.sh/release-name"] == "foreign-fixture", + "Early owner-negative mutation did not take effect") + else: + require(changed["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] + == "Unreviewed public fixture description", "Early schema-negative mutation did not take effect") + task_preview(h, rendered, f"after-{fault}-restore") + finally: + require(_snapshot(h) == before, "Task negative-restore/SSA probe changed CR data, identity or workload intent") + h.passed("Native Task SSA remained nonpersistent before and after the shared negative fixture restoration") diff --git a/tests/e2e/sre_authority/task_schema_preview_test.py b/tests/e2e/sre_authority/task_schema_preview_test.py new file mode 100644 index 000000000..39827ab89 --- /dev/null +++ b/tests/e2e/sre_authority/task_schema_preview_test.py @@ -0,0 +1,231 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit/transport contracts only; field-ownership causation requires native API evidence.""" + +import copy +import json +from pathlib import Path +import re +import subprocess +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from sre_authority.canonical_migration_test import FakeHarness +from sre_authority.schema_preparation_diagnostics import schema_preparation_failure +from sre_authority.ssa_diagnostics import MANAGERS, PATHS, ssa_conflict +from sre_authority.task_schema_conflicts import TASK_NAME, TASK_PATH, task_manager_facts, task_schema_conflict +from sre_authority.task_schema_preview import exercise_task_restore_preview + + +class TaskSchemaPreviewTests(unittest.TestCase): + def setUp(self): + self.reports = [] + for module in ("task_schema_preview", "task_schema_conflicts"): + reporter = patch(f"sre_authority.{module}.write_report", side_effect=lambda _root, file, facts: + self.reports.append((file, copy.deepcopy(facts)))) + reporter.start() + self.addCleanup(reporter.stop) + + def harness(self, failure_at=None, metadata_conflict=None, returned_change=None): + h = FakeHarness() + h.root = Path(__file__).resolve().parents[3] + target = copy.deepcopy(h.objects[("crd", TASK_NAME)]) + for key in ("uid", "resourceVersion", "managedFields"): + target["metadata"].pop(key) + target["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] = "Current public chart fixture" + target["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]["properties"]["numeric"] = { + "type": "number", "minimum": 1e-7, "maximum": 1e21} + h.rendered = json.dumps(target) + def run(args, data=None, timeout=20): + if args[0] == "helm": + return h.rendered + self.assertEqual(args[:2], ["node", str(h.root / "tests/e2e/sre_authority/task_schema_payload.mjs")]) + result = subprocess.run(args, cwd=h.root, input=data, capture_output=True, text=True, + timeout=timeout, check=False) + if result.returncode: + self.fail(f"Production fixture helper rejected input at {args[2]}") + return result.stdout + h.run = run + h.previews = [] + h.raw_previews = [] + def k(*args, data, **kwargs): + self.assertEqual(args, ("apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json", + "--dry-run=server", "--request-timeout=20s")) + self.assertEqual(kwargs, {"expected": None, "timeout": 25}) + obj = json.loads(data) + current = h.objects[("crd", TASK_NAME)] + self.assertEqual(obj["metadata"]["uid"], current["metadata"]["uid"]) + self.assertEqual(obj["metadata"]["resourceVersion"], current["metadata"]["resourceVersion"]) + self.assertEqual(obj["spec"], target["spec"]) + self.assertNotIn("managedFields", obj["metadata"]) + annotations = obj["metadata"]["annotations"] + self.assertEqual(annotations["kars.azure.com/core-schema-owner"], + '{"namespace":"kars-system","ownership":"helm","release":"kars"}') + self.assertRegex(annotations["kars.azure.com/core-schema-spec"], r"^[0-9a-f]{64}$") + h.previews.append(copy.deepcopy(obj)) + h.raw_previews.append(data) + if metadata_conflict is not None: + self.assertIn(metadata_conflict, annotations) + return SimpleNamespace(returncode=1, stdout="", stderr= + f'error: Apply failed with 1 conflict: conflict with "Python-urllib" using apiextensions.k8s.io/v1: .metadata.annotations.{metadata_conflict}\n') + if len(h.previews) == failure_at: + return SimpleNamespace(returncode=1, stdout="", stderr= + 'error: Apply failed with 1 conflict: conflict with "Python-urllib" using apiextensions.k8s.io/v1: .spec.versions\n') + if returned_change: + returned_change(obj) + return SimpleNamespace(returncode=0, stdout=json.dumps(obj), stderr="") + h.k = k + return h + + def test_full_payload_bytes_match_direct_production_builder_including_js_numeric_digest(self): + h = self.harness() + current = copy.deepcopy(h.objects[("crd", TASK_NAME)]) + script = """ +import {readFileSync} from 'node:fs'; +import {schemaDocuments} from './cli/dist/lib/schema-documents.js'; +import {buildSchemaWriteRequest} from './cli/dist/lib/schema-write-request.js'; +const {rendered,current}=JSON.parse(readFileSync(0,'utf8')); +const request=buildSchemaWriteRequest(schemaDocuments(rendered)[0],current, + {namespace:'kars-system',release:'kars',ownership:'helm'}); +process.stdout.write(JSON.stringify({args:request.args,input:JSON.stringify(request.object)})); +""" + result = subprocess.run(["node", "--input-type=module", "-e", script], cwd=h.root, + input=json.dumps({"rendered": h.rendered, "current": current}), + text=True, capture_output=True, timeout=20, check=True) + expected = json.loads(result.stdout) + exercise_task_restore_preview(h) + self.assertEqual(h.raw_previews[0], expected["input"]) + self.assertIn('"minimum":1e-7', h.raw_previews[0]) + self.assertNotIn('"minimum":1e-07', h.raw_previews[0]) + self.assertEqual(expected["args"], ["apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json"]) + + def test_metadata_only_owner_and_digest_conflicts_cannot_false_pass(self): + for annotation in ("kars.azure.com/core-schema-owner", "kars.azure.com/core-schema-spec"): + h = self.harness(metadata_conflict=annotation) + before = copy.deepcopy(h.objects) + with self.subTest(annotation=annotation), self.assertRaisesRegex(AssertionError, "SSA server-preview failed"): + exercise_task_restore_preview(h) + self.assertEqual(h.objects, before) + self.assertEqual(self.reports[-1][1]["conflictFields"], [f"metadata/annotations/{annotation}"]) + self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) + + def test_exact_production_response_validation_rejects_added_schema_and_foreign_owner(self): + changes = [ + lambda obj: obj["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]["spec"]["properties"].update( + unexpected={"type": "string"}), + lambda obj: obj["metadata"]["annotations"].update({"meta.helm.sh/release-name": "foreign"}), + lambda obj: obj["metadata"].update(resourceVersion="different"), + ] + for change in changes: + h = self.harness(returned_change=change) + before = copy.deepcopy(h.objects) + with self.subTest(change=change), self.assertRaisesRegex(AssertionError, "helper rejected input at validate"): + exercise_task_restore_preview(h) + self.assertEqual(h.objects, before) + self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) + + def test_early_probe_uses_shared_four_patch_sequence_and_exact_ssa_flags_without_persistence(self): + h = self.harness() + original = copy.deepcopy(h.objects) + exercise_task_restore_preview(h) + self.assertEqual(len(h.previews), 3) + patches = [body for method, path, body in h.calls if method == "PATCH" and path == TASK_PATH] + self.assertEqual(len(patches), 4) + self.assertEqual(patches[0]["metadata"]["annotations"], {"meta.helm.sh/release-name": "foreign-fixture"}) + self.assertEqual(patches[1]["spec"], original[("crd", TASK_NAME)]["spec"]) + self.assertEqual(patches[2]["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"], + "Unreviewed public fixture description") + self.assertEqual(patches[3]["spec"], original[("crd", TASK_NAME)]["spec"]) + self.assertTrue(all(path == TASK_PATH for method, path, _body in h.calls if method == "PATCH")) + current = h.objects[("crd", TASK_NAME)] + self.assertEqual(current["spec"], original[("crd", TASK_NAME)]["spec"]) + self.assertEqual(current["metadata"]["uid"], original[("crd", TASK_NAME)]["metadata"]["uid"]) + self.assertEqual([facts["case"] for _file, facts in self.reports if "nonPersistent" in facts], + ["before-negatives", "after-owner-restore", "after-schema-restore"]) + + def test_first_preview_failure_does_not_mutate_negative_fixtures_or_hide_conflicts(self): + h = self.harness(failure_at=1) + original = copy.deepcopy(h.objects) + with self.assertRaisesRegex(AssertionError, "Task SSA server-preview failed"): + exercise_task_restore_preview(h) + self.assertEqual(h.objects, original) + self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) + self.assertEqual(self.reports[-1][1]["conflictFields"], ["spec/versions"]) + + def test_after_restore_conflict_fails_explicitly_without_force_retries_or_schema_writes(self): + h = self.harness(failure_at=3) + original = copy.deepcopy(h.objects[("crd", TASK_NAME)]) + with self.assertRaisesRegex(AssertionError, "Task SSA server-preview failed"): + exercise_task_restore_preview(h) + self.assertEqual(len(h.previews), 3) + self.assertEqual(h.objects[("crd", TASK_NAME)]["spec"], original["spec"]) + facts = self.reports[-1][1] + self.assertEqual(facts["case"], "after-schema-restore") + self.assertEqual(facts["conflictManagers"], ["Python-urllib"]) + self.assertEqual(facts["conflictKind"], "field-manager") + + def test_restoration_refuses_external_changes_instead_of_overwriting_them(self): + h = self.harness() + with self.assertRaisesRegex(AssertionError, "changed externally"): + with task_schema_conflict(h, "schema"): + h.objects[("crd", TASK_NAME)]["spec"]["external"] = "retained" + self.assertEqual(h.objects[("crd", TASK_NAME)]["spec"]["external"], "retained") + self.assertEqual(sum(method == "PATCH" for method, _path, _body in h.calls), 1) + + def test_foreign_initial_owner_is_not_adopted(self): + h = self.harness() + h.objects[("crd", TASK_NAME)]["metadata"]["annotations"]["meta.helm.sh/release-name"] = "foreign" + with self.assertRaisesRegex(AssertionError, "foreign CRD ownership"): + with task_schema_conflict(h, "owner"): + self.fail("Foreign owner entered the fixture") + self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) + + def test_managed_fields_reports_only_known_manager_classes_and_fixed_claim_flags(self): + h = FakeHarness() + obj = h.objects[("crd", TASK_NAME)] + obj["metadata"]["managedFields"].append({ + "manager": "PRIVATE-MANAGER", "operation": "Update", "time": "PRIVATE-TIME", + "fieldsV1": {"f:spec": {"f:versions": {"k:PRIVATE-KEY": {}}}, + "f:metadata": {"f:annotations": {"f:meta.helm.sh/release-name": {}}}}, + }) + facts = task_manager_facts(obj) + self.assertNotIn("PRIVATE", json.dumps(facts)) + self.assertEqual({item["versionsClaim"] for item in facts}, {"whole", "nested"}) + self.assertIn("other", {item["managerClass"] for item in facts}) + + def test_conflict_parser_and_native_capture_are_closed_and_match_cli_vocabulary(self): + stderr = 'error: Apply failed with 1 conflict: conflict with "PRIVATE-MANAGER": .spec.versions\n' + facts = ssa_conflict(stderr) + self.assertEqual(facts, {"conflictKind": "field-manager", "conflictCount": 1, + "conflictFields": ["spec/versions"], "conflictManagers": ["other"]}) + record = {"step": "schema-server-preview", "source": "cli/src/lib/schema-stage.ts", + "kind": "KarsTask", "category": "api-rejection", "reason": "Conflict", **facts} + self.assertEqual(schema_preparation_failure("SRE-SCHEMA-PREPARATION " + json.dumps(record)), record) + self.assertIsNone(schema_preparation_failure("SRE-SCHEMA-PREPARATION " + json.dumps({ + **record, "conflictManagers": ["PRIVATE-MANAGER"]}))) + self.assertIsNone(ssa_conflict('Error from server (Conflict): the object has been modified')) + cas = ssa_conflict('error: Operation cannot be fulfilled on customresourcedefinitions.apiextensions.k8s.io ' + '"PRIVATE-NAME": the object has been modified; please apply your changes to the latest version and try again') + self.assertEqual(cas, {"conflictKind": "resource-version"}) + cas_record = {key: value for key, value in record.items() if not key.startswith("conflict")} + cas_record.update(cas) + self.assertEqual(schema_preparation_failure("SRE-SCHEMA-PREPARATION " + json.dumps(cas_record)), cas_record) + self.assertIsNone(ssa_conflict('error: Apply failed with 2 conflicts: conflict with "helm": .spec.versions')) + root = Path(__file__).resolve().parents[3] + source = (root / "cli/src/lib/schema-ssa-conflicts.ts").read_text() + manager_set = re.search(r"const managerClasses = new Set\(\[(.*?)\]\);", source, re.S).group(1) + self.assertEqual(set(re.findall(r'"([^"]+)"', manager_set)), MANAGERS) + paths = re.search(r"const conflictPaths:.*?= \{(.*?)\};", source, re.S).group(1) + self.assertEqual(dict(re.findall(r'"([^"]+)": "([^"]+)"', paths)), PATHS) + + def test_existing_early_gate_runs_task_reproduction_before_the_new_registration_create(self): + source = Path(__file__).with_name("legacy_crd_probe.py").read_text() + self.assertLess(source.index("require_task_payload_helper(h)"), source.index("with kind_proxy(root)")) + self.assertLess(source.index("dry_run_seed_data(h)"), source.index("exercise_task_restore_preview(h)")) + self.assertLess(source.index("exercise_task_restore_preview(h)"), source.index("create_registration_crd(h, obj)")) + + +if __name__ == "__main__": + unittest.main() From a4cc7483edb242cd43ec2fba4f05a4e547c44fc3 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 12:06:36 +0200 Subject: [PATCH 072/111] Trace private observer target requests without exposing upstream contents Preserve the default kube client stack and deadlines; keep request-local progress and suppress raw logging through complete response decoding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../observation_diagnostics.py | 21 +- .../test_observation_diagnostics.py | 38 +- docs/how-to/governed-credential-grants.md | 7 + inference-router/src/service_observation.rs | 21 +- .../src/service_observation_client.rs | 248 +++++++++++++ .../src/service_observation_client_tests.rs | 336 ++++++++++++++++++ 6 files changed, 666 insertions(+), 5 deletions(-) create mode 100644 inference-router/src/service_observation_client.rs create mode 100644 inference-router/src/service_observation_client_tests.rs diff --git a/bridge/tests/native-credentials/observation_diagnostics.py b/bridge/tests/native-credentials/observation_diagnostics.py index 2c3383158..6f8fa4fdd 100644 --- a/bridge/tests/native-credentials/observation_diagnostics.py +++ b/bridge/tests/native-credentials/observation_diagnostics.py @@ -35,6 +35,13 @@ "router": "kars_inference_router::observation_privacy", } +CLIENT_FIELDS = ( + "client_initialized", "request_built", "service_entered", "dispatch_observable", "after_auth_dispatch", + "response_headers", "config_observed", "https", "tls_verification", + "root_ca_present", "token_file_only", "proxy_configured", + "endpoint_environment_matches", "runtime_namespace_matches", +) + def project(raw, component): if component not in TARGETS: @@ -48,9 +55,21 @@ def project(raw, component): if not isinstance(value, dict) or value.get("target") != TARGETS.get(component): continue fields = value.get("fields") - if not isinstance(fields, dict) or fields.get("message") != "Private observation readiness pending": + if not isinstance(fields, dict): continue stage, status = fields.get("stage"), fields.get("http_status") + if fields.get("message") == "Private observation target client pending": + if (component != "router" or stage != "observer_target_client" + or type(status) is not int or not (status == 0 or 100 <= status <= 599) + or any(type(fields.get(key)) is not bool for key in CLIENT_FIELDS)): + continue + record = {"stage": stage, "http_status": status, + **{key: fields[key] for key in CLIENT_FIELDS}} + if not records or records[-1] != record: + records.append(record) + continue + if fields.get("message") != "Private observation readiness pending": + continue if (not isinstance(stage, str) or stage not in STAGES or type(status) is not int or not (status == 0 or 100 <= status <= 599) or type(fields.get("timeout")) is not bool or type(fields.get("connect")) is not bool): diff --git a/bridge/tests/native-credentials/test_observation_diagnostics.py b/bridge/tests/native-credentials/test_observation_diagnostics.py index 68177029b..576da249c 100644 --- a/bridge/tests/native-credentials/test_observation_diagnostics.py +++ b/bridge/tests/native-credentials/test_observation_diagnostics.py @@ -7,7 +7,7 @@ from native_api import BRIDGE, CORE, WRITER, core, resource import api_outcome_diagnostics as api_outcomes -from observation_diagnostics import TARGETS, VERSION, collect, project +from observation_diagnostics import CLIENT_FIELDS, TARGETS, VERSION, collect, project def event(component="router", **updates): @@ -82,6 +82,42 @@ def logs(*args, **kwargs): class ObservationDiagnosticsTests(unittest.TestCase): + def test_client_boundaries_are_fixed_value_free_and_do_not_claim_packet_delivery(self): + flags = {key: False for key in CLIENT_FIELDS} + flags.update(client_initialized=True, request_built=True, service_entered=True, + dispatch_observable=True, after_auth_dispatch=True, + config_observed=True, tls_verification=True) + raw = event(message="Private observation target client pending", stage="observer_target_client", + http_status=0, url="private-url-canary", token="private-token-canary", + certificate="private-certificate-canary", **flags) + self.assertEqual(project(raw, "router"), [ + {"stage": "observer_target_client", "http_status": 0, **flags}]) + self.assertNotIn("canary", json.dumps(project(raw, "router"))) + self.assertEqual(project(raw, "controller"), []) + self.assertNotIn("packet", json.dumps(project(raw, "router"))) + + def test_client_boundary_fields_require_booleans_and_current_router_provenance(self): + flags = {key: False for key in CLIENT_FIELDS} + valid = json.loads(event(message="Private observation target client pending", + stage="observer_target_client", http_status=0, **flags)) + for field in CLIENT_FIELDS: + for invalid in ("false", 0, None, [], {}): + value = copy.deepcopy(valid) + value["fields"][field] = invalid + self.assertEqual(project(json.dumps(value), "router"), []) + value = copy.deepcopy(valid) + del value["fields"][field] + self.assertEqual(project(json.dumps(value), "router"), []) + fixture = Fixture() + with patch("observation_diagnostics.command", return_value=json.dumps(valid)): + result = collect(SimpleNamespace(admin=fixture), TARGET) + self.assertEqual(result["samples"][1]["records"][0]["stage"], "observer_target_client") + fixture.objects[POD]["metadata"]["ownerReferences"][0]["uid"] = "replaced" + with patch("observation_diagnostics.command", return_value=json.dumps(valid)): + result = collect(SimpleNamespace(admin=fixture), TARGET) + self.assertFalse(result["samples"][1]["available"]) + self.assertEqual(result["samples"][1]["records"], []) + def test_only_fixed_fields_survive(self): result = project(event(error="private-body-canary", token="private-token-canary"), "router") self.assertEqual(result, [{"stage": "observer_target_read", "http_status": 403, diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index edc797c12..06899b1f2 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -203,6 +203,13 @@ An HTTP 403 does not alone distinguish bearer rejection from a failed live proof. Diagnostics do not make `Prepared` ready, change denial responses, cache proofs, or replace TLS, network, rotation, and unauthorized-peer tests. +`observer_target_client` adds request-local progress for client initialization, +request construction, service entry, post-auth dispatch and response headers, +plus bounded configuration-match facts. Dispatch does not prove packet delivery. +The complete target request, including body/error decoding, suppresses raw +library logging; the caller emits bounded diagnostics outside that scope. +Sibling requests keep their own logging and progress state. + ## Operator workflow Private writer/observation activation is an additional review in the existing diff --git a/inference-router/src/service_observation.rs b/inference-router/src/service_observation.rs index 5cd3af7b3..e801a2ed2 100644 --- a/inference-router/src/service_observation.rs +++ b/inference-router/src/service_observation.rs @@ -17,6 +17,9 @@ use serde_json::json; use std::{path::Path, sync::Arc}; use tokio::sync::OnceCell; +#[path = "service_observation_client.rs"] +mod client_diagnostics; + pub struct Observer { binding: Binding, token: String, @@ -79,7 +82,7 @@ impl Observer { .get_or_try_init(|| async { let config = kube::Config::incluster() .map_err(|_| "Observation metadata identity unavailable")?; - Client::try_from(config) + client_diagnostics::client(config) .map_err(|_| "Observation metadata client unavailable".into()) }) .await @@ -107,7 +110,12 @@ impl Observer { return Err("Observation service identity changed".into()); } diagnostic.stage("observer_metadata_client"); + let pending = client_diagnostics::Pending(client_diagnostics::Progress::new(format!( + "kars-{}", + scope.identity.sandbox.name + ))); let client = self.client().await?; + pending.0.initialized(); let namespace = scope.identity.sandbox.namespace.as_str(); let sandbox_name = scope.identity.sandbox.name.as_str(); let resource = ApiResource::from_gvk(&GroupVersionKind::gvk( @@ -116,13 +124,20 @@ impl Observer { "KarsSandbox", )); diagnostic.stage("observer_target_read"); - let sandbox = Api::<DynamicObject>::namespaced_with(client.clone(), namespace, &resource) - .get(sandbox_name) + let api = Api::<DynamicObject>::namespaced_with(client.clone(), namespace, &resource); + let mut request = kube::core::Request::new(api.resource_url()) + .get(sandbox_name, &Default::default()) + .map_err(|_| "Observation target request cannot be built")?; + request.extensions_mut().insert("get"); + request.extensions_mut().insert(pending.0.clone()); + pending.0.built(); + let sandbox = client_diagnostics::read_target(client, request, &pending.0) .await .map_err(|error| { diagnostic.api(&error); "Observation target cannot be verified" })?; + pending.0.decoded(); diagnostic.stage("observer_target_current"); let observed = &sandbox.data["status"][STATUS_FIELD]; if sandbox.metadata.uid.as_deref() != Some(scope.identity.sandbox.uid.as_str()) diff --git a/inference-router/src/service_observation_client.rs b/inference-router/src/service_observation_client.rs new file mode 100644 index 000000000..38c062270 --- /dev/null +++ b/inference-router/src/service_observation_client.rs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Request-local observations of the unchanged kube-client stack. No HTTP +//! request fields, credentials, endpoints or upstream error text are recorded. + +use axum::http::{Request, Response}; +use futures::future::BoxFuture; +use kube::{ + Client, Config, + client::{Body, ClientBuilder}, + core::DynamicObject, +}; +use std::{ + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, + task::{Context, Poll}, +}; +use tower::{Layer, Service}; +use tracing::{ + Subscriber, + instrument::WithSubscriber, + span::{Attributes, Id, Record}, +}; + +const INITIALIZED: u16 = 1; +const BUILT: u16 = 2; +const ENTERED: u16 = 4; +const DISPATCH: u16 = 8; +const HEADERS: u16 = 16; +const DECODED: u16 = 32; +const HTTPS: u16 = 64; +const TLS: u16 = 128; +const CA: u16 = 256; +const TOKEN_FILE: u16 = 512; +const PROXY: u16 = 1024; +const ENVIRONMENT: u16 = 2048; +const NAMESPACE: u16 = 4096; + +#[derive(Clone)] +pub(super) struct Progress { + bits: Arc<AtomicU16>, + status: Arc<AtomicU16>, + namespace: String, +} + +impl Progress { + pub(super) fn new(namespace: String) -> Self { + Self { + bits: Arc::new(AtomicU16::new(0)), + status: Arc::new(AtomicU16::new(0)), + namespace, + } + } + fn set(&self, bits: u16) { + self.bits.fetch_or(bits, Ordering::Relaxed); + } + pub(super) fn initialized(&self) { + self.set(INITIALIZED); + } + pub(super) fn built(&self) { + self.set(BUILT); + } + pub(super) fn decoded(&self) { + self.set(DECODED); + } +} + +pub(super) struct Pending(pub(super) Progress); + +pub(super) async fn read_target( + client: &Client, + request: Request<Vec<u8>>, + progress: &Progress, +) -> Result<DynamicObject, kube::Error> { + // kube-client also logs malformed payloads while collecting/decoding after + // the service has returned headers. Keep the entire request inside this + // scope; the caller's bounded Pending diagnostic is deliberately outside. + client + .request(request) + .with_subscriber(tracing::Dispatch::new(HttpBoundary(progress.clone()))) + .await +} + +impl Drop for Pending { + fn drop(&mut self) { + let bits = self.0.bits.load(Ordering::Relaxed); + if bits & DECODED != 0 { + return; + } + tracing::warn!(target: "kars_inference_router::observation_privacy", + stage = "observer_target_client", + client_initialized = bits & INITIALIZED != 0, + request_built = bits & BUILT != 0, + service_entered = bits & ENTERED != 0, + dispatch_observable = bits & ENTERED != 0 + && tracing::level_filters::STATIC_MAX_LEVEL >= tracing::level_filters::LevelFilter::DEBUG, + after_auth_dispatch = bits & DISPATCH != 0, + response_headers = bits & HEADERS != 0, + config_observed = bits & ENTERED != 0, + https = bits & HTTPS != 0, + tls_verification = bits & TLS != 0, + root_ca_present = bits & CA != 0, + token_file_only = bits & TOKEN_FILE != 0, + proxy_configured = bits & PROXY != 0, + endpoint_environment_matches = bits & ENVIRONMENT != 0, + runtime_namespace_matches = bits & NAMESPACE != 0, + http_status = self.0.status.load(Ordering::Relaxed), + "Private observation target client pending"); + } +} + +#[derive(Clone)] +struct ClientLayer { + bits: u16, + namespace: String, +} + +pub(super) fn client(config: Config) -> Result<Client, kube::Error> { + let mut bits = 0; + if config.cluster_url.scheme_str() == Some("https") { + bits |= HTTPS; + } + if !config.accept_invalid_certs { + bits |= TLS; + } + if config + .root_cert + .as_ref() + .is_some_and(|certs| !certs.is_empty()) + { + bits |= CA; + } + if config.proxy_url.is_some() { + bits |= PROXY; + } + let auth = &config.auth_info; + if auth.token_file.as_deref() == Some("/var/run/secrets/kubernetes.io/serviceaccount/token") + && auth.token.is_none() + && auth.username.is_none() + && auth.password.is_none() + && auth.exec.is_none() + && auth.auth_provider.is_none() + { + bits |= TOKEN_FILE; + } + let host = std::env::var("KUBERNETES_SERVICE_HOST").ok(); + let port = std::env::var("KUBERNETES_SERVICE_PORT") + .ok() + .and_then(|value| value.parse::<u16>().ok()); + if host.as_deref() == config.cluster_url.host() && port == config.cluster_url.port_u16() { + bits |= ENVIRONMENT; + } + let layer = ClientLayer { + bits, + namespace: config.default_namespace.clone(), + }; + Ok(ClientBuilder::try_from(config)?.with_layer(&layer).build()) +} + +struct Observed<S> { + inner: S, + config: ClientLayer, +} + +impl<S> Layer<S> for ClientLayer { + type Service = Observed<S>; + fn layer(&self, inner: S) -> Self::Service { + Observed { + inner, + config: self.clone(), + } + } +} + +impl<S, B> Service<Request<Body>> for Observed<S> +where + S: Service<Request<Body>, Response = Response<B>>, + S::Future: Send + 'static, + S::Error: Send + 'static, + B: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>; + + fn poll_ready(&mut self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { + self.inner.poll_ready(context) + } + + fn call(&mut self, request: Request<Body>) -> Self::Future { + let Some(progress) = request.extensions().get::<Progress>().cloned() else { + return Box::pin(self.inner.call(request)); + }; + progress.set( + ENTERED + | self.config.bits + | if progress.namespace == self.config.namespace { + NAMESPACE + } else { + 0 + }, + ); + let dispatch = tracing::Dispatch::new(HttpBoundary(progress.clone())); + let future = tracing::dispatcher::with_default(&dispatch, || self.inner.call(request)); + Box::pin(async move { + let result = future.with_subscriber(dispatch).await; + if let Ok(response) = &result { + progress.set(HEADERS); + progress + .status + .store(response.status().as_u16(), Ordering::Relaxed); + } + result + }) + } +} + +// kube-client 3.1's default builder places its HTTP trace span *inside* the +// authentication layer (client/builder.rs). Observing that span proves dispatch +// beyond auth, not a TCP connection or packet delivery. The scoped subscriber +// discards every span field/event, including URLs and upstream error bodies. +struct HttpBoundary(Progress); + +impl Subscriber for HttpBoundary { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.is_span() + && metadata.name() == "HTTP" + && metadata.target() == "kube_client::client::builder" + } + fn new_span(&self, attributes: &Attributes<'_>) -> Id { + if self.enabled(attributes.metadata()) { + self.0.set(DISPATCH); + } + Id::from_u64(1) + } + fn record(&self, _: &Id, _: &Record<'_>) {} + fn record_follows_from(&self, _: &Id, _: &Id) {} + fn event(&self, _: &tracing::Event<'_>) {} + fn enter(&self, _: &Id) {} + fn exit(&self, _: &Id) {} +} + +#[cfg(test)] +#[path = "service_observation_client_tests.rs"] +mod tests; diff --git a/inference-router/src/service_observation_client_tests.rs b/inference-router/src/service_observation_client_tests.rs new file mode 100644 index 000000000..25d9f4ca8 --- /dev/null +++ b/inference-router/src/service_observation_client_tests.rs @@ -0,0 +1,336 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use kube::core::DynamicObject; +use serde_json::json; +use std::{io::Write, path::PathBuf, sync::Mutex, time::Duration}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, +}; + +struct TokenFile(PathBuf); + +impl TokenFile { + fn new() -> Self { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!( + ".observer-client-token-{}.fixture", + rand::random::<u64>() + )); + std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .unwrap() + .write_all(b"observer-test-token") + .unwrap(); + Self(path) + } +} + +impl Drop for TokenFile { + fn drop(&mut self) { + std::fs::remove_file(&self.0).unwrap(); + } +} + +fn request(progress: &Progress, name: &str) -> Request<Vec<u8>> { + let mut request = + kube::core::Request::new("/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes") + .get(name, &Default::default()) + .unwrap(); + request.extensions_mut().insert("get"); + request.extensions_mut().insert(progress.clone()); + progress.built(); + request +} + +fn configured(server: &MockServer) -> Config { + let mut config = Config::new(server.uri().parse().unwrap()); + config.default_namespace = "kars-runtime".into(); + config +} + +#[tokio::test] +async fn actual_http_token_file_dispatch_keeps_auth_and_per_request_progress() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/runtime", + )) + .and(header("authorization", "Bearer observer-test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":"runtime","uid":"runtime-uid","resourceVersion":"1"} + }))) + .expect(4) + .mount(&server) + .await; + let token = TokenFile::new(); + let mut config = configured(&server); + config.auth_info.token_file = Some(token.0.to_str().unwrap().into()); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = client(config).unwrap(); + let calls = (0..4).map(|_| { + let client = client.clone(); + async move { + let progress = Progress::new("kars-runtime".into()); + progress.initialized(); + let value = read_target(&client, request(&progress, "runtime"), &progress) + .await + .unwrap(); + assert_eq!(value.metadata.uid.as_deref(), Some("runtime-uid")); + progress.decoded(); + assert_eq!(progress.status.load(Ordering::Relaxed), 200); + let bits = progress.bits.load(Ordering::Relaxed); + assert_eq!( + bits & (INITIALIZED | BUILT | ENTERED | DISPATCH | HEADERS | DECODED | NAMESPACE), + INITIALIZED | BUILT | ENTERED | DISPATCH | HEADERS | DECODED | NAMESPACE + ); + } + }); + futures::future::join_all(calls).await; +} + +#[tokio::test] +async fn cancelled_http_wait_is_distinct_from_predispatch_and_wrong_identity_is_not_hidden() { + let server = MockServer::start().await; + Mock::given(path( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/slow", + )) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + Mock::given(path("/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/denied")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","code":403,"reason":"Forbidden","message":"fixture denial" + }))).mount(&server).await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = client(configured(&server)).unwrap(); + let slow = Progress::new("kars-runtime".into()); + let result = tokio::time::timeout( + Duration::from_millis(200), + read_target(&client, request(&slow, "slow"), &slow), + ) + .await; + assert!(result.is_err()); + assert_eq!( + slow.bits.load(Ordering::Relaxed) & (ENTERED | DISPATCH | HEADERS), + ENTERED | DISPATCH + ); + let denied = Progress::new("different-runtime".into()); + let error = read_target(&client, request(&denied, "denied"), &denied) + .await + .unwrap_err(); + assert!(matches!(error, kube::Error::Api(status) if status.code == 403)); + assert_eq!(denied.status.load(Ordering::Relaxed), 403); + assert_eq!( + denied.bits.load(Ordering::Relaxed) & (HEADERS | NAMESPACE), + HEADERS + ); + assert_eq!(slow.status.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn default_stack_still_rejects_missing_token_file_and_unsupported_proxy() { + let server = MockServer::start().await; + let mut config = configured(&server); + config.auth_info.token_file = Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(format!( + ".absent-observer-token-{}.fixture", + rand::random::<u64>() + )) + .to_str() + .unwrap() + .into(), + ); + assert!(client(config).is_err()); + let mut config = configured(&server); + config.proxy_url = Some("unsupported://127.0.0.1:1".parse().unwrap()); + assert!(client(config).is_err()); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn response_headers_are_distinguished_from_response_decoding() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_string("not-json")) + .mount(&server) + .await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = client(configured(&server)).unwrap(); + let progress = Progress::new("kars-runtime".into()); + assert!( + read_target(&client, request(&progress, "runtime"), &progress) + .await + .is_err() + ); + assert_eq!( + progress.bits.load(Ordering::Relaxed) & (DISPATCH | HEADERS | DECODED), + DISPATCH | HEADERS + ); + assert_eq!(progress.status.load(Ordering::Relaxed), 200); +} + +#[derive(Clone, Default)] +struct CapturedLogs(Arc<Mutex<Vec<u8>>>); + +impl Write for CapturedLogs { + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLogs { + type Writer = Self; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +impl CapturedLogs { + fn dispatch(&self) -> tracing::Dispatch { + tracing::Dispatch::new( + tracing_subscriber::fmt() + .json() + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .with_writer(self.clone()) + .finish(), + ) + } + fn text(&self) -> String { + String::from_utf8(self.0.lock().unwrap().clone()).unwrap() + } +} + +fn error_kind(result: Result<DynamicObject, kube::Error>) -> (&'static str, u16) { + match result { + Err(kube::Error::SerdeError(_)) => ("json", 0), + Err(kube::Error::Api(response)) => ("api", response.code), + _ => panic!("unexpected controlled response class"), + } +} + +#[tokio::test] +async fn malformed_success_and_error_bodies_are_suppressed_through_complete_target_request() { + let server = MockServer::start().await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = client(configured(&server)).unwrap(); + for (name, status, canary) in [ + ("bad-success", 200, "MALFORMED_SUCCESS_PRIVATE_BODY_CANARY"), + ("bad-error", 503, "MALFORMED_ERROR_PRIVATE_BODY_CANARY"), + ] { + Mock::given(path(format!( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/{name}" + ))) + .respond_with(ResponseTemplate::new(status).set_body_string(canary)) + .expect(2) + .mount(&server) + .await; + let baseline_logs = CapturedLogs::default(); + let baseline = Progress::new("kars-runtime".into()); + let original = client + .request::<DynamicObject>(request(&baseline, name)) + .with_subscriber(baseline_logs.dispatch()) + .await; + let original_kind = error_kind(original); + // A positive control proves the upstream post-header warning is + // observable: the service-only shield does not cover body decoding. + assert!(baseline_logs.text().contains(canary)); + + let safe_logs = CapturedLogs::default(); + let safe = Progress::new("kars-runtime".into()); + let result = async { + safe.initialized(); + let pending = Pending(safe.clone()); + let result = read_target(&client, request(&safe, name), &safe).await; + drop(pending); + tracing::warn!("PUBLIC_AFTER_TARGET_REQUEST"); + result + } + .with_subscriber(safe_logs.dispatch()) + .await; + assert_eq!(error_kind(result), original_kind); + let logs = safe_logs.text(); + assert!(!logs.contains(canary)); + assert!(!logs.contains(&server.uri())); + assert!(logs.contains("PUBLIC_AFTER_TARGET_REQUEST")); + assert!(logs.contains("Private observation target client pending")); + let record: serde_json::Value = logs + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .find(|value: &serde_json::Value| { + value["fields"]["message"] == "Private observation target client pending" + }) + .unwrap(); + assert_eq!(record["fields"]["http_status"], status); + assert_eq!(record["fields"]["response_headers"], true); + assert_eq!(record["fields"]["after_auth_dispatch"], true); + assert_eq!(safe.bits.load(Ordering::Relaxed) & DECODED, 0); + } +} + +#[tokio::test] +async fn concurrent_target_body_shields_leave_sibling_logs_and_progress_request_local() { + let server = MockServer::start().await; + for (name, status, canary) in [ + ("one", 200, "CONCURRENT_ONE_PRIVATE_BODY_CANARY"), + ("two", 502, "CONCURRENT_TWO_PRIVATE_BODY_CANARY"), + ] { + Mock::given(path(format!( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/{name}" + ))) + .respond_with( + ResponseTemplate::new(status) + .set_body_string(canary) + .set_delay(Duration::from_millis(30)), + ) + .expect(1) + .mount(&server) + .await; + } + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = client(configured(&server)).unwrap(); + let logs = CapturedLogs::default(); + let run = |name: &'static str| { + let client = client.clone(); + async move { + let progress = Progress::new("kars-runtime".into()); + progress.initialized(); + let pending = Pending(progress.clone()); + let result = read_target(&client, request(&progress, name), &progress).await; + drop(pending); + (error_kind(result), progress.status.load(Ordering::Relaxed)) + } + }; + let (one, two, ()) = async { + futures::join!(run("one"), run("two"), async { + tokio::task::yield_now().await; + tracing::warn!("PUBLIC_CONCURRENT_SIBLING"); + }) + } + .with_subscriber(logs.dispatch()) + .await; + assert_eq!(one, (("json", 0), 200)); + assert_eq!(two, (("api", 502), 502)); + let text = logs.text(); + assert!(!text.contains("PRIVATE_BODY_CANARY")); + assert!(!text.contains(&server.uri())); + assert!(text.contains("PUBLIC_CONCURRENT_SIBLING")); + let diagnostics: Vec<serde_json::Value> = text + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .filter(|value: &serde_json::Value| { + value["fields"]["message"] == "Private observation target client pending" + }) + .collect(); + assert_eq!(diagnostics.len(), 2); +} From 26a5284613548ea745b2e3b005ebf1583fd9f503 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 12:40:36 +0200 Subject: [PATCH 073/111] Preserve verified Helm Apply ownership across controlled negative fixtures Preview complete owned payloads and retain UID/RV and external-drift fences; do not force or reassign production field ownership. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../sre_authority/canonical_migration_test.py | 63 +++++- .../sre_authority/task_schema_conflicts.py | 45 ++-- .../e2e/sre_authority/task_schema_helpers.py | 17 ++ tests/e2e/sre_authority/task_schema_owned.mjs | 150 +++++++++++++ .../e2e/sre_authority/task_schema_payload.mjs | 67 +++--- .../e2e/sre_authority/task_schema_preview.py | 14 +- .../sre_authority/task_schema_preview_test.py | 205 +++++++++++++++++- 7 files changed, 493 insertions(+), 68 deletions(-) create mode 100644 tests/e2e/sre_authority/task_schema_helpers.py create mode 100644 tests/e2e/sre_authority/task_schema_owned.mjs diff --git a/tests/e2e/sre_authority/canonical_migration_test.py b/tests/e2e/sre_authority/canonical_migration_test.py index f6675dd32..01dceeab4 100644 --- a/tests/e2e/sre_authority/canonical_migration_test.py +++ b/tests/e2e/sre_authority/canonical_migration_test.py @@ -4,7 +4,9 @@ """Pure checks of the native fixture; no cluster or controller execution.""" import copy +import json from pathlib import Path +import subprocess from types import SimpleNamespace import unittest from unittest.mock import patch @@ -21,7 +23,7 @@ class FakeHarness: """Transport orchestration only; this is not Kubernetes schema validation.""" def __init__(self): - self.root = Path("unused-fixture-report-root") + self.root = Path(__file__).resolve().parents[3] self.objects = { ("deployment", "kars-controller"): { "apiVersion": "apps/v1", "kind": "Deployment", @@ -36,7 +38,12 @@ def __init__(self): "metadata": {"name": name, "uid": name, "resourceVersion": "1", "labels": {"app.kubernetes.io/managed-by": "Helm"}, "annotations": {"meta.helm.sh/release-name": "kars", "meta.helm.sh/release-namespace": "kars-system"}, - "managedFields": [{"manager": "helm", "operation": "Apply", "fieldsV1": {"f:spec": {"f:versions": {}}}}]}, + "managedFields": [{"manager": "helm", "operation": "Apply", "apiVersion": "apiextensions.k8s.io/v1", + "fieldsType": "FieldsV1", "time": "2026-09-12T00:00:00Z", "fieldsV1": { + "f:metadata": { + "f:labels": {".": {}, "f:app.kubernetes.io/managed-by": {}}, + "f:annotations": {".": {}, "f:meta.helm.sh/release-name": {}, "f:meta.helm.sh/release-namespace": {}}}, + "f:spec": {"f:group": {}, "f:scope": {}, "f:names": {"f:kind": {}, "f:plural": {}}, "f:versions": {}}}}]}, "spec": {"group": "kars.azure.com", "scope": "Namespaced", "names": {"kind": "KarsTask" if name == "karstasks.kars.azure.com" else "KarsSREAction", "plural": name.split(".")[0]}, @@ -46,11 +53,58 @@ def __init__(self): self.calls = [] self.rejections = [] self.serial = 1 + self.owned_mutations = [] + self.owned_previews = [] action = self.objects[("crd", "karssreactions.kars.azure.com")] action["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"] = { "spec": {"properties": {"action": {"properties": { "params": {"type": "object", "additionalProperties": True, "description": "Public action params documentation"}}}}}} + self.manifest = copy.deepcopy(self.objects[("crd", "karstasks.kars.azure.com")]) + for key in ("uid", "resourceVersion", "managedFields"): + self.manifest["metadata"].pop(key) + + def run(self, args, data=None, timeout=20): + if args[:3] == ["helm", "get", "manifest"]: + return json.dumps(self.manifest) + assert args[:2] == ["node", str(self.root / "tests/e2e/sre_authority/task_schema_payload.mjs")] + result = subprocess.run(args, cwd=self.root, input=data, capture_output=True, text=True, + timeout=timeout, check=False) + if result.returncode: + raise AssertionError(f"Task fixture helper rejected input at {args[2]}") + return result.stdout + + def k(self, *args, data, timeout): + dry_run = "--dry-run=server" in args + assert args == ("apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json", + *(("--dry-run=server", "--show-managed-fields=true") if dry_run else ()), + "--validate=strict", "--request-timeout=20s") + assert timeout == 25 + body = json.loads(data) + current = self.objects[("crd", "karstasks.kars.azure.com")] + assert body["metadata"]["name"] == current["metadata"]["name"] + assert body["metadata"]["uid"] == current["metadata"]["uid"] + assert body["metadata"]["resourceVersion"] == current["metadata"]["resourceVersion"] + assert "managedFields" not in body["metadata"] and "status" not in body + (self.owned_previews if dry_run else self.owned_mutations).append(copy.deepcopy(body)) + current = copy.deepcopy(current) if dry_run else current + def merge(target, patch): + for key, value in patch.items(): + if isinstance(value, dict) and isinstance(target.get(key), dict): + merge(target[key], value) + else: + target[key] = copy.deepcopy(value) + merge(current, body) + if not dry_run: + current["metadata"]["resourceVersion"] = str(int(current["metadata"]["resourceVersion"]) + 1) + for key in ("categories", "shortNames"): + if current["spec"]["names"].get(key) == []: + del current["spec"]["names"][key] + current["metadata"]["generation"] = current["metadata"].get("generation", 1) + 1 + selected = next(entry for entry in current["metadata"]["managedFields"] + if entry["manager"] == "helm" and entry["operation"] == "Apply") + selected["time"] = f"2026-09-12T00:00:0{len(self.owned_mutations)}Z" + return json.dumps(current) def migrate_action_schema(self): action = self.objects[("crd", "karssreactions.kars.azure.com")] @@ -176,9 +230,10 @@ def test_negative_fixtures_use_the_public_cli_and_restore_only_exact_uid_rv_owne self.assertEqual(h.objects[("crd", "karstasks.kars.azure.com")]["spec"], before) self.assertEqual(h.objects[("crd", "karssreactions.kars.azure.com")], action) self.assertEqual(h.objects[("clusterrolebinding", "kars-sre-reader")]["subjects"], subjects) - self.assertTrue(all(method in ("GET", "PATCH") and path == f"{CRDS}/karstasks.kars.azure.com" + self.assertTrue(all(method == "GET" and path == f"{CRDS}/karstasks.kars.azure.com" for method, path, _body in h.calls)) - self.assertEqual(sum(method == "PATCH" for method, _path, _body in h.calls), 4) + self.assertEqual(len(h.owned_mutations), 4) + self.assertEqual(len(h.owned_previews), 4) def test_cleanup_is_limited_to_measured_disposable_crs_with_exact_uid_rv(self): h = FakeHarness() diff --git a/tests/e2e/sre_authority/task_schema_conflicts.py b/tests/e2e/sre_authority/task_schema_conflicts.py index 7f5c14c20..dedaec225 100644 --- a/tests/e2e/sre_authority/task_schema_conflicts.py +++ b/tests/e2e/sre_authority/task_schema_conflicts.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""The same bounded negative PATCH/restore used by full and early native tests.""" +"""Same-owner, operation-preserving SSA negatives for full and early native tests.""" from contextlib import contextmanager import copy @@ -10,6 +10,7 @@ from .common import SYSTEM, require from .registration_schema import CRD_PATH, write_report from .ssa_diagnostics import manager_class +from .task_schema_helpers import payload_helper TASK_NAME = "karstasks.kars.azure.com" TASK_PATH = f"{CRD_PATH}/{TASK_NAME}" @@ -68,42 +69,56 @@ def _values(obj): if key not in ("resourceVersion", "managedFields", "generation")}} +def _owned_request(h, original, current, manifest, fault, restore): + request = payload_helper(h, "owned-request", { + "original": original, "current": current, "manifest": manifest, "fault": fault, "restore": restore}) + require(request.get("args") == ["apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json"] + and isinstance(request.get("input"), str), "Unexpected owned fixture SSA request") + preview = json.loads(h.k(*request["args"], "--dry-run=server", "--show-managed-fields=true", + "--validate=strict", "--request-timeout=20s", + data=request["input"], timeout=25)) + _verify_owned(h, original, preview, fault, "restored" if restore else "changed") + require(preview["metadata"]["resourceVersion"] == current["metadata"]["resourceVersion"], + "Owned Task preview changed its reviewed resourceVersion") + require(read_task_schema(h) == current, "Task changed during owned SSA preflight; no mutation was issued") + h.k(*request["args"], "--validate=strict", "--request-timeout=20s", + data=request["input"], timeout=25) + + +def _verify_owned(h, original, current, fault, state): + require(payload_helper(h, "owned-check", { + "original": original, "current": current, "fault": fault, "state": state}) == {"verified": True}, + "Task fixture did not preserve its original ownership") + + @contextmanager def task_schema_conflict(h, fault): require(fault in ("owner", "schema"), "Unknown Task negative fixture") original = read_task_schema(h) require_task_owner(original) + manifest = h.run(["helm", "get", "manifest", "kars", "-n", SYSTEM], timeout=20) expected = copy.deepcopy(original) - patch = {"metadata": {"uid": original["metadata"]["uid"], - "resourceVersion": original["metadata"]["resourceVersion"]}} if fault == "owner": - patch["metadata"]["annotations"] = {"meta.helm.sh/release-name": "foreign-fixture"} expected["metadata"]["annotations"]["meta.helm.sh/release-name"] = "foreign-fixture" else: expected["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] = "Unreviewed public fixture description" - patch["spec"] = expected["spec"] before_managers = task_manager_facts(original) - # Deliberately preserve the original default-manager PATCH contract until - # native evidence establishes whether it changes SSA field ownership. - h.api("PATCH", TASK_PATH, body=patch, status=200) + _owned_request(h, original, original, manifest, fault, False) try: changed = read_task_schema(h) require(_values(changed) == _values(expected), "Task negative fixture changed outside its exact intended delta") + _verify_owned(h, original, changed, fault, "changed") yield changed finally: live = read_task_schema(h) require(_values(live) == _values(expected), "Task changed externally; fixture restoration was not issued") - restore = {"metadata": {"uid": original["metadata"]["uid"], - "resourceVersion": live["metadata"]["resourceVersion"]}, - "spec": original["spec"]} - if fault == "owner": - restore["metadata"]["annotations"] = { - "meta.helm.sh/release-name": original["metadata"]["annotations"]["meta.helm.sh/release-name"]} - h.api("PATCH", TASK_PATH, body=restore, status=200) + _owned_request(h, original, live, manifest, fault, True) restored = read_task_schema(h) require(_values(restored) == _values(original), "Task fixture did not restore its exact original values and UID") require_task_owner(restored) + _verify_owned(h, original, restored, fault, "restored") write_report(h.root, f"migration-seed-task-{fault}-restore.json", { "kind": "KarsTask", "case": fault, "valuesAndUidRestored": True, + "originalHelmApplyOwnershipPreserved": True, "beforeManagers": before_managers, "afterManagers": task_manager_facts(restored), }) diff --git a/tests/e2e/sre_authority/task_schema_helpers.py b/tests/e2e/sre_authority/task_schema_helpers.py new file mode 100644 index 000000000..b557dece9 --- /dev/null +++ b/tests/e2e/sre_authority/task_schema_helpers.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +from .common import require + + +def payload_helper(h, mode, value=None): + args = ["node", str(h.root / "tests/e2e/sre_authority/task_schema_payload.mjs"), mode] + return json.loads(h.run(args, **({"data": json.dumps(value)} if value is not None else {}), timeout=20)) + + +def require_task_payload_helper(h): + require((h.root / "cli/dist/lib/schema-write-request.js").is_file(), + "Early Task SSA requires Node.js 22+ and a CLI build: run npm ci && npm run build in cli before the schema tests") + require(payload_helper(h, "check") == {"ready": True}, "Compiled production schema helper is unavailable") diff --git a/tests/e2e/sre_authority/task_schema_owned.mjs b/tests/e2e/sre_authority/task_schema_owned.mjs new file mode 100644 index 000000000..ae6024162 --- /dev/null +++ b/tests/e2e/sre_authority/task_schema_owned.mjs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + canonicalSchema, normalizedCrd, schemaDocuments, schemaIdentity, verifySchemaOwner, +} from "../../../cli/dist/lib/schema-documents.js"; + +const owner = { namespace: "kars-system", release: "kars", ownership: "helm" }; +const name = "karstasks.kars.azure.com"; +const editedPaths = [["spec", "versions"], ["metadata", "annotations", "meta.helm.sh/release-name"]]; + +function require(value, message) { + if (!value) throw new Error(message); +} + +function map(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function entries(object) { + const result = object.metadata.managedFields; + require(Array.isArray(result) && result.length > 0 && result.length <= 64, "Unbounded or missing managedFields"); + require(result.every(entry => map(entry) && map(entry.fieldsV1)), "Invalid managedFields entry"); + return result; +} + +function originalApply(entry) { + return entry.manager === "helm" && entry.operation === "Apply" && entry.fieldsType === "FieldsV1" + && entry.apiVersion === "apiextensions.k8s.io/v1" + && (entry.subresource === undefined || entry.subresource === ""); +} + +function fieldAt(tree, path) { + let value = tree; + for (const key of path) value = map(value) ? value[`f:${key}`] : undefined; + return value; +} + +function overlaps(tree, path) { + require(map(tree), "Invalid managedFields tree"); + if (Object.keys(tree).length === 0 || Object.hasOwn(tree, ".") || path.length === 0) return true; + const next = tree[`f:${path[0]}`]; + return next !== undefined && overlaps(next, path.slice(1)); +} + +function verifiedOriginal(original) { + normalizedCrd(original); + verifySchemaOwner(original, owner); + require(original.metadata.name === name, "Another CRD entered the Task fixture"); + const rows = entries(original); + const selected = rows.filter(originalApply); + require(selected.length === 1, "Fixture requires one original Helm Apply identity, not Update or another API version"); + for (const path of editedPaths) { + const claim = fieldAt(selected[0].fieldsV1, path); + require(map(claim) && Object.keys(claim).length === 0, "Fixture edits require complete original field ownership"); + require(!rows.some(row => row !== selected[0] && overlaps(row.fieldsV1, path)), + "Fixture edits are co-owned or owned by another operation"); + } + return selected[0]; +} + +function ownershipState(object) { + return entries(object).map(entry => { + const value = structuredClone(entry); + if (originalApply(entry)) delete value.time; + return canonicalSchema(value); + }).sort(); +} + +function values(object) { + return { spec: object.spec, metadata: Object.fromEntries(Object.entries(object.metadata) + .filter(([key]) => !["resourceVersion", "managedFields", "generation"].includes(key))) }; +} + +function changedValue(original, fault) { + require(fault === "owner" || fault === "schema", "Unknown fixture change"); + const changed = structuredClone(original); + if (fault === "owner") changed.metadata.annotations["meta.helm.sh/release-name"] = "foreign-fixture"; + else changed.spec.versions[0].schema.openAPIV3Schema.description = "Unreviewed public fixture description"; + return changed; +} + +function verifyState(original, current, fault, changed) { + require(fault === "owner" || fault === "schema", "Unknown fixture change"); + verifiedOriginal(original); + schemaIdentity(current); + require(current.metadata.uid === original.metadata.uid && current.metadata.name === name, + "Task UID changed during the fixture"); + require(canonicalSchema(ownershipState(current)) === canonicalSchema(ownershipState(original)), + "Unplanned managedFields drift; no ownership recovery is permitted"); + const expected = changed ? changedValue(original, fault) : original; + require(canonicalSchema(values(current)) === canonicalSchema(values(expected)), + "Unplanned Task value change; no restoration is permitted"); +} + +function project(fields, live, manifest, path = [], budget = { nodes: 0 }) { + require(map(fields) && ++budget.nodes <= 4096 && path.length <= 32, "Unsupported owned field tree"); + if (Object.keys(fields).length === 0) { + if (live !== undefined) return structuredClone(live); + // API-omitted empty/default spec values come only from the matching Helm + // manifest, never a guessed default. Metadata cannot use this fallback. + require(path[0] === "spec" && manifest !== undefined, "An original owned value is unavailable"); + return structuredClone(manifest); + } + require(map(live) || map(manifest), "Owned map is unavailable"); + const result = {}; + for (const [field, children] of Object.entries(fields)) { + if (field === ".") { + require(map(children) && Object.keys(children).length === 0, "Invalid owned map marker"); + continue; + } + require(field.startsWith("f:") && field.length > 2, "Indexed ownership is outside the Task fixture"); + const key = field.slice(2); + require(key !== "__proto__" && key !== "constructor" && key !== "prototype", "Unsupported field key"); + result[key] = project(children, map(live) ? live[key] : undefined, + map(manifest) ? manifest[key] : undefined, [...path, key], budget); + } + return result; +} + +export function verifyOwnedTaskState(input) { + require(input.state === "changed" || input.state === "restored", "Unknown fixture state"); + verifyState(input.original, input.current, input.fault, input.state === "changed"); + return { verified: true }; +} + +export function ownedTaskRequest(input) { + const selected = verifiedOriginal(input.original); + require(typeof input.restore === "boolean" && typeof input.manifest === "string", "Missing fixture request context"); + verifyState(input.original, input.current, input.fault, input.restore); + const manifests = schemaDocuments(input.manifest).filter(object => + object.kind === "CustomResourceDefinition" && object.metadata.name === name); + require(manifests.length === 1 + && canonicalSchema(normalizedCrd(manifests[0])) === canonicalSchema(normalizedCrd(input.original)), + "Original Task spec differs from its Helm release"); + const payload = project(selected.fieldsV1, input.original, manifests[0]); + require(Object.keys(payload).every(key => ["apiVersion", "kind", "metadata", "spec"].includes(key)) + && map(payload.metadata) && map(payload.spec), "Unsupported original Helm fields"); + require(!["uid", "resourceVersion", "managedFields", "generation", "creationTimestamp", "deletionTimestamp"] + .some(key => Object.hasOwn(payload.metadata, key)), "Server identity fields cannot be managed by the fixture"); + if (!input.restore) { + if (input.fault === "owner") payload.metadata.annotations["meta.helm.sh/release-name"] = "foreign-fixture"; + else payload.spec.versions[0].schema.openAPIV3Schema.description = "Unreviewed public fixture description"; + } + payload.apiVersion = input.original.apiVersion; + payload.kind = input.original.kind; + payload.metadata = { ...payload.metadata, name, ...schemaIdentity(input.current) }; + return { args: ["apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json"], + input: JSON.stringify(payload) }; +} diff --git a/tests/e2e/sre_authority/task_schema_payload.mjs b/tests/e2e/sre_authority/task_schema_payload.mjs index 4fec44359..0334f142c 100644 --- a/tests/e2e/sre_authority/task_schema_payload.mjs +++ b/tests/e2e/sre_authority/task_schema_payload.mjs @@ -9,12 +9,14 @@ try { "../../../cli/dist/lib/schema-write-request.js"); const { normalizedCrd, schemaDocuments, schemaIdentity, verifySchemaOwner } = await import( "../../../cli/dist/lib/schema-documents.js"); - if (typeof buildSchemaWriteRequest !== "function" || typeof verifySchemaWritePreview !== "function") { + const { ownedTaskRequest, verifyOwnedTaskState } = await import("./task_schema_owned.mjs"); + if ([buildSchemaWriteRequest, verifySchemaWritePreview, ownedTaskRequest, verifyOwnedTaskState] + .some(helper => typeof helper !== "function")) { throw new Error("Required production helper exports are missing"); } const mode = process.argv[2]; phase = "mode"; - if (process.argv.length !== 3 || !["check", "build", "validate"].includes(mode)) { + if (process.argv.length !== 3 || !["check", "build", "validate", "owned-request", "owned-check"].includes(mode)) { throw new Error("Unsupported internal fixture mode"); } if (mode === "check") { @@ -29,36 +31,41 @@ try { chunks.push(chunk); } const input = JSON.parse(Buffer.concat(chunks).toString("utf8")); - phase = "target"; - if (typeof input.rendered !== "string") throw new Error("Missing rendered chart"); - const documents = schemaDocuments(input.rendered); - if (documents.length !== 1) throw new Error("Exactly one Task CRD is required"); - const desired = documents[0]; - normalizedCrd(desired); - if (desired.metadata.name !== "karstasks.kars.azure.com" || desired.spec.names.kind !== "KarsTask" - || ["uid", "resourceVersion", "ownerReferences", "namespace"].some(key => key in desired.metadata)) { - throw new Error("Unreviewed Task chart identity"); - } - const owner = { namespace: "kars-system", release: "kars", ownership: "helm" }; - phase = "current-owner"; - normalizedCrd(input.current); - verifySchemaOwner(input.current, owner); - if (input.current.metadata.name !== desired.metadata.name) throw new Error("Another current CRD"); - const currentIdentity = schemaIdentity(input.current); - if (mode === "build") { - phase = "build"; - const request = buildSchemaWriteRequest(desired, input.current, owner); - // Keep the JSON payload as a string: Python must not reserialize numbers. - process.stdout.write(JSON.stringify({ args: request.args, input: JSON.stringify(request.object) })); + if (mode === "owned-request" || mode === "owned-check") { + phase = mode; + process.stdout.write(JSON.stringify(mode === "owned-request" ? ownedTaskRequest(input) : verifyOwnedTaskState(input))); } else { - phase = "validate"; - if (typeof input.returned !== "string") throw new Error("Missing raw API response"); - const checked = JSON.parse(input.returned); - verifySchemaWritePreview(checked, desired, owner, currentIdentity.uid); - if (schemaIdentity(checked).resourceVersion !== currentIdentity.resourceVersion) { - throw new Error("Task preview changed its reviewed resourceVersion"); + phase = "target"; + if (typeof input.rendered !== "string") throw new Error("Missing rendered chart"); + const documents = schemaDocuments(input.rendered); + if (documents.length !== 1) throw new Error("Exactly one Task CRD is required"); + const desired = documents[0]; + normalizedCrd(desired); + if (desired.metadata.name !== "karstasks.kars.azure.com" || desired.spec.names.kind !== "KarsTask" + || ["uid", "resourceVersion", "ownerReferences", "namespace"].some(key => key in desired.metadata)) { + throw new Error("Unreviewed Task chart identity"); + } + const owner = { namespace: "kars-system", release: "kars", ownership: "helm" }; + phase = "current-owner"; + normalizedCrd(input.current); + verifySchemaOwner(input.current, owner); + if (input.current.metadata.name !== desired.metadata.name) throw new Error("Another current CRD"); + const currentIdentity = schemaIdentity(input.current); + if (mode === "build") { + phase = "build"; + const request = buildSchemaWriteRequest(desired, input.current, owner); + // Keep the JSON payload as a string: Python must not reserialize numbers. + process.stdout.write(JSON.stringify({ args: request.args, input: JSON.stringify(request.object) })); + } else { + phase = "validate"; + if (typeof input.returned !== "string") throw new Error("Missing raw API response"); + const checked = JSON.parse(input.returned); + verifySchemaWritePreview(checked, desired, owner, currentIdentity.uid); + if (schemaIdentity(checked).resourceVersion !== currentIdentity.resourceVersion) { + throw new Error("Task preview changed its reviewed resourceVersion"); + } + process.stdout.write(JSON.stringify({ validated: true })); } - process.stdout.write(JSON.stringify({ validated: true })); } } } catch { diff --git a/tests/e2e/sre_authority/task_schema_preview.py b/tests/e2e/sre_authority/task_schema_preview.py index 26487e760..171b56273 100644 --- a/tests/e2e/sre_authority/task_schema_preview.py +++ b/tests/e2e/sre_authority/task_schema_preview.py @@ -3,24 +3,12 @@ """Early native Task SSA probe; no schema migration or conflict workaround.""" -import json - from .canonical_seed import _snapshot from .common import SYSTEM, command_error_category, require from .registration_schema import write_report from .ssa_diagnostics import ssa_conflict from .task_schema_conflicts import read_task_schema, require_task_owner, task_manager_facts, task_schema_conflict - - -def payload_helper(h, mode, value=None): - args = ["node", str(h.root / "tests/e2e/sre_authority/task_schema_payload.mjs"), mode] - return json.loads(h.run(args, **({"data": json.dumps(value)} if value is not None else {}), timeout=20)) - - -def require_task_payload_helper(h): - require((h.root / "cli/dist/lib/schema-write-request.js").is_file(), - "Early Task SSA requires Node.js 22+ and a CLI build: run npm ci && npm run build in cli before the schema tests") - require(payload_helper(h, "check") == {"ready": True}, "Compiled production schema helper is unavailable") +from .task_schema_helpers import payload_helper, require_task_payload_helper def task_preview(h, rendered, case): diff --git a/tests/e2e/sre_authority/task_schema_preview_test.py b/tests/e2e/sre_authority/task_schema_preview_test.py index 39827ab89..24ab7b3b1 100644 --- a/tests/e2e/sre_authority/task_schema_preview_test.py +++ b/tests/e2e/sre_authority/task_schema_preview_test.py @@ -4,13 +4,17 @@ """Unit/transport contracts only; field-ownership causation requires native API evidence.""" import copy +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json from pathlib import Path import re import subprocess +import tempfile +import threading from types import SimpleNamespace import unittest from unittest.mock import patch +from urllib.parse import urlsplit from sre_authority.canonical_migration_test import FakeHarness from sre_authority.schema_preparation_diagnostics import schema_preparation_failure @@ -40,7 +44,7 @@ def harness(self, failure_at=None, metadata_conflict=None, returned_change=None) h.rendered = json.dumps(target) def run(args, data=None, timeout=20): if args[0] == "helm": - return h.rendered + return json.dumps(h.manifest) if args[1:3] == ["get", "manifest"] else h.rendered self.assertEqual(args[:2], ["node", str(h.root / "tests/e2e/sre_authority/task_schema_payload.mjs")]) result = subprocess.run(args, cwd=h.root, input=data, capture_output=True, text=True, timeout=timeout, check=False) @@ -50,7 +54,10 @@ def run(args, data=None, timeout=20): h.run = run h.previews = [] h.raw_previews = [] + owned_apply = h.k def k(*args, data, **kwargs): + if "--validate=strict" in args: + return owned_apply(*args, data=data, **kwargs) self.assertEqual(args, ("apply", "--server-side", "--field-manager=helm", "-f", "-", "-o", "json", "--dry-run=server", "--request-timeout=20s")) self.assertEqual(kwargs, {"expected": None, "timeout": 25}) @@ -126,22 +133,27 @@ def test_exact_production_response_validation_rejects_added_schema_and_foreign_o self.assertEqual(h.objects, before) self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) - def test_early_probe_uses_shared_four_patch_sequence_and_exact_ssa_flags_without_persistence(self): + def test_early_probe_uses_four_original_identity_applies_and_exact_nonpersistent_previews(self): h = self.harness() original = copy.deepcopy(h.objects) exercise_task_restore_preview(h) self.assertEqual(len(h.previews), 3) - patches = [body for method, path, body in h.calls if method == "PATCH" and path == TASK_PATH] + patches = h.owned_mutations self.assertEqual(len(patches), 4) - self.assertEqual(patches[0]["metadata"]["annotations"], {"meta.helm.sh/release-name": "foreign-fixture"}) + self.assertEqual(len(h.owned_previews), 4) + self.assertEqual(patches[0]["metadata"]["annotations"], { + "meta.helm.sh/release-name": "foreign-fixture", "meta.helm.sh/release-namespace": "kars-system"}) self.assertEqual(patches[1]["spec"], original[("crd", TASK_NAME)]["spec"]) self.assertEqual(patches[2]["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"], "Unreviewed public fixture description") self.assertEqual(patches[3]["spec"], original[("crd", TASK_NAME)]["spec"]) - self.assertTrue(all(path == TASK_PATH for method, path, _body in h.calls if method == "PATCH")) + self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) current = h.objects[("crd", TASK_NAME)] self.assertEqual(current["spec"], original[("crd", TASK_NAME)]["spec"]) self.assertEqual(current["metadata"]["uid"], original[("crd", TASK_NAME)]["metadata"]["uid"]) + for before, after in zip(original[("crd", TASK_NAME)]["metadata"]["managedFields"], current["metadata"]["managedFields"]): + self.assertEqual({key: value for key, value in before.items() if key != "time"}, + {key: value for key, value in after.items() if key != "time"}) self.assertEqual([facts["case"] for _file, facts in self.reports if "nonPersistent" in facts], ["before-negatives", "after-owner-restore", "after-schema-restore"]) @@ -172,7 +184,7 @@ def test_restoration_refuses_external_changes_instead_of_overwriting_them(self): with task_schema_conflict(h, "schema"): h.objects[("crd", TASK_NAME)]["spec"]["external"] = "retained" self.assertEqual(h.objects[("crd", TASK_NAME)]["spec"]["external"], "retained") - self.assertEqual(sum(method == "PATCH" for method, _path, _body in h.calls), 1) + self.assertEqual(len(h.owned_mutations), 1) def test_foreign_initial_owner_is_not_adopted(self): h = self.harness() @@ -182,6 +194,187 @@ def test_foreign_initial_owner_is_not_adopted(self): self.fail("Foreign owner entered the fixture") self.assertFalse(any(method == "PATCH" for method, _path, _body in h.calls)) + def test_original_apply_identity_is_required_not_just_the_helm_manager_name(self): + for fault in ("update", "version", "coowner", "same-name-update", "ancestor"): + h = self.harness() + rows = h.objects[("crd", TASK_NAME)]["metadata"]["managedFields"] + if fault == "update": + rows[0]["operation"] = "Update" + elif fault == "version": + rows[0]["apiVersion"] = "apiextensions.k8s.io/v1beta1" + else: + other = copy.deepcopy(rows[0]) + other["operation"] = "Update" + other["manager"] = "helm" if fault == "same-name-update" else "external" + other["fieldsV1"] = {"f:spec": {} if fault == "ancestor" else {"f:versions": {}}} + rows.append(other) + before = copy.deepcopy(h.objects) + with self.subTest(fault=fault), self.assertRaisesRegex(AssertionError, "helper rejected input at owned-request"): + with task_schema_conflict(h, "schema"): + self.fail("Unqualified field owner entered the fixture") + self.assertEqual(h.objects, before) + self.assertEqual(h.owned_mutations, []) + self.assertEqual(h.owned_previews, []) + + def test_all_owned_fields_are_sent_while_foreign_fields_and_original_values_are_preserved(self): + h = self.harness() + obj = h.objects[("crd", TASK_NAME)] + obj["metadata"]["labels"]["fixture.example/owned"] = "retained" + obj["metadata"]["annotations"]["external.example/note"] = "untouched" + obj["spec"]["names"]["shortNames"] = ["owned-task"] + h.manifest["spec"]["names"]["shortNames"] = ["owned-task"] + fields = obj["metadata"]["managedFields"][0]["fieldsV1"] + fields["f:metadata"]["f:labels"]["f:fixture.example/owned"] = {} + fields["f:spec"]["f:names"]["f:shortNames"] = {} + obj["metadata"]["managedFields"].append({ + "manager": "external", "operation": "Update", "apiVersion": "apiextensions.k8s.io/v1", + "fieldsType": "FieldsV1", "time": "2026-09-12T00:00:00Z", + "fieldsV1": {"f:metadata": {"f:annotations": {"f:external.example/note": {}}}}, + }) + before = copy.deepcopy(obj) + for fault in ("owner", "schema"): + with task_schema_conflict(h, fault): + self.assertEqual(h.objects[("crd", TASK_NAME)]["metadata"]["annotations"]["external.example/note"], "untouched") + after = h.objects[("crd", TASK_NAME)] + self.assertEqual(after["spec"], before["spec"]) + for key in ("name", "uid", "labels", "annotations"): + self.assertEqual(after["metadata"][key], before["metadata"][key]) + self.assertEqual(after["metadata"]["managedFields"][1], before["metadata"]["managedFields"][1]) + self.assertEqual(len(h.owned_mutations), 4) + for body in h.owned_mutations: + self.assertEqual(body["metadata"]["labels"]["fixture.example/owned"], "retained") + self.assertNotIn("external.example/note", body["metadata"]["annotations"]) + self.assertEqual(body["spec"]["names"]["shortNames"], ["owned-task"]) + self.assertNotIn("managedFields", body["metadata"]) + self.assertNotIn("status", body) + self.assertTrue(all(facts["originalHelmApplyOwnershipPreserved"] for _file, facts in self.reports)) + + def test_omitted_owned_empty_spec_value_comes_only_from_the_matching_release(self): + h = self.harness() + obj = h.objects[("crd", TASK_NAME)] + obj["metadata"]["managedFields"][0]["fieldsV1"]["f:spec"]["f:names"]["f:categories"] = {} + h.manifest["spec"]["names"]["categories"] = [] + with task_schema_conflict(h, "schema"): + self.assertNotIn("categories", h.objects[("crd", TASK_NAME)]["spec"]["names"]) + self.assertTrue(all(body["spec"]["names"]["categories"] == [] for body in h.owned_mutations)) + self.assertNotIn("categories", h.objects[("crd", TASK_NAME)]["spec"]["names"]) + + def test_missing_owned_metadata_or_mismatched_manifest_is_not_guessed(self): + for fault in ("metadata", "manifest", "indexed-fields"): + h = self.harness() + obj = h.objects[("crd", TASK_NAME)] + if fault == "metadata": + obj["metadata"]["managedFields"][0]["fieldsV1"]["f:metadata"]["f:labels"]["f:missing"] = {} + h.manifest["metadata"]["labels"]["missing"] = "not-live" + elif fault == "manifest": + h.manifest["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["description"] = "different-release" + else: + obj["metadata"]["finalizers"] = ["fixture"] + obj["metadata"]["managedFields"][0]["fieldsV1"]["f:metadata"]["f:finalizers"] = {'v:"fixture"': {}} + before = copy.deepcopy(h.objects) + with self.subTest(fault=fault), self.assertRaisesRegex(AssertionError, "helper rejected input at owned-request"): + with task_schema_conflict(h, "schema"): + self.fail("Unproven payload entered the fixture") + self.assertEqual(h.objects, before) + self.assertEqual(h.owned_mutations, []) + + def test_unplanned_managed_fields_drift_never_triggers_ownership_reset(self): + for fault in ("external-owner", "apply-to-update", "owned-field-change"): + h = self.harness() + with self.subTest(fault=fault), self.assertRaisesRegex(AssertionError, "helper rejected input at owned-request"): + with task_schema_conflict(h, "owner"): + rows = h.objects[("crd", TASK_NAME)]["metadata"]["managedFields"] + if fault == "external-owner": + rows.append({"manager": "external", "operation": "Update", "apiVersion": "apiextensions.k8s.io/v1", + "fieldsType": "FieldsV1", "fieldsV1": {"f:metadata": {"f:labels": {"f:external": {}}}}}) + elif fault == "apply-to-update": + rows[0]["operation"] = "Update" + else: + rows[0]["fieldsV1"]["f:metadata"]["f:labels"]["f:unexpected"] = {} + self.assertEqual(len(h.owned_mutations), 1) + self.assertEqual(h.objects[("crd", TASK_NAME)]["metadata"]["annotations"]["meta.helm.sh/release-name"], "foreign-fixture") + + def test_owned_preflight_rejects_field_ownership_expansion_before_any_real_mutation(self): + h = self.harness() + original_k = h.k + def k(*args, **kwargs): + result = original_k(*args, **kwargs) + if "--dry-run=server" in args and "--validate=strict" in args: + value = json.loads(result) + value["metadata"]["managedFields"][0]["fieldsV1"]["f:metadata"]["f:labels"]["f:extra"] = {} + return json.dumps(value) + return result + h.k = k + before = copy.deepcopy(h.objects) + with self.assertRaisesRegex(AssertionError, "helper rejected input at owned-check"): + with task_schema_conflict(h, "schema"): + self.fail("Ownership-expanding preview was applied") + self.assertEqual(h.objects, before) + self.assertEqual(h.owned_mutations, []) + + def test_owned_actual_write_still_cas_fails_when_rv_changes_after_dry_run(self): + h = self.harness() + original_k = h.k + def k(*args, **kwargs): + if "--dry-run=server" not in args: + h.objects[("crd", TASK_NAME)]["metadata"]["resourceVersion"] = "99" + return original_k(*args, **kwargs) + h.k = k + with self.assertRaises(AssertionError): + with task_schema_conflict(h, "schema"): + self.fail("Stale reviewed RV was applied") + self.assertEqual(h.owned_mutations, []) + self.assertEqual(h.objects[("crd", TASK_NAME)]["metadata"]["resourceVersion"], "99") + + def test_actual_kubectl_json_printer_requires_explicit_managed_fields(self): + obj = FakeHarness().get("crd", TASK_NAME) + seen = [] + routes = { + "/api": {"apiVersion": "v1", "kind": "APIVersions", "versions": ["v1"]}, + "/api/v1": {"apiVersion": "v1", "kind": "APIResourceList", "groupVersion": "v1", "resources": []}, + "/apis": {"apiVersion": "v1", "kind": "APIGroupList", "groups": [{ + "name": "apiextensions.k8s.io", "versions": [{"groupVersion": "apiextensions.k8s.io/v1", "version": "v1"}], + "preferredVersion": {"groupVersion": "apiextensions.k8s.io/v1", "version": "v1"}}]}, + "/apis/apiextensions.k8s.io/v1": {"apiVersion": "v1", "kind": "APIResourceList", + "groupVersion": "apiextensions.k8s.io/v1", "resources": [{"name": "customresourcedefinitions", + "singularName": "customresourcedefinition", "kind": "CustomResourceDefinition", + "namespaced": False, "verbs": ["get"]}]}, + TASK_PATH: obj, + } + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + seen.append((self.command, self.path, self.headers.get("Authorization"))) + value = routes.get(urlsplit(self.path).path) + body = json.dumps(value if value is not None else {"kind": "Status", "reason": "NotFound", "code": 404}).encode() + self.send_response(200 if value is not None else 404) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + with tempfile.TemporaryDirectory() as cache: + base = ["kubectl", "--kubeconfig=/dev/null", "--server", f"http://127.0.0.1:{server.server_port}", + "--cache-dir", cache, "--request-timeout=5s", "get", + "customresourcedefinitions.apiextensions.k8s.io", TASK_NAME, "-o", "json"] + for flags, expected in (([], False), (["--show-managed-fields=true"], True)): + result = subprocess.run(base + flags, capture_output=True, text=True, timeout=15, check=True) + value = json.loads(result.stdout) + self.assertEqual("managedFields" in value["metadata"], expected) + if expected: + self.assertEqual(value["metadata"]["managedFields"], obj["metadata"]["managedFields"]) + self.assertTrue(all(method == "GET" and auth is None for method, _path, auth in seen)) + finally: + server.shutdown() + server.server_close() + worker.join(timeout=5) + self.assertFalse(worker.is_alive()) + def test_managed_fields_reports_only_known_manager_classes_and_fixed_claim_flags(self): h = FakeHarness() obj = h.objects[("crd", TASK_NAME)] From 3fc124db56606086e22b1eb0433c278753a6e012 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 13:51:36 +0200 Subject: [PATCH 074/111] Retain only closed private-command failure facts in native evidence Reject unknown, duplicate, extended and malformed diagnostic data; preserve command failure and existing message behavior without exposing argv or values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../operator_diagnostics.py | 66 ++++++++++++++++--- .../test_operator_diagnostics.py | 40 ++++++++++- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index efd4162ab..693d5391f 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -51,6 +51,19 @@ ("projectionMetadataMatches", "metadata-matches"), ("deploymentTransitionMatches", "deployment"), ) +COMMAND_PREFIX = "KARS_PRIVATE_COMMAND_FAILURE " +COMMAND_PHASES = {"Unscoped", "Review", "Pausing", "Retired", "Rotating", "Restoring", "Qualified"} +COMMAND_OPERATIONS = {"get", "patch", "create", "auth", "other"} +COMMAND_KINDS = { + "Namespace", "Deployment", "KarsSandbox", "KarsTask", "Secret", "ServiceAccount", + "Pod", "ReplicaSet", "AdmissionPolicy", "AdmissionBinding", "AuthorizationInventory", + "AuthorizationCheck", "Other", +} +COMMAND_REASONS = { + "Unknown", "BadRequest", "Unauthorized", "Forbidden", "NotFound", "AlreadyExists", + "Conflict", "Invalid", "Timeout", "ServerTimeout", "TooManyRequests", "ServiceUnavailable", + "InternalError", "MethodNotAllowed", "Gone", "RequestEntityTooLarge", "UnsupportedMediaType", +} def category(stderr): @@ -70,23 +83,29 @@ def source_location(stderr): return "unavailable" +def _json_object(payload): + try: + # Preserve duplicate keys so ambiguous facts cannot silently overwrite each other. + pairs = json.loads(payload, object_pairs_hook=lambda values: values) + except json.JSONDecodeError: + return None + if (not isinstance(pairs, list) or not all(isinstance(pair, tuple) and len(pair) == 2 + and isinstance(pair[0], str) for pair in pairs)): + return None + values = dict(pairs) + return values if len(values) == len(pairs) else None + + def _checks(stderr, prefix, fields): lines = [line for line in stderr.splitlines() if line.startswith(prefix)] if not lines: return "" if len(lines) != 1 or len(lines[0]) > 512: return "unavailable" - try: - # Preserve duplicate keys so ambiguous facts cannot silently overwrite each other. - pairs = json.loads(lines[0][len(prefix):], object_pairs_hook=lambda values: values) - except json.JSONDecodeError: - return "unavailable" - if (not isinstance(pairs, list) or len(pairs) != len(fields) - or not all(isinstance(pair, tuple) and len(pair) == 2 - and isinstance(pair[0], str) and isinstance(pair[1], bool) for pair in pairs) - or {key for key, _ in pairs} != {key for key, _ in fields}): + values = _json_object(lines[0][len(prefix):]) + if (values is None or set(values) != {key for key, _ in fields} + or not all(isinstance(value, bool) for value in values.values())): return "unavailable" - values = dict(pairs) return ",".join(f"{label}={str(values[key]).lower()}" for key, label in fields) @@ -98,6 +117,30 @@ def writer_checks(stderr): return _checks(stderr, WRITER_CHECK_PREFIX, WRITER_CHECK_FIELDS) +def command_facts(stderr): + prefixes = (COMMAND_PREFIX, "PrivateCommandFailure: " + COMMAND_PREFIX) + payloads = [line[len(prefix):] for line in stderr.splitlines() + for prefix in prefixes if line.startswith(prefix)] + if not payloads: + return "" + if len(payloads) != 1 or len(payloads[0]) > 512: + return "unavailable" + values = _json_object(payloads[0]) + if values is None or set(values) != {"version", "phase", "operation", "resourceKind", "serverReason", "exitCode"}: + return "unavailable" + if (isinstance(values["version"], bool) or not isinstance(values["version"], int) or values["version"] != 1 + or not all(isinstance(values[key], str) and values[key] in allowed + for key, allowed in (("phase", COMMAND_PHASES), ("operation", COMMAND_OPERATIONS), + ("resourceKind", COMMAND_KINDS), ("serverReason", COMMAND_REASONS)))): + return "unavailable" + code = values["exitCode"] + if code is not None and (isinstance(code, bool) or not isinstance(code, int) or not 0 <= code <= 255): + return "unavailable" + exit_code = "unknown" if code is None else str(code) + return (f"phase={values['phase']},operation={values['operation']},kind={values['resourceKind']}," + f"reason={values['serverReason']},exit={exit_code}") + + def operator_command(stage, *args, timeout): if stage not in ("preview", "apply", "schemas"): raise Failure("Unknown native operator enrollment stage") @@ -109,6 +152,9 @@ def operator_command(stage, *args, timeout): recheck = writer_checks(error.stderr) if recheck: details += f" (writer-recheck={recheck})" + facts = command_facts(error.stderr) + if facts: + details += f" (command={facts})" raise Failure( f"Native operator {stage} failed: {category(error.stderr)} " f"(source={source_location(error.stderr)}){details}" diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index c6dadfe6a..886ddbc96 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -12,8 +12,8 @@ import native_api from native_api import Failure from operator_diagnostics import ( - CHECK_PREFIX, ERRORS, WRITER_CHECK_PREFIX, category, operator_command, - sandbox_checks, source_location, writer_checks, + CHECK_PREFIX, COMMAND_PREFIX, ERRORS, WRITER_CHECK_PREFIX, category, command_facts, + operator_command, sandbox_checks, source_location, writer_checks, ) PRIVATE = "DO-NOT-EMIT-TOKENS-OR-PRIVATE-API-BODIES" @@ -124,6 +124,42 @@ def test_writer_recheck_retains_only_its_three_fixed_booleans(self): self.assertEqual(writer_checks(value), "unavailable") self.assertEqual(writer_checks(PRIVATE), "") + def test_actual_sanitized_command_failure_retains_only_closed_facts(self): + facts = {"version": 1, "phase": "Pausing", "operation": "patch", "resourceKind": "KarsSandbox", + "serverReason": "Conflict", "exitCode": 1} + marker = COMMAND_PREFIX + json.dumps(facts) + expected = "phase=Pausing,operation=patch,kind=KarsSandbox,reason=Conflict,exit=1" + self.assertEqual(command_facts(marker), expected) + self.assertEqual(command_facts("PrivateCommandFailure: " + marker), expected) + output = io.StringIO() + with tempfile.TemporaryDirectory(prefix="native-command-facts-") as directory, \ + patch.object(native_api, "ROOT", Path(directory)), redirect_stdout(output), redirect_stderr(output): + with self.assertRaises(Failure) as failure: + operator_command("apply", sys.executable, "-c", + "import sys; print(sys.argv[1],file=sys.stderr); sys.exit(1)", + f"{PRIVATE}\nPrivateCommandFailure: {marker}\n{PRIVATE}", timeout=5) + self.assertIn("(command=" + expected + ")", str(failure.exception)) + self.assertNotIn(PRIVATE, str(failure.exception)) + self.assertEqual(output.getvalue(), "") + + def test_command_fact_parser_rejects_extensions_duplicates_and_unknown_values(self): + facts = {"version": 1, "phase": "Unscoped", "operation": "other", "resourceKind": "Other", + "serverReason": "Unknown", "exitCode": None} + self.assertIn("reason=Unknown,exit=unknown", command_facts(COMMAND_PREFIX + json.dumps(facts))) + invalid = [{**facts, key: PRIVATE} for key in ("phase", "operation", "resourceKind", "serverReason")] + invalid += [{**facts, "version": value} for value in (True, 1.0, 2)] + invalid += [{**facts, "exitCode": value} for value in (True, -1, 256, 1.5)] + invalid += [{**facts, "private": PRIVATE}, list(facts.items()), None] + for value in invalid: + with self.subTest(value=value): + self.assertEqual(command_facts(COMMAND_PREFIX + json.dumps(value)), "unavailable") + marker = COMMAND_PREFIX + json.dumps(facts) + for value in (marker + PRIVATE, marker + "\n" + marker, COMMAND_PREFIX + " " * 513, + COMMAND_PREFIX + '{"version":1,"version":1,"phase":"Unscoped","operation":"other",' + '"resourceKind":"Other","serverReason":"Unknown","exitCode":null}'): + self.assertEqual(command_facts(value), "unavailable") + self.assertEqual(command_facts(PRIVATE), "") + def test_success_and_unknown_stage_do_not_change_authority(self): with tempfile.TemporaryDirectory(prefix="native-operator-success-") as directory, \ patch.object(native_api, "ROOT", Path(directory)): From 39a1afe0958c9a1177a2d561c4cdd6e3293bbd6c Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 13:55:12 +0200 Subject: [PATCH 075/111] Use a nonpersisting Pending proposal for the post-migration schema probe Preserve historical Rejected fixtures and exact data/UID/RV checks while honoring the installed CREATE-only policy; never persist or execute the probe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../e2e/sre_authority/canonical_migration.py | 1 + .../sre_authority/canonical_migration_test.py | 11 +++- tests/e2e/sre_authority/canonical_seed.py | 10 ++++ tests/e2e/sre_authority/legacy_crds_test.py | 55 ++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/tests/e2e/sre_authority/canonical_migration.py b/tests/e2e/sre_authority/canonical_migration.py index 6af102133..c1cf1fc73 100644 --- a/tests/e2e/sre_authority/canonical_migration.py +++ b/tests/e2e/sre_authority/canonical_migration.py @@ -75,6 +75,7 @@ def deny_late_conflicts(h, fixtures): def finish_data_proof(h, fixtures): assert_data_unchanged(h, fixtures) + h.passed("Fixture data/UIDs/resourceVersions remain unchanged before the nested admission probe") prove_nested_params_support(h) assert_data_unchanged(h, fixtures) h.passed("Native BASE365-to-current schema migration preserved all fixture data/UIDs/resourceVersions") diff --git a/tests/e2e/sre_authority/canonical_migration_test.py b/tests/e2e/sre_authority/canonical_migration_test.py index 01dceeab4..cd9e49e54 100644 --- a/tests/e2e/sre_authority/canonical_migration_test.py +++ b/tests/e2e/sre_authority/canonical_migration_test.py @@ -55,6 +55,7 @@ def __init__(self): self.serial = 1 self.owned_mutations = [] self.owned_previews = [] + self.pending_proposals_enforced = False action = self.objects[("crd", "karssreactions.kars.azure.com")] action["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"] = { "spec": {"properties": {"action": {"properties": { @@ -112,6 +113,7 @@ def migrate_action_schema(self): assert params.pop("additionalProperties") is True params["x-kubernetes-preserve-unknown-fields"] = True action["metadata"]["resourceVersion"] = str(int(action["metadata"]["resourceVersion"]) + 1) + self.pending_proposals_enforced = True def get(self, kind, name, *_args): return copy.deepcopy(self.objects.get((kind, name))) @@ -146,8 +148,10 @@ def api(self, method, path, *, body=None, status=None): query = parse_qs(parsed.query) assert query in ({"fieldManager": ["kubectl-create"], "fieldValidation": ["Strict"]}, {"fieldManager": ["kubectl-create"], "fieldValidation": ["Strict"], "dryRun": ["All"]}) + rejected_after = nested_action_definition(after_migration=True) + rejected_after["spec"]["approval"]["state"] = "Rejected" nested = resource == "karssreaction" and body in ( - nested_action_definition(after_migration=False), nested_action_definition(after_migration=True)) + nested_action_definition(after_migration=False), nested_action_definition(after_migration=True), rejected_after) if nested: assert query["dryRun"] == ["All"] crd = self.objects[("crd", "karssreactions.kars.azure.com")] @@ -161,6 +165,11 @@ def api(self, method, path, *, body=None, status=None): assert params == {"type": "object", "x-kubernetes-preserve-unknown-fields": True} else: assert body == dict(seed_definitions())[resource] + if resource == "karssreaction" and self.pending_proposals_enforced and body["spec"]["approval"]["state"] != "Pending": + result = {"kind": "Status", "reason": "Forbidden", "message": + "ValidatingAdmissionPolicy 'kars-sre-pending-proposals' denied request: " + "SRE actions must be created Pending; approval is a separate operator action"} + return SimpleNamespace(status_code=403, json=lambda: result) if "dryRun" in query: result = copy.deepcopy(body) result["metadata"]["uid"] = "ephemeral-dry-run" diff --git a/tests/e2e/sre_authority/canonical_seed.py b/tests/e2e/sre_authority/canonical_seed.py index 43e5e093d..fb5d6764f 100644 --- a/tests/e2e/sre_authority/canonical_seed.py +++ b/tests/e2e/sre_authority/canonical_seed.py @@ -24,6 +24,8 @@ "/apis/batch/v1/jobs", "/apis/batch/v1/cronjobs", ) NESTED_FIELD = "spec.action.params.opaque.nested" +PENDING_POLICY = "kars-sre-pending-proposals" +PENDING_REQUIREMENT = "SRE actions must be created Pending; approval is a separate operator action" def seed_definitions(): @@ -50,6 +52,10 @@ def nested_action_definition(*, after_migration): suffix = "after" if after_migration else "before" obj["metadata"]["name"] += f"-nested-{suffix}" obj["spec"]["action"]["params"]["opaque"] = {"nested": [1, "retained", True]} + if after_migration: + # The new CREATE-only guard requires Pending; the stored legacy + # Rejected action is preserved and is never approved or rewritten. + obj["spec"]["approval"]["state"] = "Pending" return obj @@ -100,6 +106,10 @@ def seed_status(resource, code, body): validation.add(category) message = body.get("message") if isinstance(message, str): + if (resource == "karssreaction" and code == 403 and body.get("reason") == "Forbidden" + and PENDING_REQUIREMENT in message[:16384] + and any(quoted in message[:16384] for quoted in (f"'{PENDING_POLICY}'", f'"{PENDING_POLICY}"'))): + report["admissionRules"] = [PENDING_POLICY] # BadRequest strict-decoding errors often have no structured causes. # Only exact field paths in our fixed public bodies may leave this parser. for field in re.findall(r'unknown field "([^"\r\n]{1,256})"', message[:16384]): diff --git a/tests/e2e/sre_authority/legacy_crds_test.py b/tests/e2e/sre_authority/legacy_crds_test.py index 9af747437..2afd0fc5a 100644 --- a/tests/e2e/sre_authority/legacy_crds_test.py +++ b/tests/e2e/sre_authority/legacy_crds_test.py @@ -21,7 +21,7 @@ from sre_authority.canonical_migration import seed_data from sre_authority.canonical_migration_test import FakeHarness from sre_authority.canonical_seed import ( - SEEDS, SeedRejected, collection_path, dry_run_seed_data, nested_action_definition, + PENDING_POLICY, PENDING_REQUIREMENT, SEEDS, SeedRejected, collection_path, dry_run_seed_data, nested_action_definition, prove_nested_params_support, request_seed, seed_definitions, seed_status, ) @@ -223,12 +223,15 @@ def test_original_nested_shape_is_preserved_on_both_correct_schema_sides_with_di baseline = dict(seed_definitions())["karssreaction"] before = nested_action_definition(after_migration=False) after = nested_action_definition(after_migration=True) - self.assertEqual(before["spec"], after["spec"]) + self.assertEqual(before["spec"]["action"], after["spec"]["action"]) + self.assertEqual(before["spec"]["approval"], {"state": "Rejected"}) + self.assertEqual(after["spec"]["approval"], {"state": "Pending"}) for obj in (before, after): self.assertEqual(obj["spec"]["action"]["params"]["opaque"], {"nested": [1, "retained", True]}) scalar = copy.deepcopy(obj) scalar["metadata"]["name"] = baseline["metadata"]["name"] scalar["spec"]["action"]["params"]["opaque"] = "retained" + scalar["spec"]["approval"]["state"] = "Rejected" self.assertEqual(scalar, baseline) self.assertEqual(len({obj["metadata"]["name"] for obj in (baseline, before, after)}), 3) @@ -343,12 +346,60 @@ def test_post_migration_nested_acceptance_retains_scalar_data_and_all_identities self.assertTrue(self.reporter.call_args.args[2]["matched"]) self.assertEqual(self.reporter.call_args.args[2]["expectedHttpStatus"], 201) + def test_post_migration_proposal_is_pending_while_stored_legacy_action_remains_rejected(self): + h = FakeHarness() + fixtures = seed_data(h) + h.migrate_action_schema() + before = copy.deepcopy(h.objects) + h.calls.clear() + prove_nested_params_support(h) + posts = [(path, body) for method, path, body in h.calls if method == "POST"] + self.assertEqual(len(posts), 1) + self.assertIn("dryRun=All", posts[0][0]) + self.assertIn("fieldValidation=Strict", posts[0][0]) + self.assertEqual(posts[0][1]["spec"]["approval"], {"state": "Pending"}) + self.assertEqual(h.get("karssreaction", "e2e-migration-karssreaction")["spec"]["approval"], {"state": "Rejected"}) + self.assertEqual(h.objects, before) + self.assertEqual(len(fixtures), 5) + + def test_stage_create_guard_rejects_old_after_probe_and_diagnostic_retains_only_the_named_rule(self): + h = FakeHarness() + h.migrate_action_schema() + old_probe = nested_action_definition(after_migration=True) + old_probe["spec"]["approval"]["state"] = "Rejected" + response = h.api("POST", collection_path("karssreaction") + + "?fieldManager=kubectl-create&fieldValidation=Strict&dryRun=All", body=old_probe) + self.assertEqual(response.status_code, 403) + body = response.json() + body["message"] += " PRIVATE-RESPONSE-VALUE" + facts = seed_status("karssreaction", response.status_code, body) + self.assertEqual(facts["category"], "Forbidden") + self.assertEqual(facts["admissionRules"], [PENDING_POLICY]) + self.assertNotIn("PRIVATE-RESPONSE-VALUE", json.dumps(facts)) + self.assertNotIn("admissionRules", seed_status("karssreaction", 403, { + "kind": "Status", "reason": "Forbidden", "message": "unrelated RBAC denial"})) + + def test_pending_contract_matches_the_shipped_create_only_deny_policy(self): + root = Path(__file__).resolve().parents[3] + source = (root / "deploy/helm/kars/templates/sre-authority-admission.yaml").read_text() + documents = source.split("\n---\n") + policy = next(document for document in documents if "kind: ValidatingAdmissionPolicy\n" in document + and f"name: {PENDING_POLICY}\n" in document) + binding = next(document for document in documents if "kind: ValidatingAdmissionPolicyBinding\n" in document + and f"name: {PENDING_POLICY}\n" in document) + for clause in ('operations: ["CREATE"]', 'resources: ["karssreactions"]', "failurePolicy: Fail", + '"object.spec.approval.state == \'Pending\'"', PENDING_REQUIREMENT, "reason: Forbidden"): + self.assertIn(clause, policy) + self.assertIn(f"policyName: {PENDING_POLICY}", binding) + self.assertIn("validationActions: [Deny, Audit]", binding) + def test_post_migration_acceptance_cannot_prune_change_or_add_nested_values_or_forge_ready(self): changes = ( lambda body: body["spec"]["action"]["params"]["opaque"].pop("nested"), lambda body: body["spec"]["action"]["params"]["opaque"].update(nested=[1, "changed", True]), lambda body: body["spec"]["action"]["params"]["opaque"].update(nested=[1, "retained", 1]), lambda body: body["spec"]["action"]["params"]["opaque"].update(extra="unreviewed"), + lambda body: body["spec"]["approval"].update(state="Approved"), lambda body: body.update(status={"phase": "Ready"}), ) for change in changes: From 7fe79227b6aa7db00af9ea7f8181e41be02b75de Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 13:58:41 +0200 Subject: [PATCH 076/111] Report bounded private operator command and lifecycle failure facts Preserve command input, authority and CAS behavior while discarding raw errors, argv and causes; phase labels describe attempted steps only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/commands/credential-grants.ts | 9 ++- ...ate-activation-command-diagnostics.test.ts | 52 +++++++++++++++ .../private-activation-command-diagnostics.ts | 65 +++++++++++++++++++ .../lib/private-activation-late-scope.test.ts | 14 ++++ cli/src/lib/private-activation-late-scope.ts | 14 ++++ .../private-activation-writer-settle.test.ts | 46 +++++++++++++ docs/how-to/governed-credential-grants.md | 7 ++ 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 cli/src/lib/private-activation-command-diagnostics.test.ts create mode 100644 cli/src/lib/private-activation-command-diagnostics.ts diff --git a/cli/src/commands/credential-grants.ts b/cli/src/commands/credential-grants.ts index 65d373f9c..8e149b3c6 100644 --- a/cli/src/commands/credential-grants.ts +++ b/cli/src/commands/credential-grants.ts @@ -12,6 +12,7 @@ import { } from "../lib/private-activation.js"; import { captureGuardRetirement, refreshGuardRetirement } from "../lib/private-activation-guard-retirement.js"; import { captureWriterSettlement, observeWriterSettlement, settleWriterRetirement } from "../lib/private-activation-writer-settle.js"; +import { privateCommandFailure } from "../lib/private-activation-command-diagnostics.js"; type Execute=(args:string[],input?:string)=>Promise<string>; const resource="karscredentialgrants.kars.azure.com"; @@ -195,8 +196,12 @@ export async function applyReviewedGrant(run:Execute,document:any):Promise<void> export function credentialGrantsCommand():Command { const command=new Command("grant").description("Preview and explicitly apply operator-owned credential authority"); const execute=(context?:string):Execute=>async(args,input)=>{ - const result=await execa("kubectl",[...(context?["--context",context]:[]),...args],{stdio:"pipe",...(input?{input}:{})}); - return result.stdout; + try { + const result=await execa("kubectl",[...(context?["--context",context]:[]),...args],{stdio:"pipe",...(input?{input}:{})}); + return result.stdout; + } catch(error) { + throw privateCommandFailure(error,args); + } }; const repeat=(value:string,prior:string[])=>[...prior,value]; command.command("preview").requiredOption("--namespace <namespace>") diff --git a/cli/src/lib/private-activation-command-diagnostics.test.ts b/cli/src/lib/private-activation-command-diagnostics.test.ts new file mode 100644 index 000000000..f9fb8657e --- /dev/null +++ b/cli/src/lib/private-activation-command-diagnostics.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { privateCommandFailure, scopedCommandFailure, PrivateCommandFailure } from "./private-activation-command-diagnostics.js"; + +describe("private command failure projection", () => { + it.each(["Conflict", "Forbidden", "Invalid", "NotFound", "ServiceUnavailable"])( + "retains only the explicit server %s reason and process exit code", reason => { + const failure = privateCommandFailure({ + exitCode: 1, stderr: `Error from server (${reason}): private-stderr-canary\nsecret-value-canary`, + stdout: "private-stdout-canary", command: "kubectl patch secret private-name-canary", + message: "private-error-canary", cause: new Error("private-cause-canary"), + }, ["patch", "karssandbox", "private-name-canary", "-p", "private-payload-canary"], "Pausing"); + expect(failure.facts).toEqual({ + version: 1, phase: "Pausing", operation: "patch", resourceKind: "KarsSandbox", + serverReason: reason, exitCode: 1, + }); + expect(failure.message).toBe(`KARS_PRIVATE_COMMAND_FAILURE ${JSON.stringify(failure.facts)}`); + expect(JSON.stringify(failure) + failure.stack).not.toContain("canary"); + expect(failure).not.toHaveProperty("cause"); + expect(failure.facts).not.toHaveProperty("httpStatus"); + }); + + it.each(["private-error-canary", "Error from server (PrivateCanary): secret", "Error from server (Conflict): x\nError from server (Forbidden): y", + `Error from server (Conflict): ${"x".repeat(65_536)}`])( + "does not infer an API result from ambiguous or unsupported stderr %#", stderr => { + const failure = privateCommandFailure({ stderr, exitCode: 1 }, ["get", "secret", "private"]); + expect(failure.facts.serverReason).toBe("Unknown"); + }); + + it.each([undefined, null, "1", -1, 256, NaN])("rejects unsupported process exit status %s", exitCode => { + expect(privateCommandFailure({ exitCode }, ["patch", "namespace"]).facts.exitCode).toBeNull(); + }); + + it.each(["private-kind-canary", "__proto__", "constructor"])("does not echo unclassified resource %s", kind => { + const value = privateCommandFailure({}, ["private-operation-canary", kind, "private-name-canary"]); + expect(value.facts.operation).toBe("other"); + expect(value.facts.resourceKind).toBe("Other"); + expect(value.message).not.toContain("canary"); + }); + + it("adds the lifecycle phase without losing the underlying safe command facts", () => { + const initial = privateCommandFailure({ exitCode: 1, stderr: "Error from server (Forbidden): private" }, + ["get", "deployment", "private"]); + const scoped = scopedCommandFailure(initial, [], "Restoring"); + expect(scoped).toBeInstanceOf(PrivateCommandFailure); + expect((scoped as PrivateCommandFailure).facts).toEqual({ ...initial.facts, phase: "Restoring" }); + const semantic = new Error("fixed semantic failure"); + expect(scopedCommandFailure(semantic, [], "Pausing")).toBe(semantic); + }); +}); diff --git a/cli/src/lib/private-activation-command-diagnostics.ts b/cli/src/lib/private-activation-command-diagnostics.ts new file mode 100644 index 000000000..3ea63a17d --- /dev/null +++ b/cli/src/lib/private-activation-command-diagnostics.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type PrivateCommandPhase = "Unscoped" | "Review" | "Pausing" | "Retired" | "Rotating" | "Restoring" | "Qualified"; +const phases = new Set<PrivateCommandPhase>(["Unscoped", "Review", "Pausing", "Retired", "Rotating", "Restoring", "Qualified"]); +const reasons = new Set([ + "BadRequest", "Unauthorized", "Forbidden", "NotFound", "AlreadyExists", "Conflict", "Invalid", + "Timeout", "ServerTimeout", "TooManyRequests", "ServiceUnavailable", "InternalError", + "MethodNotAllowed", "Gone", "RequestEntityTooLarge", "UnsupportedMediaType", +]); +const kinds: Record<string, string> = { + namespace: "Namespace", namespaces: "Namespace", deployment: "Deployment", "deployments.apps": "Deployment", + karssandbox: "KarsSandbox", karstask: "KarsTask", secret: "Secret", serviceaccount: "ServiceAccount", + pods: "Pod", "replicasets.apps": "ReplicaSet", "karscredentialgrants.kars.azure.com": "KarsCredentialGrant", + validatingadmissionpolicy: "AdmissionPolicy", validatingadmissionpolicybinding: "AdmissionBinding", + "roles,rolebindings,clusterroles,clusterrolebindings": "AuthorizationInventory", +}; + +interface Facts { + version: 1; + phase: PrivateCommandPhase; + operation: "get" | "patch" | "create" | "auth" | "other"; + resourceKind: string; + serverReason: string; + exitCode: number | null; +} + +export class PrivateCommandFailure extends Error { + constructor(readonly facts: Readonly<Facts>) { + super(`KARS_PRIVATE_COMMAND_FAILURE ${JSON.stringify(facts)}`); + this.name = "PrivateCommandFailure"; + } +} + +/** Process diagnostics contain only fixed enums and an integer exit status. + * The original error/cause, argv, names, stdout and stderr are never retained. */ +export function privateCommandFailure( + error: unknown, args: readonly string[], phase: PrivateCommandPhase = "Unscoped", +): PrivateCommandFailure { + phase = phases.has(phase) ? phase : "Unscoped"; + if (error instanceof PrivateCommandFailure) { + return new PrivateCommandFailure({ ...error.facts, phase }); + } + const value = error && typeof error === "object" ? error as { stderr?: unknown; exitCode?: unknown } : {}; + const stderr = typeof value.stderr === "string" && Buffer.byteLength(value.stderr) <= 65_536 ? value.stderr : ""; + const matches = [...stderr.matchAll(/^Error from server \(([A-Za-z]+)\):/gm)]; + const reason = matches.length === 1 && reasons.has(matches[0]![1]!) ? matches[0]![1]! : "Unknown"; + const operation = (["get", "patch", "create", "auth"].includes(args[0] ?? "") ? args[0] : "other") as Facts["operation"]; + return new PrivateCommandFailure({ + version: 1, phase, operation, + resourceKind: operation === "auth" ? "AuthorizationCheck" + : Object.hasOwn(kinds, args[1] ?? "") ? kinds[args[1]!]! : "Other", + serverReason: reason, + exitCode: typeof value.exitCode === "number" && Number.isInteger(value.exitCode) + && value.exitCode >= 0 && value.exitCode <= 255 ? value.exitCode : null, + }); +} + +export function scopedCommandFailure(error: unknown, args: readonly string[], phase: PrivateCommandPhase): unknown { + if (error instanceof PrivateCommandFailure || (error && typeof error === "object" + && ("exitCode" in error || ("failed" in error && error.failed === true)))) { + return privateCommandFailure(error, args, phase); + } + return error; +} diff --git a/cli/src/lib/private-activation-late-scope.test.ts b/cli/src/lib/private-activation-late-scope.test.ts index 40cc5c011..a991ca6ee 100644 --- a/cli/src/lib/private-activation-late-scope.test.ts +++ b/cli/src/lib/private-activation-late-scope.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyReviewedGrant, credentialGrantsCommand } from "../commands/credential-grants.js"; import { continuityFixture, privateAuthoritySnapshot } from "./private-activation-fixtures.js"; import { PRIVATE_PREFIX as P, canonical, type Execute } from "./private-activation.js"; +import { PrivateCommandFailure } from "./private-activation-command-diagnostics.js"; const HISTORY = `${P}root-retirement`; const VERSION = "kars.azure.com/services-credential-version"; @@ -135,6 +136,19 @@ describe("reviewed late runtime private enrollment", () => { }); afterEach(() => { vi.restoreAllMocks(); }); + it("sanitizes actual registered command process failures before exposing the exception", async () => { + cliProcess.execute.mockRejectedValue(Object.assign(new Error("private-argv-canary"), { + exitCode: 1, stderr: "Error from server (Forbidden): private-secret-canary", stdout: "private-data-canary", + })); + const result = await credentialGrantsCommand().parseAsync([ + "preview", "--namespace", "private-name-canary", "--writer", "reader/bff", + ], { from: "user" }).then(() => undefined, error => error); + expect(result).toBeInstanceOf(PrivateCommandFailure); + expect(result.facts).toEqual({ version: 1, phase: "Unscoped", operation: "get", + resourceKind: "Namespace", serverReason: "Forbidden", exitCode: 1 }); + expect(result.message + JSON.stringify(result)).not.toContain("canary"); + }); + it("accepts an identical current Sandbox after an intervening status PATCH advances only resourceVersion", async () => { const f = await setup(); let updated = false; diff --git a/cli/src/lib/private-activation-late-scope.ts b/cli/src/lib/private-activation-late-scope.ts index bc59f5853..edbab95f5 100644 --- a/cli/src/lib/private-activation-late-scope.ts +++ b/cli/src/lib/private-activation-late-scope.ts @@ -8,6 +8,7 @@ import { type Execute, type Json, type NamespaceReview, type PrivateActivation, type ReviewedObject, } from "./private-activation.js"; import { replicaIntent } from "./private-activation-retirement.js"; +import { scopedCommandFailure, type PrivateCommandPhase } from "./private-activation-command-diagnostics.js"; const HISTORY = "kars.azure.com/private-root-retirement"; const ADMIN = "router-services-admin"; @@ -424,9 +425,22 @@ export async function captureLateWriterScope(execute: Execute, activation: Priva export async function stageLateScope( execute: Execute, activation: PrivateActivation, scope: NamespaceReview, root: string, assertRoot: () => Promise<void>, ): Promise<void> { + let phase: PrivateCommandPhase = "Review"; + const rawExecute = execute; + execute = async (args, input) => { + try { return await rawExecute(args, input); } + catch (error) { throw scopedCommandFailure(error, args, phase); } + }; + const rawAssertRoot = assertRoot; + assertRoot = async () => { + try { await rawAssertRoot(); } + catch (error) { throw scopedCommandFailure(error, [], phase); } + }; let state = receipt(await namespaceFor(execute, scope)); + phase = state?.phase ?? "Review"; let live = await current(execute, activation, scope, root, state); const save = async (next: Receipt, fields: Record<string, string> = {}) => { + phase = next.phase; await assertRoot(); await patchNamespace(execute, scope, { ...fields, [HISTORY]: encoded(next) }, { [HISTORY]: state ? encoded(state) : undefined }, true); diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts index 66a9b0c98..34db8296a 100644 --- a/cli/src/lib/private-activation-writer-settle.test.ts +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -12,6 +12,7 @@ import { continuityFixture, privateAuthoritySnapshot } from "./private-activatio import { canonical, readSecretMetadata, PRIVATE_PREFIX as P, type Execute } from "./private-activation.js"; import { captureGuardRetirement, refreshGuardRetirement } from "./private-activation-guard-retirement.js"; import { captureWriterSettlement } from "./private-activation-writer-settle.js"; +import { PrivateCommandFailure } from "./private-activation-command-diagnostics.js"; const RESOURCE = "karscredentialgrants.kars.azure.com"; const C = "kars.azure.com/credential-"; @@ -230,6 +231,51 @@ describe("late runtime authority across selected writer retirement", () => { beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); + it("reports a Pending-induced status/RV race without retrying the stale suspend PATCH", async () => { + const f = await setup(); + const review = await f.document(); + let pending = false; + let captured = false; + let advanced = false; + let suspendAttempts = 0; + const run: Execute = async (args, input) => { + if (pending && args[0] === "patch" && args[1] === "karssandbox") { + suspendAttempts++; + const patch = JSON.parse(args[args.indexOf("-p") + 1]!); + expect(patch.metadata.uid).toBe(f.sandbox.metadata.uid); + expect(patch.metadata.resourceVersion).not.toBe(f.sandbox.metadata.resourceVersion); + throw Object.assign(new Error("private-command-and-argv-canary"), { + exitCode: 1, stderr: "Error from server (Conflict): private-object-name-canary", + }); + } + const result = await f.execute(args, input); + if (args[0] === "patch" && args[1] === "namespace" && args[2] === "kars-late" + && f.namespace.metadata.annotations[`${P}state`] === "Pending") pending = true; + if (pending && args[0] === "get" && args[1] === "karssandbox") captured = true; + if (captured && !advanced && args[0] === "get" && args[1] === "deployment" && args[2] === "kars-controller") { + advanced = true; + f.sandbox.metadata.resourceVersion = String(Number(f.sandbox.metadata.resourceVersion) + 1); + f.sandbox.status = { phase: "Degraded", observedGeneration: 1, + conditions: [{ type: "Ready", status: "False", observedGeneration: 1, reason: "GovernedServicePrivacyNotReady" }] }; + f.task.status.executionPhase = "Degraded"; + } + return result; + }; + const failure = await applyReviewedGrant(run, review).then(() => undefined, error => error); + expect(failure).toBeInstanceOf(PrivateCommandFailure); + expect(failure.facts).toEqual({ version: 1, phase: "Pausing", operation: "patch", + resourceKind: "KarsSandbox", serverReason: "Conflict", exitCode: 1 }); + expect(failure.message + JSON.stringify(failure)).not.toContain("canary"); + expect(advanced).toBe(true); + expect(suspendAttempts).toBe(1); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Pending"); + expect(f.sandbox.metadata.generation).toBe(1); + expect(f.sandbox.spec.suspended).toBeUndefined(); + expect(f.deployment.spec.replicas).toBe(1); + expect(f.task.status.envelopeDigest).toBe(AUTH); + expect(f.grant().spec.writers).toEqual([]); + }); + it("proves the real JSON and metadata JSONPath printer views differ only in managedFields", async () => { const f = await setup(); const wire = await projectionWire(); diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 06899b1f2..ec7a8e325 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -210,6 +210,13 @@ The complete target request, including body/error decoding, suppresses raw library logging; the caller emits bounded diagnostics outside that scope. Sibling requests keep their own logging and progress state. +Operator command failures use `KARS_PRIVATE_COMMAND_FAILURE` with fixed phase, +operation, resource-kind and server-reason classes plus a bounded exit code. +The phase describes the attempted step, not a committed transition. Unknown +reasons stay unknown; no HTTP status is inferred. Original argv, object names, +values, stderr and error causes are not retained, and failed CAS operations +are not retried or rebased by this diagnostic path. + ## Operator workflow Private writer/observation activation is an additional review in the existing From 048910d7970963e897009540530dcb06b511d3ff Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 14:14:58 +0200 Subject: [PATCH 077/111] Patch Bridge rendering and query parser dependencies Import exact hosted-qualified Mermaid 11.16.1, DOMPurify 3.4.13 and qs 6.16.0 locks. Preserve all package graph entries and unrelated pins; trusted-types 2.0.7 changes registry provenance only. Evidence: Azure/kars Actions run 34693066037, source f1f9b9ed1b9e178f973f1212b223de288a70596f. Full component qualification still required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/teams-gateway/package-lock.json | 6 +++--- bridge/teams-gateway/package.json | 1 + bridge/web/package-lock.json | 18 +++++++++--------- bridge/web/package.json | 3 ++- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/bridge/teams-gateway/package-lock.json b/bridge/teams-gateway/package-lock.json index 508510337..d8f248383 100644 --- a/bridge/teams-gateway/package-lock.json +++ b/bridge/teams-gateway/package-lock.json @@ -2776,9 +2776,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/qs/-/qs-6.15.3.tgz", - "integrity": "sha1-doUhMqWO1cfA72fkRBubtdYGGzs=", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/bridge/teams-gateway/package.json b/bridge/teams-gateway/package.json index 283e4ec6a..43441a239 100644 --- a/bridge/teams-gateway/package.json +++ b/bridge/teams-gateway/package.json @@ -28,6 +28,7 @@ "vitest": "^3.2" }, "overrides": { + "qs": "6.16.0", "esbuild": "^0.28.1", "js-yaml@^4": "^4.3.1", "nanoid@^3": "^3.3.18" diff --git a/bridge/web/package-lock.json b/bridge/web/package-lock.json index bcf48e5d9..07e167502 100644 --- a/bridge/web/package-lock.json +++ b/bridge/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "jose": "^6.2.3", - "mermaid": "^11.16.0", + "mermaid": "11.16.1", "next": "16.3.3", "react": "19.2.4", "react-dom": "19.2.4", @@ -1987,8 +1987,8 @@ }, "node_modules/@types/trusted-types": { "version": "2.0.7", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha1-usywepcLkXB986PoumiWxX6tLRE=", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", "optional": true }, @@ -3918,9 +3918,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha1-b6ImXpu9zogsSs5BB2JgUbRI/6g=", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6586,9 +6586,9 @@ } }, "node_modules/mermaid": { - "version": "11.16.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mermaid/-/mermaid-11.16.0.tgz", - "integrity": "sha1-3JRryEvenQk7oUlA1J3x2ffYwy8=", + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", + "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", diff --git a/bridge/web/package.json b/bridge/web/package.json index dad23b705..558164cf3 100644 --- a/bridge/web/package.json +++ b/bridge/web/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "jose": "^6.2.3", - "mermaid": "^11.16.0", + "mermaid": "11.16.1", "next": "16.3.3", "react": "19.2.4", "react-dom": "19.2.4", @@ -28,6 +28,7 @@ "typescript": "^5" }, "overrides": { + "dompurify": "3.4.13", "postcss": "^8.5.25", "brace-expansion@^1": "^1.1.18", "brace-expansion@^5": "^5.0.9", From e04eeeeda744be4b608c5cc75d428284c7e44320 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 14:24:23 +0200 Subject: [PATCH 078/111] Patch gateway Vitest without changing production dependencies Import the Node 22 hosted-qualified Vitest 4.1.11 graph for GHSA-82fw-gwwq-j7x9. Preserve existing Vite/Rollup versions and byte-identical runtime dependency entries. Public run 34693494675 passed lint, types, build, 58 tests (three existing native skips), npm integrity install and zero-advisory audit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/teams-gateway/package-lock.json | 523 +++++++++++-------------- bridge/teams-gateway/package.json | 4 +- 2 files changed, 229 insertions(+), 298 deletions(-) diff --git a/bridge/teams-gateway/package-lock.json b/bridge/teams-gateway/package-lock.json index d8f248383..5f8f8d8b3 100644 --- a/bridge/teams-gateway/package-lock.json +++ b/bridge/teams-gateway/package-lock.json @@ -17,7 +17,7 @@ "oxlint": "0.16.0", "tsx": "^4", "typescript": "^5.8", - "vitest": "^3.2" + "vitest": "4.1.11" }, "engines": { "node": ">=22" @@ -488,9 +488,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha1-aRKwDSxjHA0Vzhp6tXzWV/Ko+Lo=", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -741,8 +741,8 @@ }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", - "integrity": "sha1-sKtCL7YPNYPIx4noVtZKMlB/KZ4=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", "cpu": [ "arm" ], @@ -755,8 +755,8 @@ }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.62.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", - "integrity": "sha1-BH6WfutECimfGrx3NXm1wlFvpI0=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", "cpu": [ "arm64" ], @@ -769,8 +769,8 @@ }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", - "integrity": "sha1-GDaeZ9DT/8sBqVVhIXRHt5Fydpc=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", "cpu": [ "arm64" ], @@ -783,8 +783,8 @@ }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", - "integrity": "sha1-vyOuTFwPhBsSO8eeOyRhdsbQj9c=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", "cpu": [ "x64" ], @@ -797,8 +797,8 @@ }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", - "integrity": "sha1-ySobB9AkMYHybZ3rJ4w+8J4B8GU=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", "cpu": [ "arm64" ], @@ -811,8 +811,8 @@ }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", - "integrity": "sha1-tp2gQH6gZJfTlb4OeZzGAQBLNy4=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", "cpu": [ "x64" ], @@ -825,8 +825,8 @@ }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", - "integrity": "sha1-G0tjxX/CDm6kdIqOqGTYZRMyjsQ=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", "cpu": [ "arm" ], @@ -839,8 +839,8 @@ }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", - "integrity": "sha1-ZDNPWlF4yrsV59KpH7WS2x+GIdM=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", "cpu": [ "arm" ], @@ -853,8 +853,8 @@ }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", - "integrity": "sha1-H/KZ94JfD1Ko4Qfax/Fc5zAJhIs=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", "cpu": [ "arm64" ], @@ -867,8 +867,8 @@ }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", - "integrity": "sha1-mQUjgOepT6RAuRZsZ6kJqjVy8Ik=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", "cpu": [ "arm64" ], @@ -881,8 +881,8 @@ }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", - "integrity": "sha1-X1i1drNmjt9irPDFJlgmJnt9hY8=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", "cpu": [ "loong64" ], @@ -895,8 +895,8 @@ }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", - "integrity": "sha1-qM0DN+P/OgyV6tZ3FcUa93xa/zY=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", "cpu": [ "loong64" ], @@ -909,8 +909,8 @@ }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", - "integrity": "sha1-YzXOwVtVprBj40y36WhRX6UA/9Q=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", "cpu": [ "ppc64" ], @@ -923,8 +923,8 @@ }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", - "integrity": "sha1-zytv0jjwkoxWV7uKXKd1HfwubOQ=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", "cpu": [ "ppc64" ], @@ -937,8 +937,8 @@ }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", - "integrity": "sha1-35UIyHIUN/flGZDKqmHS8423PL8=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", "cpu": [ "riscv64" ], @@ -951,8 +951,8 @@ }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", - "integrity": "sha1-DP6TBy9MOYu51mbllTNxoiq6f0o=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", "cpu": [ "riscv64" ], @@ -965,8 +965,8 @@ }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", - "integrity": "sha1-+Oe989QZhmtoiwnZNIJTklIy0cs=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", "cpu": [ "s390x" ], @@ -979,8 +979,8 @@ }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", - "integrity": "sha1-V+9/A5xPfeDpE/HyaAOFRnuGuxU=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", "cpu": [ "x64" ], @@ -993,8 +993,8 @@ }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", - "integrity": "sha1-b1tWk9S+sVK/UYxIfSWTYtvs5Ek=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", "cpu": [ "x64" ], @@ -1007,8 +1007,8 @@ }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", - "integrity": "sha1-mQkBAJPtf7JIDHO8GTqPTUT/ueM=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", "cpu": [ "x64" ], @@ -1021,8 +1021,8 @@ }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", - "integrity": "sha1-q6kLV3Jfz0xAcslabb+891AKdw0=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", "cpu": [ "arm64" ], @@ -1035,8 +1035,8 @@ }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", - "integrity": "sha1-Es7isubTfbuDYCaGgS5LuikMmqM=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", "cpu": [ "arm64" ], @@ -1049,8 +1049,8 @@ }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.62.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", - "integrity": "sha1-ORoNb4RYpnWtcgzMrpvLmkgQkBY=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", "cpu": [ "ia32" ], @@ -1063,8 +1063,8 @@ }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", - "integrity": "sha1-Pzi7GA/PHPqRl1xLNElB6YWm7RM=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", "cpu": [ "x64" ], @@ -1077,8 +1077,8 @@ }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.62.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", - "integrity": "sha1-DMr9RKjLyzP3/qqePoUDPnpSKVw=", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", "cpu": [ "x64" ], @@ -1089,10 +1089,17 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha1-jpzZ4cNYH6azQaWu1ViOsoW+C0o=", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -1102,15 +1109,15 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha1-M0MRlx06BxIefrkbaEpgXn7qnL0=", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha1-zz8Oh2177hWpOrkluCv1cKOQSiQ=", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1165,39 +1172,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha1-cKNBWDg9AIw79dgC4mQzF/Cd9tg=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha1-MxvpRMt4PGQt1CvXQ0EayiTqBGY=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.7", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1209,42 +1217,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha1-KntZP44Afp2O9+c0OqMOxz/eryk=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha1-wMCAIoGJ8fps2kD1m+CddGsKylE=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha1-o6fhlQzpnsTPAjleIN3KQDtsgY4=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1252,28 +1260,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha1-yn++5EAZUjykUDldmiKEzp7OHzE=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha1-MCyBJiEaxN/qh7O1CFwJjW0i6J4=", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1312,8 +1317,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha1-9kGhlrM1aQsQcL8AtudZP+wZC/c=", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1484,16 +1489,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cac/-/cac-6.7.14.tgz", - "integrity": "sha1-gE4eb1Bu42PLDjzLsJytXdmHCVk=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1524,32 +1519,15 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chai/-/chai-5.3.3.tgz", - "integrity": "sha1-3T2pVeJwkWpL0/Yl9LkZmWrafgY=", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha1-JCc2ERe3DMqNyJaA6tMrFXAZyvU=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1584,6 +1562,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-0.7.2.tgz", @@ -1636,16 +1621,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha1-S3VtjXcKklcwCCXVKiws/5nDo0E=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1730,9 +1705,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha1-kVlgFWGICoXyc0VgqQmbLDHlNyo=", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -1813,8 +1788,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha1-Z8PlSexAKkh7T8GT0ZU6UkdSNA0=", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -1900,8 +1875,8 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -2229,13 +2204,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha1-LsQ5ZGWENSlvZ2GzThBnHC2VJ/Q=", - "dev": true, - "license": "MIT" - }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -2406,13 +2374,6 @@ "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha1-AJXPVtxbepp8CP9bGoeW7IrRfnY=", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lru-cache/-/lru-cache-6.0.0.tgz", @@ -2437,8 +2398,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha1-VnY+wJoPqAkd8nh5/ZTRkHjADZE=", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2511,9 +2472,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -2588,6 +2549,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-finished/-/on-finished-2.4.1.tgz", @@ -2679,32 +2654,22 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha1-PsvsVUIWhbcKnahyss/z4cvtFxY=", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha1-iFXFooma8HLWrAXRHkYEWtDcYF0=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha1-UepXoX2G9gX4EDlZX7xA7QalX6s=", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2715,9 +2680,9 @@ } }, "node_modules/postcss": { - "version": "8.5.24", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.24.tgz", - "integrity": "sha1-AdiwMkUeG57EGuZurwKEP0KnINI=", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -2735,7 +2700,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2833,8 +2798,8 @@ }, "node_modules/rollup": { "version": "4.62.3", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rollup/-/rollup-4.62.3.tgz", - "integrity": "sha1-A+6Z4rWwdE3Zm+bUI4svzUCHtPY=", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3109,8 +3074,8 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3134,9 +3099,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha1-2BCyfjoHMEeyteQANIgfXqb5yDs=", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -3160,19 +3125,6 @@ "text-decoder": "^1.1.0" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha1-IiskPdLUnAvNDeiQatvYQXcZYDI=", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/tar-fs": { "version": "3.1.3", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tar-fs/-/tar-fs-3.1.3.tgz", @@ -3225,16 +3177,19 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha1-lBeU5leoXklld5lcbu9m9T9Cs9I=", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -3248,30 +3203,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha1-BZ8tBCvTdWf7wBfT1Ca90qJhJZE=", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha1-lQmyFiQ2MV6A4+7g/M5EdNJEQpQ=", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha1-13oAL7U6iKoUKbQZwckkkuDIH3g=", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -3383,8 +3318,8 @@ }, "node_modules/vite": { "version": "7.3.5", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-7.3.5.tgz", - "integrity": "sha1-kMLQt7lKIk5+fc8i0pEv8LUpEWU=", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { @@ -3456,89 +3391,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha1-82dtlMSvHnaJjBYsknKLymX3uwc=", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha1-GUS27QE6Jf0mpz0Y4a+SwQpXr2w=", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -3549,6 +3475,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/bridge/teams-gateway/package.json b/bridge/teams-gateway/package.json index 43441a239..a4e41e66d 100644 --- a/bridge/teams-gateway/package.json +++ b/bridge/teams-gateway/package.json @@ -25,10 +25,12 @@ "oxlint": "0.16.0", "tsx": "^4", "typescript": "^5.8", - "vitest": "^3.2" + "vitest": "4.1.11" }, "overrides": { "qs": "6.16.0", + "vite": "7.3.5", + "rollup": "4.62.3", "esbuild": "^0.28.1", "js-yaml@^4": "^4.3.1", "nanoid@^3": "^3.3.18" From cebeed67860ad34604042981fc91fe037a08a5c9 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 14:37:36 +0200 Subject: [PATCH 079/111] Harden Bridge proxy, evidence link and Foundry transport boundaries Register executable proxy and link regression contracts in public Bridge CI and document safe upstream failure diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 4 +- bridge/bff/src/routes/foundry.rs | 66 ++++++-- bridge/docs/identity.md | 8 + bridge/docs/troubleshooting.md | 6 + bridge/teams-gateway/tests/chart.test.ts | 2 +- bridge/web/src/app/api/[...path]/route.ts | 8 +- bridge/web/src/app/dex/[...path]/route.ts | 6 +- .../teams/[name]/runs/[run]/page.tsx | 31 +++- bridge/web/tests/proxy-routes.test.mjs | 150 ++++++++++++++++++ bridge/web/tests/team-run-links.test.mjs | 73 +++++++++ ci/no-stubs-ts.mjs | 6 +- ci/tests/no_stubs_test.py | 26 +++ 12 files changed, 352 insertions(+), 34 deletions(-) create mode 100644 bridge/web/tests/proxy-routes.test.mjs create mode 100644 bridge/web/tests/team-run-links.test.mjs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index c8a78f0f0..44c495c62 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -83,8 +83,8 @@ jobs: - run: npm ci - run: npm run lint - run: npx --no-install tsc --noEmit - - name: Check credential form and shared DTO contracts - run: node --experimental-strip-types --test tests/credential-review.test.mjs tests/type-contract.test.mjs + - name: Check credential forms, DTOs, proxies and evidence links + run: node --experimental-strip-types --test tests/credential-review.test.mjs tests/type-contract.test.mjs tests/proxy-routes.test.mjs tests/team-run-links.test.mjs - name: Build the production web image without publishing run: docker build --tag kars-bridge-web-qualification:latest . - name: Start web with an immutable root filesystem diff --git a/bridge/bff/src/routes/foundry.rs b/bridge/bff/src/routes/foundry.rs index 140ee7ba6..2b1c7d39a 100644 --- a/bridge/bff/src/routes/foundry.rs +++ b/bridge/bff/src/routes/foundry.rs @@ -63,10 +63,11 @@ pub struct FoundryStatus { /// Extract the host from an https URL, for DNS checks. fn host_of(url: &str) -> Option<String> { - let s = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://"))?; - Some(s.split(['/', ':']).next().unwrap_or(s).to_lowercase()) + let url = reqwest::Url::parse(url).ok()?; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { + return None; + } + url.host_str().map(str::to_string) } async fn resolves(host: &str) -> bool { @@ -234,6 +235,15 @@ const FOUNDRY_API_VERSION: &str = "2025-05-01"; /// The OAuth2 scope for the Foundry project data-plane. const FOUNDRY_SCOPE: &str = "https://ai.azure.com/.default"; +fn foundry_https_client(timeout: Duration) -> Result<reqwest::Client, reqwest::Error> { + reqwest::Client::builder() + .https_only(true) + // Neither the federated assertion nor a data-plane key may follow a redirect. + .redirect(reqwest::redirect::Policy::none()) + .timeout(timeout) + .build() +} + /// Acquire an AAD bearer token for the Foundry data-plane using the same /// no-Azure-SDK REST paths the router uses, mirroring DefaultAzureCredential's /// order: (1) AKS **workload identity** (federated token file → AAD exchange), @@ -241,10 +251,7 @@ const FOUNDRY_SCOPE: &str = "https://ai.azure.com/.default"; /// dev — a kind cluster has no managed identity). Returns `(token, source)` or /// `None` when no credential is available. async fn foundry_bearer_token() -> Option<(String, &'static str)> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(8)) - .build() - .ok()?; + let client = foundry_https_client(Duration::from_secs(8)).ok()?; // (1) Workload identity: federated token file + AAD token endpoint. if let (Ok(client_id), Ok(tenant), Ok(token_file)) = ( @@ -278,9 +285,18 @@ async fn foundry_bearer_token() -> Option<(String, &'static str)> { } } - // (2) IMDS managed identity. - let imds = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://ai.azure.com/"; - if let Ok(resp) = client.get(imds).header("Metadata", "true").send().await + // (2) IMDS is a fixed link-local HTTP service, not a secret-bearing outbound + // request. Keep it off proxies and redirects, separate from the HTTPS client. + if let Ok(imds) = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(8)) + .build() + && let Ok(resp) = imds + .get("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://ai.azure.com/") + .header("Metadata", "true") + .send() + .await && let Ok(v) = resp.json::<serde_json::Value>().await && let Some(t) = v.get("access_token").and_then(|t| t.as_str()) { @@ -435,9 +451,7 @@ pub async fn verify_foundry(State(state): State<AppState>) -> AppResult<Json<Fou }, }); - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() + let http = foundry_https_client(Duration::from_secs(10)) .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; // 2. Determine the data-plane credential. Prefer an ambient AAD token @@ -649,6 +663,30 @@ mod tests { assert_eq!(host_of("not a url"), None); } + #[test] + fn project_host_requires_https_without_url_credentials() { + for url in [ + "http://r.services.ai.azure.com/api/projects/p", + "https://", + "https://user@r.services.ai.azure.com/api/projects/p", + "https://user:password@r.services.ai.azure.com/api/projects/p", + ] { + assert_eq!(host_of(url), None, "{url}"); + } + } + + #[tokio::test] + async fn credential_client_rejects_plaintext_before_connecting() { + let client = foundry_https_client(Duration::from_secs(1)).unwrap(); + for url in [ + "http://127.0.0.1:9/oauth2/v2.0/token", + "http://127.0.0.1:9/api/projects/p/deployments", + ] { + let error = client.post(url).send().await.unwrap_err(); + assert!(error.is_builder(), "{error}"); + } + } + #[test] fn list_names_parses_deployments() { let v = json!({"value":[{"name":"gpt-4o"},{"name":"o3-mini"},{"noname":1}]}); diff --git a/bridge/docs/identity.md b/bridge/docs/identity.md index 6ee3cedf1..7421cb535 100644 --- a/bridge/docs/identity.md +++ b/bridge/docs/identity.md @@ -68,6 +68,14 @@ localhost redirect because localhost refers to the browser pod itself. For ingress, set an HTTPS issuer and callback URI reachable by users and Dex. +## Foundry service authentication + +The BFF's Foundry workload-identity token exchange and data-plane requests +require HTTPS and do not follow redirects. `AZURE_AUTHORITY_HOST` must use +HTTPS. Azure IMDS retains its fixed link-local HTTP endpoint through a separate +client that bypasses proxies and redirects. This does not change the supported +in-cluster web-to-BFF or Dex HTTP paths. + ## Sessions and logout - Session cookies are signed. diff --git a/bridge/docs/troubleshooting.md b/bridge/docs/troubleshooting.md index 020280815..bf2555fd9 100644 --- a/bridge/docs/troubleshooting.md +++ b/bridge/docs/troubleshooting.md @@ -17,6 +17,7 @@ kubectl -n kars-system logs deploy/kars-controller --since=15m | Auditor can enter Workspace | Incorrect role implication | Auditor must not imply user | | Login follows localhost and fails in managed Playwright | Port-forward Dex split horizon | Use redirect-manual evidence or configure ingress issuer | | BFF returns Kubernetes 403 | Missing ServiceAccount verb | Test `kubectl auth can-i` as `kars-bridge` | +| Web proxy returns `bad_gateway` (502) | BFF or Dex upstream request failed | Check the fixed web log marker, configured upstream and service endpoints | | Mission launch returns 422 | Budget or preflight failure | Read the structured error and Console budget/MCP status | | MCP appears Ready but tools fail | Stale generation/session or auth | Inspect `McpServer.status` and sandbox router logs | | Playwright opens a blank page mid-run | Session reaped or non-isolated server | Verify managed preset and router keepalive | @@ -24,6 +25,11 @@ kubectl -n kars-system logs deploy/kars-controller --since=15m | Team forgets earlier work | No harvested substantive deliverable | Inspect team commons and run health | | Delete works as admin but fails in Bridge | Developer kubeconfig hid RBAC gap | Test through deployed BFF ServiceAccount | +Web proxy failures deliberately omit exception messages, upstream URLs, query +parameters and stack traces from both the response and the web failure marker. +Use the request timestamp to correlate BFF/Dex logs and Kubernetes events; +do not enable logging of cookies, login codes or signed principal tokens. + ## Managed MCP ```bash diff --git a/bridge/teams-gateway/tests/chart.test.ts b/bridge/teams-gateway/tests/chart.test.ts index 5cd1f8111..3cb6c0db4 100644 --- a/bridge/teams-gateway/tests/chart.test.ts +++ b/bridge/teams-gateway/tests/chart.test.ts @@ -170,7 +170,7 @@ describe("Bridge optional add-on boundary (offline Helm manifests)", () => { const role = resource<V1ClusterRole>(resources, "ClusterRole", "kars-bridge-kars-bridge"); for (const api of requiredApis) { expect(role.rules?.some((rule) => - rule.apiGroups?.includes("kars.azure.com") + rule.apiGroups?.some((group) => group === "kars.azure.com") && rule.resources?.includes(api) && rule.verbs?.includes("list"), ), `readiness list permission for ${api}`).toBe(true); diff --git a/bridge/web/src/app/api/[...path]/route.ts b/bridge/web/src/app/api/[...path]/route.ts index 2d6421b19..e540a80f1 100644 --- a/bridge/web/src/app/api/[...path]/route.ts +++ b/bridge/web/src/app/api/[...path]/route.ts @@ -76,16 +76,14 @@ async function forward(req: NextRequest): Promise<Response> { let upstream: Response; try { upstream = await fetch(target, init); - } catch (err) { - const cause = (err as { cause?: unknown })?.cause; + } catch { + // Request URLs and fetch errors can contain credentials; log only the event. + console.error("[bridge/api] BFF upstream request failed"); return new Response( JSON.stringify({ error: { code: "bad_gateway", message: "BFF unreachable", - detail: String(err), - cause: String(cause ?? ""), - target, }, }), { status: 502, headers: { "content-type": "application/json" } }, diff --git a/bridge/web/src/app/dex/[...path]/route.ts b/bridge/web/src/app/dex/[...path]/route.ts index dece74874..51edcbe21 100644 --- a/bridge/web/src/app/dex/[...path]/route.ts +++ b/bridge/web/src/app/dex/[...path]/route.ts @@ -68,14 +68,14 @@ async function forward(req: NextRequest): Promise<Response> { let upstream: Response; try { upstream = await fetch(target, init); - } catch (err) { + } catch { + // OIDC URLs and fetch errors can contain codes or state; log only the event. + console.error("[bridge/dex] OIDC IdP upstream request failed"); return new Response( JSON.stringify({ error: { code: "bad_gateway", message: "OIDC IdP (Dex) unreachable", - detail: String(err), - target, }, }), { status: 502, headers: { "content-type": "application/json" } }, diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx index 3f52af2ea..ab6cd1bf9 100644 --- a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx @@ -218,7 +218,7 @@ export default async function TeamRunPage({ : null; const claimedPullRequest = task.result?.status === "error" && - /github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/i.test(task.result.output); + githubPullRequests(task.result.output).length > 0; return ( <div className="space-y-6"> @@ -580,22 +580,37 @@ function RunUnavailable({ run }: { run: string }) { ); } -function archivedPullRequests( - text: string, - repos: string[], -): Array<{ repo: string; number: number; url: string }> { +function githubPullRequests(text: string): Array<{ repo: string; number: number; url: string }> { const found = new Map<string, { repo: string; number: number; url: string }>(); - for (const segment of text.split("github.com/").slice(1)) { - const match = segment.match(/^([^/\s]+)\/([^/\s]+)\/pulls?\/(\d+)/); + for (const token of text.split(/[\s<>()"'`[\]]+/)) { + const candidate = token.replace(/[.,;:!?]+$/, ""); + let parsed: URL; + try { + const bare = candidate.toLowerCase().startsWith("github.com/"); + parsed = new URL(bare ? `https://${candidate}` : candidate); + } catch { + continue; + } + if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") + || parsed.hostname !== "github.com" || parsed.port + || parsed.username || parsed.password) continue; + const match = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pulls?\/([1-9]\d*)(?:\/|$)/.exec(parsed.pathname); if (!match) continue; const repo = `${match[1]}/${match[2]}`; const number = Number(match[3]); + if (!Number.isSafeInteger(number)) continue; const url = `https://github.com/${repo}/pull/${number}`; found.set(url, { repo, number, url }); } - if (repos.length === 1) { + return [...found.values()]; +} + +function archivedPullRequests(text: string, repos: string[]): Array<{ repo: string; number: number; url: string }> { + const found = new Map(githubPullRequests(text).map((pr) => [pr.url, pr])); + if (repos.length === 1 && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repos[0])) { for (const match of text.matchAll(/\bPR\s*#(\d+)\b/gi)) { const number = Number(match[1]); + if (!Number.isSafeInteger(number) || number <= 0) continue; const repo = repos[0]; const url = `https://github.com/${repo}/pull/${number}`; found.set(url, { repo, number, url }); diff --git a/bridge/web/tests/proxy-routes.test.mjs b/bridge/web/tests/proxy-routes.test.mjs new file mode 100644 index 000000000..27612d7d6 --- /dev/null +++ b/bridge/web/tests/proxy-routes.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); + +function proxy(kind, { fetch, sso = false, valid = true } = {}) { + const source = readFileSync(new URL(`../src/app/${kind}/[...path]/route.ts`, import.meta.url), "utf8"); + const logs = []; + const requests = []; + const verified = []; + const env = { + BRIDGE_BFF_URL: "http://bff.private.svc:8081/", + DEX_UPSTREAM_URL: "http://dex.private.svc:5556/", + }; + const context = { + exports: {}, Headers, Response, process: { env }, + console: { error: (...args) => logs.push(args) }, + require: (name) => { + if (name === "@/lib/oidc-config") return { ssoConfigured: () => sso }; + if (name === "@/lib/session-token") return { + SESSION_COOKIE: "bridge-session", + verifySession: async (token) => { verified.push(token); return valid; }, + }; + throw new Error(`Unexpected route dependency: ${name}`); + }, + fetch: async (...args) => { + requests.push(args); + return fetch ? fetch(...args) : new Response(null, { status: 204 }); + }, + }; + const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + runInNewContext(output, context); + return { routes: context.exports, requests, logs, verified, env }; +} + +function request(kind, { method = "GET", headers = {}, token, body = null, suffix = "/items?q=one" } = {}) { + return { + method, body, headers: new Headers(headers), + nextUrl: new URL(`https://bridge.example/${kind}${suffix}`), + cookies: { get: () => token ? { value: token } : undefined }, + }; +} + +for (const kind of ["api", "dex"]) { + test(`${kind}: errors expose only a stable 502 and log no credentials or upstream details`, async () => { + const secret = "PRIVATE_QUERY_AND_ERROR"; + const upstreamError = new Error(`fetch failed at private.svc:8081 ${secret}`, { + cause: new Error(`internal connection details ${secret}`), + }); + const service = proxy(kind, { fetch: async () => { throw upstreamError; } }); + const response = await service.routes.GET(request(kind, { suffix: `/items?code=${secret}` })); + assert.equal(response.status, 502); + assert.equal(response.headers.get("content-type"), "application/json"); + assert.deepEqual(await response.json(), { + error: { code: "bad_gateway", message: kind === "api" ? "BFF unreachable" : "OIDC IdP (Dex) unreachable" }, + }); + assert.deepEqual(service.logs, [[kind === "api" + ? "[bridge/api] BFF upstream request failed" : "[bridge/dex] OIDC IdP upstream request failed"]]); + assert.equal(JSON.stringify(service.logs).includes(secret), false); + }); + + test(`${kind}: all methods retain fixed upstream routing, bodies, and manual redirects`, async () => { + const service = proxy(kind); + for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) { + const body = new ReadableStream({ start(controller) { controller.close(); } }); + const response = await service.routes[method](request(kind, { + method, body, suffix: "/https://untrusted.example/items?next=https://other.example", + headers: { host: "untrusted.example", connection: "keep-alive", "x-request-id": "trace" }, + })); + assert.equal(response.status, 204); + const [target, init] = service.requests.at(-1); + const expected = kind === "api" ? "http://bff.private.svc:8081" : "http://dex.private.svc:5556"; + assert.equal(target, `${expected}/${kind}/https://untrusted.example/items?next=https://other.example`); + assert.equal(init.redirect, "manual"); + assert.equal(init.method, method); + assert.equal(init.headers.get("host"), null); + assert.equal(init.headers.get("connection"), null); + assert.equal(init.headers.get("x-request-id"), "trace"); + const hasBody = method !== "GET" && method !== "HEAD"; + assert.equal(init.body, hasBody ? body : undefined); + assert.equal(init.duplex, hasBody ? "half" : undefined); + } + service.env[kind === "api" ? "BRIDGE_BFF_URL" : "DEX_UPSTREAM_URL"] = "http://changed.svc:9000"; + await service.routes.GET(request(kind)); + assert.equal(service.requests.at(-1)[0], `http://changed.svc:9000/${kind}/items?q=one`); + }); +} + +test("api: strips browser cookies and internal identity headers even without SSO", async () => { + const service = proxy("api"); + await service.routes.GET(request("api", { headers: { + cookie: "bridge-session=forged", "x-kars-principal-token": "forged", + "x-teams-internal-secret": "forged", "x-teams-internal-signature": "forged", + authorization: "Bearer caller-credential", + } })); + const headers = service.requests[0][1].headers; + for (const name of ["cookie", "x-kars-principal-token", "x-teams-internal-secret", "x-teams-internal-signature"]) { + assert.equal(headers.get(name), null); + } + assert.equal(headers.get("authorization"), "Bearer caller-credential"); +}); + +test("api: only a verified session can supply the principal token when SSO is enabled", async () => { + const service = proxy("api", { sso: true }); + await service.routes.GET(request("api", { + token: "signed-session", headers: { "x-kars-principal-token": "forged" }, + })); + assert.deepEqual(service.verified, ["signed-session"]); + assert.equal(service.requests[0][1].headers.get("x-kars-principal-token"), "signed-session"); + for (const token of [undefined, "invalid-session"]) { + const denied = proxy("api", { sso: true, valid: false }); + const response = await denied.routes.GET(request("api", { token })); + assert.equal(response.status, 401); + assert.equal(denied.requests.length, 0); + } +}); + +test("api: streams SSE unchanged while removing hop-by-hop response headers", async () => { + const upstream = new Response("data: event\n\n", { + headers: { "content-type": "text/event-stream", connection: "keep-alive" }, + }); + const service = proxy("api", { fetch: async () => upstream }); + const response = await service.routes.GET(request("api")); + assert.equal(response.body, upstream.body); + assert.equal(response.headers.get("content-type"), "text/event-stream"); + assert.equal(response.headers.get("connection"), null); + assert.equal(await response.text(), "data: event\n\n"); +}); + +test("dex: forwards login cookies, redirects, and separate Set-Cookie headers", async () => { + const headers = new Headers({ location: "/auth/callback?code=opaque", connection: "keep-alive" }); + headers.append("set-cookie", "csrf=one; Path=/dex; HttpOnly"); + headers.append("set-cookie", "session=two; Path=/dex; HttpOnly"); + const service = proxy("dex", { + fetch: async () => new Response(null, { status: 302, statusText: "Found", headers }), + }); + const response = await service.routes.GET(request("dex", { headers: { cookie: "csrf=one" } })); + assert.equal(service.requests[0][1].headers.get("cookie"), "csrf=one"); + assert.equal(response.status, 302); + assert.equal(response.statusText, "Found"); + assert.equal(response.headers.get("location"), "/auth/callback?code=opaque"); + assert.deepEqual(response.headers.getSetCookie(), headers.getSetCookie()); + assert.equal(response.headers.get("connection"), null); +}); diff --git a/bridge/web/tests/team-run-links.test.mjs b/bridge/web/tests/team-run-links.test.mjs new file mode 100644 index 000000000..c08ab2ee9 --- /dev/null +++ b/bridge/web/tests/team-run-links.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const path = new URL("../src/app/workspace/teams/[name]/runs/[run]/page.tsx", import.meta.url); +const source = ts.createSourceFile(path.pathname, readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const names = ["githubPullRequests", "archivedPullRequests"]; +const functions = source.statements.filter(statement => + ts.isFunctionDeclaration(statement) && names.includes(statement.name?.text)); +assert.equal(functions.length, names.length); +const context = { exports: {}, URL }; +const helpers = functions.map(statement => statement.getText(source)).join("\n"); +runInNewContext(ts.transpileModule(`${helpers}\nexport { ${names.join(", ")} };`, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText, context); +const links = (text, repos = []) => + JSON.parse(JSON.stringify(context.exports.archivedPullRequests(text, repos))); +const expected = [{ repo: "Azure/kars", number: 563, url: "https://github.com/Azure/kars/pull/563" }]; + +test("live claim warnings and archive links use the same host-checked parser", () => { + let claim; + function visit(node) { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === "claimedPullRequest") claim = node; + ts.forEachChild(node, visit); + } + visit(source); + assert.ok(claim); + assert.match(claim.initializer.getText(source), /githubPullRequests\(task\.result\.output\)/); +}); + +test("GitHub links in prose, markdown, and supported bare URLs retain canonical HTTPS output", () => { + for (const text of [ + "Created https://github.com/Azure/kars/pull/563.", + "[PR](https://github.com/Azure/kars/pull/563)", + "<https://github.com/Azure/kars/pull/563/files?diff=split#review>", + "`github.com/Azure/kars/pull/563`", + "HTTP://GITHUB.COM/Azure/kars/pulls/563", + "https://github.com:443/Azure/kars/pull/563", + ]) assert.deepEqual(links(text), expected, text); + assert.deepEqual(links("https://github.com/Azure/kars/pull/563 github.com/Azure/kars/pull/563"), expected); +}); + +test("domain substrings, credentials, other protocols, and malformed PR paths are not GitHub evidence", () => { + for (const text of [ + "https://evilgithub.com/Azure/kars/pull/563", + "https://github.com.evil.example/Azure/kars/pull/563", + "https://evil.example/github.com/Azure/kars/pull/563", + "https://evil.example/?next=github.com/Azure/kars/pull/563", + "https://github.com@evil.example/Azure/kars/pull/563", + "https://user:password@github.com/Azure/kars/pull/563", + "https://github.com:8443/Azure/kars/pull/563", + "ftp://github.com/Azure/kars/pull/563", + "evilgithub.com/Azure/kars/pull/563", + "https://github.com/Azure/kars/pull/563oops", + "https://github.com/Azure/kars/pull/0", + "https://github.com/Azure/kars/pull/9007199254740992", + ]) assert.deepEqual(links(text), [], text); +}); + +test("PR shorthand remains tied to one valid configured repository", () => { + assert.deepEqual(links("Delivered PR #563", ["Azure/kars"]), expected); + assert.deepEqual(links("Delivered PR #563"), []); + assert.deepEqual(links("Delivered PR #563", ["Azure/kars", "Azure/other"]), []); + for (const repo of ["@evil.example/Azure/kars", "Azure/kars?next=evil", "Azure/kars/extra"]) { + assert.deepEqual(links("Delivered PR #563", [repo]), []); + } + assert.deepEqual(links("PR #0, PR #9007199254740992", ["Azure/kars"]), []); +}); diff --git a/ci/no-stubs-ts.mjs b/ci/no-stubs-ts.mjs index 6f57fa99c..e8c3b8816 100644 --- a/ci/no-stubs-ts.mjs +++ b/ci/no-stubs-ts.mjs @@ -10,6 +10,11 @@ const [base, file, patterns] = process.argv.slice(2); if (!base || !file || !patterns) { throw new Error("fail: source gate requires a base revision, file and marker patterns"); } +const markers = /TODO\b|FIXME\b|XXX\b|HACK\b|unimplemented!\(|\btodo!\(|panic!\("not[ _-]impl|\bplaceholder\b|\.stub\(\)|\.mock\(\)|return None; \/\/ placeholder|return Ok\(\(\)\); \/\/ stub/; +// The shell gate shares this fixed policy; arguments must not become executable patterns. +if (patterns !== markers.source.replaceAll("\\/", "/")) { + throw new Error("fail: source gate marker patterns differ from the fixed policy"); +} const source = execFileSync("git", ["show", `HEAD:${file}`], { encoding: "utf8" }); const diff = execFileSync("git", ["diff", "--unified=0", `${base}...HEAD`, "--", file], @@ -79,7 +84,6 @@ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) { parts.push(source.slice(cursor)); const original = source.split("\n"); const masked = parts.join("").split("\n"); -const markers = new RegExp(patterns); let lineNumber; for (const line of diff.split("\n")) { const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); diff --git a/ci/tests/no_stubs_test.py b/ci/tests/no_stubs_test.py index 8e84796bd..7690d2dd0 100644 --- a/ci/tests/no_stubs_test.py +++ b/ci/tests/no_stubs_test.py @@ -13,6 +13,7 @@ from git_fixture import GitFixture GATE = Path(__file__).resolve().parents[1] / "no-stubs.sh" +TS_GATE = GATE.with_name("no-stubs-ts.mjs") class NoStubsTests(GitFixture): @@ -40,6 +41,31 @@ def test_keeps_existing_inline_override_semantics(self): result = self.gate() self.assertEqual((result.returncode, result.stderr), (0, "")) + def test_javascript_gate_uses_the_same_fixed_markers_as_the_shell_gate(self): + markers = ["TODO work", "FIXME work", "XXX work", "HACK work", + "unimplemented!()", "todo!()", 'panic!("not implemented")', + "placeholder", "value.stub()", "value.mock()", + "return None; // placeholder", "return Ok(()); // stub"] + lines = ["// " + marker for marker in markers] + self.write("bridge/web/src/fixture.ts", "\n".join(lines) + "\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.stderr.splitlines(), [ + "fail: bridge/web/src/fixture.ts: new stub/placeholder introduced: " + line + for line in lines]) + + def test_javascript_gate_rejects_caller_supplied_patterns(self): + self.write("bridge/web/src/fixture.ts", "// TODO work\n") + self.commit() + for pattern in ("(a+)+$", "[", "^$", "TODO"): + with self.subTest(pattern=pattern): + result = subprocess.run( + ["node", str(TS_GATE), self.base, "bridge/web/src/fixture.ts", pattern], + cwd=self.root, text=True, capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn("marker patterns differ from the fixed policy", result.stderr) + def test_javascript_placeholder_identifiers_are_not_unfinished_implementations(self): self.write("bridge/web/src/fixture.tsx", """ type Props = { placeholder?: string }; From ef4cf5b450785364985753c2649f5f71c10302ca Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 14:41:07 +0200 Subject: [PATCH 080/111] Require the complete Bridge component qualification graph Reject missing or unexpected job identities, exercise the real aggregate entrypoint, and document integration-only required-check activation without changing live protection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/contributing.md | 15 +++++++--- ci/bridge_component_results.py | 12 ++++++-- ci/tests/bridge_contracts_test.py | 42 +++++++++++++++++++++++++++- docs/operations/branch-protection.md | 32 ++++++++++++++++++++- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/bridge/docs/contributing.md b/bridge/docs/contributing.md index e34047ba6..fc32cfaad 100644 --- a/bridge/docs/contributing.md +++ b/bridge/docs/contributing.md @@ -67,9 +67,13 @@ installation. Runtime acceptance uses that same core preparation entrypoint. Schema and admission warnings remain failures; no policy status or generation is changed merely to refresh a diagnostic. -`Bridge component acceptance` aggregates every BFF, web, audit and add-on job -and runs even for core-only PRs. Failed, cancelled or skipped component jobs -cannot satisfy it. Together with `Require both native API and runtime acceptance`, +`Bridge component acceptance` requires the exact component job set: `addon`, +`bff`, `dependencies`, `lockfiles`, `rust-dependencies`, `secrets`, `security` +and `web`. It runs even for core-only PRs. Missing, unexpected, failed, cancelled +or skipped component jobs cannot satisfy it; removing a workflow dependency +must not silently turn incomplete evidence green. Keep the workflow `needs` +list, aggregate policy and regression cases in agreement when changing this set. +Together with `Require both native API and runtime acceptance`, it provides stable check names for the integration merge policy rather than relying on path-filtered jobs that may never report. @@ -80,6 +84,9 @@ add-on install/upgrade/uninstall checks retain their core resource/data preservation assertions. These workflow changes still require hosted qualification and integration into -the required merge-check policy. Complete supported-version and standing-Team +the required merge-check policy. See the +[integration branch protection requirements](../../docs/operations/branch-protection.md#bridge-integration-branch) +for activation order and exact required status names. +Complete supported-version and standing-Team workflow coverage remains a separate acceptance requirement; passing scope or template checks alone does not establish compatibility. diff --git a/ci/bridge_component_results.py b/ci/bridge_component_results.py index 1360df685..6ebdab41f 100644 --- a/ci/bridge_component_results.py +++ b/ci/bridge_component_results.py @@ -1,15 +1,21 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Require every declared Bridge component dependency to have succeeded.""" +"""Require the complete Bridge component qualification graph to succeed.""" import json import os +REQUIRED_JOBS = frozenset({ + "addon", "bff", "dependencies", "lockfiles", + "rust-dependencies", "secrets", "security", "web", +}) + + def require_success(results): - if not isinstance(results, dict) or not results: - raise ValueError("Bridge component results are missing or malformed") + if not isinstance(results, dict) or results.keys() != REQUIRED_JOBS: + raise ValueError("Bridge component results must include exactly the required jobs") if any(not isinstance(value, dict) or value.get("result") != "success" for value in results.values()): raise ValueError("Every Bridge component, audit and add-on job must succeed") diff --git a/ci/tests/bridge_contracts_test.py b/ci/tests/bridge_contracts_test.py index 511c646a4..74aa7bf7b 100644 --- a/ci/tests/bridge_contracts_test.py +++ b/ci/tests/bridge_contracts_test.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import importlib.util +import json import os from pathlib import Path import runpy @@ -90,14 +91,53 @@ def test_sparse_core_checkout_really_removes_only_bridge(self): class ContractAggregateTests(unittest.TestCase): + components = ( + "addon", "bff", "dependencies", "lockfiles", + "rust-dependencies", "secrets", "security", "web", + ) + + def component_success(self): + return {name: {"result": "success"} for name in self.components} + def test_component_aggregate_rejects_missing_failed_or_skipped_jobs(self): check = runpy.run_path(str(CI / "bridge_component_results.py"))["require_success"] - check({"bff": {"result": "success"}, "web": {"result": "success"}}) + check(self.component_success()) for result in ({}, [], None, {"bff": {}}, {"bff": None}, {"bff": {"result": "success"}, "web": {"result": "failure"}}, {"bff": {"result": "skipped"}}, {"bff": {"result": "cancelled"}}): with self.subTest(result=result), self.assertRaises(ValueError): check(result) + for name in self.components: + missing = self.component_success() + del missing[name] + with self.subTest(missing=name), self.assertRaises(ValueError): + check(missing) + for outcome in (None, {}, {"result": "failure"}, {"result": "cancelled"}, + {"result": "skipped"}, {"result": "neutral"}): + result = self.component_success() + result[name] = outcome + with self.subTest(job=name, outcome=outcome), self.assertRaises(ValueError): + check(result) + unexpected = {**self.component_success(), "unexpected": {"result": "success"}} + with self.assertRaises(ValueError): + check(unexpected) + + def test_component_workflow_entrypoint_requires_complete_results(self): + partial = self.component_success() + del partial["security"] + environment = {key: value for key, value in os.environ.items() + if key != "COMPONENT_RESULTS"} + for payload, expected in ((json.dumps(self.component_success()), 0), + (json.dumps(partial), 1), ("not-json", 1), + ("null", 1), (None, 1)): + with self.subTest(payload=payload): + result = subprocess.run( + ["python3", str(CI / "bridge_component_results.py")], + text=True, capture_output=True, timeout=10, + env={**environment, **({} if payload is None else + {"COMPONENT_RESULTS": payload})}, + ) + self.assertEqual(result.returncode, expected, result.stderr) def test_only_required_success_or_explicit_docs_skip_passes(self): for required, scope_result, api, runtime, expected in ( diff --git a/docs/operations/branch-protection.md b/docs/operations/branch-protection.md index bc6b518c5..c1c6c8f02 100644 --- a/docs/operations/branch-protection.md +++ b/docs/operations/branch-protection.md @@ -1,4 +1,4 @@ -# Branch Protection — `dev` and `main` +# Branch Protection — `dev`, `main` and Bridge integration This is the canonical list of CI jobs that must be set as **required status checks** on `dev` and `main` for kars. Setting these as @@ -57,3 +57,33 @@ Any new permanent CI row added under "supply-chain" or "conformance" should be added to the table above and to the branch-protection configuration in the same PR. Document the criterion in `docs/operations/supply-chain.md` if it gates a release surface. + +## Bridge integration branch + +`Azure/kars:kars-bridge` is the optional Bridge integration target. Its +protection must retain all existing core, source-review and supply-chain +requirements and additionally require these exact GitHub Actions status names: + +| Status name | Workflow | Required evidence | +|---|---|---| +| `Bridge component acceptance` | `bridge-ci.yml` | The complete BFF, web, dependency, lockfile, security and add-on lifecycle job set succeeds | +| `Require both native API and runtime acceptance` | `bridge-native.yml` | Scope selection succeeds and both API/admission and runtime gates succeed, or an explicitly allowlisted documentation-only change reports no native execution | + +Bind both checks to the GitHub Actions app (ID `15368`), retain strict +up-to-date-branch checking, and preserve the existing review, conversation +resolution and administrator-enforcement settings. Do not replace the existing +required checks or accept a similarly named status from another app. + +During initial publication, land the qualified core prerequisites first. +Those core-only PRs do not yet contain the Bridge workflows and cannot report +these statuses. After the complete application PR reports both aggregate +checks, add them to the `kars-bridge` protection rule **before merging that PR** +and verify the resulting rule and exact-head outcomes. Keep them required for +subsequent core and Bridge changes. This document specifies the required +configuration; it does not apply or attest to live branch protection. + +This integration policy does not change `main` or `dev`, normal releases, or +standalone Kars installation. Core CI continues to build and exercise core with +`bridge/` absent. Paired CI qualifies the optional add-on against the same +immutable source, not every historical or future version combination; full +standing-Team workflow acceptance remains a separate requirement. From df643e9f59908a11ca1ad5c2a74cf4484e064d08 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 14:58:10 +0200 Subject: [PATCH 081/111] fix(observer): normalize endpoint and expose bounded transport progress Treat an omitted HTTPS URI port as 443 and compare canonical IP literals. Observe fixed positive TCP and HTTP setup events without recording upstream values or changing the request stack, TLS, authentication, or deadlines. Preserve old diagnostic records when the optional transport group is absent. Add real HTTP/TLS and cancellation regressions and document pooling and attribution limits. The native observer timeout remains unresolved; hosted Rust and native qualification are still required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 22 +- .../observation_diagnostics.py | 10 + .../test_observation_diagnostics.py | 29 +- docs/how-to/governed-credential-grants.md | 39 +- .../src/service_observation_client.rs | 138 ++++++- .../src/service_observation_client_tests.rs | 355 +++++++++++++++++- 6 files changed, 560 insertions(+), 33 deletions(-) diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 24cbff438..be67aefac 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -253,12 +253,32 @@ The native observer enablement failure records `observationReadiness` alongside `metadataAtFailure` in `native.json`. Collection is read-only and bounded to the core controller and observation-target routers. Only the fixed core readiness stage vocabulary, numeric HTTP status (`0` means none recorded), -timeout/connect booleans, and collection-availability booleans survive parsing. +timeout/connect booleans, bounded `observer_target_client` configuration/progress +booleans, and collection-availability booleans survive parsing. Raw logs, span fields, exception text, tokens, identities, and response bodies are never written to this evidence. Collection cannot qualify any assertion. TLS negatives, 9447/9448 paths, CNI peer denial, and credential rotation remain required unchanged. +`observer_target_client` optionally carries an atomic group of five booleans: +`transport_debug_observable`, `transport_trace_observable`, +`tcp_connect_started`, `tcp_connected`, and `http_handshake_complete`. +All five must be present and boolean if any is present; a partial or mistyped +group discards the record. Older cores can omit the entire group: the collector +preserves that absence as unknown, never synthesizing `false`. Extra upstream +fields are discarded. Connection observations are positive-only, request-poll +local facts, not wire/socket correlation: pooling can bypass events and spawned +futures need not inherit the subscriber. Neither `false` nor compiled-level +availability proves/excludes connectivity, TLS completion, or CNI denial. + +Core normalizes the default HTTPS port for `endpoint_environment_matches`: +locked kube-client 3.1.0 omits explicit `:443` in its in-cluster URI. Older +diagnostics therefore reported a false mismatch even for the matching API +endpoint. This repairs diagnostic interpretation only. The native `Prepared` +observer deadline's root cause remains unknown; no timeout fix or qualification +is claimed. See the core [observer diagnostic contract](../../docs/how-to/governed-credential-grants.md) +for HTTP-setup and spawned-work limitations. + Labels and ServiceAccount names are only prefilters, never diagnostic provenance. The collector anchors the canonical controller Deployment and the specific native observation target's published namespace/Deployment UIDs, diff --git a/bridge/tests/native-credentials/observation_diagnostics.py b/bridge/tests/native-credentials/observation_diagnostics.py index 6f8fa4fdd..6f64d007a 100644 --- a/bridge/tests/native-credentials/observation_diagnostics.py +++ b/bridge/tests/native-credentials/observation_diagnostics.py @@ -42,6 +42,12 @@ "endpoint_environment_matches", "runtime_namespace_matches", ) +# Absent on older cores; absence is unknown, not a negative transport result. +CLIENT_TRANSPORT_FIELDS = ( + "transport_debug_observable", "transport_trace_observable", + "tcp_connect_started", "tcp_connected", "http_handshake_complete", +) + def project(raw, component): if component not in TARGETS: @@ -65,6 +71,10 @@ def project(raw, component): continue record = {"stage": stage, "http_status": status, **{key: fields[key] for key in CLIENT_FIELDS}} + if any(key in fields for key in CLIENT_TRANSPORT_FIELDS): + if any(type(fields.get(key)) is not bool for key in CLIENT_TRANSPORT_FIELDS): + continue + record.update({key: fields[key] for key in CLIENT_TRANSPORT_FIELDS}) if not records or records[-1] != record: records.append(record) continue diff --git a/bridge/tests/native-credentials/test_observation_diagnostics.py b/bridge/tests/native-credentials/test_observation_diagnostics.py index 576da249c..b268dc6c7 100644 --- a/bridge/tests/native-credentials/test_observation_diagnostics.py +++ b/bridge/tests/native-credentials/test_observation_diagnostics.py @@ -7,7 +7,7 @@ from native_api import BRIDGE, CORE, WRITER, core, resource import api_outcome_diagnostics as api_outcomes -from observation_diagnostics import CLIENT_FIELDS, TARGETS, VERSION, collect, project +from observation_diagnostics import CLIENT_FIELDS, CLIENT_TRANSPORT_FIELDS, TARGETS, VERSION, collect, project def event(component="router", **updates): @@ -82,6 +82,33 @@ def logs(*args, **kwargs): class ObservationDiagnosticsTests(unittest.TestCase): + def test_transport_facts_are_optional_atomic_booleans_and_old_absence_stays_unknown(self): + flags = {key: False for key in CLIENT_FIELDS} + old = json.loads(event(message="Private observation target client pending", + stage="observer_target_client", http_status=0, **flags)) + old_record = project(json.dumps(old), "router")[0] + self.assertTrue(all(key not in old_record for key in CLIENT_TRANSPORT_FIELDS)) + for started, connected, handshake in [(False, False, False), (True, False, False), + (True, True, False), (True, True, True)]: + value = copy.deepcopy(old) + transport = dict.fromkeys(CLIENT_TRANSPORT_FIELDS, True) + transport.update(tcp_connect_started=started, tcp_connected=connected, + http_handshake_complete=handshake) + value["fields"].update(transport, endpoint="private-endpoint-canary", + error="private-error-canary", body="private-body-canary") + records = project(json.dumps(value), "router") + self.assertEqual(records, [{**old_record, **transport}]) + self.assertNotIn("canary", json.dumps(records)) + self.assertEqual(project(json.dumps(value), "controller"), []) + for key in CLIENT_TRANSPORT_FIELDS: + for invalid in ("private-value-canary", 0, None, [], {}): + value = copy.deepcopy(old) + value["fields"].update(dict.fromkeys(CLIENT_TRANSPORT_FIELDS, False)) + value["fields"][key] = invalid + self.assertEqual(project(json.dumps(value), "router"), []) + del value["fields"][key] + self.assertEqual(project(json.dumps(value), "router"), []) + def test_client_boundaries_are_fixed_value_free_and_do_not_claim_packet_delivery(self): flags = {key: False for key in CLIENT_FIELDS} flags.update(client_initialized=True, request_built=True, service_entered=True, diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index ec7a8e325..da31ca663 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -206,9 +206,42 @@ cache proofs, or replace TLS, network, rotation, and unauthorized-peer tests. `observer_target_client` adds request-local progress for client initialization, request construction, service entry, post-auth dispatch and response headers, plus bounded configuration-match facts. Dispatch does not prove packet delivery. -The complete target request, including body/error decoding, suppresses raw -library logging; the caller emits bounded diagnostics outside that scope. -Sibling requests keep their own logging and progress state. +`endpoint_environment_matches` compares effective HTTPS ports and canonical IP +literals against the in-cluster environment. Locked kube-client 3.1.0 omits +`:443` when constructing the in-cluster URI; an absent explicit URI port means +443, not an endpoint mismatch. Missing/invalid environment values, another +host/port, or a non-HTTPS URI still produce `false`. + +The optional transport diagnostic group contains only booleans: + +- `transport_debug_observable` / `transport_trace_observable`: service entry + occurred and the corresponding tracing level is compiled in; these do not + guarantee complete connection-event coverage. +- `tcp_connect_started`: the locked hyper-util 0.1.20 connector reached its + fixed pre-connect event. +- `tcp_connected`: that connector observed a successful TCP connection. +- `http_handshake_complete`: hyper-util completed HTTP client connection + setup after the connector returned (including TLS for HTTPS). This is before + the background HTTP dispatcher is started, not proof of request delivery, + response headers, API authorization, or observer readiness. + +These are request-local observations made while the target service call/future +is polled, not socket identifiers. A pooled connection can skip all three +progress events. A speculative connection can emit events before a different +pooled connection wins; its later work, and HTTP dispatcher futures spawned by +hyper-util, do not inherit this subscriber automatically. Consequently, `false` +does not prove a TCP/TLS failure, absence of traffic, or a CNI denial. + +The target service and body/error decoding use the scoped subscriber, which +retains only fixed progress bits and discards raw library events. Literal +matching stops before address/error suffixes; the caller emits bounded +diagnostics outside that scope. Sibling requests keep their own logging and +progress state. This is not process-wide instrumentation of spawned work. + +The native `Prepared` observer deadline's root cause remains unknown. Default +port normalization fixes a diagnostic false-negative only; these additive +observations do not fix the timeout or establish/exclude a network-policy +cause. Hosted transport regressions and native qualification remain required. Operator command failures use `KARS_PRIVATE_COMMAND_FAILURE` with fixed phase, operation, resource-kind and server-reason classes plus a bounded exit code. diff --git a/inference-router/src/service_observation_client.rs b/inference-router/src/service_observation_client.rs index 38c062270..a8270db97 100644 --- a/inference-router/src/service_observation_client.rs +++ b/inference-router/src/service_observation_client.rs @@ -12,6 +12,8 @@ use kube::{ core::DynamicObject, }; use std::{ + fmt, + net::IpAddr, sync::{ Arc, atomic::{AtomicU16, Ordering}, @@ -21,6 +23,7 @@ use std::{ use tower::{Layer, Service}; use tracing::{ Subscriber, + field::{Field, Visit}, instrument::WithSubscriber, span::{Attributes, Id, Record}, }; @@ -38,6 +41,12 @@ const TOKEN_FILE: u16 = 512; const PROXY: u16 = 1024; const ENVIRONMENT: u16 = 2048; const NAMESPACE: u16 = 4096; +const TCP_STARTED: u16 = 8192; +const TCP_CONNECTED: u16 = 16384; +const HTTP_HANDSHAKE: u16 = 32768; + +const TCP_TARGET: &str = "hyper_util::client::legacy::connect::http"; +const HTTP_TARGET: &str = "hyper_util::client::legacy::client"; #[derive(Clone)] pub(super) struct Progress { @@ -98,6 +107,13 @@ impl Drop for Pending { dispatch_observable = bits & ENTERED != 0 && tracing::level_filters::STATIC_MAX_LEVEL >= tracing::level_filters::LevelFilter::DEBUG, after_auth_dispatch = bits & DISPATCH != 0, + transport_debug_observable = bits & ENTERED != 0 + && tracing::level_filters::STATIC_MAX_LEVEL >= tracing::level_filters::LevelFilter::DEBUG, + transport_trace_observable = bits & ENTERED != 0 + && tracing::level_filters::STATIC_MAX_LEVEL >= tracing::level_filters::LevelFilter::TRACE, + tcp_connect_started = bits & TCP_STARTED != 0, + tcp_connected = bits & TCP_CONNECTED != 0, + http_handshake_complete = bits & HTTP_HANDSHAKE != 0, response_headers = bits & HEADERS != 0, config_observed = bits & ENTERED != 0, https = bits & HTTPS != 0, @@ -147,10 +163,8 @@ pub(super) fn client(config: Config) -> Result<Client, kube::Error> { bits |= TOKEN_FILE; } let host = std::env::var("KUBERNETES_SERVICE_HOST").ok(); - let port = std::env::var("KUBERNETES_SERVICE_PORT") - .ok() - .and_then(|value| value.parse::<u16>().ok()); - if host.as_deref() == config.cluster_url.host() && port == config.cluster_url.port_u16() { + let port = std::env::var("KUBERNETES_SERVICE_PORT").ok(); + if endpoint_environment_matches(&config.cluster_url, host.as_deref(), port.as_deref()) { bits |= ENVIRONMENT; } let layer = ClientLayer { @@ -160,6 +174,36 @@ pub(super) fn client(config: Config) -> Result<Client, kube::Error> { Ok(ClientBuilder::try_from(config)?.with_layer(&layer).build()) } +fn endpoint_environment_matches( + uri: &axum::http::Uri, + host: Option<&str>, + port: Option<&str>, +) -> bool { + let (Some(actual), Some(expected), Some(port)) = ( + uri.host(), + host, + port.and_then(|value| value.parse::<u16>().ok()), + ) else { + return false; + }; + // kube-client 3.1 incluster_env omits :443 and canonicalizes IP literals. + // An absent explicit URI port is not an absent HTTPS destination port. + if uri.scheme_str() != Some("https") || uri.port_u16().unwrap_or(443) != port { + return false; + } + let ip = |host: &str| { + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) + .parse::<IpAddr>() + }; + match (ip(actual), ip(expected)) { + (Ok(actual), Ok(expected)) => actual == expected, + (Err(_), Err(_)) => actual.eq_ignore_ascii_case(expected), + _ => false, + } +} + struct Observed<S> { inner: S, config: ClientLayer, @@ -221,14 +265,22 @@ where // kube-client 3.1's default builder places its HTTP trace span *inside* the // authentication layer (client/builder.rs). Observing that span proves dispatch // beyond auth, not a TCP connection or packet delivery. The scoped subscriber -// discards every span field/event, including URLs and upstream error bodies. +// discards every span field, including URLs and upstream error bodies. +// hyper-util 0.1.20's fixed connector messages distinguish TCP progress from +// HTTP connection setup (after TLS for HTTPS). These are positive-only facts: +// pooled connections can skip them, and detached work is not attributed here. struct HttpBoundary(Progress); impl Subscriber for HttpBoundary { fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { - metadata.is_span() + (metadata.is_span() && metadata.name() == "HTTP" - && metadata.target() == "kube_client::client::builder" + && metadata.target() == "kube_client::client::builder") + || (metadata.is_event() + && ((metadata.target() == TCP_TARGET + && *metadata.level() == tracing::Level::DEBUG) + || (metadata.target() == HTTP_TARGET + && *metadata.level() == tracing::Level::TRACE))) } fn new_span(&self, attributes: &Attributes<'_>) -> Id { if self.enabled(attributes.metadata()) { @@ -238,11 +290,81 @@ impl Subscriber for HttpBoundary { } fn record(&self, _: &Id, _: &Record<'_>) {} fn record_follows_from(&self, _: &Id, _: &Id) {} - fn event(&self, _: &tracing::Event<'_>) {} + fn event(&self, event: &tracing::Event<'_>) { + if self.enabled(event.metadata()) { + event.record(&mut TransportMessage { + progress: &self.0, + tcp: event.metadata().target() == TCP_TARGET, + }); + } + } fn enter(&self, _: &Id) {} fn exit(&self, _: &Id) {} } +struct TransportMessage<'a> { + progress: &'a Progress, + tcp: bool, +} + +impl Visit for TransportMessage<'_> { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() != "message" { + return; + } + let bits = if self.tcp { + if message_starts_with(value, "connecting to ") { + TCP_STARTED + } else if message_starts_with(value, "connected to ") { + TCP_CONNECTED + } else { + 0 + } + } else if message_starts_with( + value, + "http1 handshake complete, spawning background dispatcher task", + ) || message_starts_with( + value, + "http2 handshake complete, spawning background dispatcher task", + ) { + HTTP_HANDSHAKE + } else { + 0 + }; + self.progress.set(bits); + } +} + +fn message_starts_with(value: &dyn fmt::Debug, expected: &'static str) -> bool { + struct Prefix { + remaining: &'static [u8], + matched: bool, + } + impl fmt::Write for Prefix { + fn write_str(&mut self, value: &str) -> fmt::Result { + let count = value.len().min(self.remaining.len()); + if value.as_bytes()[..count] != self.remaining[..count] { + return Err(fmt::Error); + } + self.remaining = &self.remaining[count..]; + self.matched = self.remaining.is_empty(); + if self.matched { + Err(fmt::Error) + } else { + Ok(()) + } + } + } + // Compare only fixed literals, retain no message data, and stop formatting + // before the address/error suffix. Never forward an upstream event. + let mut prefix = Prefix { + remaining: expected.as_bytes(), + matched: false, + }; + let _ = fmt::write(&mut prefix, format_args!("{value:?}")); + prefix.matched +} + #[cfg(test)] #[path = "service_observation_client_tests.rs"] mod tests; diff --git a/inference-router/src/service_observation_client_tests.rs b/inference-router/src/service_observation_client_tests.rs index 25d9f4ca8..60feaac9b 100644 --- a/inference-router/src/service_observation_client_tests.rs +++ b/inference-router/src/service_observation_client_tests.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use super::*; +use futures::FutureExt; use kube::core::DynamicObject; use serde_json::json; use std::{io::Write, path::PathBuf, sync::Mutex, time::Duration}; @@ -10,6 +11,28 @@ use wiremock::{ matchers::{header, method, path}, }; +const TEST_DEADLINE: Duration = Duration::from_secs(15); + +async fn cancel_after_receipt( + future: impl std::future::Future<Output = Result<DynamicObject, kube::Error>>, + received: tokio::sync::oneshot::Receiver<()>, +) { + tokio::pin!(future); + tokio::time::timeout(TEST_DEADLINE, async { + tokio::select! { + biased; + _ = &mut future => panic!("Held request completed before fixture receipt"), + receipt = received => receipt.expect("Fixture receipt sender closed"), + } + assert!( + future.as_mut().now_or_never().is_none(), + "Held request must remain pending after fixture receipt" + ); + }) + .await + .expect("Real request did not reach the fixture within the test deadline"); +} + struct TokenFile(PathBuf); impl TokenFile { @@ -52,6 +75,129 @@ fn configured(server: &MockServer) -> Config { config } +#[test] +fn endpoint_comparison_uses_effective_https_ports_and_canonical_ip_literals() { + for (uri, host, port, expected) in [ + ("https://10.96.0.1/", Some("10.96.0.1"), Some("443"), true), + ( + "https://10.96.0.1:443/", + Some("10.96.0.1"), + Some("443"), + true, + ), + ( + "https://10.96.0.1:6443/", + Some("10.96.0.1"), + Some("6443"), + true, + ), + ( + "https://[2001:db8::1]/", + Some("2001:0db8:0:0:0:0:0:1"), + Some("443"), + true, + ), + ( + "https://[2001:db8::1]:6443/", + Some("2001:db8::1"), + Some("6443"), + true, + ), + ( + "https://api.internal/", + Some("API.INTERNAL"), + Some("443"), + true, + ), + ("https://10.96.0.1/", Some("10.96.0.2"), Some("443"), false), + ("https://10.96.0.1/", Some("10.96.0.1"), Some("6443"), false), + ( + "https://10.96.0.1:6443/", + Some("10.96.0.1"), + Some("443"), + false, + ), + ("https://10.96.0.1/", None, Some("443"), false), + ("https://10.96.0.1/", Some("10.96.0.1"), None, false), + ( + "https://10.96.0.1/", + Some("10.96.0.1"), + Some("invalid"), + false, + ), + ( + "https://10.96.0.1/", + Some("10.96.0.1"), + Some("65536"), + false, + ), + ("https://10.96.0.1/", Some("10.96.0.1"), Some(""), false), + ( + "https://[2001:db8::1]/", + Some("2001:db8::2"), + Some("443"), + false, + ), + ( + "https://api.internal/", + Some("10.96.0.1"), + Some("443"), + false, + ), + ( + "http://10.96.0.1:443/", + Some("10.96.0.1"), + Some("443"), + false, + ), + ("/", None, None, false), + ] { + assert_eq!( + endpoint_environment_matches(&uri.parse().unwrap(), host, port), + expected, + ); + } +} + +#[test] +fn transport_observations_never_format_private_suffixes_or_unrelated_fields() { + struct Private; + impl fmt::Debug for Private { + fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { + panic!("private fields must not be formatted"); + } + } + assert!(message_starts_with( + &format_args!("connecting to {:?}", Private), + "connecting to ", + )); + assert!(!message_starts_with( + &format_args!("other {:?}", Private), + "connecting to ", + )); + assert!(!message_starts_with( + &format_args!("connect"), + "connecting to " + )); + let progress = Progress::new("kars-runtime".into()); + tracing::dispatcher::with_default( + &tracing::Dispatch::new(HttpBoundary(progress.clone())), + || { + tracing::debug!(target: "unrelated", "connected to {:?}", Private); + tracing::debug!(target: "hyper_util::client::legacy::connect::http", + unrelated = ?Private, "connecting to {:?}", Private); + tracing::debug!(target: "hyper_util::client::legacy::connect::http", + "connected to {:?}", Private); + tracing::trace!(target: "hyper_util::client::legacy::client", + "http2 handshake complete, spawning background dispatcher task"); + }, + ); + assert_eq!( + progress.bits.load(Ordering::Relaxed), + TCP_STARTED | TCP_CONNECTED | HTTP_HANDSHAKE, + ); +} + #[tokio::test] async fn actual_http_token_file_dispatch_keeps_auth_and_per_request_progress() { let server = MockServer::start().await; @@ -94,35 +240,104 @@ async fn actual_http_token_file_dispatch_keeps_auth_and_per_request_progress() { } #[tokio::test] -async fn cancelled_http_wait_is_distinct_from_predispatch_and_wrong_identity_is_not_hidden() { +async fn pooled_http_success_does_not_inherit_previous_connection_progress() { let server = MockServer::start().await; - Mock::given(path( - "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/slow", - )) - .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) - .mount(&server) - .await; - Mock::given(path("/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/denied")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({ - "apiVersion":"v1","kind":"Status","status":"Failure","code":403,"reason":"Forbidden","message":"fixture denial" - }))).mount(&server).await; - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":"runtime"} + }))) + .expect(2..=65) + .mount(&server) + .await; let client = client(configured(&server)).unwrap(); + let connection = TCP_STARTED | TCP_CONNECTED | HTTP_HANDSHAKE; + tokio::time::timeout(TEST_DEADLINE, async { + let first = Progress::new("kars-runtime".into()); + read_target(&client, request(&first, "runtime"), &first) + .await + .unwrap(); + assert_eq!( + first.bits.load(Ordering::Relaxed) & (connection | HEADERS), + connection | HEADERS + ); + assert_eq!(first.status.load(Ordering::Relaxed), 200); + // hyper-util may return an HTTP/1 connection to its idle pool in a + // spawned future. Require the same strict observation without assuming + // that future has run before the immediately following request. + for _ in 0..64 { + tokio::task::yield_now().await; + let progress = Progress::new("kars-runtime".into()); + read_target(&client, request(&progress, "runtime"), &progress) + .await + .unwrap(); + assert_eq!(progress.status.load(Ordering::Relaxed), 200); + assert_ne!(progress.bits.load(Ordering::Relaxed) & HEADERS, 0); + if progress.bits.load(Ordering::Relaxed) & connection == 0 { + return; + } + } + panic!("No successful request without fresh connection observations"); + }) + .await + .expect("Pooled request regression exceeded its test deadline"); +} + +#[tokio::test] +async fn cancelled_http_wait_is_distinct_from_predispatch_and_wrong_identity_is_not_hidden() { + use axum::{Json, Router, http::StatusCode, routing::get}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sent, received) = tokio::sync::oneshot::channel(); + let sent = Arc::new(Mutex::new(Some(sent))); + let router = Router::new() + .route( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/slow", + get(move || { + let sent = sent.clone(); + async move { + sent.lock().unwrap().take().unwrap().send(()).unwrap(); + std::future::pending::<StatusCode>().await + } + }), + ) + .route( + "/apis/kars.azure.com/v1alpha1/namespaces/work/karssandboxes/denied", + get(|| async { + ( + StatusCode::FORBIDDEN, + Json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","code":403, + "reason":"Forbidden","message":"fixture denial" + })), + ) + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let mut config = Config::new(format!("http://{address}").parse().unwrap()); + config.default_namespace = "kars-runtime".into(); + let client = client(config).unwrap(); let slow = Progress::new("kars-runtime".into()); - let result = tokio::time::timeout( - Duration::from_millis(200), + cancel_after_receipt( read_target(&client, request(&slow, "slow"), &slow), + received, ) .await; - assert!(result.is_err()); assert_eq!( - slow.bits.load(Ordering::Relaxed) & (ENTERED | DISPATCH | HEADERS), - ENTERED | DISPATCH + slow.bits.load(Ordering::Relaxed) + & (ENTERED | DISPATCH | TCP_STARTED | TCP_CONNECTED | HTTP_HANDSHAKE | HEADERS), + ENTERED | DISPATCH | TCP_STARTED | TCP_CONNECTED | HTTP_HANDSHAKE ); let denied = Progress::new("different-runtime".into()); - let error = read_target(&client, request(&denied, "denied"), &denied) - .await - .unwrap_err(); + let error = tokio::time::timeout( + TEST_DEADLINE, + read_target(&client, request(&denied, "denied"), &denied), + ) + .await + .unwrap() + .unwrap_err(); assert!(matches!(error, kube::Error::Api(status) if status.code == 403)); assert_eq!(denied.status.load(Ordering::Relaxed), 403); assert_eq!( @@ -130,6 +345,106 @@ async fn cancelled_http_wait_is_distinct_from_predispatch_and_wrong_identity_is_ HEADERS ); assert_eq!(slow.status.load(Ordering::Relaxed), 0); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn stalled_tls_is_distinct_from_completed_tcp_and_http_setup() { + use tokio::{io::AsyncReadExt, net::TcpListener}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (sent, received) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut hello = [0u8; 6]; + stream.read_exact(&mut hello).await.unwrap(); + // TLS handshake record header followed by ClientHello's message type. + assert_eq!(hello[0], 22); + assert_eq!(hello[5], 1); + sent.send(()).unwrap(); + std::future::pending::<()>().await; + }); + let client = client(Config::new(format!("https://{address}").parse().unwrap())).unwrap(); + let progress = Progress::new("default".into()); + cancel_after_receipt( + read_target(&client, request(&progress, "runtime"), &progress), + received, + ) + .await; + assert_eq!( + progress.bits.load(Ordering::Relaxed) + & (TCP_STARTED | TCP_CONNECTED | HTTP_HANDSHAKE | HEADERS), + TCP_STARTED | TCP_CONNECTED + ); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn actual_tls_progress_preserves_ca_and_server_identity_verification() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let key = rcgen::KeyPair::generate().unwrap(); + let certificate = rcgen::CertificateParams::new(vec!["127.0.0.1".into()]) + .unwrap() + .self_signed(&key) + .unwrap(); + let listener = crate::sre_proxy::Listener { + tcp: tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(), + tls: crate::sre_proxy::tls_from_pem( + certificate.pem().as_bytes(), + key.serialize_pem().as_bytes(), + ) + .unwrap(), + }; + let address = listener.tcp.local_addr().unwrap(); + let server = tokio::spawn(async move { + let router = axum::Router::new().fallback(|| async { + axum::Json(json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsSandbox", + "metadata":{"name":"runtime","uid":"runtime-uid"} + })) + }); + axum::serve(listener, router).await.unwrap(); + }); + for mode in ["trusted", "untrusted", "wrong-name"] { + let mut config = Config::new(format!("https://{address}").parse().unwrap()); + if mode != "untrusted" { + config.root_cert = Some(vec![certificate.der().to_vec()]); + } + if mode == "wrong-name" { + config.tls_server_name = Some("different.invalid".into()); + } + let client = client(config).unwrap(); + let progress = Progress::new("default".into()); + let result = tokio::time::timeout( + TEST_DEADLINE, + read_target(&client, request(&progress, "runtime"), &progress), + ) + .await + .unwrap(); + assert_eq!(result.is_ok(), mode == "trusted"); + let bits = progress.bits.load(Ordering::Relaxed); + assert_eq!( + bits & (TCP_STARTED | TCP_CONNECTED), + TCP_STARTED | TCP_CONNECTED + ); + assert_eq!( + bits & (HTTP_HANDSHAKE | HEADERS), + if mode == "trusted" { + HTTP_HANDSHAKE | HEADERS + } else { + 0 + } + ); + assert_eq!( + progress.status.load(Ordering::Relaxed), + if mode == "trusted" { 200 } else { 0 } + ); + } + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); } #[tokio::test] From 1ee16e433652094c0f54c7068add7b7f77f75863 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 15:41:58 +0200 Subject: [PATCH 082/111] test(router): initialize TLS provider in isolated observer regressions Mirror production startup and the existing test setup before constructing kube clients. Nextest runs each test in its own process, so these tests cannot rely on another test installing the Rustls provider. Preserve all real connection, pooling and cancellation assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- inference-router/src/service_observation_client_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/inference-router/src/service_observation_client_tests.rs b/inference-router/src/service_observation_client_tests.rs index 60feaac9b..958ae4bbb 100644 --- a/inference-router/src/service_observation_client_tests.rs +++ b/inference-router/src/service_observation_client_tests.rs @@ -241,6 +241,7 @@ async fn actual_http_token_file_dispatch_keeps_auth_and_per_request_progress() { #[tokio::test] async fn pooled_http_success_does_not_inherit_previous_connection_progress() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let server = MockServer::start().await; Mock::given(method("GET")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -353,6 +354,7 @@ async fn cancelled_http_wait_is_distinct_from_predispatch_and_wrong_identity_is_ async fn stalled_tls_is_distinct_from_completed_tcp_and_http_setup() { use tokio::{io::AsyncReadExt, net::TcpListener}; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let (sent, received) = tokio::sync::oneshot::channel(); From 022c81fc79a3c8de2432e74f024a0f240904761f Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 16:21:48 +0200 Subject: [PATCH 083/111] test(bridge): retain private-safe enrollment template drift evidence Classify the observed template-review refusal without retrying or changing approval. Compare pre-preview runtime, controller and BFF snapshots against the existing review and live UID/RV-fenced objects using the actual shipped CLI templateDigest. Emit only fixed comparison booleans; retain no template values or hashes. Preserve the original failed outcome. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 10 + .../native-credentials/observation_cases.py | 6 + .../operator_diagnostics.py | 1 + bridge/tests/native-credentials/run.py | 6 +- .../template_diagnostics.py | 162 +++++++++++++++ .../test_operator_diagnostics.py | 10 + .../test_template_diagnostics.py | 193 ++++++++++++++++++ 7 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 bridge/tests/native-credentials/template_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_template_diagnostics.py diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index be67aefac..acd17629f 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -260,6 +260,16 @@ are never written to this evidence. Collection cannot qualify any assertion. TLS negatives, 9447/9448 paths, CNI peer denial, and credential rotation remain required unchanged. +An operator template-drift refusal also records `enrollmentTemplateDrift`. +It compares the fixture's pre-preview runtime, controller and BFF Deployment +snapshots with the existing CLI review and current objects, using the shipped +CLI's `templateDigest` (including its epoch normalization). Only fixed actor +labels and comparison booleans are emitted; templates, values and hashes stay +private. `baselineMatchesReview` must be true before attributing differences +to changes after review. Each current Deployment is UID/RV-rechecked; missing, +ambiguous or changing evidence is explicitly unavailable. This failure-only +diagnostic neither retries enrollment, refreshes approval nor qualifies a test. + `observer_target_client` optionally carries an atomic group of five booleans: `transport_debug_observable`, `transport_trace_observable`, `tcp_connect_started`, `tcp_connected`, and `http_handshake_complete`. diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py index 22910f69b..85a0c976c 100644 --- a/bridge/tests/native-credentials/observation_cases.py +++ b/bridge/tests/native-credentials/observation_cases.py @@ -25,6 +25,7 @@ class ObservationCases: def __init__(self, setup, bff, lifecycle): self.setup, self.bff, self.lifecycle = setup, bff, lifecycle self.observer_target = None + self.enrollment_templates = None def target(self): require(self.observer_target is not None, "Independent native observation Task is not ready") @@ -207,6 +208,11 @@ def enable(self): grant = self.setup.ready_grant(CORE) writer = self.setup.admin.get(core(BRIDGE, "serviceaccounts", WRITER)) before = self.late_runtime_before() + self.enrollment_templates = { + "runtime": before["deployment"], + "controller": before["rootDeployment"], + "bff": self.setup.admin.get(resource(BRIDGE, "deployments", "kars-bridge-bff", "/apis/apps/v1")), + } enroll(self.setup, CORE, writer, grant["spec"]["agentKeys"], previous=grant, observations=[ {"kind": "KarsSandbox", "namespace": CORE, "name": target["sandbox"], "uid": uid(value)}, ]) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index 693d5391f..b6903c0fd 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -14,6 +14,7 @@ "Private activation requires live API UID/resourceVersion identities": "missing-live-identity", "Reviewed private namespace changed": "namespace-review-changed", "Consumer execution differs from the reviewed controller template; preserve it for explicit Pod review": "consumer-execution-drift", + "Private consumer template changed after protection was enabled": "consumer-template-drift", "Private activation staging requires the existing cluster-scoped credential operator authority": "operator-authority", "Explicit credential-grant operator permission is required": "operator-authority", "Late private runtime retirement changed or is unsupported; preserve the runtime and re-preview its original review": "late-runtime-review", diff --git a/bridge/tests/native-credentials/run.py b/bridge/tests/native-credentials/run.py index 717cc6d5a..4398c1d46 100644 --- a/bridge/tests/native-credentials/run.py +++ b/bridge/tests/native-credentials/run.py @@ -16,6 +16,7 @@ from observation_cases import ObservationCases from observation_diagnostics import collect as observation_diagnostics from observer_network_diagnostics import collect as observer_network_diagnostics +from template_diagnostics import collect as template_diagnostics def diagnostics(setup): @@ -116,6 +117,8 @@ def case(name, operation, allowed=True): } if setup: if name == "private-bff-observer-and-fresh-privacy-rpc": + report["cases"][name]["enrollmentTemplateDrift"] = template_diagnostics( + setup, observations.enrollment_templates, report["cases"][name]["failure"]) report["cases"][name]["observationReadiness"] = observation_diagnostics( setup, observations.observer_target) report["cases"][name]["actorApiOutcomes"] = api_outcome_diagnostics( @@ -135,7 +138,8 @@ def case(name, operation, allowed=True): save() print(json.dumps({"nativeCase": name, **{key: value for key, value in report["cases"][name].items() if key not in ("metadataAtFailure", "observationReadiness", - "actorApiOutcomes", "observerApiReachability")}}), flush=True) + "actorApiOutcomes", "observerApiReachability", + "enrollmentTemplateDrift")}}), flush=True) def passed(name): return report["cases"].get(name, {}).get("result") == "passed" diff --git a/bridge/tests/native-credentials/template_diagnostics.py b/bridge/tests/native-credentials/template_diagnostics.py new file mode 100644 index 000000000..d4092a646 --- /dev/null +++ b/bridge/tests/native-credentials/template_diagnostics.py @@ -0,0 +1,162 @@ +"""Failure-only template comparisons; neither template values nor hashes are published.""" + +import json +import re + +from native_api import ROOT, Failure, command, require, resource +from observation_diagnostics import READ_ERRORS + +ACTORS = ("runtime", "controller", "bff") +LIMIT = 2 * 1024 * 1024 +UNAVAILABLE = "Native template comparison unavailable" +HASH_SCRIPT = """ +import {readFileSync} from 'node:fs'; +import {pathToFileURL} from 'node:url'; +const {templateDigest} = await import(pathToFileURL(process.argv[1]).href); +process.stdout.write(JSON.stringify(JSON.parse(readFileSync(0, 'utf8')).map(templateDigest))); +""" + + +def encoded(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + + +def identity(value): + require(isinstance(value, dict) and value.get("kind") == "Deployment" + and value.get("apiVersion") == "apps/v1", UNAVAILABLE) + meta = value.get("metadata") + require(isinstance(meta, dict) and not meta.get("deletionTimestamp") + and all(isinstance(meta.get(key), str) and meta[key] + for key in ("name", "namespace", "uid", "resourceVersion")), UNAVAILABLE) + return meta["namespace"], meta["name"], meta["uid"] + + +def template(value): + require(isinstance(value.get("spec"), dict), UNAVAILABLE) + result = value["spec"].get("template") + require(isinstance(result, dict) and isinstance(result.get("metadata"), dict) + and isinstance(result.get("spec"), dict), UNAVAILABLE) + return result + + +def hashes(values): + raw = encoded(values) + require(len(raw.encode("utf8")) <= LIMIT, UNAVAILABLE) + module = ROOT / ".native/core/cli/dist/lib/private-activation.js" + result = command("node", "--input-type=module", "-e", HASH_SCRIPT, str(module), + stdin=raw, timeout=15) + require(len(result) <= 512, UNAVAILABLE) + parsed = json.loads(result) + require(isinstance(parsed, list) and len(parsed) == len(values) + and all(isinstance(value, str) and re.fullmatch(r"[a-f0-9]{64}", value) + for value in parsed), UNAVAILABLE) + return parsed + + +def container_changes(before, current): + require(all(isinstance(values, list) and len(values) <= 16 + and all(isinstance(value, dict) and isinstance(value.get("name"), str) + for value in values) for values in (before, current)), UNAVAILABLE) + groups = { + "imagesChanged": ("image", "imagePullPolicy"), + "environmentChanged": ("env", "envFrom"), + "commandsChanged": ("command", "args", "workingDir"), + "mountsChanged": ("volumeMounts", "volumeDevices"), + "containerSecurityChanged": ("securityContext",), + "containerResourcesChanged": ("resources",), + } + result = {} + for name, fields in groups.items(): + def select(values): + return [{key: value[key] for key in ("name", *fields) if key in value} for value in values] + result[name] = encoded(select(before)) != encoded(select(current)) + known = {"name", *(key for fields in groups.values() for key in fields)} + result["otherContainerFieldsChanged"] = encoded([ + {key: value for key, value in entry.items() if key not in known} for entry in before + ]) != encoded([{key: value for key, value in entry.items() if key not in known} for entry in current]) + return result + + +def project(before, current, review, before_digest, current_digest): + previous_identity = identity(before) + current_identity = identity(current) + require(previous_identity[:2] == current_identity[:2], UNAVAILABLE) + require(isinstance(review, dict) and review.get("kind") == "Deployment" + and isinstance(review.get("object"), dict) + and review["object"].get("name") == previous_identity[1] + and review["object"].get("uid") == previous_identity[2] + and isinstance(review["object"].get("resourceVersion"), str) + and review["object"]["resourceVersion"] + and isinstance(review.get("templateDigest"), str) + and re.fullmatch(r"[a-f0-9]{64}", review["templateDigest"]), UNAVAILABLE) + baseline, actual = template(before), template(current) + # Hashes come from the shipped CLI, including its private-epoch normalization. + same_uid = previous_identity[2] == current_identity[2] + result = { + "available": True, + "sameUid": same_uid, + "baselineMatchesReview": before_digest == review["templateDigest"], + "currentMatchesReview": same_uid and current_digest == review["templateDigest"], + "sameTemplate": before_digest == current_digest, + } + for key in ("labels", "annotations"): + result[key + "Changed"] = encoded(baseline["metadata"].get(key)) != encoded(actual["metadata"].get(key)) + sections = ("containers", "initContainers", "volumes", "securityContext", "serviceAccountName") + for key in sections: + result[key + "Changed"] = encoded(baseline["spec"].get(key)) != encoded(actual["spec"].get(key)) + result["otherPodSpecChanged"] = encoded({ + key: value for key, value in baseline["spec"].items() if key not in sections + }) != encoded({key: value for key, value in actual["spec"].items() if key not in sections}) + result.update(container_changes(baseline["spec"].get("containers"), actual["spec"].get("containers"))) + return result + + +def collect(setup, baselines, failure): + result = {"diagnosticOnly": True, "available": False, "category": "not-eligible"} + if not isinstance(failure, str) or not failure.startswith( + "Native operator apply failed: consumer-template-drift "): + return result + result["category"] = "unavailable" + try: + require(isinstance(baselines, dict) and set(baselines) == set(ACTORS), UNAVAILABLE) + namespaces = {identity(value)[0] for value in baselines.values()} + # The fixture uses one existing review document, never a fresh approval. + review_path = ROOT / ".native/grant-review-kars-system.json" + with review_path.open("rb") as stream: + raw = stream.read(LIMIT + 1) + require(len(raw) <= LIMIT, UNAVAILABLE) + document = json.loads(raw) + require(isinstance(document, dict) and document.get("kind") == "KarsCredentialGrant" + and document.get("apiVersion") == "kars.azure.com/v1alpha1" + and isinstance(document.get("metadata"), dict) + and document["metadata"].get("namespace") == "kars-system" + and document["metadata"].get("name") == "workspace", UNAVAILABLE) + review = document["spec"]["privateActivation"] + scopes = review["namespaces"] + require(isinstance(scopes, list) and len(scopes) <= 32 + and review.get("phase") == "reviewed", UNAVAILABLE) + require(all(isinstance(scope, dict) and isinstance(scope.get("namespace"), dict) + and isinstance(scope.get("consumers"), list) and len(scope["consumers"]) <= 32 + and all(isinstance(consumer, dict) and isinstance(consumer.get("object"), dict) + for consumer in scope["consumers"]) for scope in scopes), UNAVAILABLE) + selected = [scope for scope in scopes if scope["namespace"]["name"] in namespaces] + comparisons = {} + for actor in ACTORS: + before = baselines[actor] + namespace, name, uid = identity(before) + matches = [consumer for scope in selected if scope["namespace"]["name"] == namespace + for consumer in scope["consumers"] + if consumer.get("kind") == "Deployment" and consumer["object"].get("uid") == uid] + require(len(matches) == 1, UNAVAILABLE) + path = resource(namespace, "deployments", name, "/apis/apps/v1") + current = setup.admin.get(path) + before_hash, current_hash = hashes([before, current]) + rechecked = setup.admin.get(path) + require(identity(rechecked) == identity(current) + and rechecked["metadata"]["resourceVersion"] == current["metadata"]["resourceVersion"], + UNAVAILABLE) + comparisons[actor] = project(before, current, matches[0], before_hash, current_hash) + result.update(available=True, category="compared", actors=comparisons) + except READ_ERRORS: + result["category"] = "provenance-or-comparison-unavailable" + return result diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 886ddbc96..5a2560cf3 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -47,6 +47,16 @@ def test_error_bodies_cannot_create_arbitrary_categories_or_paths(self): f" at object (/cli/dist/lib/private-activation.js:1:2){PRIVATE}"): self.assertEqual(source_location(line), "unavailable") + def test_template_drift_uses_a_fixed_category_without_weakening_the_failure(self): + stderr = ("Error: Private consumer template changed after protection was enabled\n" + f" at reviewedOwner (/private/{PRIVATE}/cli/dist/lib/private-activation.js:680:23)\n") + with self.assertRaises(Failure) as failure: + operator_command("apply", sys.executable, "-c", + "import sys; print(sys.argv[1],file=sys.stderr); sys.exit(1)", stderr, timeout=5) + self.assertEqual(str(failure.exception), "Native operator apply failed: consumer-template-drift " + "(source=lib/private-activation:680)") + self.assertNotIn(PRIVATE, str(failure.exception)) + def test_late_scope_leaf_is_retained_instead_of_only_its_awaiting_caller(self): stderr = ( f"{PRIVATE}\n" diff --git a/bridge/tests/native-credentials/test_template_diagnostics.py b/bridge/tests/native-credentials/test_template_diagnostics.py new file mode 100644 index 000000000..79a549580 --- /dev/null +++ b/bridge/tests/native-credentials/test_template_diagnostics.py @@ -0,0 +1,193 @@ +"""Prove template drift diagnostics retain only fixed, identity-bound facts.""" + +import copy +import hashlib +import io +import json +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch + +from native_api import Failure +import template_diagnostics as diagnostics + +PRIVATE = "DO-NOT-EMIT-PRIVATE-TEMPLATE-VALUES" +FAILURE = "Native operator apply failed: consumer-template-drift (source=lib/private-activation:680)" + + +def deployment(name): + return {"apiVersion": "apps/v1", "kind": "Deployment", + "metadata": {"name": name, "namespace": "work", "uid": name + "-uid", "resourceVersion": "1"}, + "spec": {"template": {"metadata": {"labels": {"app": PRIVATE}}, + "spec": {"containers": [{"name": "router", "env": [ + {"name": "PRIVATE_TEST_INPUT", "value": PRIVATE}]}]}}}} + + +def fixture_hashes(values): + return [hashlib.sha256(diagnostics.encoded(diagnostics.template(value)).encode()).hexdigest() + for value in values] + + +def review(value): + return {"kind": "Deployment", "object": {key: value["metadata"][key] + for key in ("name", "uid", "resourceVersion")}, "templateDigest": fixture_hashes([value])[0]} + + +class TemplateDiagnosticsTests(unittest.TestCase): + def test_projection_distinguishes_review_drift_without_retaining_values_or_hashes(self): + before = deployment("runtime") + current = copy.deepcopy(before) + current["spec"]["template"]["spec"]["containers"][0]["env"][0]["value"] += "-changed" + facts = diagnostics.project(before, current, review(before), *fixture_hashes([before, current])) + self.assertTrue(facts["available"]) + self.assertTrue(facts["sameUid"]) + self.assertTrue(facts["baselineMatchesReview"]) + self.assertFalse(facts["currentMatchesReview"]) + self.assertFalse(facts["sameTemplate"]) + self.assertTrue(facts["containersChanged"]) + self.assertTrue(facts["environmentChanged"]) + self.assertFalse(facts["imagesChanged"]) + self.assertFalse(facts["otherContainerFieldsChanged"]) + self.assertFalse(facts["otherPodSpecChanged"]) + self.assertTrue(all(type(value) is bool for value in facts.values())) + self.assertNotIn(PRIVATE, json.dumps(facts)) + for value in fixture_hashes([before, current]): + self.assertNotIn(value, json.dumps(facts)) + + def test_baseline_must_match_the_actual_review_before_changes_can_be_attributed(self): + before = deployment("runtime") + approved = review(before) + approved["templateDigest"] = "f" * 64 + facts = diagnostics.project(before, before, approved, *fixture_hashes([before, before])) + self.assertFalse(facts["baselineMatchesReview"]) + self.assertFalse(facts["currentMatchesReview"]) + self.assertTrue(facts["sameTemplate"]) + replacement = copy.deepcopy(before) + replacement["metadata"]["uid"] = "replacement" + facts = diagnostics.project(before, replacement, review(before), *fixture_hashes([before, replacement])) + self.assertFalse(facts["sameUid"]) + self.assertFalse(facts["currentMatchesReview"]) + + def test_section_comparisons_preserve_types_and_cover_other_pod_fields(self): + before = deployment("runtime") + current = copy.deepcopy(before) + before["spec"]["template"]["spec"]["hostNetwork"] = False + current["spec"]["template"]["spec"]["hostNetwork"] = 0 + facts = diagnostics.project(before, current, review(before), *fixture_hashes([before, current])) + self.assertTrue(facts["otherPodSpecChanged"]) + self.assertFalse(facts["containersChanged"]) + for invalid in (None, {}, {"metadata": {}, "spec": None}): + value = copy.deepcopy(before) + value["spec"]["template"] = invalid + with self.assertRaises(Failure): + diagnostics.project(value, current, review(before), "a" * 64, "b" * 64) + + def test_wrong_namespaces_owners_and_review_shapes_are_refused(self): + before = deployment("runtime") + for key in ("namespace", "name"): + current = copy.deepcopy(before) + current["metadata"][key] = "foreign" + with self.assertRaises(Failure): + diagnostics.project(before, current, review(before), "a" * 64, "b" * 64) + for invalid in (None, {}, {**review(before), "templateDigest": PRIVATE}, + {**review(before), "object": {"name": "runtime", "uid": "foreign"}}): + with self.assertRaises(Failure): + diagnostics.project(before, before, invalid, "a" * 64, "b" * 64) + + def collect(self, *, mutate_review=None, changed_on_recheck=False, unavailable_hashes=False): + baselines = {actor: deployment(actor) for actor in diagnostics.ACTORS} + document = {"apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsCredentialGrant", + "metadata": {"name": "workspace", "namespace": "kars-system"}, + "spec": {"privateActivation": {"phase": "reviewed", "namespaces": [ + {"namespace": {"name": "work"}, "consumers": [review(value) for value in baselines.values()]} + ]}}} + if mutate_review: + mutate_review(document) + reads = [] + + def get(path): + reads.append(path) + value = copy.deepcopy(baselines[path.rsplit("/", 1)[-1]]) + if changed_on_recheck and len(reads) % 2 == 0: + value["metadata"]["resourceVersion"] = "2" + return value + + with tempfile.TemporaryDirectory(prefix="native-template-review-") as directory: + root = Path(directory) + (root / ".native").mkdir() + (root / ".native/grant-review-kars-system.json").write_text(json.dumps(document)) + side_effect = Failure(PRIVATE) if unavailable_hashes else fixture_hashes + output = io.StringIO() + with patch.object(diagnostics, "ROOT", root), \ + patch.object(diagnostics, "hashes", side_effect=side_effect), \ + redirect_stdout(output), redirect_stderr(output): + result = diagnostics.collect(SimpleNamespace(admin=SimpleNamespace(get=get)), baselines, FAILURE) + self.assertEqual(output.getvalue(), "") + self.assertNotIn(PRIVATE, json.dumps(result)) + return result, reads + + def test_complete_collector_rechecks_all_three_exact_deployments(self): + result, reads = self.collect() + self.assertEqual(result["category"], "compared") + self.assertTrue(result["available"]) + self.assertTrue(result["diagnosticOnly"]) + self.assertEqual(set(result["actors"]), set(diagnostics.ACTORS)) + self.assertEqual(len(reads), 6) + self.assertTrue(all(value["currentMatchesReview"] for value in result["actors"].values())) + + def test_changed_resource_or_failed_hashing_never_returns_partial_comparisons(self): + for options in ({"changed_on_recheck": True}, {"unavailable_hashes": True}): + result, _ = self.collect(**options) + self.assertFalse(result["available"]) + self.assertEqual(result["category"], "provenance-or-comparison-unavailable") + self.assertNotIn("actors", result) + + def test_ambiguous_or_nonreviewed_documents_are_unavailable(self): + def duplicate(document): + scope = document["spec"]["privateActivation"]["namespaces"][0] + scope["consumers"].append(copy.deepcopy(scope["consumers"][0])) + + def malformed(document): + document["spec"]["privateActivation"]["namespaces"][0]["consumers"] = [None] + + def qualified(document): + document["spec"]["privateActivation"]["phase"] = "qualified" + + def foreign(document): + document["metadata"]["namespace"] = "another-workspace" + + for mutation in (duplicate, malformed, qualified, foreign): + result, _ = self.collect(mutate_review=mutation) + self.assertFalse(result["available"]) + self.assertNotIn("actors", result) + + def test_ineligible_failures_do_not_read_anything(self): + for failure in ("Deadline: core-issued current observer capability", "", None): + with patch.object(diagnostics, "hashes") as hashing: + result = diagnostics.collect(None, None, failure) + hashing.assert_not_called() + self.assertEqual(result["category"], "not-eligible") + + def test_hashing_invokes_the_shipped_helper_and_enforces_closed_output(self): + value = deployment("runtime") + with patch.object(diagnostics, "command", return_value=json.dumps(["a" * 64])) as execute: + self.assertEqual(diagnostics.hashes([value]), ["a" * 64]) + args, kwargs = execute.call_args + self.assertEqual(args[:3], ("node", "--input-type=module", "-e")) + self.assertIn(".map(templateDigest)", args[3]) + self.assertEqual(Path(args[4]), diagnostics.ROOT / ".native/core/cli/dist/lib/private-activation.js") + self.assertEqual(json.loads(kwargs["stdin"]), [value]) + self.assertEqual(kwargs["timeout"], 15) + for invalid in (json.dumps([PRIVATE]), json.dumps(["a" * 64, "b" * 64]), "null", " " * 513): + with patch.object(diagnostics, "command", return_value=invalid), self.assertRaises(Failure): + diagnostics.hashes([value]) + with patch.object(diagnostics, "command") as execute, self.assertRaises(Failure): + diagnostics.hashes([{"private": "x" * diagnostics.LIMIT}]) + execute.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From f1f8776b383e7f7441e1ba8927d76260cce07f2b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 17:25:48 +0200 Subject: [PATCH 084/111] fix(cli): recheck concurrent writer retirement snapshots Reread only recognized moving snapshots before judging an empty projection or typed lineage-template refusal. Preserve the rejected snapshot's transition check, all authority and data fences, actual withdrawal/pause/refill witnesses, and the existing deadline. Recheck identity after lineage before settlement. Native diagnostics retain fixed typed-error and writer-failure categories without publishing private values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../operator_diagnostics.py | 15 +- .../test_operator_diagnostics.py | 9 + .../private-activation-writer-settle.test.ts | 185 +++++++++++++++++- .../lib/private-activation-writer-settle.ts | 74 +++++-- cli/src/lib/private-activation.ts | 16 +- docs/how-to/governed-credential-grants.md | 10 + 6 files changed, 287 insertions(+), 22 deletions(-) diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index b6903c0fd..df29e261d 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -27,6 +27,15 @@ "Late private credential provenance is missing or conflicting": "late-admin-provenance", "Reviewed runtime has not consumed its current controller-owned admin credential version": "late-admin-version", "Customized or missing late private credential keys require explicit operator recovery": "late-admin-keys", + "Writer retirement changed the captured runtime authority; preserve quiescence and obtain explicit operator recovery": "writer-authority-drift", + "Projection changed without the captured authority withdrawal and owned pause": "writer-projection-withdrawal", + "Projection revision advanced without witnessed fresh revoke/refill; exact review preserved": "writer-projection-refill", + "Unreviewed template or controller pause/restore generation changed": "writer-runtime-transition", + "Task lost authority for an unreviewed reason during writer retirement": "writer-task-authority", + "Stale Task attestation cannot settle writer retirement": "writer-stale-task-attestation", + "Consumer revision advanced without a witnessed owned pause; exact review preserved": "writer-unwitnessed-pause", + "Unreviewed consumer appeared during writer retirement": "writer-pod-lineage", + "Writer retirement is still awaiting fresh Task attestation and the captured owned runtime; no stale authority or new activation was published": "writer-settlement-timeout", } MODULES = ( "commands/credential-grants", "lib/private-activation", @@ -68,8 +77,12 @@ def category(stderr): + lines = set(stderr.splitlines()) categories = {value for message, value in ERRORS.items() - if f"Error: {message}" in stderr.splitlines()} + if f"Error: {message}" in lines} + if any(f"{prefix}: Private consumer template changed after protection was enabled" in lines + for prefix in ("PrivateConsumerTemplateChanged", "PrivateConsumerTemplateChanged [Error]")): + categories.add("consumer-template-drift") return sorted(categories)[0] if categories else "unclassified-cli-error" diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 5a2560cf3..9793c7fd2 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -57,6 +57,15 @@ def test_template_drift_uses_a_fixed_category_without_weakening_the_failure(self "(source=lib/private-activation:680)") self.assertNotIn(PRIVATE, str(failure.exception)) + def test_typed_template_errors_keep_only_the_exact_fixed_category(self): + message = "Private consumer template changed after protection was enabled" + for prefix in ("PrivateConsumerTemplateChanged", "PrivateConsumerTemplateChanged [Error]"): + line = f"{prefix}: {message}" + self.assertEqual(category(line), "consumer-template-drift") + self.assertEqual(category(line + PRIVATE), "unclassified-cli-error") + self.assertEqual(category(prefix + ": " + PRIVATE), "unclassified-cli-error") + self.assertEqual(category(f"{PRIVATE}: {message}"), "unclassified-cli-error") + def test_late_scope_leaf_is_retained_instead_of_only_its_awaiting_caller(self): stderr = ( f"{PRIVATE}\n" diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts index 34db8296a..c4075ad84 100644 --- a/cli/src/lib/private-activation-writer-settle.test.ts +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -11,7 +11,7 @@ import { applyReviewedGrant } from "../commands/credential-grants.js"; import { continuityFixture, privateAuthoritySnapshot } from "./private-activation-fixtures.js"; import { canonical, readSecretMetadata, PRIVATE_PREFIX as P, type Execute } from "./private-activation.js"; import { captureGuardRetirement, refreshGuardRetirement } from "./private-activation-guard-retirement.js"; -import { captureWriterSettlement } from "./private-activation-writer-settle.js"; +import { captureWriterSettlement, observeWriterSettlement } from "./private-activation-writer-settle.js"; import { PrivateCommandFailure } from "./private-activation-command-diagnostics.js"; const RESOURCE = "karscredentialgrants.kars.azure.com"; @@ -231,6 +231,189 @@ describe("late runtime authority across selected writer retirement", () => { beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); + async function quiesced() { + const f = await setup(); + const review = await f.document(); + const settlement = await captureWriterSettlement(f.execute, review.spec.privateActivation, f.grant()); + if (!settlement) throw new Error("Fixture requires a captured late runtime"); + const beforeTask = structuredClone(f.task); + const beforeDeployment = structuredClone(f.deployment); + f.neverRestore(); + await f.execute(["patch", RESOURCE, "workspace", "-n", "work", "--type=merge", "-p", JSON.stringify({ + metadata: { uid: f.grant().metadata.uid, resourceVersion: f.grant().metadata.resourceVersion }, + spec: { ...f.grant().spec, writers: [] }, + })]); + f.calls.length = 0; + return { f, review, settlement, beforeTask, beforeDeployment }; + } + + it.each(["karstask", "deployments.apps"])( + "rereads a torn %s snapshot before judging the later empty projection", async kind => { + const { f, review, settlement, beforeTask, beforeDeployment } = await quiesced(); + let stale = true; + const run: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (stale && args[0] === "get" && args[1] === kind && args[2] === "late") { + stale = false; + return JSON.stringify(kind === "karstask" ? beforeTask : beforeDeployment); + } + return result; + }; + await expect(observeWriterSettlement(run, review.spec.privateActivation, settlement)).resolves.toBe(false); + expect(stale).toBe(false); + expect(settlement.runtimes[0]!.emptyVersion).toBeUndefined(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.grant().spec.writers).toEqual([]); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)).resolves.toBe(false); + expect(settlement.runtimes[0]!.emptyVersion).toBe(f.projection.metadata.resourceVersion); + f.restore(); + await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)).resolves.toBe(true); + }); + + it("still rejects a stable empty projection without the witnessed Task withdrawal", async () => { + const { f, review, settlement, beforeTask } = await quiesced(); + f.task.status = beforeTask.status; + f.task.metadata.resourceVersion = beforeTask.metadata.resourceVersion; + await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)) + .rejects.toThrow("Projection changed without the captured authority withdrawal and owned pause"); + expect(settlement.runtimes[0]!.emptyVersion).toBeUndefined(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.grant().spec.writers).toEqual([]); + }); + + it.each([false, true])("handles an owned refill during lineage lookup without accepting template drift=%s", async unreviewed => { + const { f, review, settlement } = await quiesced(); + let changed = false; + const run: Execute = async (args, input) => { + if (!changed && args[0] === "get" && args[1] === "pods" && args.includes("kars-late")) { + changed = true; + f.restore(); + if (unreviewed) f.deployment.spec.template.spec.containers[0].image = "unreviewed-image"; + } + return f.execute(args, input); + }; + const result = observeWriterSettlement(run, review.spec.privateActivation, settlement); + if (unreviewed) { + await expect(result).rejects.toThrow(/template|authority/); + } else { + await expect(result).resolves.toBe(false); + expect(settlement.runtimes[0]!.emptyVersion).toBeDefined(); + await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)).resolves.toBe(true); + } + expect(changed).toBe(true); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.grant().spec.writers).toEqual([]); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + }); + + it("never hides an observed unreviewed template behind a later valid Deployment", async () => { + const { f, review, settlement } = await quiesced(); + let lineage = false; + let rejectedSnapshot = false; + const run: Execute = async (args, input) => { + if (!lineage && args[0] === "get" && args[1] === "pods" && args.includes("kars-late")) { + f.restore(); + lineage = true; + } + const result = await f.execute(args, input); + if (lineage && args[0] === "get" && args[1] === "deployments.apps" && args[2] === "late") { + const observed = JSON.parse(result); + observed.spec.template.metadata.annotations.unreviewed = "private-template-canary"; + rejectedSnapshot = true; + return JSON.stringify(observed); + } + return result; + }; + await expect(observeWriterSettlement(run, review.spec.privateActivation, settlement)) + .rejects.toThrow("Private consumer template changed after protection was enabled"); + expect(rejectedSnapshot).toBe(true); + expect(f.deployment.spec.template.metadata.annotations.unreviewed).toBeUndefined(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it("does not retry unrelated lineage lookup errors", async () => { + const { f, review, settlement } = await quiesced(); + const failure = new Error("Unrelated owner lookup failure"); + let lineage = false; + const run: Execute = async (args, input) => { + if (!lineage && args[0] === "get" && args[1] === "pods" && args.includes("kars-late")) { + f.restore(); + lineage = true; + } + if (lineage && args[0] === "get" && args[1] === "replicasets.apps") throw failure; + return f.execute(args, input); + }; + await expect(observeWriterSettlement(run, review.spec.privateActivation, settlement)).rejects.toBe(failure); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + }); + + it("rechecks projection identity after successful lineage lookup before reporting settlement", async () => { + const { f, review, settlement } = await quiesced(); + await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)).resolves.toBe(false); + f.restore(); + let lineage = false; + const run: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] === "get" && args[1] === "pods" && args.includes("kars-late")) lineage = true; + if (lineage && args[0] === "get" && args[1] === "deployments.apps" && args[2] === "late") { + f.projection.metadata.uid = "replacement"; + } + return result; + }; + await expect(observeWriterSettlement(run, review.spec.privateActivation, settlement)) + .rejects.toThrow("captured runtime authority"); + expect(settlement.runtimes[0]!.restored).toBeUndefined(); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + }); + + it.each(["karstask", "deployments.apps"])("completes shipped apply after a torn %s retirement read", async kind => { + const f = await setup(); + const review = await f.document(); + const before = f.preserved(); + const initial = structuredClone(kind === "karstask" ? f.task : f.deployment); + f.delayRestore(); + let retired = false; + let stale = true; + const run: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] === "patch" && args[1] === RESOURCE && f.grant().spec.writers.length === 0) retired = true; + if (retired && stale && args[0] === "get" && args[1] === kind && args[2] === "late") { + stale = false; + return JSON.stringify(initial); + } + return result; + }; + await applyReviewedGrant(run, review); + expect(stale).toBe(false); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); + expect(f.preserved()).toEqual(before); + expect(f.projection.data).toEqual(data); + expect(f.task.status.envelopeDigest).toBe(AUTH); + }); + + it("does not invent withdrawal witnesses when revoke/refill completes between reads", async () => { + const f = await setup(); + const review = await f.document(); + const beforeTask = structuredClone(f.task); + let retired = false; + let stale = true; + const run: Execute = async (args, input) => { + const result = await f.execute(args, input); + if (args[0] === "patch" && args[1] === RESOURCE && f.grant().spec.writers.length === 0) retired = true; + if (retired && stale && args[0] === "get" && args[1] === "karstask" && args[2] === "late") { + stale = false; + return JSON.stringify(beforeTask); + } + return result; + }; + await expect(applyReviewedGrant(run, review)).rejects.toThrow("without witnessed fresh revoke/refill"); + expect(f.wasRestored()).toBe(true); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + expect(f.grant().spec.writers).toEqual([]); + }); + it("reports a Pending-induced status/RV race without retrying the stale suspend PATCH", async () => { const f = await setup(); const review = await f.document(); diff --git a/cli/src/lib/private-activation-writer-settle.ts b/cli/src/lib/private-activation-writer-settle.ts index 9be18e3b7..7785082f0 100644 --- a/cli/src/lib/private-activation-writer-settle.ts +++ b/cli/src/lib/private-activation-writer-settle.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { - at, canonical, digest, read, readSecretMetadata, record, reviewed, reviewedOwner, template, templateDigest, + at, canonical, digest, PrivateConsumerTemplateChanged, read, readSecretMetadata, record, reviewed, reviewedOwner, template, templateDigest, type Execute, type Json, type PrivateActivation, } from "./private-activation.js"; import { captureLateWriterScope } from "./private-activation-late-scope.js"; @@ -115,6 +115,34 @@ function possibleTransition(current: ObjectValue, runtime: RuntimeReview): boole return at(current, "status", "observedGeneration") !== gen(current) && sameBody(current, expected, true, true); } +async function snapshotCurrent( + execute: Execute, runtime: RuntimeReview, task: ObjectValue, deployment: ObjectValue, projection: ObjectValue, +): Promise<boolean> { + const before = runtime.captured; + const namespace = before.scope.namespace.name; + const projectionAfter = await readSecretMetadata(execute, reviewed(projection).name, namespace); + const deploymentAfter = await read(execute, "deployments.apps", reviewed(deployment).name, namespace); + const taskAfter = await read(execute, "karstask", reviewed(task).name, String(at(before.task, "metadata", "namespace"))); + const checks = { + projectionMetadataPresent: projectionAfter !== undefined, + projectionMetadataMatches: projectionAfter !== undefined && unchangedSecretMetadata({ + metadata: projectionMetadataView(projectionAfter, runtime.projection), type: "Opaque", + }, { metadata: runtime.projection.metadata!, type: "Opaque" }), + deploymentTransitionMatches: possibleTransition(deployment, runtime) && possibleTransition(deploymentAfter, runtime), + }; + if (!checks.projectionMetadataPresent || !checks.projectionMetadataMatches || !checks.deploymentTransitionMatches) { + console.error(`KARS_PRIVATE_WRITER_RECHECK ${JSON.stringify(checks)}`); + throw new Error(ERROR); + } + if (!sameBody(taskAfter, before.task, true)) throw new Error(ERROR); + if (!readyTask(taskAfter, before.task) && !withdrawn(taskAfter, before.task)) { + throw new Error("Task lost authority for an unreviewed reason during writer retirement"); + } + return reviewed({ metadata: projectionAfter }).resourceVersion === reviewed(projection).resourceVersion + && reviewed(deploymentAfter).resourceVersion === reviewed(deployment).resourceVersion + && reviewed(taskAfter).resourceVersion === reviewed(task).resourceVersion; +} + export async function captureWriterSettlement( execute: Execute, activation: PrivateActivation, grant: unknown, ): Promise<WriterSettlement | undefined> { @@ -246,7 +274,14 @@ export async function observeWriterSettlement( runtime.pauseSeen = true; } if (!projectionSame) { - if (!isPause || !runtime.withdrawnVersion || Object.keys(data(projection)).length) throw new Error("Projection changed without the captured authority withdrawal and owned pause"); + if (Object.keys(data(projection)).length) throw new Error("Projection changed without the captured authority withdrawal and owned pause"); + if (!isPause || !runtime.withdrawnVersion) { + if (!await snapshotCurrent(execute, runtime, task, deployment, projection)) { + allReady = false; + continue; + } + throw new Error("Projection changed without the captured authority withdrawal and owned pause"); + } runtime.emptyVersion = reviewed(projection).resourceVersion; } const restored = bodySpec(before.deployment, initialReplicas, revision); @@ -266,21 +301,7 @@ export async function observeWriterSettlement( throw new Error("Unreviewed template or controller pause/restore generation changed"); } if (unchanged && gen(deployment) !== gen(before.deployment) && (!runtime.pauseSeen || gen(deployment) !== restoredGeneration)) throw new Error(ERROR); - const projectionAfter = await readSecretMetadata(execute, reviewed(projection).name, ns); - const deploymentAfter = await read(execute, "deployments.apps", reviewed(deployment).name, ns); - const checks = { - projectionMetadataPresent: projectionAfter !== undefined, - projectionMetadataMatches: projectionAfter !== undefined && unchangedSecretMetadata({ - metadata: projectionMetadataView(projectionAfter, runtime.projection), type: "Opaque", - }, { metadata: runtime.projection.metadata!, type: "Opaque" }), - deploymentTransitionMatches: possibleTransition(deploymentAfter, runtime), - }; - if (!checks.projectionMetadataPresent || !checks.projectionMetadataMatches || !checks.deploymentTransitionMatches) { - console.error(`KARS_PRIVATE_WRITER_RECHECK ${JSON.stringify(checks)}`); - throw new Error(ERROR); - } - if (reviewed({ metadata: projectionAfter }).resourceVersion !== reviewed(projection).resourceVersion - || reviewed(deploymentAfter).resourceVersion !== reviewed(deployment).resourceVersion) { + if (!await snapshotCurrent(execute, runtime, task, deployment, projection)) { allReady = false; continue; } @@ -288,7 +309,24 @@ export async function observeWriterSettlement( if (at(list, "metadata", "continue")) throw new Error(ERROR); const pods = array(list.items); const liveScope = { ...before.scope, consumers: [{ ...before.scope.consumers[0]!, templateDigest: templateDigest(deployment) }] }; - for (const pod of pods) if (!await reviewedOwner(execute, pod, liveScope)) throw new Error("Unreviewed consumer appeared during writer retirement"); + let lineageChanged = false; + for (const pod of pods) { + let permittedTransition = false; + try { + if (!await reviewedOwner(execute, pod, liveScope, current => { + permittedTransition = possibleTransition(current, runtime); + })) throw new Error("Unreviewed consumer appeared during writer retirement"); + } catch (error) { + if (!(error instanceof PrivateConsumerTemplateChanged) || !permittedTransition + || await snapshotCurrent(execute, runtime, task, deployment, projection)) throw error; + lineageChanged = true; + break; + } + } + if (lineageChanged || !await snapshotCurrent(execute, runtime, task, deployment, projection)) { + allReady = false; + continue; + } const oldGone = pods.every(pod => !before.pods.some(old => reviewed(old, true).uid === reviewed(pod, true).uid)); const sandboxReady = at(sandbox, "status", "phase") === "Running" && at(sandbox, "status", "observedGeneration") === gen(before.sandbox) diff --git a/cli/src/lib/private-activation.ts b/cli/src/lib/private-activation.ts index 3f5d47805..e2dc4fce1 100644 --- a/cli/src/lib/private-activation.ts +++ b/cli/src/lib/private-activation.ts @@ -664,14 +664,26 @@ export function consumesPrivateAuthority(value: unknown, namespace: string, acti ["ALL", "SYS_ADMIN", "SYS_PTRACE", "SYS_MODULE", "SYS_RAWIO", "BPF", "PERFMON", "CHECKPOINT_RESTORE", "DAC_READ_SEARCH"].includes(String(k)))); } -export async function reviewedOwner(execute: Execute, pod: Json, scope: NamespaceReview): Promise<ReviewedConsumer | undefined> { +export class PrivateConsumerTemplateChanged extends Error { + constructor() { + super("Private consumer template changed after protection was enabled"); + } +} + +export async function reviewedOwner( + execute: Execute, pod: Json, scope: NamespaceReview, onTemplateChange?: (current: RecordValue) => void, +): Promise<ReviewedConsumer | undefined> { let current = record(pod); if (!current.kind) current = { ...current, kind: "Pod" }; for (let depth = 0; depth < 4; depth++) { const id = reviewed(current, current.kind === "Pod"); const approved = scope.consumers.find(c => c.object.uid === id.uid && c.kind === current.kind); if (approved) { - if (templateDigest(current) !== approved.templateDigest) throw new Error("Private consumer template changed after protection was enabled"); + if (templateDigest(current) !== approved.templateDigest) { + // The hook observes the rejected snapshot; it cannot authorize it. + onTemplateChange?.(current); + throw new PrivateConsumerTemplateChanged(); + } return approved; } const owners = list(at(current, "metadata", "ownerReferences") ?? []).map(record).filter(o => o.controller === true); diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index da31ca663..3c17e304e 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -315,6 +315,16 @@ The projection recheck aligns kubectl JSON and JSONPath views only for `managedFields` and all other metadata remain compared; this does not grant ownership or weaken source, value, revision or template checks. +Task, Deployment and projection reads are not atomic. When an empty-projection +or lineage-template check conflicts with a concurrent, recognized controller +transition, apply rechecks the exact objects and retries observation within the +same 120-second bound. Both the rejected and current Deployment snapshots must +fit the captured transition; unrelated errors and observed unreviewed templates +still fail. Historical withdrawal, pause and empty-revision witnesses are +retained, never invented. Stable missing witnesses or authority drift still +block enrollment. A final recheck after lineage inspection prevents reporting +settlement from an outdated snapshot. No mutation or stale write is retried. + For first qualification, namespace protection is then enabled in `Pending`, identities/templates are rechecked, and only approved authority-consuming controller replicas are paused. This From 2e89861b5fb462bc75f0085ffed626ea26850713 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Sat, 12 Sep 2026 18:43:19 +0200 Subject: [PATCH 085/111] test(cli): harden wire qualification and expose restoration checks Keep runtime acceptance and production deadlines unchanged. Emit a closed boolean restoration comparison on the existing refusal and project it through native diagnostics without values or hashes. Expand failure-only template comparisons to writer restoration. Exercise actual kubectl printing with a delayed response and bounded fixture budgets; retain safe child-failure facts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 10 ++- .../operator_diagnostics.py | 16 +++++ .../template_diagnostics.py | 4 +- .../test_operator_diagnostics.py | 27 +++++++- .../test_template_diagnostics.py | 9 ++- .../private-activation-writer-settle.test.ts | 69 ++++++++++++++++--- .../lib/private-activation-writer-settle.ts | 55 ++++++++++++++- docs/how-to/governed-credential-grants.md | 7 ++ 8 files changed, 179 insertions(+), 18 deletions(-) diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index acd17629f..5a50fde7f 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -260,7 +260,7 @@ are never written to this evidence. Collection cannot qualify any assertion. TLS negatives, 9447/9448 paths, CNI peer denial, and credential rotation remain required unchanged. -An operator template-drift refusal also records `enrollmentTemplateDrift`. +An operator template-drift or writer-restoration refusal also records `enrollmentTemplateDrift`. It compares the fixture's pre-preview runtime, controller and BFF Deployment snapshots with the existing CLI review and current objects, using the shipped CLI's `templateDigest` (including its epoch normalization). Only fixed actor @@ -270,6 +270,14 @@ to changes after review. Each current Deployment is UID/RV-rechecked; missing, ambiguous or changing evidence is explicitly unavailable. This failure-only diagnostic neither retries enrollment, refreshes approval nor qualifies a test. +The CLI's `KARS_PRIVATE_WRITER_TRANSITION` marker is projected as one closed +14-boolean group in the failure description. It distinguishes captured +withdrawal/pause/refill witnesses, generation and replica expectations, +projection and Kubernetes Deployment revisions, and invariant metadata/spec/ +template comparisons. Missing fields, duplicates, extra fields and non-booleans +make the group unavailable. Compared values and hashes are never copied. +The original refusal and all production deadlines remain unchanged. + `observer_target_client` optionally carries an atomic group of five booleans: `transport_debug_observable`, `transport_trace_observable`, `tcp_connect_started`, `tcp_connected`, and `http_handshake_complete`. diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index df29e261d..af1328ca7 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -61,6 +61,15 @@ ("projectionMetadataMatches", "metadata-matches"), ("deploymentTransitionMatches", "deployment"), ) +TRANSITION_PREFIX = "KARS_PRIVATE_WRITER_TRANSITION " +TRANSITION_FIELDS = ( + ("unchanged", "unchanged"), ("paused", "paused"), ("projectionSame", "projection"), + ("restoring", "restoring"), ("generationMatches", "generation"), + ("pauseSeen", "pause-witness"), ("withdrawnSeen", "withdrawal-witness"), ("emptySeen", "empty-witness"), + ("projectionMatches", "projection-version"), ("revisionMatches", "deployment-revision"), + ("metadataMatches", "metadata"), ("specMatches", "spec"), + ("templateMatches", "template"), ("replicasMatches", "replicas"), +) COMMAND_PREFIX = "KARS_PRIVATE_COMMAND_FAILURE " COMMAND_PHASES = {"Unscoped", "Review", "Pausing", "Retired", "Rotating", "Restoring", "Qualified"} COMMAND_OPERATIONS = {"get", "patch", "create", "auth", "other"} @@ -131,6 +140,10 @@ def writer_checks(stderr): return _checks(stderr, WRITER_CHECK_PREFIX, WRITER_CHECK_FIELDS) +def transition_checks(stderr): + return _checks(stderr, TRANSITION_PREFIX, TRANSITION_FIELDS) + + def command_facts(stderr): prefixes = (COMMAND_PREFIX, "PrivateCommandFailure: " + COMMAND_PREFIX) payloads = [line[len(prefix):] for line in stderr.splitlines() @@ -166,6 +179,9 @@ def operator_command(stage, *args, timeout): recheck = writer_checks(error.stderr) if recheck: details += f" (writer-recheck={recheck})" + transition = transition_checks(error.stderr) + if transition: + details += f" (writer-transition={transition})" facts = command_facts(error.stderr) if facts: details += f" (command={facts})" diff --git a/bridge/tests/native-credentials/template_diagnostics.py b/bridge/tests/native-credentials/template_diagnostics.py index d4092a646..bade5bc02 100644 --- a/bridge/tests/native-credentials/template_diagnostics.py +++ b/bridge/tests/native-credentials/template_diagnostics.py @@ -113,8 +113,8 @@ def project(before, current, review, before_digest, current_digest): def collect(setup, baselines, failure): result = {"diagnosticOnly": True, "available": False, "category": "not-eligible"} - if not isinstance(failure, str) or not failure.startswith( - "Native operator apply failed: consumer-template-drift "): + if not isinstance(failure, str) or not any(failure.startswith(f"Native operator apply failed: {category} ") + for category in ("consumer-template-drift", "writer-runtime-transition")): return result result["category"] = "unavailable" try: diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 9793c7fd2..9fb0eae4c 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -12,8 +12,8 @@ import native_api from native_api import Failure from operator_diagnostics import ( - CHECK_PREFIX, COMMAND_PREFIX, ERRORS, WRITER_CHECK_PREFIX, category, command_facts, - operator_command, sandbox_checks, source_location, writer_checks, + CHECK_PREFIX, COMMAND_PREFIX, ERRORS, TRANSITION_FIELDS, TRANSITION_PREFIX, WRITER_CHECK_PREFIX, category, command_facts, + operator_command, sandbox_checks, source_location, transition_checks, writer_checks, ) PRIVATE = "DO-NOT-EMIT-TOKENS-OR-PRIVATE-API-BODIES" @@ -66,6 +66,29 @@ def test_typed_template_errors_keep_only_the_exact_fixed_category(self): self.assertEqual(category(prefix + ": " + PRIVATE), "unclassified-cli-error") self.assertEqual(category(f"{PRIVATE}: {message}"), "unclassified-cli-error") + def test_restoration_failure_retains_only_the_closed_boolean_snapshot(self): + facts = {key: True for key, _ in TRANSITION_FIELDS} + facts["revisionMatches"] = False + marker = TRANSITION_PREFIX + json.dumps(facts) + stderr = (f"{PRIVATE}\n{marker}\n" + "Error: Unreviewed template or controller pause/restore generation changed\n") + with self.assertRaises(Failure) as failure: + operator_command("apply", sys.executable, "-c", + "import sys; print(sys.argv[1],file=sys.stderr); sys.exit(1)", stderr, timeout=5) + message = str(failure.exception) + self.assertIn("writer-runtime-transition", message) + self.assertIn("writer-transition=", message) + self.assertIn("deployment-revision=false", message) + self.assertIn("generation=true", message) + self.assertNotIn(PRIVATE, message) + for value in (marker + PRIVATE, marker + "\n" + marker, + TRANSITION_PREFIX + json.dumps({**facts, "private": PRIVATE}), + TRANSITION_PREFIX + json.dumps({**facts, "revisionMatches": 0}), + TRANSITION_PREFIX + json.dumps({key: value for key, value in facts.items() if key != "paused"}), + TRANSITION_PREFIX + '{"paused":true,"paused":false}'): + self.assertEqual(transition_checks(value), "unavailable") + self.assertEqual(transition_checks(PRIVATE), "") + def test_late_scope_leaf_is_retained_instead_of_only_its_awaiting_caller(self): stderr = ( f"{PRIVATE}\n" diff --git a/bridge/tests/native-credentials/test_template_diagnostics.py b/bridge/tests/native-credentials/test_template_diagnostics.py index 79a549580..d5d1485db 100644 --- a/bridge/tests/native-credentials/test_template_diagnostics.py +++ b/bridge/tests/native-credentials/test_template_diagnostics.py @@ -97,7 +97,7 @@ def test_wrong_namespaces_owners_and_review_shapes_are_refused(self): with self.assertRaises(Failure): diagnostics.project(before, before, invalid, "a" * 64, "b" * 64) - def collect(self, *, mutate_review=None, changed_on_recheck=False, unavailable_hashes=False): + def collect(self, *, mutate_review=None, changed_on_recheck=False, unavailable_hashes=False, failure=FAILURE): baselines = {actor: deployment(actor) for actor in diagnostics.ACTORS} document = {"apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsCredentialGrant", "metadata": {"name": "workspace", "namespace": "kars-system"}, @@ -124,7 +124,7 @@ def get(path): with patch.object(diagnostics, "ROOT", root), \ patch.object(diagnostics, "hashes", side_effect=side_effect), \ redirect_stdout(output), redirect_stderr(output): - result = diagnostics.collect(SimpleNamespace(admin=SimpleNamespace(get=get)), baselines, FAILURE) + result = diagnostics.collect(SimpleNamespace(admin=SimpleNamespace(get=get)), baselines, failure) self.assertEqual(output.getvalue(), "") self.assertNotIn(PRIVATE, json.dumps(result)) return result, reads @@ -138,6 +138,11 @@ def test_complete_collector_rechecks_all_three_exact_deployments(self): self.assertEqual(len(reads), 6) self.assertTrue(all(value["currentMatchesReview"] for value in result["actors"].values())) + def test_writer_restoration_refusal_also_gets_value_free_template_comparisons(self): + result, reads = self.collect(failure="Native operator apply failed: writer-runtime-transition (source=unavailable)") + self.assertTrue(result["available"]) + self.assertEqual(len(reads), 6) + def test_changed_resource_or_failed_hashing_never_returns_partial_comparisons(self): for options in ({"changed_on_recheck": True}, {"unavailable_hashes": True}): result, _ = self.collect(**options) diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts index c4075ad84..df74a1a35 100644 --- a/cli/src/lib/private-activation-writer-settle.test.ts +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -25,7 +25,7 @@ const data = { SLACK_BOT_TOKEN: Buffer.from("original-customer-token").toString( const managedFields = [{ manager: "kars-controller", operation: "Update", apiVersion: "v1", fieldsType: "FieldsV1", fieldsV1: { "f:data": { ".": {}, "f:SLACK_BOT_TOKEN": {} } } }]; -async function projectionWire() { +async function projectionWire(delayResponseMs = 0) { let secret: any; const requests: string[] = []; const server = createServer((request, response) => { @@ -38,8 +38,14 @@ async function projectionWire() { resources: [{ name: "secrets", singularName: "secret", namespaced: true, kind: "Secret", verbs: ["get", "list"] }] }, [`/api/v1/namespaces/${secret?.metadata.namespace}/secrets/${secret?.metadata.name}`]: secret, }; - response.writeHead(path in objects ? 200 : 404, { "Content-Type": "application/json", Connection: "close" }); - response.end(JSON.stringify(objects[path] ?? { apiVersion: "v1", kind: "Status", status: "Failure", reason: "NotFound", code: 404 })); + const send = () => { + response.writeHead(path in objects ? 200 : 404, { "Content-Type": "application/json", Connection: "close" }); + response.end(JSON.stringify(objects[path] ?? { apiVersion: "v1", kind: "Status", status: "Failure", reason: "NotFound", code: 404 })); + }; + if (delayResponseMs && path.includes("/secrets/")) { + setTimeout(send, delayResponseMs); + delayResponseMs = 0; + } else send(); }); await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); @@ -50,11 +56,21 @@ async function projectionWire() { secret = structuredClone({ apiVersion: "v1", ...value }); // A regular project file is a non-directory cache root: kubectl cannot // create cache files, and the fixture never touches a user's kubeconfig. - const result = await promisify(execFile)("kubectl", [ - "--kubeconfig", devNull, "--cache-dir", fileURLToPath(new URL("../../package.json", import.meta.url)), - "--server", `http://127.0.0.1:${address.port}`, "--request-timeout=3s", ...args, - ], { encoding: "utf8", timeout: 10_000, windowsHide: true }); - return result.stdout; + try { + const result = await promisify(execFile)("kubectl", [ + "--kubeconfig", devNull, "--cache-dir", fileURLToPath(new URL("../../package.json", import.meta.url)), + "--server", `http://127.0.0.1:${address.port}`, "--request-timeout=10s", ...args, + ], { encoding: "utf8", timeout: 20_000, windowsHide: true }); + return result.stdout; + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || !("killed" in error)) throw error; + const code = typeof error.code === "number" && Number.isInteger(error.code) + && error.code >= 0 && error.code <= 255 ? error.code : "unknown"; + const killed = typeof error.killed === "boolean" ? error.killed : null; + const signal = "signal" in error && (error.signal === "SIGTERM" || error.signal === "SIGKILL") + ? error.signal : "signal" in error && error.signal === null ? null : "other"; + throw new Error(`Loopback kubectl fixture failed ${JSON.stringify({ code, killed, signal, requests: requests.length })}`); + } }, close: () => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())), }; @@ -282,6 +298,31 @@ describe("late runtime authority across selected writer retirement", () => { expect(f.grant().spec.writers).toEqual([]); }); + it("reports only boolean restoration differences while preserving the refusal", async () => { + const { f, review, settlement } = await quiesced(); + await observeWriterSettlement(f.execute, review.spec.privateActivation, settlement); + f.restore(); + f.deployment.metadata.annotations[REVISION] = "1"; + await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)) + .rejects.toThrow("Unreviewed template or controller pause/restore generation changed"); + const prefix = "KARS_PRIVATE_WRITER_TRANSITION "; + const lines = vi.mocked(console.error).mock.calls.map(([value]) => String(value)) + .filter(value => value.startsWith(prefix)); + expect(lines).toHaveLength(1); + expect(lines[0]!.length).toBeLessThan(512); + const facts = JSON.parse(lines[0]!.slice(prefix.length)); + expect(Object.keys(facts).sort()).toEqual(["unchanged", "paused", "projectionSame", "restoring", + "generationMatches", "pauseSeen", "withdrawnSeen", "emptySeen", "projectionMatches", + "revisionMatches", "metadataMatches", "specMatches", "templateMatches", "replicasMatches"].sort()); + expect(Object.values(facts).every(value => typeof value === "boolean")).toBe(true); + expect(facts).toMatchObject({ generationMatches: true, pauseSeen: true, withdrawnSeen: true, + emptySeen: true, projectionMatches: true, revisionMatches: false, metadataMatches: true, + specMatches: true, templateMatches: true, replicasMatches: true, restoring: false }); + expect(lines[0]).not.toContain(data.SLACK_BOT_TOKEN); + expect(f.calls.every(args => args[0] === "get")).toBe(true); + expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); + }); + it.each([false, true])("handles an owned refill during lineage lookup without accepting template drift=%s", async unreviewed => { const { f, review, settlement } = await quiesced(); let changed = false; @@ -461,7 +502,7 @@ describe("late runtime authority across selected writer retirement", () => { it("proves the real JSON and metadata JSONPath printer views differ only in managedFields", async () => { const f = await setup(); - const wire = await projectionWire(); + const wire = await projectionWire(3200); f.projection.metadata.managedFields = managedFields; try { const args = ["get", "secret", f.projection.metadata.name, "-n", "kars-late", "-o", "json"]; @@ -475,8 +516,16 @@ describe("late runtime authority across selected writer retirement", () => { expect(metadata).not.toHaveProperty("data"); expect(JSON.stringify(metadata)).not.toContain(data.SLACK_BOT_TOKEN); expect(wire.requests.every(request => request.startsWith("GET "))).toBe(true); + const failure: unknown = await wire.get([ + "get", "secret", "private-missing-canary", "-n", "kars-late", "-o", "json", + ], f.projection).then(() => undefined, error => error); + if (!(failure instanceof Error)) throw new Error("Missing fixture object must fail"); + expect(failure.message).toContain("Loopback kubectl fixture failed"); + expect(failure.message).toContain('"code":1'); + expect(failure.message).not.toContain("private-missing-canary"); + expect(failure.message).not.toContain(data.SLACK_BOT_TOKEN); } finally { await wire.close(); } - }, 20_000); + }, 45_000); it("completes shipped apply with the actual kubectl projection printer views", async () => { const f = await setup(); diff --git a/cli/src/lib/private-activation-writer-settle.ts b/cli/src/lib/private-activation-writer-settle.ts index 7785082f0..7a53a15a0 100644 --- a/cli/src/lib/private-activation-writer-settle.ts +++ b/cli/src/lib/private-activation-writer-settle.ts @@ -115,6 +115,49 @@ function possibleTransition(current: ObjectValue, runtime: RuntimeReview): boole return at(current, "status", "observedGeneration") !== gen(current) && sameBody(current, expected, true, true); } +function transitionParts(deployment: ObjectValue) { + const metadata = structuredClone(record(deployment.metadata)); + delete metadata.resourceVersion; + delete metadata.generation; + const annotations = at(metadata, "annotations"); + if (annotations) { + delete record(annotations)[REVISION]; + if (!Object.keys(record(annotations)).length) delete metadata.annotations; + } + const spec = structuredClone(record(deployment.spec)); + delete spec.replicas; + delete spec.template; + const pod = structuredClone(template(deployment)); + const podAnnotations = at(pod, "metadata", "annotations"); + if (podAnnotations) { + delete record(podAnnotations)[PROJECTION]; + if (!Object.keys(record(podAnnotations)).length) delete record(pod.metadata).annotations; + } + return { metadata, spec, pod }; +} + +function reportTransition( + deployment: ObjectValue, expected: ObjectValue, runtime: RuntimeReview, + state: { unchanged: boolean; paused: boolean; projectionSame: boolean; restoring: boolean; generationMatches: boolean }, +): void { + const current = transitionParts(deployment); + const before = transitionParts(expected); + console.error(`KARS_PRIVATE_WRITER_TRANSITION ${JSON.stringify({ + ...state, + pauseSeen: runtime.pauseSeen, + withdrawnSeen: runtime.withdrawnVersion !== undefined, + emptySeen: runtime.emptyVersion !== undefined, + projectionMatches: at(template(deployment), "metadata", "annotations", PROJECTION) + === at(template(expected), "metadata", "annotations", PROJECTION), + revisionMatches: at(deployment, "metadata", "annotations", REVISION) + === at(expected, "metadata", "annotations", REVISION), + metadataMatches: canonical(current.metadata) === canonical(before.metadata), + specMatches: canonical(current.spec) === canonical(before.spec), + templateMatches: canonical(current.pod) === canonical(before.pod), + replicasMatches: replicaIntent(deployment) === replicaIntent(expected), + })}`); +} + async function snapshotCurrent( execute: Execute, runtime: RuntimeReview, task: ObjectValue, deployment: ObjectValue, projection: ObjectValue, ): Promise<boolean> { @@ -298,9 +341,19 @@ export async function observeWriterSettlement( } const restoredGeneration = gen(before.deployment) + (initialReplicas ? 2 : Number(changedRevision)); if (!unchanged && !isPause && (!restoringShape || !runtime.pauseSeen || gen(deployment) !== restoredGeneration)) { + reportTransition(deployment, restored, runtime, { + unchanged, paused: isPause, projectionSame, restoring: restoringShape, + generationMatches: gen(deployment) === restoredGeneration, + }); throw new Error("Unreviewed template or controller pause/restore generation changed"); } - if (unchanged && gen(deployment) !== gen(before.deployment) && (!runtime.pauseSeen || gen(deployment) !== restoredGeneration)) throw new Error(ERROR); + if (unchanged && gen(deployment) !== gen(before.deployment) && (!runtime.pauseSeen || gen(deployment) !== restoredGeneration)) { + reportTransition(deployment, restored, runtime, { + unchanged, paused: isPause, projectionSame, restoring: restoringShape, + generationMatches: gen(deployment) === restoredGeneration, + }); + throw new Error(ERROR); + } if (!await snapshotCurrent(execute, runtime, task, deployment, projection)) { allReady = false; continue; diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 3c17e304e..39fec4877 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -325,6 +325,13 @@ retained, never invented. Stable missing witnesses or authority drift still block enrollment. A final recheck after lineage inspection prevents reporting settlement from an outdated snapshot. No mutation or stale write is retried. +If restoration validation still refuses the observed state, the CLI emits +`KARS_PRIVATE_WRITER_TRANSITION` with fixed booleans distinguishing generation, +projection/revision, replica, metadata, executable-template and witness +mismatches. No compared values or hashes are emitted. These facts only explain +the unchanged refusal; they do not broaden accepted transitions or the +120-second production bound. + For first qualification, namespace protection is then enabled in `Pending`, identities/templates are rechecked, and only approved authority-consuming controller replicas are paused. This From 526597e92b04bd8be0005f42e814a521aa1198a0 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 10:36:19 +0200 Subject: [PATCH 086/111] fix(cli): revalidate a conflicted reviewed sandbox pause Recover only a confirmed Pausing Sandbox conflict after full scope, runtime, Task, private-key and root revalidation. Require a different live revision before another guarded update, cap attempts at three within the existing deadline, and preserve all other failures. Exercise the native Pending-induced status race, bounded recovery, expiry, stale identities/intent and already-applied pause behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../lib/private-activation-late-scope.test.ts | 133 ++++++++++++++++++ cli/src/lib/private-activation-late-scope.ts | 33 ++++- .../private-activation-writer-settle.test.ts | 34 ++--- docs/how-to/governed-credential-grants.md | 12 +- 4 files changed, 189 insertions(+), 23 deletions(-) diff --git a/cli/src/lib/private-activation-late-scope.test.ts b/cli/src/lib/private-activation-late-scope.test.ts index a991ca6ee..0e6b837cf 100644 --- a/cli/src/lib/private-activation-late-scope.test.ts +++ b/cli/src/lib/private-activation-late-scope.test.ts @@ -136,6 +136,139 @@ describe("reviewed late runtime private enrollment", () => { }); afterEach(() => { vi.restoreAllMocks(); }); + it.each(["uid", "spec", "task", "template", "namespace", "root", "private-key", "unchanged-version", "deadline"])( + "does not retry a pause conflict after %s changes or lacks a fresh version", async fault => { + const f = await setup(); + const review = await f.document(); + let attempts = 0; + const run: Execute = async (args, input) => { + if (args[0] === "patch" && args[1] === "karssandbox" + && JSON.parse(args[args.indexOf("-p") + 1]!).spec.suspended === true) { + attempts++; + if (fault !== "unchanged-version") f.sandbox.metadata.resourceVersion = "fresh-version"; + if (fault === "uid") f.sandbox.metadata.uid = "replacement"; + if (fault === "spec") f.sandbox.spec.unreviewed = true; + if (fault === "task") f.task.status.envelopeDigest = `sha256:${"b".repeat(64)}`; + if (fault === "template") f.deployment.spec.template.spec.containers[0].image = "unreviewed"; + if (fault === "namespace") f.namespace.metadata.annotations[`${P}state`] = "Qualified"; + if (fault === "root") f.objects.get(f.key("namespace", "core")).metadata.annotations[HISTORY] = "{}"; + if (fault === "private-key") f.secret.data["control-token"] = Buffer.from("C".repeat(64)).toString("base64"); + if (fault === "deadline") vi.spyOn(Date, "now").mockReturnValue(Date.now() + 121_000); + throw Object.assign(new Error("private-command-canary"), { + exitCode: 1, stderr: "Error from server (Conflict): private-object-canary", + }); + } + return f.execute(args, input); + }; + const failure = await applyReviewedGrant(run, review).then(() => undefined, error => error); + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(TypeError); + expect(failure.message + JSON.stringify(failure)).not.toContain("canary"); + expect(attempts).toBe(1); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe(fault === "namespace" ? "Qualified" : "Pending"); + expect(f.sandbox.spec.suspended).toBeUndefined(); + expect(f.grant().spec.writers).toEqual([]); + }); + + it.each(["Forbidden", "Timeout", "Unknown"])("does not retry an ambiguous or %s pause error", async reason => { + const f = await setup(); + const review = await f.document(); + let attempts = 0; + const run: Execute = async (args, input) => { + if (args[0] === "patch" && args[1] === "karssandbox" + && JSON.parse(args[args.indexOf("-p") + 1]!).spec.suspended === true) { + attempts++; + throw Object.assign(new Error("private-command-canary"), { + exitCode: 1, stderr: reason === "Unknown" ? "private-error-canary" : `Error from server (${reason}): private-error-canary`, + }); + } + return f.execute(args, input); + }; + const failure = await applyReviewedGrant(run, review).then(() => undefined, error => error); + expect(failure).toBeInstanceOf(PrivateCommandFailure); + expect(failure.facts.serverReason).toBe(reason); + expect(failure.message).not.toContain("canary"); + expect(attempts).toBe(1); + }); + + it("bounds repeated known pause conflicts and preserves the last sanitized failure", async () => { + const f = await setup(); + const review = await f.document(); + const versions: string[] = []; + const run: Execute = async (args, input) => { + if (args[0] === "patch" && args[1] === "karssandbox" + && JSON.parse(args[args.indexOf("-p") + 1]!).spec.suspended === true) { + const patch = JSON.parse(args[args.indexOf("-p") + 1]!); + expect(patch.metadata.uid).toBe(f.sandbox.metadata.uid); + expect(patch.metadata.resourceVersion).toBe(f.sandbox.metadata.resourceVersion); + versions.push(patch.metadata.resourceVersion); + f.sandbox.metadata.resourceVersion = `${versions.length + 1}`; + throw Object.assign(new Error("private-command-canary"), { + exitCode: 1, stderr: "Error from server (Conflict): private-object-canary", + }); + } + return f.execute(args, input); + }; + const failure = await applyReviewedGrant(run, review).then(() => undefined, error => error); + expect(failure).toBeInstanceOf(PrivateCommandFailure); + expect(failure.facts).toMatchObject({ phase: "Pausing", operation: "patch", resourceKind: "KarsSandbox", serverReason: "Conflict" }); + expect(versions).toHaveLength(3); + expect(new Set(versions).size).toBe(3); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Pending"); + expect(f.sandbox.spec.suspended).toBeUndefined(); + expect(f.grant().spec.writers).toEqual([]); + }); + + it("recognizes an already-applied reviewed pause without issuing another suspend update", async () => { + const f = await setup(); + const review = await f.document(); + let attempts = 0; + const before = f.preserved(); + const run: Execute = async (args, input) => { + if (args[0] === "patch" && args[1] === "karssandbox" + && JSON.parse(args[args.indexOf("-p") + 1]!).spec.suspended === true) { + attempts++; + await f.execute(args, input); + throw Object.assign(new Error("concurrent reviewed pause"), { + exitCode: 1, stderr: "Error from server (Conflict): changed version", + }); + } + return f.execute(args, input); + }; + await applyReviewedGrant(run, review); + expect(attempts).toBe(1); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); + expect(f.preserved()).toEqual(before); + expect(f.sandbox.spec.suspended).toBeUndefined(); + expect(f.deployment.spec.replicas).toBe(1); + }); + + it("does not issue another pause if revalidation consumes the remaining deadline", async () => { + const f = await setup(); + const review = await f.document(); + let attempts = 0; + let expired = false; + const run: Execute = async (args, input) => { + if (args[0] === "patch" && args[1] === "karssandbox") { + attempts++; + f.sandbox.metadata.resourceVersion = "fresh-version"; + throw Object.assign(new Error("conflict"), { + exitCode: 1, stderr: "Error from server (Conflict): changed version", + }); + } + const result = await f.execute(args, input); + if (attempts && !expired && args[0] === "get" && args[1] === "deployment" && args[2] === "kars-controller") { + expired = true; + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 121_000); + } + return result; + }; + await expect(applyReviewedGrant(run, review)).rejects.toBeInstanceOf(PrivateCommandFailure); + expect(expired).toBe(true); + expect(attempts).toBe(1); + expect(f.sandbox.spec.suspended).toBeUndefined(); + }); + it("sanitizes actual registered command process failures before exposing the exception", async () => { cliProcess.execute.mockRejectedValue(Object.assign(new Error("private-argv-canary"), { exitCode: 1, stderr: "Error from server (Forbidden): private-secret-canary", stdout: "private-data-canary", diff --git a/cli/src/lib/private-activation-late-scope.ts b/cli/src/lib/private-activation-late-scope.ts index edbab95f5..5a3a1362b 100644 --- a/cli/src/lib/private-activation-late-scope.ts +++ b/cli/src/lib/private-activation-late-scope.ts @@ -8,7 +8,7 @@ import { type Execute, type Json, type NamespaceReview, type PrivateActivation, type ReviewedObject, } from "./private-activation.js"; import { replicaIntent } from "./private-activation-retirement.js"; -import { scopedCommandFailure, type PrivateCommandPhase } from "./private-activation-command-diagnostics.js"; +import { PrivateCommandFailure, scopedCommandFailure, type PrivateCommandPhase } from "./private-activation-command-diagnostics.js"; const HISTORY = "kars.azure.com/private-root-retirement"; const ADMIN = "router-services-admin"; @@ -456,12 +456,33 @@ export async function stageLateScope( if (!state) throw new Error(failure); const deadline = Date.now() + 120_000; if (state.phase === "Pausing") { - live = await current(execute, activation, scope, root, state); - if (live.runtime.suspended !== true) { + let conflict: { error: PrivateCommandFailure; resourceVersion: string } | undefined; + for (let attempt = 0; ; attempt++) { + if (conflict && Date.now() >= deadline) throw conflict.error; + live = await current(execute, activation, scope, root, state); + if (live.runtime.suspended === true) break; + if (conflict) { + if (live.runtime.sandbox.resourceVersion === conflict.resourceVersion) throw conflict.error; + if ((await material(execute, scope, live.runtime)).key !== state.baseline.key) { + throw new Error("Late private key changed before retirement; no pre-retirement rotation was qualified"); + } + } await assertRoot(); - await execute(["patch", "karssandbox", state.runtime.sandbox.name, "-n", state.runtime.workspace, "--type=merge", "-p", - JSON.stringify({ metadata: { uid: live.runtime.sandbox.uid, resourceVersion: live.runtime.sandbox.resourceVersion }, - spec: { suspended: true } })]); + if (conflict && Date.now() >= deadline) throw conflict.error; + try { + await execute(["patch", "karssandbox", state.runtime.sandbox.name, "-n", state.runtime.workspace, "--type=merge", "-p", + JSON.stringify({ metadata: { uid: live.runtime.sandbox.uid, resourceVersion: live.runtime.sandbox.resourceVersion }, + spec: { suspended: true } })]); + break; + } catch (error) { + if (!(error instanceof PrivateCommandFailure) || error.facts.serverReason !== "Conflict" + || error.facts.phase !== "Pausing" || error.facts.operation !== "patch" + || error.facts.resourceKind !== "KarsSandbox" || error.facts.exitCode !== 1 + || attempt >= 2) throw error; + // A rejected write cannot refresh approval: recheck the full receipt, + // runtime, Task and private material before using a different revision. + conflict = { error, resourceVersion: live.runtime.sandbox.resourceVersion }; + } } live = await current(execute, activation, scope, root, state); if (replicaIntent(live.deployment) !== 0) { diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts index df74a1a35..ba76c7c6e 100644 --- a/cli/src/lib/private-activation-writer-settle.test.ts +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -12,7 +12,6 @@ import { continuityFixture, privateAuthoritySnapshot } from "./private-activatio import { canonical, readSecretMetadata, PRIVATE_PREFIX as P, type Execute } from "./private-activation.js"; import { captureGuardRetirement, refreshGuardRetirement } from "./private-activation-guard-retirement.js"; import { captureWriterSettlement, observeWriterSettlement } from "./private-activation-writer-settle.js"; -import { PrivateCommandFailure } from "./private-activation-command-diagnostics.js"; const RESOURCE = "karscredentialgrants.kars.azure.com"; const C = "kars.azure.com/credential-"; @@ -455,22 +454,29 @@ describe("late runtime authority across selected writer retirement", () => { expect(f.grant().spec.writers).toEqual([]); }); - it("reports a Pending-induced status/RV race without retrying the stale suspend PATCH", async () => { + it("recovers a Pending-induced status/RV race using a freshly revalidated suspend PATCH", async () => { const f = await setup(); const review = await f.document(); let pending = false; let captured = false; let advanced = false; let suspendAttempts = 0; + const versions: string[] = []; + const before = f.preserved(); const run: Execute = async (args, input) => { - if (pending && args[0] === "patch" && args[1] === "karssandbox") { + if (pending && args[0] === "patch" && args[1] === "karssandbox" + && JSON.parse(args[args.indexOf("-p") + 1]!).spec.suspended === true) { suspendAttempts++; const patch = JSON.parse(args[args.indexOf("-p") + 1]!); + versions.push(patch.metadata.resourceVersion); expect(patch.metadata.uid).toBe(f.sandbox.metadata.uid); - expect(patch.metadata.resourceVersion).not.toBe(f.sandbox.metadata.resourceVersion); - throw Object.assign(new Error("private-command-and-argv-canary"), { - exitCode: 1, stderr: "Error from server (Conflict): private-object-name-canary", - }); + if (suspendAttempts === 1) { + expect(patch.metadata.resourceVersion).not.toBe(f.sandbox.metadata.resourceVersion); + throw Object.assign(new Error("private-command-and-argv-canary"), { + exitCode: 1, stderr: "Error from server (Conflict): private-object-name-canary", + }); + } + expect(patch.metadata.resourceVersion).toBe(f.sandbox.metadata.resourceVersion); } const result = await f.execute(args, input); if (args[0] === "patch" && args[1] === "namespace" && args[2] === "kars-late" @@ -485,19 +491,15 @@ describe("late runtime authority across selected writer retirement", () => { } return result; }; - const failure = await applyReviewedGrant(run, review).then(() => undefined, error => error); - expect(failure).toBeInstanceOf(PrivateCommandFailure); - expect(failure.facts).toEqual({ version: 1, phase: "Pausing", operation: "patch", - resourceKind: "KarsSandbox", serverReason: "Conflict", exitCode: 1 }); - expect(failure.message + JSON.stringify(failure)).not.toContain("canary"); + await applyReviewedGrant(run, review); expect(advanced).toBe(true); - expect(suspendAttempts).toBe(1); - expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Pending"); - expect(f.sandbox.metadata.generation).toBe(1); + expect(suspendAttempts).toBe(2); + expect(new Set(versions).size).toBe(2); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); expect(f.sandbox.spec.suspended).toBeUndefined(); expect(f.deployment.spec.replicas).toBe(1); expect(f.task.status.envelopeDigest).toBe(AUTH); - expect(f.grant().spec.writers).toEqual([]); + expect(f.preserved()).toEqual(before); }); it("proves the real JSON and metadata JSONPath printer views differ only in managedFields", async () => { diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 39fec4877..6832eb11a 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -541,7 +541,17 @@ including completed v4 scopes. If it disappears or is replaced, no replacement is adopted or created; explicit operator recovery is required. Ordinary core creation and existing v3 scope handling do not acquire this v4 identity fence. -Re-preview after a CAS conflict or lost response resumes the recorded attempt, +During `Pausing`, a confirmed Kubernetes `Conflict` on the Sandbox suspension +PATCH permits at most three total attempts within the existing 120-second +bound. Each attempt revalidates the full recorded scope, runtime and Task, +and the shared-root proof. A replacement attempt requires a different live +resourceVersion and the unchanged private-key baseline; it never replays the +rejected PATCH. An already-applied, fully verified suspension needs no duplicate +write. Replaced identities, changed intent/templates/authority, unchanged +versions, exhausted bounds, and non-conflict or ambiguous failures still stop +enrollment. This exception does not cover other mutations or refresh approval. + +Re-preview after an unresolved CAS conflict or lost response resumes the recorded attempt, original intent and epoch; it cannot adopt changed templates/specifications or invent missing retirement evidence. Failure preserves suspension and recovery records. Task, Sandbox, namespace, source bundles, projections, agent keys and From 7ebfbcc67dfd9e0163c8070843c0d5a25f1654a4 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 10:55:05 +0200 Subject: [PATCH 087/111] test(bridge): collect bounded observer API packet evidence Observe endpoint-filtered Cilium drop, trace and policy verdicts before and during the existing failure-only experiment. Retain only bounded validated facts with provenance checks and exact-child cleanup. Keep packet receipt timing distinct from request freshness and policy realization; do not change the original failed outcome, shipping network policy, or readiness deadlines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 22 + .../observer_network_diagnostics.py | 33 ++ .../observer_packet_diagnostics.py | 402 +++++++++++++++++ .../test_observer_network_diagnostics.py | 3 + .../test_observer_packet_diagnostics.py | 422 ++++++++++++++++++ 5 files changed, 882 insertions(+) create mode 100644 bridge/tests/native-credentials/observer_packet_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_observer_packet_diagnostics.py diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 5a50fde7f..7fb3d0b1c 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -407,6 +407,28 @@ progress; observer readiness is not required. The policy is removed with its captured UID and current resourceVersion, and absence is verified. Replaced namespaces or policy objects are not deleted; unverified cleanup is explicit. +Failure-only `baselinePackets` observes 30 seconds before the existing policy +experiment; `policyWindowPackets` attempts to start before its CREATE. Both use +the pinned node agent's read-only `cilium-dbg monitor --related-to <endpoint>` +with only drop, trace and policy-verdict notifications. The +[1.18.5 formatter](https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/format/format.go) +emits drop/trace JSON but **text** policy verdicts even with `--json`. +Only validated API/Pod tuples, numeric identities, TCP flags, fixed verdicts and +observation points survive projection; raw summary strings and payload data +never enter artifacts. Each monitor is limited to 90 seconds, 256 KiB plus +8 KiB cleanup drain, 8 KiB lines and 96 matching records. stdin closure reaps +its exact remote child; an independent 95-second remote timeout with a +2-second kill grace bounds orphan lifetime, and unverified cleanup is explicit. +The existing UID/process/configuration fences are rechecked around collection. +Packet times are **local receipt times**, not generation timestamps: Cilium's +[wire records](https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/datapath_trace.go) +lack PID/socket/request correlation. Separately projected, timestamp-bounded +router completions and Pod-bound authenticated audit arrivals cannot establish +which packet belongs to which fresh router probe. `freshRouterRequest` and +packet-to-request correlation therefore remain **unavailable**, not success; +missing events are **unobserved** only when the source/cleanup permit that +claim, never a denial. Trace forwarding is not a policy allow verdict. + This is correlation evidence, not a production fix or CNI acceptance. Policy revision observations alone are not proof that a particular rule was realized, and Kubernetes object removal is not a claim about datapath convergence. No RBAC, TLS, diff --git a/bridge/tests/native-credentials/observer_network_diagnostics.py b/bridge/tests/native-credentials/observer_network_diagnostics.py index bbb88360c..fad52e927 100644 --- a/bridge/tests/native-credentials/observer_network_diagnostics.py +++ b/bridge/tests/native-credentials/observer_network_diagnostics.py @@ -360,6 +360,7 @@ def remove_policy(setup, before, path, created): def collect(setup, target, failed_case): from observer_cilium_diagnostics import snapshot as cilium_snapshot + from observer_packet_diagnostics import start as start_packets result = {"diagnosticOnly": True, "originalResult": "failed", "available": False, "category": "not-eligible", "policyCreated": False, "cleanup": "not-required", @@ -368,6 +369,9 @@ def collect(setup, target, failed_case): or failed_case.get("failure") != FAILURE): return result before = created = path = None + baseline_packets = policy_packets = None + baseline_packets_stable = False + packets_stable = False try: result["stage"] = "disposable-host" require(os.environ.get("GITHUB_ACTIONS") == "true" @@ -419,12 +423,21 @@ def recheck(phase, temporary_name=None): require(observed["stable"] == before["stable"], UNAVAILABLE) return observed + # Only the already-failed case gains this read-only baseline window. + # This does not extend the original readiness deadline or issue a probe. + baseline_packets = start_packets(before) + time.sleep(30) + current = recheck("packet-baseline") + result["baselinePackets"] = baseline_packets.finish(setup, target, True) current = recheck("pre-create") + baseline_packets_stable = True if current["ready"]: result.update(category="already-ready-without-intervention", samePodObserverReady=True) return result require(selected_origin(setup) == origin, "Diagnostic API origin changed before policy creation") desired = policy_plan(before, name) + # Subscribe before the write: no late monitor can establish a missed SYN. + policy_packets = start_packets(before) result["stage"] = "temporary-policy-create" result["cleanup"] = "creation-unconfirmed" candidate = setup.admin.create(resource(before["actor"]["namespace"], "ciliumnetworkpolicies", group=CILIUM), desired) @@ -435,6 +448,11 @@ def recheck(phase, temporary_name=None): result.update(policyCreated=True, policy=metadata(created), cleanup="pending") require(created["spec"] == desired["spec"], "Diagnostic policy was mutated") started = datetime.now(timezone.utc) + result["packetPolicyBoundary"] = { + "createdAt": started.isoformat(), + "monitorLaunchAttemptedBeforeCreate": True, + "receiptTimingDoesNotProvePolicyRealization": True, + } deadline = time.monotonic() + 60 result["category"] = "no-progress-observed" result["stage"] = "same-pod-observation" @@ -455,6 +473,9 @@ def recheck(phase, temporary_name=None): break time.sleep(5) recheck("final", name) + result["policyWindowPackets"] = policy_packets.finish(setup, target, True) + recheck("after-packet-requests", name) + packets_stable = True except READ_ERRORS: result["category"] = "provenance-or-operation-unavailable" if result.get("stage") == "baseline-snapshot" and result.get("baselineSnapshot"): @@ -462,6 +483,18 @@ def recheck(phase, temporary_name=None): if result.get("observationStage"): result["observationStoppingStage"] = result["observationStage"] finally: + for label, window in (("baselinePackets", baseline_packets), ("policyWindowPackets", policy_packets)): + if window is not None: + evidence = window.close() + if label not in result: + result[label] = {**evidence, "available": False, "category": "provenance-unverified", + "events": [], "provenanceUnchanged": False} + if policy_packets is not None and not packets_stable and "policyWindowPackets" in result: + result["policyWindowPackets"].update(available=False, category="provenance-unverified", + events=[], requests={}, provenanceUnchanged=False) + if baseline_packets is not None and not baseline_packets_stable and "baselinePackets" in result: + result["baselinePackets"].update(available=False, category="provenance-unverified", + events=[], requests={}, provenanceUnchanged=False) if created is not None: try: result["cleanup"] = remove_policy(setup, before, path, created) diff --git a/bridge/tests/native-credentials/observer_packet_diagnostics.py b/bridge/tests/native-credentials/observer_packet_diagnostics.py new file mode 100644 index 000000000..6e89ff8b0 --- /dev/null +++ b/bridge/tests/native-credentials/observer_packet_diagnostics.py @@ -0,0 +1,402 @@ +"""Bounded, read-only Cilium monitor evidence, never an acceptance oracle. + +Wire contract (v1.18.5): pkg/monitor/{datapath_drop,datapath_trace,dissect}.go. +Policy verdicts remain DumpInfo text even with --json: format/format.go. +Neither format provides packet timestamps, PIDs, socket cookies or request IDs. +""" + +from datetime import datetime, timezone +import ipaddress +import json +import os +import re +import selectors +import signal +import subprocess +import threading +import time + +from api_outcome_diagnostics import project as project_audit, read_audit_tail, rules_for, timestamp +from native_api import ROOT, require +from observation_diagnostics import READ_ERRORS, project as project_router +import observer_network_diagnostics as network + +MAX_BYTES = 262144 +MAX_LINE = 8192 +MAX_EVENTS = 96 +MAX_SECONDS = 90 +UNAVAILABLE = "Packet diagnostic unavailable" +OBSERVATION_POINTS = frozenset(""" +to-endpoint to-proxy to-host to-stack to-overlay to-network to-crypto +from-endpoint from-proxy from-host from-stack from-overlay from-network from-crypto +""".split()) +STATES = frozenset("new established reply related reopened unknown srv6-encap srv6-decap encrypt-overlay".split()) +# The remote timeout also bounds the process if kubectl or this Python runner dies. +# stdin EOF requests early cleanup; wait reaps the exact child, never a name match. +MONITOR_SCRIPT = """ +cilium-dbg monitor --json --numeric --type drop --type trace --type policy-verdict --related-to "$1" 2>/dev/null & +child=$! +trap 'kill "$child" 2>/dev/null; wait "$child" 2>/dev/null' EXIT +trap 'exit 124' TERM INT +printf 'native-monitor-started\\n' +IFS= read -r stop +kill "$child" 2>/dev/null +wait "$child" +status=$? +trap - EXIT +printf '\\nnative-monitor-reaped:%s\\n' "$status" +""" +VERDICT = re.compile( + r"Policy verdict log: flow 0x[0-9a-f]{1,8} local EP ID ([0-9]{1,5}), " + r"remote ID ([0-9]{1,10}), proto 6, (egress|ingress), action (allow|deny|redirect|audit), " + r"auth: (disabled|spire|test-always-fail), match (none|L3-Only|L3-L4|L4-Only|all|L3-Proto|Proto-Only|unknown), " + r"(\S{1,60}) -> (\S{1,60}) tcp ((?:SYN|ACK|RST|FIN)(?:, (?:SYN|ACK|RST|FIN))*)?" +) +TCP_FLAG = re.compile(r"(?:^|[ {\t])(?P<name>SYN|ACK|RST|FIN)=(?P<value>true|false)(?=[ }\t]|$)") + + +def uint(value, maximum): + require(type(value) is int and 0 <= value <= maximum, UNAVAILABLE) + return value + + +def address_port(value): + require(isinstance(value, str) and len(value) <= 60, UNAVAILABLE) + address, port = value.rsplit(":", 1) + address = address.removeprefix("[").removesuffix("]") + require(re.fullmatch(r"[0-9]{1,5}", port) is not None, UNAVAILABLE) + port = int(port) + require(0 < port <= 65535, UNAVAILABLE) + return str(ipaddress.ip_address(address)), port + + +def tcp_flags(value): + # LayerString contains more than flags (including options). Never retain it. + require(isinstance(value, str) and len(value) <= 4096 + and value.startswith("TCP\t"), UNAVAILABLE) + entries = [(item["name"], item["value"]) for item in TCP_FLAG.finditer(value)] + require(len(entries) == 4 and {key for key, _ in entries} == {"SYN", "ACK", "RST", "FIN"}, UNAVAILABLE) + return [key for key in ("SYN", "ACK", "RST", "FIN") if (key, "true") in entries] + + +def flow_tuple(summary): + require(isinstance(summary, dict) and summary.get("tunnel") is None + and not any(summary.get(key) for key in ("udp", "sctp", "icmpv4", "icmpv6")), UNAVAILABLE) + l3, l4 = summary["l3"], summary["l4"] + require(isinstance(l3, dict) and isinstance(l4, dict), UNAVAILABLE) + result = [] + for side in ("src", "dst"): + address = l3[side] + port = l4[side] + require(isinstance(address, str) and len(address) <= 45 + and isinstance(port, str) and re.fullmatch(r"[0-9]{1,5}", port), UNAVAILABLE) + result.append(address_port(f"[{address}]:{port}")) + return *result, tcp_flags(summary["tcp"]) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, UNAVAILABLE) + result[key] = value + return result + + +def project_line(raw, binding, destinations): + """Allowlist real mixed monitor wire records; never echo input strings.""" + require(isinstance(raw, bytes) and len(raw) <= MAX_LINE, UNAVAILABLE) + try: + text = raw.decode("utf8") + if text.startswith("Policy verdict log:"): + matched = VERDICT.fullmatch(text) + require(matched is not None, UNAVAILABLE) + endpoint, remote, direction, action, auth, match, src, dst, flags = matched.groups() + require(int(endpoint) == binding["endpointId"] and int(remote) < 2**32 + and direction == "egress", UNAVAILABLE) + src, dst = address_port(src), address_port(dst) + flags = flags.split(", ") if flags else [] + require(len(flags) == len(set(flags)), UNAVAILABLE) + record = {"kind": "policy-verdict", "verdict": action, "remoteIdentity": int(remote), + "policyMatch": match, "authentication": auth, "direction": "egress"} + else: + event = json.loads(text, object_pairs_hook=unique_object) + require(isinstance(event, dict) and event.get("type") in ("drop", "trace"), UNAVAILABLE) + source = uint(event["source"], 65535) + dst_id = uint(event["dstID"], 2**32 - 1) + src_label = uint(event["srcLabel"], 2**32 - 1) + dst_label = uint(event["dstLabel"], 2**32 - 1) + uint(event["bytes"], 2**32 - 1) + src, dst, flags = flow_tuple(event["summary"]) + outbound = source == binding["endpointId"] and src_label == binding["securityIdentity"] + inbound = dst_id == binding["endpointId"] and dst_label == binding["securityIdentity"] + require(outbound != inbound, UNAVAILABLE) + record = {"kind": event["type"], "sourceIdentity": src_label, "destinationIdentity": dst_label, + "direction": "egress" if outbound else "ingress"} + if event["type"] == "trace": + require(event["observationPoint"] in OBSERVATION_POINTS + and event["state"] in STATES, UNAVAILABLE) + record.update(observationPoint=event["observationPoint"], connectionState=event["state"], + verdict="trace-not-a-policy-verdict") + else: + # Drop reason is an arbitrary string; only one exact upstream reason is named. + record.update(verdict="drop", reason="policy-denied" if event.get("reason") == "Policy denied" + else "other-or-unavailable") + record["bpfFile"] = uint(event["File"], 255) + record["bpfLine"] = uint(event["Line"], 65535) + record["ifindex"] = uint(event["Ifindex"], 2**32 - 1) + pod_side, api_side = (src, dst) if record["direction"] == "egress" else (dst, src) + require(pod_side[0] in binding["addresses"] and api_side in destinations, UNAVAILABLE) + return {**record, "podAddress": pod_side[0], "podPort": pod_side[1], + "apiAddress": api_side[0], "apiPort": api_side[1], "tcpFlags": flags} + except READ_ERRORS + (AttributeError, IndexError, RecursionError, UnicodeError): + return None + + +def empty_result(): + return {"available": False, "category": "source-unavailable", "events": [], + "freshRouterRequest": "unavailable", "packetProcessAttribution": "unavailable", + "freshnessReason": "monitor-has-no-request-id-pid-or-generation-timestamp", + "timing": "local-receipt-not-packet-generation", "missingEventsAreNotDenials": True, + "remoteCleanup": "not-started", "localCleanup": "not-started"} + + +def command_for(before): + bindings = list(before["stable"]["cilium"]["endpoints"].values()) + require(len(bindings) == len(before["actor"]["pods"]) == 1, UNAVAILABLE) + binding = bindings[0] + require(binding["podUid"] == before["actor"]["pods"][0]["uid"], UNAVAILABLE) + require(0 < uint(binding["endpointId"], 65535), UNAVAILABLE) + agent = before["facts"]["cilium"]["agent"] + network.metadata({"metadata": agent}) + return [ + "kubectl", "--context", "kind-bridge-native", "--request-timeout=100s", + "exec", "-i", "-n", "kube-system", agent["name"], "-c", "cilium-agent", "--", + "timeout", "--signal=TERM", "--kill-after=2s", "95s", + "sh", "-c", MONITOR_SCRIPT, "native-observer-monitor", str(binding["endpointId"]), + ], binding + + +class Window: + """Drain a narrow monitor continuously, retaining only bounded projections.""" + + def __init__(self, before): + self.result = empty_result() + self.started = datetime.now(timezone.utc) + self.ended = None + self.result["startedAt"] = self.started.isoformat() + self.clock = time.monotonic() + self.stop = threading.Event() + self.launched = threading.Event() + self.process = self.thread = None + self.before = before + self.closed = False + try: + arguments, self.binding = command_for(before) + self.destinations = {(item["address"], item["port"]) for item in before["facts"]["destinations"]} + self.process = subprocess.Popen(arguments, cwd=ROOT, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + start_new_session=True) + self.thread = threading.Thread(target=self._run, name="native-observer-monitor", daemon=True) + self.thread.start() + self.launched.wait(3) + except READ_ERRORS: + self.close() + + def _run(self): + process = self.process + pending = b"" + received = 0 + stopping_at = None + with selectors.DefaultSelector() as selector: + try: + selector.register(process.stdout, selectors.EVENT_READ) + while True: + now = time.monotonic() + if stopping_at is None and (self.stop.is_set() or now - self.clock >= MAX_SECONDS): + stopping_at = now + process.stdin.close() + if stopping_at is not None and now - stopping_at > 5: + break + ready = selector.select(0.1) + if not ready: + if process.poll() is not None: + break + continue + remaining = MAX_BYTES + MAX_LINE - received + if remaining <= 0: + break + chunk = os.read(process.stdout.fileno(), min(4096, remaining)) + if not chunk: + break + received += len(chunk) + if received > MAX_BYTES: + self.result["category"] = "byte-limit" + self.stop.set() + pending += chunk + if len(pending) > MAX_LINE and b"\n" not in pending: + self.result["category"] = "line-limit" + self.stop.set() + pending = b"" + continue + while b"\n" in pending: + line, pending = pending.split(b"\n", 1) + if line == b"native-monitor-started": + self.launched.set() + self.result.update(category="unobserved", localCleanup="pending", + remoteCleanup="pending") + elif re.fullmatch(rb"native-monitor-reaped:[0-9]{1,3}", line): + self.result["remoteCleanup"] = "exact-child-reaped" + self.result["monitorExitStatus"] = int(line.rsplit(b":", 1)[1]) + elif stopping_at is None and not self.stop.is_set() and len(line) <= MAX_LINE: + record = project_line(line, self.binding, self.destinations) + if record is not None: + elapsed = time.monotonic() - self.clock + if elapsed >= MAX_SECONDS: + self.stop.set() + continue + record["receivedAfterStartMs"] = round(elapsed * 1000) + self.result["events"].append(record) + self.result.update(available=True, category="positive-packet-evidence") + if len(self.result["events"]) >= MAX_EVENTS: + self.result["category"] = "event-limit" + self.stop.set() + else: + self.result["unmatchedOrInvalidLines"] = self.result.get("unmatchedOrInvalidLines", 0) + 1 + except (OSError, ValueError): + self.result["category"] = "stream-unavailable" + finally: + if pending: + self.result["partialLineDiscarded"] = True + self.result["bytesRead"] = received + self.ended = datetime.now(timezone.utc) + self.result["stoppedAt"] = self.ended.isoformat() + self.result["observedDurationMs"] = round((time.monotonic() - self.clock) * 1000) + self._reap() + + def _reap(self): + process = self.process + if process is None: + return + try: + if process.stdin and not process.stdin.closed: + process.stdin.close() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=1) + self.result["localCleanup"] = "reaped" + except (OSError, subprocess.SubprocessError): + self.result["localCleanup"] = "unverified" + finally: + if process.stdout: + process.stdout.close() + + def close(self): + if not self.closed: + self.closed = True + self.stop.set() + if self.thread is not None: + try: + self.thread.join(10) + except RuntimeError: + self._reap() + if self.thread.is_alive(): + self.result["localCleanup"] = "unverified" + else: + self._reap() + if self.result["remoteCleanup"] == "pending": + self.result["remoteCleanup"] = "unverified-remote-timeout-95s-plus-2s" + if not self.launched.is_set(): + self.result["category"] = "source-unavailable" + elif (self.result["category"] == "unobserved" + and self.result.get("monitorExitStatus") not in (143, 137)): + self.result["category"] = "source-unavailable" + return self.result + + def finish(self, setup, target, stable): + result = self.close() + result["provenanceUnchanged"] = stable + if not stable: + result.update(available=False, category="provenance-changed", events=[]) + return result + if not hasattr(self, "binding"): + return result + result["podUid"] = self.binding["podUid"] + result["endpointId"] = self.binding["endpointId"] + result["securityIdentity"] = self.binding["securityIdentity"] + result["routerContainerProcessUnchanged"] = True + result["configuredRouterUid"] = 1001 + result["requests"] = request_evidence(setup, target, self.before, self.started, + self.ended or datetime.now(timezone.utc)) + return result + + +def start(before): + return Window(before) + + +def request_evidence(setup, target, before, since, until): + """Record actual completions and authenticated arrivals, not inferred SYN requests.""" + result = {"routerCompletions": [], "freshPodBoundApiRequests": [], + "routerLogStatus": "unavailable", "auditStatus": "unavailable", + "completionDoesNotProveRequestStartedInWindow": True, + "packetToRequestCorrelation": "unavailable"} + try: + require(isinstance(since, datetime) and isinstance(until, datetime) + and since.tzinfo is not None and until.tzinfo is not None + and 0 <= (until - since).total_seconds() <= 110, UNAVAILABLE) + except READ_ERRORS: + return result + try: + pod = before["actor"]["pods"][0] + raw = subprocess.run( + ["kubectl", "--context", "kind-bridge-native", "--request-timeout=10s", "logs", + "-n", before["actor"]["namespace"], pod["name"], "-c", "inference-router", + "--tail=512", "--limit-bytes=131072", "--since-time=" + since.isoformat()], + cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=12, check=False) + require(raw.returncode == 0 and len(raw.stdout) <= 131072, UNAVAILABLE) + for line in raw.stdout.splitlines(): + if len(line) > MAX_LINE: + continue + try: + value = json.loads(line, object_pairs_hook=unique_object) + at = timestamp(value.get("timestamp")) + records = project_router(line.decode("utf8"), "router") + if at is not None and since <= at <= until and records and records[0]["stage"] == "observer_target_client": + result["routerCompletions"].append( + {"completedAfterStartMs": round((at - since).total_seconds() * 1000), **records[0]}) + except READ_ERRORS + (AttributeError, RecursionError, UnicodeError): + continue + result["routerCompletions"] = result["routerCompletions"][-12:] + result["routerLogStatus"] = "observed" if result["routerCompletions"] else "unobserved" + except READ_ERRORS: + pass + try: + actor = before["actor"] + rules = rules_for(setup, "observer_router", target, actor) + raw = read_audit_tail() + for line in raw.splitlines(): + if len(line) > MAX_LINE: + continue + try: + records = project_audit(line, actor, rules, since, until) + if records: + event = json.loads(line, object_pairs_hook=unique_object) + received = timestamp(event["requestReceivedTimestamp"]) + completed = timestamp(event["stageTimestamp"]) + result["freshPodBoundApiRequests"].append({ + "receivedAfterStartMs": round((received - since).total_seconds() * 1000), + "completedAfterStartMs": round((completed - since).total_seconds() * 1000), + "httpStatus": records[0]["http_status"]}) + except READ_ERRORS + (AttributeError, RecursionError): + continue + result["freshPodBoundApiRequests"] = result["freshPodBoundApiRequests"][-12:] + result["auditStatus"] = "observed" if result["freshPodBoundApiRequests"] else "unobserved" + except READ_ERRORS: + pass + return result diff --git a/bridge/tests/native-credentials/test_observer_network_diagnostics.py b/bridge/tests/native-credentials/test_observer_network_diagnostics.py index 9d999531c..f9fe911b3 100644 --- a/bridge/tests/native-credentials/test_observer_network_diagnostics.py +++ b/bridge/tests/native-credentials/test_observer_network_diagnostics.py @@ -13,6 +13,7 @@ from observation_diagnostics import VERSION import observer_network_diagnostics as network import observer_cilium_diagnostics as cilium +import observer_packet_diagnostics as packets from test_observation_diagnostics import Fixture, SANDBOX, RUNTIME, POD, DEPLOYMENT, REPLICA_SET TARGET = {"workspace": CORE, "sandbox": "agent", "uid": "sandbox-uid", "task": "native-observation-task"} @@ -181,6 +182,8 @@ def collect(self, outcomes=OUTCOMES, failed=FAILED, config=None, commands=None): patch.object(cilium, "read_projection", side_effect=self.api.read_projection), \ patch.object(cilium, "read_kube_proxy_projection", side_effect=self.api.read_kube_proxy_projection), \ patch.object(network, "api_outcomes", return_value=outcomes), \ + patch.object(packets, "start", return_value=SimpleNamespace( + finish=lambda *_args: packets.empty_result(), close=packets.empty_result)), \ patch.object(network.time, "sleep"): return network.collect(self.setup, TARGET, failed) diff --git a/bridge/tests/native-credentials/test_observer_packet_diagnostics.py b/bridge/tests/native-credentials/test_observer_packet_diagnostics.py new file mode 100644 index 000000000..398585b4d --- /dev/null +++ b/bridge/tests/native-credentials/test_observer_packet_diagnostics.py @@ -0,0 +1,422 @@ +import copy +from datetime import datetime, timedelta, timezone +import json +import os +import shlex +import subprocess +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import observer_cilium_diagnostics as cilium +import observer_network_diagnostics as network +import observer_packet_diagnostics as packets +from observation_diagnostics import CLIENT_FIELDS, CLIENT_TRANSPORT_FIELDS, TARGETS +from test_observer_network_diagnostics import NetworkFixture, TARGET +import test_observer_network_diagnostics as fixtures + + +# Representative monitor JSON generated by the v1.18.5 verbose structs, not +# Hubble's unrelated flow schema. L4 ports are strings, identities are integers. +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/datapath_drop.go +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/datapath_trace.go +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/dissect_test.go +# LayerString rendering (including tab and bool syntax) is defined here: +# https://github.com/cilium/cilium/blob/v1.18.5/vendor/github.com/gopacket/gopacket/packet.go +DROP = { + "cpu": "CPU 01:", "type": "drop", "mark": "0x1234", "reason": "Policy denied", + "source": 123, "bytes": 74, "srcLabel": 12345, "dstLabel": 7, "dstID": 0, + "Line": 1975, "File": 1, "ExtError": 0, "Ifindex": 8, + "summary": { + "ethernet": "Ethernet\t{Contents=[..14..] Payload=[..60..] SrcMAC=00:00:00:00:00:01 DstMAC=00:00:00:00:00:02 EthernetType=IPv4 Length=0}", + "ipv4": "IPv4\t{Contents=[..20..] Payload=[..40..] Version=4 IHL=5 TOS=0 Length=60 Id=1 Flags=DF FragOffset=0 TTL=64 Protocol=TCP Checksum=0 SrcIP=10.244.1.10 DstIP=10.96.0.1 Options=[] Padding=[]}", + "tcp": "TCP\t{Contents=[..40..] Payload=[] SrcPort=35000 DstPort=443(https) Seq=100 Ack=0 DataOffset=10 FIN=false SYN=true RST=false PSH=false ACK=false URG=false ECE=false CWR=false NS=false Window=64240 Checksum=0 Urgent=0 Options=[TCPOption(MSS:1460)] Padding=[]}", + "l2": {"src": "00:00:00:00:00:01", "dst": "00:00:00:00:00:02"}, + "l3": {"src": "10.244.1.10", "dst": "10.96.0.1"}, + "l4": {"src": "35000", "dst": "443"}, + }, +} +TRACE = { + "cpu": "CPU 01:", "type": "trace", "mark": "0x1234", "ifindex": "eth0", "state": "new", + "observationPoint": "to-stack", "traceSummary": "-> stack", "source": 123, + "bytes": 74, "srcLabel": 12345, "dstLabel": 7, "dstID": 0, "summary": DROP["summary"], +} +# format.go calls PolicyVerdictNotify.DumpInfo even when --json is selected. +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/format/format.go +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/monitor/datapath_policy.go +VERDICT = ("Policy verdict log: flow 0x1234 local EP ID 123, remote ID 7, proto 6, " + "egress, action allow, auth: disabled, match L3-L4, " + "10.244.1.10:35000 -> 172.18.0.3:6443 tcp SYN") +BINDING = {"podUid": "agent-pod-uid", "endpointId": 123, "securityIdentity": 12345, + "addresses": ["10.244.1.10"]} +DESTINATIONS = {("10.96.0.1", 443), ("172.18.0.3", 6443)} + + +class PacketProjectionTests(unittest.TestCase): + def project(self, event): + raw = event.encode() if isinstance(event, str) else json.dumps(event).encode() + return packets.project_line(raw, BINDING, DESTINATIONS) + + def test_real_mixed_json_and_policy_text_schema(self): + drop, trace, verdict = map(self.project, (DROP, TRACE, VERDICT)) + self.assertEqual(drop["verdict"], "drop") + self.assertEqual(drop["reason"], "policy-denied") + self.assertEqual(drop["tcpFlags"], ["SYN"]) + self.assertEqual((drop["bpfFile"], drop["bpfLine"], drop["ifindex"]), (1, 1975, 8)) + self.assertEqual(trace["observationPoint"], "to-stack") + self.assertEqual(trace["verdict"], "trace-not-a-policy-verdict") + self.assertEqual(verdict["verdict"], "allow") + self.assertEqual(verdict["apiAddress"], "172.18.0.3") + + def test_policy_audit_and_redirect_are_not_allows(self): + for action in ("deny", "redirect", "audit"): + self.assertEqual(self.project(VERDICT.replace("action allow", "action " + action))["verdict"], action) + self.assertIsNone(self.project(VERDICT.replace("egress", "ingress"))) + self.assertIsNone(self.project({"type": "policy-verdict", "verdict": "allow"})) + + def test_return_path_is_separately_pinned(self): + event = copy.deepcopy(TRACE) + event.update(source=0, dstID=123, srcLabel=7, dstLabel=12345, state="reply", observationPoint="to-endpoint") + event["summary"]["l3"] = {"src": "172.18.0.3", "dst": "10.244.1.10"} + event["summary"]["l4"] = {"src": "6443", "dst": "35000"} + event["summary"]["tcp"] = event["summary"]["tcp"].replace("ACK=false", "ACK=true") + self.assertEqual(self.project(event)["direction"], "ingress") + self.assertEqual(self.project(event)["tcpFlags"], ["SYN", "ACK"]) + event["dstLabel"] = 54321 + self.assertIsNone(self.project(event)) + + def test_unrelated_endpoints_identities_targets_and_ambiguous_direction_rejected(self): + for key, value in (("source", 124), ("srcLabel", 54321), ("source", True), ("dstID", 2**32)): + event = copy.deepcopy(DROP) + event[key] = value + self.assertIsNone(self.project(event)) + for address, port in (("172.18.0.99", "6443"), ("10.96.0.1", "6443"), + ("169.254.169.254", "443"), ("10.96.0.1", 443), ("10.96.0.1", "0")): + event = copy.deepcopy(DROP) + event["summary"]["l3"]["dst"] = address + event["summary"]["l4"]["dst"] = port + self.assertIsNone(self.project(event)) + self.assertIsNone(self.project(VERDICT.replace("ID 123,", "ID 124,"))) + + def test_canaries_are_never_retained(self): + event = copy.deepcopy(DROP) + event.update(reason="private-reason-canary", mark="private-mark-canary", + cpu="private-cpu-canary", payload="Authorization: Bearer private-token-canary") + event["summary"]["tcp"] = event["summary"]["tcp"].replace("Payload=[]", "Payload=[private-body-canary]") + event["summary"]["ipv4"] = "private-ip-string-canary" + result = self.project(event) + self.assertEqual(result["reason"], "other-or-unavailable") + self.assertNotIn("canary", json.dumps(result)) + self.assertNotIn("summary", result) + self.assertIsNone(self.project(VERDICT + " private-payload-canary")) + + def test_incomplete_duplicate_malformed_and_foreign_schema_are_unobserved(self): + for raw in (b'{"type":', b"\xff", b'null', b'[]', b'{"type":"drop","type":"trace"}', + b'{"flow":{"verdict":"FORWARDED"}}', b"CPU 01: Lost 12 events"): + self.assertIsNone(packets.project_line(raw, BINDING, DESTINATIONS)) + for edit in ( + lambda value: value.pop("srcLabel"), + lambda value: value["summary"].pop("tcp"), + lambda value: value["summary"].update(tunnel={}), + lambda value: value["summary"].update(udp="private"), + lambda value: value["summary"].update(tcp="TCP\t{SYN=true}"), + lambda value: value["summary"].update(tcp=value["summary"]["tcp"] + " SYN=false"), + lambda value: value.update(summary=[]), + ): + event = copy.deepcopy(DROP) + edit(event) + self.assertIsNone(self.project(event)) + + +class PacketWindowTests(unittest.TestCase): + def setUp(self): + self.api = NetworkFixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + with patch.object(network, "selected_origin", return_value=("127.0.0.1", 36443)), \ + patch.object(cilium, "read_projection", side_effect=self.api.read_projection), \ + patch.object(cilium, "read_kube_proxy_projection", side_effect=self.api.read_kube_proxy_projection): + self.before = cilium.snapshot(self.setup, TARGET) + + def window(self, source): + command = [sys.executable, "-u", "-c", source] + with patch.object(packets, "command_for", return_value=(command, BINDING)): + return packets.Window(self.before) + + def source(self, lines, count=1): + return ( + "import sys\n" + "print('native-monitor-started', flush=True)\n" + f"sys.stdout.buffer.write({lines!r} * {count}); sys.stdout.buffer.flush()\n" + "sys.stdin.buffer.read()\n" + "print('\\nnative-monitor-reaped:143', flush=True)\n" + ) + + def test_command_has_only_narrow_read_only_events_and_remote_watchdog(self): + command, binding = packets.command_for(self.before) + self.assertEqual(binding["endpointId"], 123) + self.assertIn("--related-to \"$1\"", command[-3]) + self.assertIn("--json --numeric --type drop --type trace --type policy-verdict", command[-3]) + self.assertIn("95s", command) + self.assertIn("--kill-after=2s", command) + for unsafe in ("--hex", "--verbose", "--type l7", "--type capture", "tcpdump", "curl"): + self.assertNotIn(unsafe, " ".join(command)) + self.assertEqual(command[-1], "123") + + def test_real_pipe_mixed_wire_cleanup_and_idempotence(self): + lines = json.dumps(DROP).encode() + b"\n" + json.dumps(TRACE).encode() + b"\n" + window = self.window(self.source(lines)) + try: + window.thread.join(0.05) + finally: + result = window.close() + self.assertEqual(len(result["events"]), 2) + self.assertTrue(result["available"]) + self.assertEqual(result["remoteCleanup"], "exact-child-reaped") + self.assertEqual(result["localCleanup"], "reaped") + self.assertIsNotNone(window.process.poll()) + self.assertEqual(result["freshRouterRequest"], "unavailable") + self.assertIs(result, window.close()) + + def test_actual_remote_shell_reaps_its_exact_child(self): + script = packets.MONITOR_SCRIPT + invocation = next(line for line in script.splitlines() if line.startswith("cilium-dbg ")) + python = shlex.quote(sys.executable) + source = shlex.quote("import time; time.sleep(60)") + script = script.replace(invocation, f"{python} -c {source} &") + with patch.object(packets, "command_for", return_value=(["sh", "-c", script, "test", "123"], BINDING)): + window = packets.Window(self.before) + result = window.close() + self.assertEqual(result["remoteCleanup"], "exact-child-reaped") + self.assertEqual(result["localCleanup"], "reaped") + self.assertEqual(result["monitorExitStatus"], 143) + + def test_unobserved_unavailable_and_partial_are_distinct(self): + for source, category in ( + (self.source(b""), "unobserved"), + ("import sys; sys.exit(127)", "source-unavailable"), + ): + window = self.window(source) + result = window.close() + self.assertEqual(result["category"], category) + self.assertFalse(result["available"]) + window = self.window("print('native-monitor-started', flush=True)\nprint('{\"type\":', end='', flush=True)") + window.thread.join(1) + result = window.close() + self.assertTrue(result["partialLineDiscarded"]) + self.assertFalse(result["available"]) + + def test_byte_line_and_event_limits_stop_and_reap(self): + for lines, count, limit in ( + (b"private-long-line-canary", 1000, "line-limit"), + (json.dumps(DROP).encode() + b"\n", 110, "event-limit"), + (b"private-unrelated-canary" * 100 + b"\n", 150, "byte-limit"), + ): + original_read, read_sizes = os.read, [] + def counted_read(fd, size): + data = original_read(fd, size) + read_sizes.append(len(data)) + return data + with patch.object(packets.os, "read", side_effect=counted_read): + window = self.window(self.source(lines, count)) + window.thread.join(2) + result = window.close() + self.assertEqual(result["category"], limit) + self.assertLessEqual(len(result["events"]), packets.MAX_EVENTS) + self.assertLessEqual(result["bytesRead"], packets.MAX_BYTES + packets.MAX_LINE) + self.assertEqual(sum(read_sizes), result["bytesRead"]) + self.assertNotIn("canary", json.dumps(result)) + self.assertIsNotNone(window.process.poll()) + + def test_wall_clock_bound_stops_idle_source(self): + # A late record flushed during the cleanup drain must not become evidence. + source = ( + "import sys\n" + "print('native-monitor-started', flush=True)\n" + "sys.stdin.buffer.read()\n" + f"print({json.dumps(DROP)!r}, flush=True)\n" + "print('native-monitor-reaped:143', flush=True)\n") + with patch.object(packets, "MAX_SECONDS", 0.1): + window = self.window(source) + window.thread.join(2) + self.assertFalse(window.thread.is_alive()) + self.assertEqual(window.close()["remoteCleanup"], "exact-child-reaped") + self.assertEqual(window.close()["events"], []) + + def test_unresponsive_child_is_killed_locally_without_claiming_remote_cleanup(self): + source = ("import time\n" + "print('native-monitor-started', flush=True)\n" + "time.sleep(60)\n") + window = self.window(source) + result = window.close() + self.assertFalse(window.thread.is_alive()) + self.assertIsNotNone(window.process.poll()) + self.assertEqual(result["localCleanup"], "reaped") + self.assertEqual(result["remoteCleanup"], "unverified-remote-timeout-95s-plus-2s") + self.assertFalse(result["available"]) + + def test_stale_provenance_discards_all_positive_packet_evidence(self): + window = self.window(self.source(json.dumps(DROP).encode() + b"\n")) + window.thread.join(0.05) + result = window.finish(self.setup, TARGET, False) + self.assertFalse(result["available"]) + self.assertEqual(result["events"], []) + self.assertEqual(result["category"], "provenance-changed") + + def test_failed_launch_has_no_exception_or_process_evidence(self): + with patch.object(packets.subprocess, "Popen", side_effect=OSError("private-error-canary")): + window = packets.Window(self.before) + with patch.object(packets, "request_evidence", return_value={}): + result = window.finish(self.setup, TARGET, True) + self.assertFalse(result["available"]) + self.assertNotIn("canary", json.dumps(result)) + + +class PacketRequestTests(unittest.TestCase): + def setUp(self): + api = NetworkFixture() + self.setup = SimpleNamespace(admin=api, cluster={"server": api.server}) + self.before = network.snapshot(self.setup, TARGET) + self.since = datetime(2026, 9, 14, 10, 0, tzinfo=timezone.utc) + self.until = self.since + timedelta(seconds=60) + actor = self.before["actor"] + self.log = { + "timestamp": (self.since + timedelta(seconds=30)).isoformat(), + "target": TARGETS["router"], "private": "private-log-canary", + "fields": {"message": "Private observation target client pending", "stage": "observer_target_client", + "http_status": 0, **{key: True for key in CLIENT_FIELDS + CLIENT_TRANSPORT_FIELDS}, + "private": "private-body-canary"}, + } + self.audit = { + "level": "Metadata", "stage": "ResponseComplete", + "requestReceivedTimestamp": (self.since + timedelta(seconds=5)).isoformat(), + "stageTimestamp": (self.since + timedelta(seconds=6)).isoformat(), + "user": {"username": actor["username"], "uid": actor["serviceAccountUid"], + "extra": {"authentication.kubernetes.io/pod-uid": [actor["pods"][0]["uid"]], + "authentication.kubernetes.io/pod-name": [actor["pods"][0]["name"]]}}, + "objectRef": {"apiGroup": "kars.azure.com", "apiVersion": "v1alpha1", + "resource": "karssandboxes", "namespace": TARGET["workspace"], + "name": TARGET["sandbox"], "uid": TARGET["uid"]}, + "verb": "get", "responseStatus": {"code": 403, "message": "private-status-canary"}, + "requestURI": "private-uri-canary", "annotations": {"private": "private-audit-canary"}, + } + + def collect(self, logs=None, audits=None): + logs = [self.log] if logs is None else logs + audits = [self.audit] if audits is None else audits + with patch.object(packets.subprocess, "run", return_value=SimpleNamespace( + returncode=0, stdout=b"\n".join(json.dumps(value).encode() for value in logs))), \ + patch.object(packets, "read_audit_tail", return_value=b"\n".join( + json.dumps(value).encode() for value in audits)): + return packets.request_evidence(self.setup, TARGET, self.before, self.since, self.until) + + def test_actual_completion_and_fresh_authenticated_request_have_separate_timing(self): + result = self.collect() + self.assertEqual(result["routerLogStatus"], "observed") + self.assertEqual(result["routerCompletions"][0]["completedAfterStartMs"], 30000) + self.assertEqual(result["freshPodBoundApiRequests"], [{ + "receivedAfterStartMs": 5000, "completedAfterStartMs": 6000, "httpStatus": 403}]) + self.assertEqual(result["packetToRequestCorrelation"], "unavailable") + self.assertTrue(result["completionDoesNotProveRequestStartedInWindow"]) + self.assertNotIn("canary", json.dumps(result)) + + def test_stale_future_and_partial_router_records_never_prove_fresh_request(self): + for mutation in ( + lambda value: value.pop("timestamp"), + lambda value: value.update(timestamp=(self.since - timedelta(seconds=1)).isoformat()), + lambda value: value.update(timestamp=(self.until + timedelta(seconds=1)).isoformat()), + lambda value: value["fields"].pop("request_built"), + lambda value: value["fields"].update(tcp_connect_started="true"), + ): + value = copy.deepcopy(self.log) + mutation(value) + result = self.collect(logs=[value], audits=[]) + self.assertEqual(result["routerLogStatus"], "unobserved") + self.assertEqual(result["auditStatus"], "unobserved") + self.assertEqual(result["packetToRequestCorrelation"], "unavailable") + + def test_stale_or_unbound_audit_requests_are_unobserved_not_denied(self): + for mutation in ( + lambda value: value.update(requestReceivedTimestamp=(self.since - timedelta(seconds=1)).isoformat()), + lambda value: value.update(stageTimestamp=(self.until + timedelta(seconds=1)).isoformat()), + lambda value: value.update(stage="RequestReceived"), + lambda value: value["user"].update(uid="replacement"), + lambda value: value["user"]["extra"].update({"authentication.kubernetes.io/pod-uid": ["foreign"]}), + lambda value: value["objectRef"].update(uid="replacement"), + lambda value: value["objectRef"].update(name="foreign"), + lambda value: value.update(impersonatedUser={"username": "foreign"}), + ): + value = copy.deepcopy(self.audit) + mutation(value) + result = self.collect(logs=[], audits=[value]) + self.assertEqual(result["auditStatus"], "unobserved") + self.assertEqual(result["freshPodBoundApiRequests"], []) + + def test_request_bounds_and_unavailable_sources(self): + result = self.collect(logs=[self.log] * 100, audits=[self.audit] * 100) + self.assertLessEqual(len(result["routerCompletions"]), 12) + self.assertLessEqual(len(result["freshPodBoundApiRequests"]), 12) + self.until = self.since + timedelta(seconds=111) + result = self.collect() + self.assertEqual(result["routerLogStatus"], "unavailable") + self.assertEqual(result["auditStatus"], "unavailable") + with patch.object(packets.subprocess, "run", side_effect=OSError("private-canary")), \ + patch.object(packets, "read_audit_tail", side_effect=OSError("private-canary")): + result = packets.request_evidence(self.setup, TARGET, self.before, self.since, + self.since + timedelta(seconds=60)) + self.assertEqual(result["routerLogStatus"], "unavailable") + self.assertEqual(result["auditStatus"], "unavailable") + self.assertNotIn("canary", json.dumps(result)) + + +class PacketIntegrationTests(unittest.TestCase): + def test_existing_policy_experiment_survives_missing_monitor_and_keeps_failure(self): + fixture = fixtures.ObserverNetworkTests() + fixture.setUp() + result = fixture.collect() + self.assertEqual(result["originalResult"], "failed") + self.assertFalse(result["cniAcceptanceQualified"]) + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertFalse(result["baselinePackets"]["available"]) + self.assertFalse(result["policyWindowPackets"]["available"]) + + def test_monitors_bracket_existing_policy_and_cleanup_even_after_stability_failure(self): + for mutation in (False, True): + fixture = fixtures.ObserverNetworkTests() + fixture.setUp() + order = [] + windows = [] + def start(_before): + label = len(windows) + order.append(("start", label)) + window = SimpleNamespace( + finish=lambda *_args: packets.empty_result(), + close=lambda: order.append(("close", label)) or packets.empty_result()) + windows.append(window) + return window + def created(): + order.append(("create", None)) + if mutation: + fixture.api.policy_revisions = [15, 15] + fixture.api.objects[fixtures.POD]["status"]["containerStatuses"][0]["restartCount"] = 1 + fixture.api.after_create = created + with patch.dict(os.environ, fixtures.ENV), \ + patch.object(network, "command", side_effect=lambda *args, **_kwargs: + json.dumps(fixtures.redacted_context()) if args[0] == "kubectl" else "bridge-native"), \ + patch.object(cilium, "read_projection", side_effect=fixture.api.read_projection), \ + patch.object(cilium, "read_kube_proxy_projection", side_effect=fixture.api.read_kube_proxy_projection), \ + patch.object(network, "api_outcomes", return_value=fixtures.OUTCOMES), \ + patch.object(network.time, "sleep"), patch.object(packets, "start", side_effect=start): + result = network.collect(fixture.setup, TARGET, copy.deepcopy(fixtures.FAILED)) + self.assertEqual(order[:3], [("start", 0), ("start", 1), ("create", None)]) + self.assertIn(("close", 0), order) + self.assertIn(("close", 1), order) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(result["originalResult"], "failed") + if mutation: + self.assertFalse(result["policyWindowPackets"]["provenanceUnchanged"]) + + +if __name__ == "__main__": + unittest.main() From 5dec50497857e1af0ea2a6220e1b230da88b78e3 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 10:58:23 +0200 Subject: [PATCH 088/111] test(ci): run native diagnostic contracts before cluster qualification Require the full native diagnostic unittest inventory in the existing scope job and assert that wiring in CLI workflow contracts. Keep real API and runtime acceptance mandatory; unit diagnostics do not qualify live behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-native.yml | 2 ++ bridge/docs/governed-credentials.md | 4 ++++ cli/src/lib/bridge-contract-ci.test.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml index 4039c14ef..be2ccb96a 100644 --- a/.github/workflows/bridge-native.yml +++ b/.github/workflows/bridge-native.yml @@ -40,6 +40,8 @@ jobs: persist-credentials: false - name: Verify contract scope and aggregate behavior run: python3 -m unittest discover -s ci/tests -p bridge_contracts_test.py + - name: Verify native diagnostic contracts + run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s bridge/tests/native-credentials -p 'test_*.py' - id: scope run: | python3 ci/bridge_contracts.py \ diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 7fb3d0b1c..3168a740d 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -278,6 +278,10 @@ template comparisons. Missing fields, duplicates, extra fields and non-booleans make the group unavailable. Compared values and hashes are never copied. The original refusal and all production deadlines remain unchanged. +The native workflow runs all `test_*.py` diagnostic contracts in its scope job +before starting the cluster lanes. These parser, provenance and cleanup tests +must pass; they do not substitute for the live API and runtime acceptance jobs. + `observer_target_client` optionally carries an atomic group of five booleans: `transport_debug_observable`, `transport_trace_observable`, `tcp_connect_started`, `tcp_connected`, and `http_handshake_complete`. diff --git a/cli/src/lib/bridge-contract-ci.test.ts b/cli/src/lib/bridge-contract-ci.test.ts index 719c6fdb4..37bb128b6 100644 --- a/cli/src/lib/bridge-contract-ci.test.ts +++ b/cli/src/lib/bridge-contract-ci.test.ts @@ -78,6 +78,9 @@ describe("permanent core and Bridge CI boundary", () => { expect(mapping(scope.outputs).required).toBe("${{ steps.scope.outputs.required }}"); const steps = jobSteps(native, "contract-scope"); expect(steps.some(step => String(step.run).includes("ci/bridge_contracts.py"))).toBe(true); + const diagnostics = steps.find(step => step.name === "Verify native diagnostic contracts"); + expect(diagnostics?.run).toBe("PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s bridge/tests/native-credentials -p 'test_*.py'"); + expect(diagnostics?.["continue-on-error"]).toBeUndefined(); const checkout = steps.find(step => String(step.uses).startsWith("actions/checkout@")); expect(mapping(checkout?.with)["fetch-depth"]).toBe(0); const api = mapping(mapping(native.jobs)["api-admission"]); From 49b78c80a8ae1ef53b4e9595195bfc59d1545886 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 11:45:43 +0200 Subject: [PATCH 089/111] test(bridge): select effective Cilium identities for observer diagnostics Do not copy pod-template-hash into a Cilium endpoint selector: the pinned Cilium release excludes it from security identity labels. Verify source-qualified sandbox and namespace labels against CEP and agent identities, while retaining complete rollout, Pod UID, process and inventory fences. Reject old, foreign, orphaned, duplicate, paginated and changed consumers. Production policy and native acceptance remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 18 +- .../observer_cilium_diagnostics.py | 71 +++++- .../observer_network_diagnostics.py | 34 ++- .../test_observer_cilium_diagnostics.py | 2 +- .../test_observer_cilium_selector.py | 202 ++++++++++++++++++ .../test_observer_network_diagnostics.py | 17 +- 6 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 bridge/tests/native-credentials/test_observer_cilium_selector.py diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 3168a740d..62512d2b1 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -400,7 +400,8 @@ One CREATE-only, Deployment-owned **namespace-scoped CiliumNetworkPolicy** permits only `toEntities: [kube-apiserver]` and the observed TCP HTTPS ports 443/6443, as described by the [tagged entity semantics](https://github.com/cilium/cilium/blob/v1.18.5/Documentation/security/policy/language.rst#L247-L288). -It uses the actual Sandbox and Pod-template-hash labels, never an empty/global +It uses only the source-qualified `k8s:kars.azure.com/sandbox` and +`k8s:io.kubernetes.pod.namespace` identity labels, never an empty/global selector, `host`, `remote-node`, `cluster`, `world`, `toServices`, or a global Cilium configuration change. An observer without existing matching Kubernetes egress isolation gets no new policy. A 60-second observation deadline bounds @@ -411,6 +412,21 @@ progress; observer readiness is not required. The policy is removed with its captured UID and current resourceVersion, and absence is verified. Replaced namespaces or policy objects are not deleted; unverified cleanup is explicit. +The diagnostic rollout selector remains separate: Cilium 1.18.5 +[excludes `pod-template-hash` from identity labels by default](https://github.com/cilium/cilium/blob/v1.18.5/pkg/labelsfilter/filter.go). +Copying that Kubernetes Pod predicate into a CNP can therefore select no +endpoint. Before creating the corrected diagnostic CNP, the collector requires +its two labels in the exact Pod-owned CEP identity and verifies the entire +identity-label digest against the pinned agent's `endpoint get` projection. +It keeps the full current rollout hash, Deployment, Pod UID, process and API +fences. All namespace Pods carrying the Sandbox label—not only the current +rollout—and all CNP-matching CEPs must represent exactly the captured consumers. +Missing, duplicate, foreign, old-rollout or changed matching consumers fail +closed; inventories are rechecked before and during intervention, and detected +changes trigger owned-policy cleanup. This is a snapshot/recheck fence, not an +atomic guarantee against future Pod creation. Only the validated selector and +proof booleans enter evidence; arbitrary identity-label strings do not. + Failure-only `baselinePackets` observes 30 seconds before the existing policy experiment; `policyWindowPackets` attempts to start before its CREATE. Both use the pinned node agent's read-only `cilium-dbg monitor --related-to <endpoint>` diff --git a/bridge/tests/native-credentials/observer_cilium_diagnostics.py b/bridge/tests/native-credentials/observer_cilium_diagnostics.py index e7de69095..75e605838 100644 --- a/bridge/tests/native-credentials/observer_cilium_diagnostics.py +++ b/bridge/tests/native-credentials/observer_cilium_diagnostics.py @@ -25,7 +25,8 @@ r'{[0].status.policy.realized.policy-revision}{"\t"}' r'{[0].status.policy.realized.policy-enabled}{"\t"}' r'{[0].status.external-identifiers.k8s-namespace}{"\t"}' - r'{[0].status.external-identifiers.k8s-pod-name}{"\n"}' + r'{[0].status.external-identifiers.k8s-pod-name}{"\t"}' + r'{[0].status.identity.labels}{"\n"}' ) UNAVAILABLE = "Cilium diagnostic provenance unavailable" STAGES = frozenset(""" @@ -37,6 +38,7 @@ endpoint_read endpoint_identity endpoint_owner endpoint_fields endpoint_addresses endpoint_pins endpoint_exec endpoint_framing endpoint_projection_fields endpoint_projection_pins endpoint_revision_bounds endpoint_recheck endpoint_snapshot_match cnp_list_read cnp_inventory cnp_identity cnp_digest +endpoint_labels endpoint_label_projection endpoint_inventory_read endpoint_inventory anchor_read anchor_identity cnp_recheck network_recheck complete """.split()) FACT_KEYS = frozenset(""" @@ -54,6 +56,7 @@ numericFieldsValid endpointIdMatches securityIdMatches policyModeRecognized namespaceFieldMatches podFieldMatches realizedAheadOfDesired desiredRevisionValid realizedRevisionValid bindingMatches specShape specsShape managedFieldsShape authorityMatches anchorKind configurationMatches +labelsShape labelsMatch selectorMatchesIdentity inventoryMatches """.split()) FACT_VALUES = frozenset(""" object array null string number boolean other missing empty true false json-null @@ -220,6 +223,21 @@ def policies(setup, namespace, temporary_name, witness=None): return result +def identity_labels(raw): + """Cilium v1.18.5 Identity.Labels / EndpointIdentity.Labels wire strings.""" + values = network.bounded_items(raw, 128) + require(values, UNAVAILABLE) + labels = {} + for value in values: + require(isinstance(value, str) and 0 < len(value) <= 512 + and not any(character.isspace() or ord(character) < 32 for character in value), UNAVAILABLE) + key, _, text = value.partition("=") + require(re.fullmatch(r"[a-z][a-z0-9-]*:[A-Za-z0-9_.:/-]{1,253}", key) + and key not in labels, UNAVAILABLE) + labels[key] = text + return labels + + def endpoint_binding(endpoint, pod, agent, witness=None): checkpoint(witness, "endpoint_identity", **identity_facts(endpoint)) metadata = network.metadata(endpoint) @@ -270,8 +288,42 @@ def endpoint_binding(endpoint, pod, agent, witness=None): require(addresses and addresses == pod_addresses and node_ip == network.private_ip(pod["status"]["hostIP"]) == network.private_ip(agent["status"]["hostIP"]), UNAVAILABLE) + checkpoint(witness, "endpoint_labels", labelsShape=shape(status["identity"].get("labels"))) + labels = identity_labels(status["identity"].get("labels")) + expected_labels = { + "k8s:" + network.SANDBOX_LABEL: pod["metadata"]["labels"][network.SANDBOX_LABEL], + "k8s:io.kubernetes.pod.namespace": pod["metadata"]["namespace"], + } + selected = all(labels.get(key) == value for key, value in expected_labels.items()) + checkpoint(witness, "endpoint_labels", selectorMatchesIdentity=selected) + require(selected, UNAVAILABLE) return {"uid": metadata["uid"], "podUid": identity(pod)[0], "endpointId": endpoint_id, - "securityIdentity": security_id, "nodeAddress": node_ip, "addresses": addresses} + "securityIdentity": security_id, "nodeAddress": node_ip, "addresses": addresses, + "identityLabelsDigest": network.spec_digest(labels)} + + +def endpoint_inventory(setup, base, agent, bindings, witness): + """Require every CNP-selected CEP to represent one of the exact current Pods.""" + path = resource(base["actor"]["namespace"], "ciliumendpoints", group=CILIUM) + listed = api_read(setup, path, witness, "endpoint_inventory_read") + values = network.complete_inventory(listed, 64) + selected = {} + pods = {item["name"]: base["actor"]["anchors"][ + core(base["actor"]["namespace"], "pods", item["name"])] for item in base["actor"]["pods"]} + selector = network.cilium_selector(base) + for endpoint in values: + metadata = network.metadata(endpoint) + require(metadata.get("namespace") == base["actor"]["namespace"], UNAVAILABLE) + labels = identity_labels(endpoint["status"]["identity"].get("labels")) + if not network.matches(selector, labels): + continue + name = metadata["name"] + require(name in pods, UNAVAILABLE) + endpoint_path = resource(base["actor"]["namespace"], "ciliumendpoints", name, CILIUM) + require(endpoint_path not in selected, UNAVAILABLE) + selected[endpoint_path] = endpoint_binding(endpoint, pods[name], agent, witness) + checkpoint(witness, "endpoint_inventory", count=len(selected), inventoryMatches=selected == bindings) + require(selected == bindings, UNAVAILABLE) def endpoint_revision(fields, binding, pod, witness=None): @@ -440,7 +492,13 @@ def read(path, kind): binding = endpoint_binding(endpoint, pod, agent, witness) checkpoint(witness, "endpoint_exec", exitStatus=None, timedOut=False) fields = read_projection(agent, binding["endpointId"], witness=witness) - revision = endpoint_revision(fields, binding, pod, witness) + revision = endpoint_revision(fields[:7], binding, pod, witness) + checkpoint(witness, "endpoint_label_projection", fieldCount=len(fields)) + require(len(fields) == 8, UNAVAILABLE) + labels = identity_labels(json.loads(fields[7])) + labels_match = network.spec_digest(labels) == binding["identityLabelsDigest"] + checkpoint(witness, "endpoint_label_projection", labelsMatch=labels_match) + require(labels_match, UNAVAILABLE) expected &= revision["policyEnabled"] in ("egress", "both") current = api_read(setup, path, witness, "endpoint_recheck") current_binding = endpoint_binding(current, pod, agent, witness) @@ -448,6 +506,7 @@ def read(path, kind): require(current_binding == binding, UNAVAILABLE) bindings[path] = binding endpoints.append({"identity": network.metadata(current), "podUid": item["uid"], **revision}) + endpoint_inventory(setup, base, agent, bindings, witness) baseline = policies(setup, base["actor"]["namespace"], temporary_name, witness) policy_identities = {} policy_facts = [] @@ -476,16 +535,20 @@ def read(path, kind): "configMap": desired_config, "effectiveAgentConfig": effective_config, "configurationMatchesExpected": expected, "agent": network.metadata(agent), "daemonSet": network.metadata(daemonset), "endpoints": endpoints, + "endpointSelector": network.cilium_selector(base), + "selectorIdentityLabelsVerified": True, "matchingEndpointInventoryVerified": True, + "podTemplateHashUsedOnlyForPodProvenance": True, "baselineCiliumNetworkPolicies": policy_facts, "policyRevisionIsNotRuleSpecificProof": True} base["stable"]["cilium"] = { "anchors": {path: identity(value) for path, value in anchors.items()}, "configDigest": network.spec_digest(config.get("data")), "effectiveConfig": effective_config, "agentProcess": (statuses[0]["containerID"], statuses[0]["restartCount"]), - "endpoints": bindings, "policies": policy_identities} + "endpoints": bindings, "endpointSelector": network.cilium_selector(base), "policies": policy_identities} checkpoint(witness, "network_recheck") current_base = network.snapshot(setup, target) require(current_base["stable"] == { key: value for key, value in base["stable"].items() if key != "cilium"}, UNAVAILABLE) + endpoint_inventory(setup, base, agent, bindings, witness) base["ready"] = current_base["ready"] base["actor"] = current_base["actor"] if witness is not None: diff --git a/bridge/tests/native-credentials/observer_network_diagnostics.py b/bridge/tests/native-credentials/observer_network_diagnostics.py index fad52e927..f70221499 100644 --- a/bridge/tests/native-credentials/observer_network_diagnostics.py +++ b/bridge/tests/native-credentials/observer_network_diagnostics.py @@ -25,6 +25,7 @@ API_SERVICE = core("default", "services", "kubernetes") API_ENDPOINTS = core("default", "endpoints", "kubernetes") SELECTOR_KEYS = ("kars.azure.com/sandbox", "pod-template-hash") +SANDBOX_LABEL = SELECTOR_KEYS[0] def loopback_origin(server): @@ -101,6 +102,16 @@ def bounded_items(value, maximum): return value +def complete_inventory(value, maximum): + require(isinstance(value, dict) and isinstance(value.get("metadata", {}), dict), UNAVAILABLE) + metadata = value.get("metadata", {}) + remaining = metadata.get("remainingItemCount") + continuation = metadata.get("continue", "") + require(isinstance(continuation, str) and not continuation + and (remaining is None or (type(remaining) is int and remaining == 0)), UNAVAILABLE) + return bounded_items(value["items"], maximum) + + def metadata(value): identity(value) result = {key: value["metadata"][key] for key in ("name", "uid", "resourceVersion")} @@ -275,8 +286,12 @@ def snapshot(setup, target, temporary_name=None): and statuses[0].get("ready") is True and statuses[0].get("containerID") and type(statuses[0].get("restartCount")) is int, UNAVAILABLE) pod_processes.append((identity(pod)[0], statuses[0]["containerID"], statuses[0]["restartCount"])) - all_pods = bounded_items(setup.admin.get(core(namespace, "pods"))["items"], 64) - consumers = [pod for pod in all_pods if matches(selector, pod["metadata"].get("labels", {}))] + all_pods = complete_inventory(setup.admin.get(core(namespace, "pods")), 64) + # The CNP uses identity-relevant labels; the rollout hash remains a separate + # provenance fence. Count old/foreign rollouts that the CNP could also select. + consumers = [pod for pod in all_pods if matches( + {"matchLabels": {SANDBOX_LABEL: selector["matchLabels"][SANDBOX_LABEL]}}, + pod["metadata"].get("labels", {}))] require(sorted(identity(pod)[0] for pod in consumers) == sorted(item["uid"] for item in actor["pods"]), "Diagnostic selector has unknown or foreign consumers") require(all(pod["metadata"].get("namespace") == namespace @@ -316,17 +331,27 @@ def snapshot(setup, target, temporary_name=None): "ready": source["status"]["serviceObservation"]["phase"] == "Ready"} +def cilium_selector(before): + return {"matchLabels": { + "k8s:" + SANDBOX_LABEL: before["selector"]["matchLabels"][SANDBOX_LABEL], + "k8s:io.kubernetes.pod.namespace": before["actor"]["namespace"], + }} + + def policy_plan(before, name): ports = sorted({target["port"] for target in before["facts"]["destinations"]}) require(ports == [443, 6443] and all(type(target["port"]) is int for target in before["facts"]["destinations"]), UNAVAILABLE) + selector = cilium_selector(before) + require(before["facts"]["cilium"]["endpointSelector"] == selector + and before["stable"]["cilium"]["endpointSelector"] == selector, UNAVAILABLE) return {"apiVersion": "cilium.io/v2", "kind": "CiliumNetworkPolicy", "metadata": {"name": name, "namespace": before["actor"]["namespace"], "ownerReferences": [{"apiVersion": "apps/v1", "kind": "Deployment", "name": before["deployment"]["metadata"]["name"], "uid": identity(before["deployment"])[0], "controller": False, "blockOwnerDeletion": False}]}, - "spec": {"endpointSelector": copy.deepcopy(before["selector"]), + "spec": {"endpointSelector": selector, "egress": [{"toEntities": ["kube-apiserver"], "toPorts": [{"ports": [{"protocol": "TCP", "port": str(port)} for port in ports]}]}]}} @@ -418,7 +443,8 @@ def recheck(phase, temporary_name=None): result["ciliumStableChecks"] = { key: observed["stable"].get("cilium", {}).get(key) == before["stable"].get("cilium", {}).get(key) - for key in ("anchors", "configDigest", "effectiveConfig", "agentProcess", "endpoints", "policies") + for key in ("anchors", "configDigest", "effectiveConfig", "agentProcess", "endpoints", + "endpointSelector", "policies") } require(observed["stable"] == before["stable"], UNAVAILABLE) return observed diff --git a/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py b/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py index bcc8561f0..7621fccff 100644 --- a/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py +++ b/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py @@ -197,7 +197,7 @@ def test_fixed_cli_only_prints_requested_fields_and_never_mutates_configuration( for projection in (cilium.CONFIG_OUTPUT, cilium.KUBE_PROXY_OUTPUT, cilium.ENDPOINT_OUTPUT): self.assertTrue(projection.startswith("jsonpath=")) self.assertNotIn(".log", projection) - self.assertNotIn(".labels", projection) + self.assertNotIn(".labels", projection.replace("{[0].status.identity.labels}", "")) self.assertNotIn("token", projection) def test_projection_bounds_and_unknown_outputs_fail_closed(self): diff --git a/bridge/tests/native-credentials/test_observer_cilium_selector.py b/bridge/tests/native-credentials/test_observer_cilium_selector.py new file mode 100644 index 000000000..5b6ba550b --- /dev/null +++ b/bridge/tests/native-credentials/test_observer_cilium_selector.py @@ -0,0 +1,202 @@ +"""Effective identity-label selection; the original Pod rollout fences remain.""" + +import copy +import json +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from native_api import Failure, core, resource +import observer_cilium_diagnostics as cilium +import observer_network_diagnostics as network +import test_observer_network_diagnostics as fixtures + +# Cilium 1.18.5 explicitly excludes !pod-template-hash from identity labels. +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/labelsfilter/filter.go +# CEP EndpointIdentity.Labels and agent models.Identity.Labels use source:key=value. +# https://github.com/cilium/cilium/blob/v1.18.5/pkg/k8s/apis/cilium.io/v2/types.go +# https://github.com/cilium/cilium/blob/v1.18.5/api/v1/models/identity.go +SELECTOR = {"matchLabels": { + "k8s:kars.azure.com/sandbox": "agent", + "k8s:io.kubernetes.pod.namespace": fixtures.RUNTIME, +}} + + +class CiliumSelectorTests(unittest.TestCase): + def setUp(self): + self.api = fixtures.NetworkFixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + + def collect(self): + return fixtures.ObserverNetworkTests.collect(self) + + def snapshot(self): + with patch.object(network, "command", return_value=json.dumps(fixtures.redacted_context())), \ + patch.object(cilium, "read_projection", side_effect=self.api.read_projection), \ + patch.object(cilium, "read_kube_proxy_projection", side_effect=self.api.read_kube_proxy_projection): + return cilium.snapshot(self.setup, fixtures.TARGET) + + def test_hash_exclusion_does_not_prevent_proven_identity_selector(self): + before = self.snapshot() + plan = network.policy_plan(before, "diagnostic") + self.assertEqual(plan["spec"]["endpointSelector"], SELECTOR) + self.assertEqual(before["selector"]["matchLabels"]["pod-template-hash"], "abc123") + self.assertEqual(before["facts"]["pods"][0]["selector"], before["selector"]) + facts = before["facts"]["cilium"] + self.assertTrue(facts["selectorIdentityLabelsVerified"]) + self.assertTrue(facts["matchingEndpointInventoryVerified"]) + self.assertTrue(facts["podTemplateHashUsedOnlyForPodProvenance"]) + labels = cilium.identity_labels(self.api.identity_labels) + self.assertNotIn("k8s:pod-template-hash", labels) + self.assertTrue(network.matches(SELECTOR, labels)) + self.assertFalse(network.matches({"matchLabels": { + **SELECTOR["matchLabels"], "k8s:pod-template-hash": "abc123"}}, labels)) + self.assertNotIn("canary", json.dumps(before["facts"])) + + def test_missing_or_excluded_required_identity_labels_never_authorize_policy(self): + for index in (0, 1): + for source in ("cep", "agent"): + self.setUp() + labels = self.api.objects[fixtures.CEP]["status"]["identity"]["labels"] if source == "cep" else self.api.identity_labels + labels.pop(index) + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + self.assertNotIn("canary", json.dumps(result)) + self.setUp() + self.api.identity_labels = ["k8s:pod-template-hash=abc123"] + self.assertFalse(self.collect()["policyCreated"]) + + def test_selector_cannot_be_replaced_with_excluded_or_unproven_labels(self): + before = self.snapshot() + for replacement in ( + {"matchLabels": {}}, + {"matchLabels": {**SELECTOR["matchLabels"], "pod-template-hash": "abc123"}}, + {"matchLabels": {**SELECTOR["matchLabels"], "k8s:pod-template-hash": "abc123"}}, + {"matchLabels": {"kars.azure.com/sandbox": "agent"}}, + {"matchLabels": {"container:kars.azure.com/sandbox": "agent"}}, + ): + value = copy.deepcopy(before) + value["facts"]["cilium"]["endpointSelector"] = replacement + value["stable"]["cilium"]["endpointSelector"] = replacement + with self.assertRaises(Failure): + network.policy_plan(value, "diagnostic") + + def test_identity_source_value_duplicates_and_partial_shapes_fail_closed(self): + for replacement in ( + None, [], [True], {}, ["k8s:kars.azure.com/sandbox=agent"] * 2, + ["k8s:kars.azure.com/sandbox=agent", "k8s:kars.azure.com/sandbox=foreign"], + ["container:kars.azure.com/sandbox=agent", "k8s:io.kubernetes.pod.namespace=" + fixtures.RUNTIME], + ["k8s:kars.azure.com/sandbox=foreign", "k8s:io.kubernetes.pod.namespace=" + fixtures.RUNTIME], + ["private-label-canary"], ["k8s:private=" + "x" * 513], ["k8s:private=secret\ncanary"], + ): + self.setUp() + self.api.objects[fixtures.CEP]["status"]["identity"]["labels"] = replacement + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + self.assertNotIn("canary", json.dumps(result)) + + def test_agent_projection_must_match_cep_identity_not_only_numeric_id(self): + self.api.identity_labels.append("k8s:private=another-value") + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + self.setUp() + self.api.identity_labels.append("k8s:extra=private-mismatch-canary") + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(result["baselineStoppingStage"], "endpoint_label_projection") + self.assertNotIn("canary", json.dumps(result)) + + def extra_pod(self, change): + pod = copy.deepcopy(self.api.objects[fixtures.POD]) + pod["metadata"].update(name="old-pod", uid="old-pod-uid") + pod["metadata"]["labels"]["pod-template-hash"] = "old123" + change(pod) + self.api.objects[core(fixtures.RUNTIME, "pods", "old-pod")] = pod + + def test_old_rollout_foreign_and_unrepresented_pods_fail_before_create(self): + for change in ( + lambda pod: pod["metadata"]["annotations"].update({fixtures.VERSION: "old"}), + lambda pod: pod["metadata"].update(ownerReferences=[]), + lambda pod: pod["spec"].update(serviceAccountName="foreign"), + lambda pod: pod["metadata"].update(deletionTimestamp="terminating"), + lambda pod: pod["metadata"]["labels"].pop("pod-template-hash"), + ): + self.setUp() + self.extra_pod(change) + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_extra_matching_cep_is_rejected_even_without_a_live_pod(self): + endpoint = copy.deepcopy(self.api.objects[fixtures.CEP]) + endpoint["metadata"].update(name="orphan", uid="orphan-cep") + endpoint["metadata"]["ownerReferences"][0].update(name="orphan", uid="orphan-pod") + endpoint["status"]["id"] = 456 + self.api.objects[resource(fixtures.RUNTIME, "ciliumendpoints", "orphan", cilium.CILIUM)] = endpoint + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_missing_duplicate_unknown_and_replaced_cep_inventory_fail_closed(self): + original_get = self.api.get + path = resource(fixtures.RUNTIME, "ciliumendpoints", group=cilium.CILIUM) + endpoint = copy.deepcopy(self.api.objects[fixtures.CEP]) + for values in ([], [endpoint, endpoint], [dict(endpoint, status={})]): + with patch.object(self.api, "get", side_effect=lambda requested: + {"items": values} if requested == path else original_get(requested)): + self.assertFalse(self.collect()["policyCreated"]) + endpoint["metadata"]["uid"] = "replacement" + with patch.object(self.api, "get", side_effect=lambda requested: + {"items": [endpoint]} if requested == path else original_get(requested)): + self.assertFalse(self.collect()["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_inventory_race_during_snapshot_is_rejected(self): + original_get = self.api.get + path = resource(fixtures.RUNTIME, "ciliumendpoints", group=cilium.CILIUM) + reads = 0 + def racing_get(requested): + nonlocal reads + if requested == path: + reads += 1 + if reads == 2: + return {"items": []} + return original_get(requested) + with patch.object(self.api, "get", side_effect=racing_get): + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertEqual(self.api.created, []) + + def test_paginated_pod_or_cep_inventory_never_authorizes_policy(self): + original_get = self.api.get + for path in (core(fixtures.RUNTIME, "pods"), + resource(fixtures.RUNTIME, "ciliumendpoints", group=cilium.CILIUM)): + for metadata in ({"continue": "private-continuation-canary"}, {"remainingItemCount": 1}, + {"remainingItemCount": False}, []): + def partial_get(requested): + value = original_get(requested) + if requested == path: + value["metadata"] = metadata + return value + with patch.object(self.api, "get", side_effect=partial_get): + result = self.collect() + self.assertFalse(result["policyCreated"]) + self.assertNotIn("canary", json.dumps(result)) + self.assertEqual(self.api.created, []) + + def test_new_old_rollout_during_intervention_invalidates_and_cleans_policy(self): + self.api.after_create = lambda: self.extra_pod( + lambda pod: pod["metadata"].update(ownerReferences=[])) + result = self.collect() + self.assertTrue(result["policyCreated"]) + self.assertEqual(result["cleanup"], "uid-rv-deletion-verified") + self.assertEqual(result["originalResult"], "failed") + self.assertFalse(result["cniAcceptanceQualified"]) + self.assertFalse(result["policyWindowPackets"]["provenanceUnchanged"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/bridge/tests/native-credentials/test_observer_network_diagnostics.py b/bridge/tests/native-credentials/test_observer_network_diagnostics.py index f9fe911b3..7e9d0ddc4 100644 --- a/bridge/tests/native-credentials/test_observer_network_diagnostics.py +++ b/bridge/tests/native-credentials/test_observer_network_diagnostics.py @@ -110,17 +110,24 @@ def __init__(self): {"name": "cilium-agent", "ready": True, "containerID": "private-cilium-container-canary", "restartCount": 0}]}} self.objects[CEP] = { + "kind": "CiliumEndpoint", "metadata": {"name": "agent-pod", "namespace": RUNTIME, "uid": "cep-uid", "resourceVersion": "1", "ownerReferences": [{"apiVersion": "v1", "kind": "Pod", "name": "agent-pod", "uid": "agent-pod-uid"}]}, - "status": {"id": 123, "identity": {"id": 12345, "labels": ["private-label-canary"]}, + "status": {"id": 123, "identity": {"id": 12345, "labels": [ + "k8s:kars.azure.com/sandbox=agent", "k8s:io.kubernetes.pod.namespace=" + RUNTIME, + "k8s:private=private-label-canary"]}, "networking": {"node": "172.18.0.2", "addressing": [{"ipv4": "10.244.1.10"}]}, "log": ["private-endpoint-log-canary"]}} self.effective_config = ["[]", "true", "true"] self.kube_proxy_status = ["False"] self.policy_revisions = [7, 7] + self.identity_labels = copy.deepcopy(self.objects[CEP]["status"]["identity"]["labels"]) def get(self, path): + if path.endswith("/ciliumendpoints"): + return {"items": copy.deepcopy([item for item in self.objects.values() + if item.get("kind") == "CiliumEndpoint"])} if path.endswith("/networkpolicies"): return {"items": copy.deepcopy([item for item in self.objects.values() if item.get("kind") == "NetworkPolicy"])} @@ -133,7 +140,7 @@ def read_projection(self, _agent, endpoint_id=None, witness=None): if endpoint_id is None: return self.effective_config return [str(endpoint_id), "12345", *(str(value) for value in self.policy_revisions), - "both", RUNTIME, "agent-pod"] + "both", RUNTIME, "agent-pod", json.dumps(self.identity_labels)] def read_kube_proxy_projection(self, _agent, witness=None): return self.kube_proxy_status @@ -326,7 +333,8 @@ def test_only_exact_host_ports_are_added_and_owned_cleanup_is_fenced(self): self.assertEqual(policy["apiVersion"], "cilium.io/v2") self.assertTrue(self.api.created[0][0].endswith("/ciliumnetworkpolicies")) self.assertEqual(policy["spec"], { - "endpointSelector": {"matchLabels": {"kars.azure.com/sandbox": "agent", "pod-template-hash": "abc123"}}, + "endpointSelector": {"matchLabels": {"k8s:kars.azure.com/sandbox": "agent", + "k8s:io.kubernetes.pod.namespace": RUNTIME}}, "egress": [{"toEntities": ["kube-apiserver"], "toPorts": [{"ports": [ {"protocol": "TCP", "port": "443"}, {"protocol": "TCP", "port": "6443"}]}]}]}) self.assertTrue(result["apiResponseObserved"]) @@ -471,7 +479,8 @@ def test_ipv6_targets_do_not_widen_entity_or_ports(self): service = self.api.objects[network.API_SERVICE] service["spec"].update(clusterIP="fd00::1", clusterIPs=["fd00::1"]) self.api.objects[network.API_ENDPOINTS]["subsets"][0]["addresses"] = [{"ip": "fd01::2"}] - plan = network.policy_plan(network.snapshot(self.setup, TARGET), "diagnostic") + self.assertTrue(self.collect()["policyCreated"]) + plan = self.api.created[0][1] self.assertEqual(plan["spec"]["egress"], [{"toEntities": ["kube-apiserver"], "toPorts": [{"ports": [ {"protocol": "TCP", "port": "443"}, {"protocol": "TCP", "port": "6443"}]}]}]) From d526b631e2b4d8067438655a268354f8df16ddb4 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 12:42:05 +0200 Subject: [PATCH 090/111] fix(cli): observe retirement independently of Task status timing The Sandbox reconciler can pause and revoke its projection while the independent Task controller remains Ready. Require the witnessed owned pause and empty projection without inventing a mandatory transient Task status. Preserve current Task authorization, fresh refill and consumed grant/Deployment revisions, old-Pod retirement, and all identity/data fences. Observed Task withdrawal still requires fresh attestation. Cover both schedules and double the negative authority matrix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../private-activation-writer-settle.test.ts | 63 ++++++++++--------- .../lib/private-activation-writer-settle.ts | 4 +- docs/how-to/governed-credential-grants.md | 11 +++- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/cli/src/lib/private-activation-writer-settle.test.ts b/cli/src/lib/private-activation-writer-settle.test.ts index ba76c7c6e..72b399452 100644 --- a/cli/src/lib/private-activation-writer-settle.test.ts +++ b/cli/src/lib/private-activation-writer-settle.test.ts @@ -75,7 +75,7 @@ async function projectionWire(delayResponseMs = 0) { }; } -async function setup(originalRuntime = false) { +async function setup(originalRuntime = false, continuousTask = false) { const f = continuityFixture(); if (originalRuntime) { await applyReviewedGrant(f.execute, { apiVersion: "kars.azure.com/v1alpha1", kind: "KarsCredentialGrant", @@ -165,9 +165,11 @@ async function setup(originalRuntime = false) { let fault: ((stage: string) => void) | undefined; const restore = () => { restored = true; - task.status = { phase: "Ready", observedGeneration: 1, envelopeDigest: AUTH, sandboxRef: { name: "late" }, - conditions: [{ type: "Ready", status: "True", observedGeneration: 1, reason: "Reconciled" }] }; - bump(task); + if (!continuousTask) { + task.status = { phase: "Ready", observedGeneration: 1, envelopeDigest: AUTH, sandboxRef: { name: "late" }, + conditions: [{ type: "Ready", status: "True", observedGeneration: 1, reason: "Reconciled" }] }; + bump(task); + } sandbox.status = { phase: "Running", observedGeneration: 1, conditions: [{ type: "Ready", status: "True", observedGeneration: 1 }] }; bump(sandbox); bundle.metadata.annotations[INPUTS] = JSON.stringify({ ...inputs, grantGeneration: f.grant().metadata.generation }); @@ -193,9 +195,11 @@ async function setup(originalRuntime = false) { if (args[1] === RESOURCE && patch.spec.writers.length === 0) { retired = true; f.grant().status.phase = "Ready"; - task.status = { phase: "Degraded", observedGeneration: 1, envelopeDigest: null, sandboxRef: { name: "late" }, - conditions: [{ type: "Ready", status: "False", observedGeneration: 1, reason: "CredentialAuthorityUnavailable" }] }; - bump(task); + if (!continuousTask) { + task.status = { phase: "Degraded", observedGeneration: 1, envelopeDigest: null, sandboxRef: { name: "late" }, + conditions: [{ type: "Ready", status: "False", observedGeneration: 1, reason: "CredentialAuthorityUnavailable" }] }; + bump(task); + } sandbox.status = { phase: "Degraded", observedGeneration: 1, conditions: [ { type: "Ready", status: "False", observedGeneration: 1, reason: "CredentialSourceUnavailable" }] }; bump(sandbox); @@ -276,7 +280,9 @@ describe("late runtime authority across selected writer retirement", () => { }; await expect(observeWriterSettlement(run, review.spec.privateActivation, settlement)).resolves.toBe(false); expect(stale).toBe(false); - expect(settlement.runtimes[0]!.emptyVersion).toBeUndefined(); + expect(settlement.runtimes[0]!.emptyVersion).toBe( + kind === "karstask" ? f.projection.metadata.resourceVersion : undefined); + expect(settlement.runtimes[0]!.restored).toBeUndefined(); expect(f.calls.every(args => args[0] === "get")).toBe(true); expect(f.grant().spec.writers).toEqual([]); expect(f.namespace.metadata.annotations[`${P}root-retirement`]).toBeUndefined(); @@ -286,15 +292,18 @@ describe("late runtime authority across selected writer retirement", () => { await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)).resolves.toBe(true); }); - it("still rejects a stable empty projection without the witnessed Task withdrawal", async () => { - const { f, review, settlement, beforeTask } = await quiesced(); - f.task.status = beforeTask.status; - f.task.metadata.resourceVersion = beforeTask.metadata.resourceVersion; - await expect(observeWriterSettlement(f.execute, review.spec.privateActivation, settlement)) - .rejects.toThrow("Projection changed without the captured authority withdrawal and owned pause"); - expect(settlement.runtimes[0]!.emptyVersion).toBeUndefined(); - expect(f.calls.every(args => args[0] === "get")).toBe(true); - expect(f.grant().spec.writers).toEqual([]); + it("qualifies an observed owned revoke/refill while the independent Task stays Ready", async () => { + const f = await setup(false, true); + const review = await f.document(); + const task = structuredClone(f.task); + const before = f.preserved(); + f.delayRestore(); + await applyReviewedGrant(f.execute, review); + expect(f.namespace.metadata.annotations[`${P}state`]).toBe("Qualified"); + expect(f.task).toEqual(task); + expect(f.preserved()).toEqual(before); + expect(f.projection.data).toEqual(data); + expect(f.deployment.spec.replicas).toBe(1); }); it("reports only boolean restoration differences while preserving the refusal", async () => { @@ -433,19 +442,13 @@ describe("late runtime authority across selected writer retirement", () => { expect(f.task.status.envelopeDigest).toBe(AUTH); }); - it("does not invent withdrawal witnesses when revoke/refill completes between reads", async () => { + it("does not invent an empty-projection witness when the whole revoke/refill cycle was missed", async () => { const f = await setup(); const review = await f.document(); - const beforeTask = structuredClone(f.task); - let retired = false; - let stale = true; + f.neverRestore(); const run: Execute = async (args, input) => { const result = await f.execute(args, input); - if (args[0] === "patch" && args[1] === RESOURCE && f.grant().spec.writers.length === 0) retired = true; - if (retired && stale && args[0] === "get" && args[1] === "karstask" && args[2] === "late") { - stale = false; - return JSON.stringify(beforeTask); - } + if (args[0] === "patch" && args[1] === RESOURCE && f.grant().spec.writers.length === 0) f.restore(); return result; }; await expect(applyReviewedGrant(run, review)).rejects.toThrow("without witnessed fresh revoke/refill"); @@ -666,9 +669,10 @@ describe("late runtime authority across selected writer retirement", () => { it.each(["disabled", "keys", "grant-uid", "source", "source-uid", "task-spec", "task-uid", "task-owner", "task-generation", "sandbox-spec", "sandbox-uid", "sandbox-owner", "template", "private-key", "projection-key", "bundle-anchor", - "additional-private", "projection-uid", "bundle-data", "namespace", "deployment-uid", "deployment-generation"])( - "does not settle changed %s authority", async fault => { - const f = await setup(); + "additional-private", "projection-uid", "bundle-data", "namespace", "deployment-uid", "deployment-generation", "unpaused"] + .flatMap(fault => [false, true].map(continuousTask => ({ fault, continuousTask }))))( + "does not settle changed $fault authority (Task continuously Ready: $continuousTask)", async ({ fault, continuousTask }) => { + const f = await setup(false, continuousTask); const review = await f.document(); f.neverRestore(); f.fault(stage => { @@ -696,6 +700,7 @@ describe("late runtime authority across selected writer retirement", () => { if (fault === "namespace") f.namespace.metadata.annotations.unreviewed = "changed"; if (fault === "deployment-uid") f.deployment.metadata.uid = "different"; if (fault === "deployment-generation") f.deployment.metadata.generation += 4; + if (fault === "unpaused") { f.deployment.spec.replicas = 1; f.deployment.metadata.generation = 1; } }); f.calls.length = 0; await expect(applyReviewedGrant(f.execute, review)).rejects.toThrow(); diff --git a/cli/src/lib/private-activation-writer-settle.ts b/cli/src/lib/private-activation-writer-settle.ts index 7a53a15a0..7ce72d2d7 100644 --- a/cli/src/lib/private-activation-writer-settle.ts +++ b/cli/src/lib/private-activation-writer-settle.ts @@ -318,7 +318,9 @@ export async function observeWriterSettlement( } if (!projectionSame) { if (Object.keys(data(projection)).length) throw new Error("Projection changed without the captured authority withdrawal and owned pause"); - if (!isPause || !runtime.withdrawnVersion) { + // Sandbox reconciliation can revoke the projection while the independent + // Task controller never observes the transient grant-readiness gap. + if (!isPause) { if (!await snapshotCurrent(execute, runtime, task, deployment, projection)) { allReady = false; continue; diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 6832eb11a..25ba7cf9f 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -301,15 +301,22 @@ Roles/Bindings; another workspace's grant is not reset. For a verified late-enrollment v2 Task runtime, retirement may temporarily withdraw Task authorization while the controller observes the new grant -generation. Apply waits up to 120 seconds for genuine re-attestation under the +generation. Apply waits up to 120 seconds for current authority under the exact quiescent grant. Task/Sandbox identity and intent, source data, private material and executable templates remain pinned. An observed projection revocation requires a fresh refill revision distinct from both the original and empty revisions, consumed by the owned Deployment. Only those proven controller metadata transitions can advance; this is not a new user review, stale-digest reuse or an arbitrary revision refresh. Already-qualified scopes -retain their independently verified path. Missing witnesses or other drift +retain their independently verified path. Missing retirement/refill witnesses or other drift preserve retirement and require explicit recovery; no new authority is published. +The Sandbox and Task controllers reconcile independently: the Sandbox may pause +and empty its projection while the Task remains Ready. Apply therefore requires +the actual owned pause and empty projection, not observation of an incidental +Task-status transition. Current Task authorization, the revalidated grant/input +generation, fresh refill, consumed Deployment revision and old-Pod retirement +are still required. If Task authorization was observed withdrawn, a fresh +Ready attestation is required; the captured digest is never substituted for it. The projection recheck aligns kubectl JSON and JSONPath views only for `managedFields` absent from the captured JSON view. Originally captured `managedFields` and all other metadata remain compared; this does not grant From 4f5d90dd741e0e73b006eed4e809d895c17d4d16 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 14:36:48 +0200 Subject: [PATCH 091/111] fix(controller): allow enrolled observers to verify Kubernetes metadata Reuse canonical exact API targets in the owned observer policy. For installed Cilium, manage only a namespaced API-entity policy with validated ports and effective selectors. Preserve original namespace/claim/UID/RV fences, remove stale extensions, and retain a cleanup hint for interrupted retirement. Grant CNP management only to the controller; no new CNI installation, global settings, agent privileges, TLS or deadline changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../testing/credential-grant-contract.test.ts | 13 + .../credential_grants/observer_metadata.rs | 278 +++++- .../observer_metadata/api_egress.rs | 352 +++++++ .../observer_metadata/api_egress/tests.rs | 891 ++++++++++++++++++ controller/src/credential_grants/operator.rs | 6 +- controller/src/reconciler/mod.rs | 2 +- controller/src/reconciler/sre_egress.rs | 2 +- .../kars/templates/credential-grant-rbac.yaml | 5 + docs/how-to/governed-credential-grants.md | 37 +- 9 files changed, 1549 insertions(+), 37 deletions(-) create mode 100644 controller/src/credential_grants/observer_metadata/api_egress.rs create mode 100644 controller/src/credential_grants/observer_metadata/api_egress/tests.rs diff --git a/cli/src/testing/credential-grant-contract.test.ts b/cli/src/testing/credential-grant-contract.test.ts index 671e480b0..a04812485 100644 --- a/cli/src/testing/credential-grant-contract.test.ts +++ b/cli/src/testing/credential-grant-contract.test.ts @@ -206,6 +206,19 @@ describe("governed credential public contract",()=>{ expect(runtime).toContain("set.uid().as_deref() != Some(owner.uid.as_str())"); }); + it("limits optional observer Cilium permissions to the controller and namespaced policies",()=>{ + const owners=manifests.filter(item=>["Role","ClusterRole"].includes(item.kind) + &&item.rules?.some((rule:{apiGroups?:string[]})=>rule.apiGroups?.includes("cilium.io"))); + expect(owners.map(item=>item.metadata.name)).toEqual(["kars-credential-grant-controller"]); + expect(owners[0].rules.filter((rule:{apiGroups:string[]})=>rule.apiGroups.includes("cilium.io"))) + .toEqual([{apiGroups:["cilium.io"],resources:["ciliumnetworkpolicies"], + verbs:["get","list","create","update","delete"]}]); + expect(resource("ClusterRoleBinding","kars-credential-grant-controller").subjects) + .toEqual([{kind:"ServiceAccount",namespace:"kars-system",name:"kars-controller"}]); + expect(manifests.some(item=>["CiliumNetworkPolicy","CiliumClusterwideNetworkPolicy"].includes(item.kind))) + .toBe(false); + }); + it("gates ordinary Task readiness before execution and preserves state during credential failure",()=>{ const task=source("controller/src/kars_task_reconciler.rs"); expect(task.indexOf("readiness::enforce(")).toBeLessThan(task.indexOf("reconcile_execution(&ctx.client")); diff --git a/controller/src/credential_grants/observer_metadata.rs b/controller/src/credential_grants/observer_metadata.rs index c4e83bec1..73849602c 100644 --- a/controller/src/credential_grants/observer_metadata.rs +++ b/controller/src/credential_grants/observer_metadata.rs @@ -13,9 +13,53 @@ use kube::{ use serde_json::Value; use std::collections::BTreeSet; +mod api_egress; + const LABEL: &str = "kars.azure.com/observer-metadata-grant"; +const NAMESPACE_UID: &str = "kars.azure.com/observer-namespace-uid"; +const GENERATION: &str = "kars.azure.com/observer-grant-generation"; + +fn policy_prefix(grant: &KarsCredentialGrant, uid: &str) -> Result<String, String> { + Ok(format!( + "kars-observer-meta-{}-{}-g{}", + grant + .uid() + .ok_or("Grant UID missing")? + .chars() + .take(12) + .collect::<String>(), + uid.chars().take(12).collect::<String>(), + grant.metadata.generation.unwrap_or_default(), + )) +} + +fn same_namespace(live: &Namespace, expected: &Namespace) -> bool { + live.uid() == expected.uid() + && live.metadata.deletion_timestamp.is_none() + && [ + crate::reconciler::namespace_ownership::VERSION, + crate::reconciler::namespace_ownership::SOURCE_NAMESPACE, + crate::reconciler::namespace_ownership::SOURCE_NAME, + crate::reconciler::namespace_ownership::SOURCE_UID, + ] + .iter() + .all(|key| { + live.metadata + .annotations + .as_ref() + .and_then(|values| values.get(*key)) + == expected + .metadata + .annotations + .as_ref() + .and_then(|values| values.get(*key)) + }) +} fn resource(kind: &str) -> ApiResource { + if kind == "CiliumNetworkPolicy" { + return ApiResource::from_gvk(&GroupVersionKind::gvk("cilium.io", "v2", kind)); + } let group = if kind == "NetworkPolicy" { "networking.k8s.io" } else { @@ -31,6 +75,38 @@ async fn apply( kind: &str, name: &str, data: Value, +) -> Result<(), String> { + apply_owned(client, grant, namespace, None, kind, name, data).await +} + +async fn apply_runtime( + client: &Client, + grant: &KarsCredentialGrant, + namespace: &Namespace, + kind: &str, + name: &str, + data: Value, +) -> Result<(), String> { + apply_owned( + client, + grant, + Some(&namespace.name_any()), + Some(namespace), + kind, + name, + data, + ) + .await +} + +async fn apply_owned( + client: &Client, + grant: &KarsCredentialGrant, + namespace: Option<&str>, + expected_namespace: Option<&Namespace>, + kind: &str, + name: &str, + data: Value, ) -> Result<(), String> { let resource = resource(kind); let api = if let Some(namespace) = namespace { @@ -46,11 +122,25 @@ async fn apply( .get(namespace) .await .map_err(|e| api_error("Verify observer metadata namespace", e))?; + identity(&ns.metadata)?; + if expected_namespace.is_some_and(|expected| !same_namespace(&ns, expected)) { + return Err("Observer metadata namespace was replaced".into()); + } definition["metadata"]["namespace"] = namespace.into(); definition["metadata"]["annotations"]["kars.azure.com/observer-namespace-uid"] = json!(ns.metadata.uid); definition["metadata"]["ownerReferences"] = json!([{"apiVersion":"v1","kind":"Namespace", "name":namespace,"uid":ns.metadata.uid,"controller":true,"blockOwnerDeletion":false}]); + if kind == "CiliumNetworkPolicy" { + let target = ns + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(crate::reconciler::namespace_ownership::SOURCE_UID)) + .ok_or("Observer API namespace target UID missing")?; + definition["metadata"]["annotations"] + [crate::reconciler::namespace_ownership::SOURCE_UID] = json!(target); + } } for (key, value) in data .as_object() @@ -58,11 +148,21 @@ async fn apply( { definition[key] = value.clone(); } - if let Some(current) = api + let current = api .get_opt(name) .await - .map_err(|e| api_error("Read observer metadata resource", e))? - { + .map_err(|e| api_error("Read observer metadata resource", e))?; + if let Some(expected) = expected_namespace { + let live = Api::<Namespace>::all(client.clone()) + .get(&expected.name_any()) + .await + .map_err(|error| api_error("Recheck observer policy namespace before write", error))?; + if !same_namespace(&live, expected) { + return Err("Observer policy namespace changed before write".into()); + } + } + if let Some(current) = current { + identity(¤t.metadata)?; if current .metadata .annotations @@ -81,19 +181,63 @@ async fn apply( { return Err("Foreign observer metadata resource preserved".into()); } - if data + let policy = matches!(kind, "NetworkPolicy" | "CiliumNetworkPolicy"); + if kind == "CiliumNetworkPolicy" + && current + .metadata + .annotations + .as_ref() + .and_then(|values| values.get(crate::reconciler::namespace_ownership::SOURCE_UID)) + .map(String::as_str) + != definition["metadata"]["annotations"] + [crate::reconciler::namespace_ownership::SOURCE_UID] + .as_str() + { + return Err("Foreign observer API target policy preserved".into()); + } + if policy + && (current.metadata.labels.as_ref().and_then(|a| a.get(LABEL)) + != grant.metadata.uid.as_ref() + || serde_json::to_value(¤t.metadata.owner_references).ok() + != Some(definition["metadata"]["ownerReferences"].clone()) + || current + .metadata + .finalizers + .as_ref() + .is_some_and(|values| !values.is_empty())) + { + return Err("Foreign observer network policy preserved".into()); + } + let fields_match = data .as_object() .unwrap() .iter() - .all(|(key, value)| current.data.get(key) == Some(value)) - { + .all(|(key, value)| current.data.get(key) == Some(value)); + let exact_policy = !policy + || (current.data.as_object().is_some_and(|fields| { + fields + .keys() + .all(|key| key == "status" || data.get(key).is_some()) + }) && serde_json::to_value(¤t.metadata.annotations).ok() + == Some(definition["metadata"]["annotations"].clone()) + && serde_json::to_value(¤t.metadata.labels).ok() + == Some(definition["metadata"]["labels"].clone())); + if fields_match && exact_policy { return Ok(()); } definition["metadata"]["uid"] = json!(current.metadata.uid); definition["metadata"]["resourceVersion"] = json!(current.metadata.resource_version); - api.patch(name, &PatchParams::default(), &Patch::Merge(definition)) - .await - .map_err(|e| api_error("Update owned observer metadata resource", e))?; + if policy { + let value: DynamicObject = serde_json::from_value(definition) + .map_err(|_| "Observer network policy serialization failed")?; + api.replace(name, &PostParams::default(), &value) + .await + .map_err(|e| api_error("Replace owned observer network policy", e))?; + } else { + api.patch(name, &PatchParams::default(), &Patch::Merge(definition)) + .await + .map_err(|e| api_error("Update owned observer metadata resource", e))?; + } } else { let value: DynamicObject = serde_json::from_value(definition) .map_err(|_| "Observer metadata serialization failed")?; @@ -111,24 +255,28 @@ pub(super) async fn ensure( namespace: &Namespace, binding: &Binding, ) -> Result<(), String> { + api_egress::approved(grant, sandbox, namespace)?; + if Some(binding.grant.uid.as_str()) != grant.metadata.uid.as_deref() + || Some(binding.grant.namespace.as_str()) != grant.metadata.namespace.as_deref() + || binding.grant.generation != grant.metadata.generation.unwrap_or_default() + || binding.workspace_uid != grant.spec.workspace_uid + { + return Err("Observer metadata binding differs from its approved grant".into()); + } let recipients = &binding.recipients; let verifier = binding .verifier .as_ref() .ok_or("Privacy verifier capability missing")?; super::observation_network::rpc_baseline(client, sandbox, namespace, verifier).await?; + let service_host = std::env::var("KUBERNETES_SERVICE_HOST") + .map_err(|_| "Observer API Service host is unavailable")?; + let service_port = std::env::var("KUBERNETES_SERVICE_PORT_HTTPS") + .or_else(|_| std::env::var("KUBERNETES_SERVICE_PORT")) + .map_err(|_| "Observer API HTTPS port is unavailable")?; + let api_path = api_egress::plan(client, &service_host, &service_port).await?; let uid = sandbox.uid().ok_or("Observer source UID missing")?; - let prefix = format!( - "kars-observer-meta-{}-{}-g{}", - grant - .uid() - .ok_or("Grant UID missing")? - .chars() - .take(12) - .collect::<String>(), - uid.chars().take(12).collect::<String>(), - grant.metadata.generation.unwrap_or_default() - ); + let prefix = policy_prefix(grant, &uid)?; let runtime = namespace.name_any(); let workspace = sandbox .namespace() @@ -204,11 +352,18 @@ pub(super) async fn ensure( let controller_peer = json!({"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":verifier.namespace}}, "podSelector":{"matchLabels":{"app.kubernetes.io/name":"kars","app.kubernetes.io/component":"controller", crate::observation_privacy::REVISION_LABEL:verifier.revision()}}}); - apply(client,grant,Some(&runtime),"NetworkPolicy",&format!("{prefix}-rpc"),json!({"spec":{ + super::verify(client, grant).await?; + crate::reconciler::namespace_ownership::recheck(client, sandbox, namespace) + .await + .map_err(|_| "Observer API runtime namespace changed")?; + let mut runtime_egress = api_path.rules.clone(); + runtime_egress.push(json!({"to":[controller_peer],"ports":[{"protocol":"TCP","port":crate::observation_privacy::PORT}]})); + apply_runtime(client,grant,namespace,"NetworkPolicy",&format!("{prefix}-rpc"),json!({"spec":{ "podSelector":{"matchLabels":{"kars.azure.com/sandbox":sandbox.name_any()}},"policyTypes":["Ingress","Egress"], - "egress":[{"to":[controller_peer],"ports":[{"protocol":"TCP","port":crate::observation_privacy::PORT}]}], + "egress":runtime_egress, "ingress":[{"from":[controller_peer],"ports":[{"protocol":"TCP","port":crate::service_observer::PORT}]}], }})).await?; + api_egress::ensure(client, grant, sandbox, namespace, &prefix, &api_path).await?; apply(client,grant,Some(&verifier.namespace),"NetworkPolicy",&format!("{prefix}-rpc"),json!({"spec":{ "podSelector":{"matchLabels":{"app.kubernetes.io/name":"kars","app.kubernetes.io/component":"controller"}}, "policyTypes":["Ingress","Egress"], @@ -239,8 +394,31 @@ async fn retire( client: &Client, grant: &KarsCredentialGrant, keep_current: bool, +) -> Result<(), String> { + // CNP cleanup has its own namespace index: partial RBAC/KNP cleanup must not + // hide a remaining API allowance, or prevent other revocation attempts. + let api = api_egress::retire(client, grant, keep_current).await; + let metadata = retire_metadata(client, grant, keep_current).await; + api.and(metadata) +} + +async fn retire_metadata( + client: &Client, + grant: &KarsCredentialGrant, + keep_current: bool, ) -> Result<(), String> { let selector = format!("{LABEL}={}", grant.uid().ok_or("Grant UID missing")?); + let prefixes = grant + .spec + .observation_targets + .iter() + .filter(|target| { + target.kind == "KarsSandbox" + && !target.uid.is_empty() + && Some(target.namespace.as_str()) == grant.metadata.namespace.as_deref() + }) + .map(|target| policy_prefix(grant, &target.uid)) + .collect::<Result<Vec<_>, _>>()?; for kind in [ "RoleBinding", "Role", @@ -255,7 +433,31 @@ async fn retire( .await .map_err(|e| api_error("Read observer metadata for retirement", e))? { + identity(&object.metadata)?; + if object + .metadata + .annotations + .as_ref() + .and_then(|values| values.get(GRANT_OWNER)) + != grant.metadata.uid.as_ref() + || object + .metadata + .labels + .as_ref() + .and_then(|values| values.get(LABEL)) + != grant.metadata.uid.as_ref() + || object.metadata.name.as_deref().is_none_or(str::is_empty) + { + return Err("Foreign observer metadata resource preserved".into()); + } if keep_current + && grant.spec.enabled + && prefixes.iter().any(|prefix| { + let name = object.name_any(); + name == *prefix + || name == format!("{prefix}-sa") + || name == format!("{prefix}-rpc") + }) && object .metadata .annotations @@ -265,16 +467,30 @@ async fn retire( { continue; } - if object - .metadata - .annotations - .as_ref() - .and_then(|a| a.get(GRANT_OWNER)) - != grant.metadata.uid.as_ref() - { - return Err("Foreign observer metadata resource preserved".into()); - } let api = if let Some(namespace) = object.namespace() { + if kind == "NetworkPolicy" { + let live = Api::<Namespace>::all(client.clone()) + .get(&namespace) + .await + .map_err(|error| { + api_error("Verify observer policy retirement namespace", error) + })?; + identity(&live.metadata)?; + if object + .metadata + .annotations + .as_ref() + .and_then(|values| values.get(NAMESPACE_UID)) + != live.metadata.uid.as_ref() + || serde_json::to_value(&object.metadata.owner_references).ok() + != Some( + json!([{"apiVersion":"v1","kind":"Namespace","name":namespace, + "uid":live.metadata.uid,"controller":true,"blockOwnerDeletion":false}]), + ) + { + return Err("Foreign observer policy namespace preserved".into()); + } + } Api::namespaced_with(client.clone(), &namespace, &resource) } else { all.clone() diff --git a/controller/src/credential_grants/observer_metadata/api_egress.rs b/controller/src/credential_grants/observer_metadata/api_egress.rs new file mode 100644 index 000000000..1efcafb4b --- /dev/null +++ b/controller/src/credential_grants/observer_metadata/api_egress.rs @@ -0,0 +1,352 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Optional installed-Cilium representation of the approved observer API path. + +use super::*; +use crate::reconciler::namespace_ownership as claim; + +// A durable discovery hint, not authority. It survives partial policy/RBAC +// cleanup and disappears with the namespace; ordinary runtimes never get it. +const INDEX: &str = "kars.azure.com/observer-api-policy"; +const KIND: &str = "CiliumNetworkPolicy"; + +#[derive(Debug)] +pub(super) struct Plan { + pub rules: Vec<Value>, + cilium: bool, +} + +pub(super) fn approved( + grant: &KarsCredentialGrant, + sandbox: &KarsSandbox, + namespace: &Namespace, +) -> Result<(), String> { + identity(&grant.metadata)?; + identity(&sandbox.metadata)?; + identity(&namespace.metadata)?; + if !grant.spec.enabled + || !grant.spec.observation_targets.iter().any(|target| { + target.kind == "KarsSandbox" + && Some(target.namespace.as_str()) == grant.metadata.namespace.as_deref() + && sandbox.namespace() == grant.namespace() + && Some(target.name.as_str()) == sandbox.metadata.name.as_deref() + && Some(target.uid.as_str()) == sandbox.metadata.uid.as_deref() + }) + || !claim::claimed(namespace, sandbox) + .map_err(|_| "Observer API namespace claim is invalid")? + { + return Err("Observer API path requires an approved current Sandbox and namespace".into()); + } + Ok(()) +} + +async fn installed(client: &Client) -> Result<bool, String> { + let resources = match client.list_api_group_resources("cilium.io/v2").await { + Ok(resources) => resources, + Err(kube::Error::Api(error)) if error.code == 404 && error.reason == "NotFound" => { + return Ok(false); + } + Err(error) => return Err(api_error("Discover observer Cilium policy API", error)), + }; + let candidates: Vec<_> = resources + .resources + .iter() + .filter(|resource| resource.name == "ciliumnetworkpolicies") + .collect(); + if resources.group_version != "cilium.io/v2" + || candidates.len() != 1 + || candidates[0].kind != KIND + || !candidates[0].namespaced + || !["get", "list", "create", "update", "delete"] + .iter() + .all(|verb| { + candidates[0] + .verbs + .iter() + .any(|value| value.as_str() == *verb) + }) + { + return Err("Installed Cilium policy API has an unsupported resource contract".into()); + } + Ok(true) +} + +pub(super) async fn plan(client: &Client, host: &str, port: &str) -> Result<Plan, String> { + let rules = crate::reconciler::sre_egress::rules(client, host, port).await?; + Ok(Plan { + rules, + cilium: installed(client).await?, + }) +} + +fn spec(sandbox: &KarsSandbox, namespace: &Namespace, plan: &Plan) -> Result<Value, String> { + let ports: BTreeSet<u16> = plan + .rules + .iter() + .map(|rule| { + rule["ports"][0]["port"] + .as_u64() + .and_then(|port| u16::try_from(port).ok()) + .filter(|port| *port != 0) + .ok_or_else(|| "Canonical observer API port is invalid".to_string()) + }) + .collect::<Result<_, _>>()?; + if ports.is_empty() { + return Err("Canonical observer API targets are unavailable".into()); + } + Ok(json!({ + "endpointSelector":{"matchLabels":{ + "k8s:kars.azure.com/sandbox":sandbox.name_any(), + "k8s:io.kubernetes.pod.namespace":namespace.name_any() + }}, + "egress":[{"toEntities":["kube-apiserver"],"toPorts":[{ + "ports":ports.into_iter().map(|port|json!({"port":port.to_string(),"protocol":"TCP"})) + .collect::<Vec<_>>() + }]}] + })) +} + +pub(super) async fn ensure( + client: &Client, + grant: &KarsCredentialGrant, + sandbox: &KarsSandbox, + namespace: &Namespace, + prefix: &str, + plan: &Plan, +) -> Result<(), String> { + approved(grant, sandbox, namespace)?; + if !plan.cilium { + return Ok(()); + } + super::super::verify(client, grant).await?; + let live = Api::<Namespace>::all(client.clone()) + .get(&namespace.name_any()) + .await + .map_err(|error| api_error("Read observer API namespace index", error))?; + approved(grant, sandbox, &live)?; + if live.uid() != namespace.uid() { + return Err("Observer API namespace was replaced".into()); + } + match live + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(INDEX)) + .map(String::as_str) + { + Some("v1") => {} + None => { + Api::<Namespace>::all(client.clone()) + .patch( + &live.name_any(), + &PatchParams::default(), + &Patch::Merge(json!({"metadata":{"uid":live.metadata.uid, + "resourceVersion":live.metadata.resource_version,"labels":{INDEX:"v1"}}})), + ) + .await + .map_err(|error| api_error("Index owned observer API policy namespace", error))?; + } + Some(_) => return Err("Foreign observer API namespace index preserved".into()), + } + apply_runtime( + client, + grant, + namespace, + KIND, + &format!("{prefix}-api"), + json!({"spec":spec(sandbox, namespace, plan)?}), + ) + .await?; + super::super::verify(client, grant).await?; + claim::recheck(client, sandbox, namespace) + .await + .map_err(|_| "Observer API namespace changed during policy issuance".to_string()) +} + +fn annotation<'a>(metadata: &'a kube::api::ObjectMeta, key: &str) -> Option<&'a str> { + metadata.annotations.as_ref()?.get(key).map(String::as_str) +} + +fn owned( + object: &DynamicObject, + namespace: &Namespace, + grant: &KarsCredentialGrant, +) -> Result<(), String> { + identity(&object.metadata)?; + identity(&namespace.metadata)?; + let owners = object + .metadata + .owner_references + .as_deref() + .unwrap_or_default(); + if object.metadata.name.as_deref().is_none_or(str::is_empty) + || object.namespace() != Some(namespace.name_any()) + || object + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(LABEL)) + != grant.metadata.uid.as_ref() + || annotation(&object.metadata, GRANT_OWNER) != grant.metadata.uid.as_deref() + || annotation(&object.metadata, NAMESPACE_UID) != namespace.metadata.uid.as_deref() + || annotation(&object.metadata, claim::SOURCE_UID) + != annotation(&namespace.metadata, claim::SOURCE_UID) + || annotation(&namespace.metadata, claim::SOURCE_UID).is_none_or(str::is_empty) + || namespace + .metadata + .owner_references + .as_ref() + .is_some_and(|values| !values.is_empty()) + || annotation(&namespace.metadata, claim::VERSION) != Some("v1") + || annotation(&namespace.metadata, claim::SOURCE_NAMESPACE) + != grant.metadata.namespace.as_deref() + || namespace.name_any() + != format!( + "kars-{}", + annotation(&namespace.metadata, claim::SOURCE_NAME).unwrap_or("") + ) + || owners.len() != 1 + || owners[0].api_version != "v1" + || owners[0].kind != "Namespace" + || owners[0].name != namespace.name_any() + || Some(owners[0].uid.as_str()) != namespace.metadata.uid.as_deref() + || owners[0].controller != Some(true) + || owners[0].block_owner_deletion != Some(false) + { + return Err("Foreign observer API policy or namespace preserved".into()); + } + Ok(()) +} + +pub(super) async fn retire( + client: &Client, + grant: &KarsCredentialGrant, + keep_current: bool, +) -> Result<(), String> { + let grant_uid = grant + .uid() + .filter(|uid| !uid.is_empty()) + .ok_or("Observer grant UID missing")?; + let workspace = grant + .namespace() + .filter(|name| !name.is_empty()) + .ok_or("Observer grant namespace missing")?; + let namespaces = Api::<Namespace>::all(client.clone()) + .list(&ListParams::default().labels(&format!("{INDEX}=v1"))) + .await + .map_err(|error| api_error("Read observer API namespace index", error))?; + if namespaces + .metadata + .continue_ + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + return Err("Observer API namespace index is incomplete".into()); + } + let namespaces: Vec<_> = namespaces + .into_iter() + .filter(|namespace| { + annotation(&namespace.metadata, claim::SOURCE_NAMESPACE) == Some(workspace.as_str()) + }) + .collect(); + if namespaces.is_empty() || !installed(client).await? { + return Ok(()); + } + let resource = resource(KIND); + for expected in namespaces { + let namespace = Api::<Namespace>::all(client.clone()) + .get(&expected.name_any()) + .await + .map_err(|error| api_error("Recheck observer API retirement namespace", error))?; + identity(&namespace.metadata)?; + if namespace.uid() != expected.uid() + || namespace + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(INDEX)) + .map(String::as_str) + != Some("v1") + { + return Err("Observer API retirement namespace changed".into()); + } + let api: Api<DynamicObject> = + Api::namespaced_with(client.clone(), &namespace.name_any(), &resource); + let objects = api + .list(&ListParams::default().labels(&format!("{LABEL}={grant_uid}"))) + .await + .map_err(|error| { + api_error( + "Read namespaced observer API policies for retirement", + error, + ) + })?; + if objects + .metadata + .continue_ + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + return Err("Observer API policy inventory is incomplete".into()); + } + for object in objects { + owned(&object, &namespace, grant)?; + let keep = keep_current + && grant.spec.enabled + && annotation(&object.metadata, GENERATION) + == Some( + grant + .metadata + .generation + .unwrap_or_default() + .to_string() + .as_str(), + ) + && grant.spec.observation_targets.iter().any(|target| { + target.kind == "KarsSandbox" + && Some(target.namespace.as_str()) == grant.metadata.namespace.as_deref() + && Some(target.name.as_str()) + == annotation(&namespace.metadata, claim::SOURCE_NAME) + && Some(target.uid.as_str()) + == annotation(&object.metadata, claim::SOURCE_UID) + }); + if keep { + continue; + } + let live = Api::<Namespace>::all(client.clone()) + .get(&namespace.name_any()) + .await + .map_err(|error| { + api_error("Recheck observer API namespace before deletion", error) + })?; + if !same_namespace(&live, &namespace) { + return Err("Observer API namespace changed before deletion".into()); + } + api.delete( + &object.name_any(), + &DeleteParams { + preconditions: Some(Preconditions { + uid: object.metadata.uid.clone(), + resource_version: object.metadata.resource_version.clone(), + }), + ..Default::default() + }, + ) + .await + .map_err(|error| api_error("Retire owned observer API policy", error))?; + if api + .get_opt(&object.name_any()) + .await + .map_err(|error| api_error("Verify observer API policy retirement", error))? + .is_some() + { + return Err("Observer API policy retirement is pending".into()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/controller/src/credential_grants/observer_metadata/api_egress/tests.rs b/controller/src/credential_grants/observer_metadata/api_egress/tests.rs new file mode 100644 index 000000000..75d9ce513 --- /dev/null +++ b/controller/src/credential_grants/observer_metadata/api_egress/tests.rs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const NS: &str = "/api/v1/namespaces/kars-agent"; +const GRANT: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace"; +const API: &str = "/apis/cilium.io/v2"; +const POLICIES: &str = "/apis/cilium.io/v2/namespaces/kars-agent/ciliumnetworkpolicies"; +const POLICY: &str = + "/apis/cilium.io/v2/namespaces/kars-agent/ciliumnetworkpolicies/observer-g1-api"; +const SERVICE: &str = "/api/v1/namespaces/default/services/kubernetes"; +const ENDPOINTS: &str = "/api/v1/namespaces/default/endpoints/kubernetes"; + +#[derive(Default)] +struct State { + objects: BTreeMap<String, Value>, + calls: Vec<(String, String, Value)>, + errors: BTreeMap<String, u16>, + delete_conflict: bool, + replace_conflict: bool, + retain_deleted: bool, + namespace_replacement_on_policy_read: Option<String>, +} + +fn failure(code: u16) -> ResponseTemplate { + ResponseTemplate::new(code).set_body_json(json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","code":code, + "reason":if code == 404 {"NotFound"} else {"Forbidden"}, + "message":"private-api-error-canary" + })) +} + +async fn fixture() -> ( + MockServer, + Client, + Arc<Mutex<State>>, + KarsCredentialGrant, + KarsSandbox, + Namespace, +) { + let server = MockServer::start().await; + let grant: KarsCredentialGrant = serde_json::from_value(json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsCredentialGrant", + "metadata":{"name":"workspace","namespace":"work","uid":"grant-uid","resourceVersion":"1","generation":1}, + "spec":{"enabled":true,"workspaceUid":"workspace-uid","writers":[], + "observationTargets":[{"kind":"KarsSandbox","namespace":"work","name":"agent","uid":"sandbox-uid"}]} + })).unwrap(); + let legacy: Value = serde_json::from_str(include_str!( + "../../../../../tests/compat/fixtures/namespace-legacy.json" + )) + .unwrap(); + let mut sandbox = legacy["sandbox"].clone(); + sandbox["metadata"] = json!({"name":"agent","namespace":"work","uid":"sandbox-uid","resourceVersion":"1", + "annotations":{claim::NAMESPACE_UID:"runtime-uid"}}); + let sandbox: KarsSandbox = serde_json::from_value(sandbox).unwrap(); + let namespace: Namespace = serde_json::from_value(json!({ + "apiVersion":"v1","kind":"Namespace","metadata":{"name":"kars-agent","uid":"runtime-uid", + "resourceVersion":"1","annotations":{claim::VERSION:"v1",claim::SOURCE_NAMESPACE:"work", + claim::SOURCE_NAME:"agent",claim::SOURCE_UID:"sandbox-uid"}}})) + .unwrap(); + let mut data = State::default(); + data.objects + .insert(GRANT.into(), serde_json::to_value(&grant).unwrap()); + data.objects + .insert(NS.into(), serde_json::to_value(&namespace).unwrap()); + data.objects.insert( + "/api/v1/namespaces/work".into(), + json!({"apiVersion":"v1","kind":"Namespace", + "metadata":{"name":"work","uid":"workspace-uid","resourceVersion":"1"}}), + ); + data.objects.insert(SERVICE.into(), json!({"apiVersion":"v1","kind":"Service", + "metadata":{"name":"kubernetes","namespace":"default","uid":"service-uid","resourceVersion":"1"}, + "spec":{"clusterIP":"10.96.0.1","ports":[{"name":"https","port":443,"protocol":"TCP"}]}})); + data.objects.insert(ENDPOINTS.into(), json!({"apiVersion":"v1","kind":"Endpoints", + "metadata":{"name":"kubernetes","namespace":"default","uid":"endpoint-uid","resourceVersion":"1"}, + "subsets":[{"addresses":[{"ip":"172.18.0.3"}],"notReadyAddresses":[{"ip":"172.18.0.99"}], + "ports":[{"name":"https","port":6443,"protocol":"TCP"}]}]})); + data.objects.insert(API.into(), json!({"apiVersion":"v1","kind":"APIResourceList", + "groupVersion":"cilium.io/v2","resources":[{"name":"ciliumnetworkpolicies","singularName":"", + "namespaced":true,"kind":"CiliumNetworkPolicy","verbs":["get","list","create","update","delete"]}]})); + let state = Arc::new(Mutex::new(data)); + let captured = state.clone(); + Mock::given(|_: &wiremock::Request| true).respond_with(move |request: &wiremock::Request| { + let mut state = captured.lock().unwrap(); + let path = request.url.path(); + let body: Value = request.body_json().unwrap_or(Value::Null); + state.calls.push((request.method.to_string(), path.into(), body.clone())); + if let Some(code) = state.errors.get(path) { + return failure(*code); + } + if request.method == "GET" { + if state.namespace_replacement_on_policy_read.as_deref() == Some(path) { + state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = "replacement".into(); + } + if let Some(value) = state.objects.get(path) { + return ResponseTemplate::new(200).set_body_json(value); + } + for (suffix, kind, version) in [ + ("/namespaces", "Namespace", "v1"), + ("/ciliumnetworkpolicies", "CiliumNetworkPolicy", "cilium.io/v2"), + ("/networkpolicies", "NetworkPolicy", "networking.k8s.io/v1"), + ("/roles", "Role", "rbac.authorization.k8s.io/v1"), + ("/rolebindings", "RoleBinding", "rbac.authorization.k8s.io/v1"), + ("/clusterroles", "ClusterRole", "rbac.authorization.k8s.io/v1"), + ("/clusterrolebindings", "ClusterRoleBinding", "rbac.authorization.k8s.io/v1"), + ] { + if path.ends_with(suffix) { + let selector = request.url.query_pairs().find(|(key, _)| key == "labelSelector") + .map(|(_, value)| value.into_owned()); + let items: Vec<_> = state.objects.iter().filter(|(key, object)| { + object["kind"] == kind + && (!path.contains("/namespaces/") || key.starts_with(&format!("{path}/"))) + && selector.as_ref().is_none_or(|selector| { + let (key, value) = selector.split_once('=').unwrap(); + object["metadata"]["labels"][key] == value + }) + }).map(|(_, object)|object.clone()).collect(); + return ResponseTemplate::new(200).set_body_json(json!({ + "apiVersion":version,"kind":format!("{kind}List"),"metadata":{},"items":items + })); + } + } + } + if request.method == "PATCH" && path == NS { + let current = state.objects.get_mut(path).unwrap(); + assert_eq!(current["metadata"]["uid"], body["metadata"]["uid"]); + assert_eq!(current["metadata"]["resourceVersion"], body["metadata"]["resourceVersion"]); + let labels = current["metadata"].as_object_mut().unwrap() + .entry("labels").or_insert_with(||json!({})).as_object_mut().unwrap(); + for (key, value) in body["metadata"]["labels"].as_object().unwrap() { + labels.insert(key.clone(), value.clone()); + } + current["metadata"]["resourceVersion"] = "2".into(); + return ResponseTemplate::new(200).set_body_json(current.clone()); + } + if request.method == "POST" { + let name = body["metadata"]["name"].as_str().unwrap(); + let key = format!("{path}/{name}"); + if state.objects.contains_key(&key) { return failure(409); } + let mut created = body; + created["metadata"]["uid"] = "policy-uid".into(); + created["metadata"]["resourceVersion"] = "10".into(); + state.objects.insert(key, created.clone()); + return ResponseTemplate::new(201).set_body_json(created); + } + if request.method == "PUT" && state.objects.contains_key(path) { + let current = &state.objects[path]; + assert_eq!(current["metadata"]["uid"], body["metadata"]["uid"]); + assert_eq!(current["metadata"]["resourceVersion"], body["metadata"]["resourceVersion"]); + if state.replace_conflict { return failure(409); } + let mut replaced = body; + replaced["metadata"]["resourceVersion"] = "11".into(); + state.objects.insert(path.into(), replaced.clone()); + return ResponseTemplate::new(200).set_body_json(replaced); + } + if request.method == "DELETE" && state.objects.contains_key(path) { + let current = &state.objects[path]; + assert_eq!(current["metadata"]["uid"], body["preconditions"]["uid"]); + assert_eq!(current["metadata"]["resourceVersion"], body["preconditions"]["resourceVersion"]); + if state.delete_conflict { return failure(409); } + if !state.retain_deleted { state.objects.remove(path); } + return ResponseTemplate::new(200).set_body_json(json!({ + "apiVersion":"v1","kind":"Status","status":"Success","code":200 + })); + } + failure(404) + }).mount(&server).await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + (server, client, state, grant, sandbox, namespace) +} + +fn mutations(state: &Arc<Mutex<State>>) -> Vec<(String, String, Value)> { + state + .lock() + .unwrap() + .calls + .iter() + .filter(|(method, _, _)| method != "GET") + .cloned() + .collect() +} + +#[tokio::test] +async fn observer_api_canonical_wire_targets_and_cilium_policy_are_exact() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + assert_eq!( + plan.rules, + vec![ + json!({"to":[{"ipBlock":{"cidr":"10.96.0.1/32"}}],"ports":[{"protocol":"TCP","port":443}]}), + json!({"to":[{"ipBlock":{"cidr":"172.18.0.3/32"}}],"ports":[{"protocol":"TCP","port":6443}]}) + ] + ); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + let object = state.lock().unwrap().objects[POLICY].clone(); + assert_eq!( + object["spec"], + json!({ + "endpointSelector":{"matchLabels":{"k8s:kars.azure.com/sandbox":"agent", + "k8s:io.kubernetes.pod.namespace":"kars-agent"}}, + "egress":[{"toEntities":["kube-apiserver"],"toPorts":[{"ports":[ + {"port":"443","protocol":"TCP"},{"port":"6443","protocol":"TCP"}]}]}] + }) + ); + assert_eq!( + object["metadata"]["annotations"][NAMESPACE_UID], + "runtime-uid" + ); + assert_eq!(object["metadata"]["annotations"][GRANT_OWNER], "grant-uid"); + assert_eq!( + object["metadata"]["annotations"][claim::SOURCE_UID], + "sandbox-uid" + ); + assert!(object.get("specs").is_none()); + assert!(object["spec"].get("ingress").is_none()); + assert!(!object.to_string().contains("pod-template-hash")); + assert!( + state + .lock() + .unwrap() + .calls + .iter() + .all(|(_, path, _)| !path.contains("ciliumclusterwide") + && path != "/apis/cilium.io/v2/ciliumnetworkpolicies") + ); +} + +#[tokio::test] +async fn observer_api_absent_cilium_keeps_portable_rules_without_optional_writes() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + state.lock().unwrap().errors.insert(API.into(), 404); + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + assert!(!plan.cilium); + assert_eq!(plan.rules.len(), 2); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + assert!(mutations(&state).is_empty()); + let rules = json!({"spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, + "policyTypes":["Egress"],"egress":plan.rules}}); + apply_runtime( + &client, + &grant, + &namespace, + "NetworkPolicy", + "portable", + rules.clone(), + ) + .await + .unwrap(); + let object = state.lock().unwrap().objects[ + "/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/portable"].clone(); + assert_eq!(object["spec"], rules["spec"]); + assert!( + state.lock().unwrap().objects[NS]["metadata"]["labels"] + .get(INDEX) + .is_none() + ); +} + +#[tokio::test] +async fn observer_api_discovery_errors_and_malformed_contracts_never_mean_absent() { + for code in [401, 403, 429, 500, 503] { + let (_server, client, state, _, _, _) = fixture().await; + state.lock().unwrap().errors.insert(API.into(), code); + let error = plan(&client, "10.96.0.1", "443").await.unwrap_err(); + assert!(error.contains(&code.to_string())); + assert!(!error.contains("canary")); + assert!(mutations(&state).is_empty()); + } + for (pointer, value) in [ + ("/groupVersion", json!("foreign/v2")), + ("/resources", json!([])), + ("/resources/0/namespaced", json!(false)), + ("/resources/0/kind", json!("CiliumClusterwideNetworkPolicy")), + ("/resources/0/verbs", json!(["get", "list"])), + ("/resources/0/verbs", json!("private-malformed-canary")), + ] { + let (_server, client, state, _, _, _) = fixture().await; + *state + .lock() + .unwrap() + .objects + .get_mut(API) + .unwrap() + .pointer_mut(pointer) + .unwrap() = value; + assert!( + plan(&client, "10.96.0.1", "443").await.is_err(), + "{pointer}" + ); + assert!(mutations(&state).is_empty()); + } +} + +#[tokio::test] +async fn observer_api_reuses_canonical_refusals_before_cilium_discovery() { + for (path, pointer, value) in [ + (SERVICE, "/spec/clusterIP", json!("10.96.0.2")), + (SERVICE, "/metadata/uid", Value::Null), + (SERVICE, "/metadata/namespace", json!("foreign")), + (ENDPOINTS, "/subsets/0/addresses", json!([])), + ( + ENDPOINTS, + "/subsets/0/addresses/0/ip", + json!("169.254.169.254"), + ), + (ENDPOINTS, "/subsets/0/ports/0/port", json!(0)), + (ENDPOINTS, "/subsets/0/ports/0/protocol", json!("UDP")), + ] { + let (_server, client, state, _, _, _) = fixture().await; + *state + .lock() + .unwrap() + .objects + .get_mut(path) + .unwrap() + .pointer_mut(pointer) + .unwrap() = value; + assert!( + plan(&client, "10.96.0.1", "443").await.is_err(), + "{pointer}" + ); + assert!( + !state + .lock() + .unwrap() + .calls + .iter() + .any(|(_, path, _)| path == API) + ); + assert!(mutations(&state).is_empty()); + } +} + +#[tokio::test] +async fn observer_api_ipv6_wire_targets_are_host_routes_not_subnets() { + let (_server, client, state, _, _, _) = fixture().await; + { + let mut state = state.lock().unwrap(); + state.objects.get_mut(SERVICE).unwrap()["spec"]["clusterIP"] = "fd00::1".into(); + state.objects.get_mut(ENDPOINTS).unwrap()["subsets"][0]["addresses"] = + json!([{"ip":"fd01::3"}]); + } + let plan = plan(&client, "fd00::1", "443").await.unwrap(); + assert_eq!(plan.rules[0]["to"][0]["ipBlock"]["cidr"], "fd00::1/128"); + assert_eq!(plan.rules[1]["to"][0]["ipBlock"]["cidr"], "fd01::3/128"); +} + +#[tokio::test] +async fn observer_api_updates_replace_extensions_under_uid_rv_and_then_are_idempotent() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + let object = state.objects.get_mut(POLICY).unwrap(); + object["specs"] = json!([{"endpointSelector":{},"egress":[{}]}]); + object["spec"]["endpointSelector"] = json!({}); + object["spec"]["ingress"] = json!([{}]); + object["metadata"]["annotations"]["private-extension"] = "private-canary".into(); + state.calls.clear(); + } + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + assert_eq!(mutations(&state).len(), 1); + assert_eq!(mutations(&state)[0].0, "PUT"); + assert_eq!( + state.lock().unwrap().objects[POLICY]["metadata"]["uid"], + "policy-uid" + ); + assert!(state.lock().unwrap().objects[POLICY].get("specs").is_none()); + assert!( + state.lock().unwrap().objects[POLICY]["spec"] + .get("ingress") + .is_none() + ); + state.lock().unwrap().calls.clear(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + assert!(mutations(&state).is_empty()); +} + +#[tokio::test] +async fn observer_api_foreign_grant_namespace_owner_target_and_finalizers_are_preserved() { + for (pointer, value) in [ + ( + format!("/metadata/annotations/{GRANT_OWNER}") + .replace("kars.azure.com/", "kars.azure.com~1"), + json!("foreign"), + ), + ( + format!("/metadata/annotations/{NAMESPACE_UID}") + .replace("kars.azure.com/", "kars.azure.com~1"), + json!("foreign"), + ), + ( + format!("/metadata/annotations/{}", claim::SOURCE_UID) + .replace("kars.azure.com/", "kars.azure.com~1"), + json!("foreign"), + ), + ("/metadata/ownerReferences/0/uid".into(), json!("foreign")), + ( + "/metadata/labels/kars.azure.com~1observer-metadata-grant".into(), + json!("foreign"), + ), + ("/metadata/uid".into(), Value::Null), + ("/metadata/finalizers".into(), json!(["foreign"])), + ] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + state.objects.get_mut(POLICY).unwrap()["metadata"]["finalizers"] = json!([]); + *state + .objects + .get_mut(POLICY) + .unwrap() + .pointer_mut(&pointer) + .unwrap() = value; + state.calls.clear(); + } + assert!( + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .is_err(), + "{pointer}" + ); + assert!(mutations(&state).is_empty()); + } +} + +#[tokio::test] +async fn observer_api_approval_and_namespace_fences_precede_all_writes() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + for mode in [ + "disabled", + "removed", + "target-uid", + "workspace", + "generation", + ] { + let mut candidate = grant.clone(); + match mode { + "disabled" => candidate.spec.enabled = false, + "removed" => candidate.spec.observation_targets.clear(), + "target-uid" => candidate.spec.observation_targets[0].uid = "foreign".into(), + "workspace" => candidate.spec.observation_targets[0].namespace = "foreign".into(), + _ => candidate.metadata.generation = Some(2), + } + assert!( + ensure( + &client, + &candidate, + &sandbox, + &namespace, + "observer-g1", + &plan + ) + .await + .is_err(), + "{mode}" + ); + assert!(mutations(&state).is_empty()); + } + state.lock().unwrap().objects.get_mut(NS).unwrap()["metadata"]["uid"] = "replacement".into(); + assert!( + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .is_err() + ); + assert!(mutations(&state).is_empty()); +} + +#[tokio::test] +async fn observer_api_missing_isolation_preflight_never_reads_targets_or_writes_policy() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + state.lock().unwrap().objects.insert("/api/v1/namespaces/controller".into(), json!({ + "apiVersion":"v1","kind":"Namespace","metadata":{"name":"controller","uid":"controller-ns","resourceVersion":"1"} + })); + let binding: Binding = serde_json::from_value(json!({ + "capability":crate::service_observer::CAPABILITY,"identity":{"managed":true}, + "grant":{"name":"workspace","namespace":"work","uid":"grant-uid","generation":1}, + "recipients":[],"privacyRevision":"fixture","privacyEpoch":null, + "serverName":"observer-sandbox-uid.kars.internal","caPem":"fixture","workspaceUid":"workspace-uid", + "verifier":{"capability":crate::observation_privacy::CAPABILITY,"namespace":"controller", + "namespaceUid":"controller-ns","controllerUid":"controller-uid","serviceUid":"service-uid", + "port":9448,"descriptorUid":"descriptor","tlsUid":"tls","tlsVersion":"1", + "serverName":"privacy-controller-ns.kars.internal","caPem":"fixture","expiresAt":100} + })).unwrap(); + assert!( + super::super::ensure(&client, &grant, &sandbox, &namespace, &binding) + .await + .unwrap_err() + .contains("existing controller/runtime network isolation") + ); + assert!(mutations(&state).is_empty()); + assert!( + !state + .lock() + .unwrap() + .calls + .iter() + .any(|(_, path, _)| path == SERVICE || path == API) + ); +} + +#[tokio::test] +async fn observer_api_retirement_finds_orphans_without_other_metadata_and_keeps_only_approved_generation() + { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + retire(&client, &grant, true).await.unwrap(); + assert!(state.lock().unwrap().objects.contains_key(POLICY)); + let mut next = grant.clone(); + next.metadata.generation = Some(2); + retire(&client, &next, true).await.unwrap(); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + assert_eq!( + state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], + "v1" + ); + retire(&client, &next, true).await.unwrap(); + let deletes: Vec<_> = mutations(&state) + .into_iter() + .filter(|(method, _, _)| method == "DELETE") + .collect(); + assert_eq!(deletes.len(), 1); + assert_eq!( + deletes[0].2["preconditions"], + json!({"uid":"policy-uid","resourceVersion":"10"}) + ); +} + +#[tokio::test] +async fn observer_api_removed_disabled_and_full_revoke_remove_even_same_generation_policies() { + for mode in ["removed", "disabled", "revoke"] { + let (_server, client, state, mut grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + if mode == "removed" { + grant.spec.observation_targets.clear(); + } + if mode == "disabled" { + grant.spec.enabled = false; + } + retire(&client, &grant, mode != "revoke").await.unwrap(); + assert!( + !state.lock().unwrap().objects.contains_key(POLICY), + "{mode}" + ); + } +} + +#[tokio::test] +async fn observer_api_cleanup_conflicts_pending_deletes_and_api_errors_remain_retryable() { + for mode in ["conflict", "pending", "forbidden"] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + state.delete_conflict = mode == "conflict"; + state.retain_deleted = mode == "pending"; + if mode == "forbidden" { + state.errors.insert(POLICIES.into(), 403); + } + } + assert!(retire(&client, &grant, false).await.is_err()); + assert!(state.lock().unwrap().objects.contains_key(POLICY)); + assert_eq!( + state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], + "v1" + ); + { + let mut state = state.lock().unwrap(); + state.delete_conflict = false; + state.retain_deleted = false; + state.errors.clear(); + } + retire(&client, &grant, false).await.unwrap(); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + } +} + +#[tokio::test] +async fn observer_api_partial_other_metadata_failure_cannot_skip_cnp_revocation() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + state.lock().unwrap().errors.insert( + "/apis/rbac.authorization.k8s.io/v1/rolebindings".into(), + 403, + ); + assert!(super::super::revoke(&client, &grant).await.is_err()); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + assert_eq!( + state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], + "v1" + ); + state.lock().unwrap().errors.clear(); + super::super::revoke(&client, &grant).await.unwrap(); +} + +#[tokio::test] +async fn observer_api_namespace_race_before_create_never_writes_into_the_replacement() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + state.lock().unwrap().namespace_replacement_on_policy_read = Some(POLICY.into()); + assert!( + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .is_err() + ); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + assert!( + !mutations(&state) + .iter() + .any(|(_, path, _)| path.contains("ciliumnetworkpolicies")) + ); +} + +#[tokio::test] +async fn observer_api_retirement_preserves_foreign_owners_and_namespace_replacements() { + for mode in ["grant", "owner", "target", "namespace", "workspace"] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + match mode { + "grant" => { + state.objects.get_mut(POLICY).unwrap()["metadata"]["annotations"][GRANT_OWNER] = + "foreign".into() + } + "owner" => { + state.objects.get_mut(POLICY).unwrap()["metadata"]["ownerReferences"][0]["uid"] = + "foreign".into() + } + "target" => { + state.objects.get_mut(POLICY).unwrap()["metadata"]["annotations"] + [claim::SOURCE_UID] = "foreign".into() + } + "namespace" => { + state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = "replacement".into() + } + _ => { + state.objects.get_mut(NS).unwrap()["metadata"]["annotations"] + [claim::SOURCE_NAMESPACE] = "foreign".into() + } + } + state.calls.clear(); + } + let result = retire(&client, &grant, false).await; + if mode != "workspace" { + assert!(result.is_err(), "{mode}"); + } + assert!(state.lock().unwrap().objects.contains_key(POLICY)); + assert!(mutations(&state).is_empty()); + } +} + +#[tokio::test] +async fn observer_api_ordinary_namespaces_do_not_probe_cilium_or_gain_an_index() { + let (_server, client, state, mut grant, _, _) = fixture().await; + grant.spec.observation_targets.clear(); + state.lock().unwrap().errors.insert(API.into(), 403); + retire(&client, &grant, true).await.unwrap(); + assert!(mutations(&state).is_empty()); + assert!( + !state + .lock() + .unwrap() + .calls + .iter() + .any(|(_, path, _)| path == API) + ); +} + +#[tokio::test] +async fn observer_api_portable_policy_is_revoked_when_last_target_is_removed() { + let (_server, client, state, mut grant, sandbox, namespace) = fixture().await; + let name = format!( + "{}-rpc", + policy_prefix(&grant, &sandbox.uid().unwrap()).unwrap() + ); + let path = format!("/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/{name}"); + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + apply_runtime( + &client, + &grant, + &namespace, + "NetworkPolicy", + &name, + json!({ + "spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, + "policyTypes":["Egress"],"egress":plan.rules} + }), + ) + .await + .unwrap(); + state.lock().unwrap().calls.clear(); + state.lock().unwrap().errors.insert(API.into(), 403); + grant.spec.observation_targets.clear(); + super::super::revoke_stale(&client, &grant).await.unwrap(); + assert!(!state.lock().unwrap().objects.contains_key(&path)); + assert!( + !state + .lock() + .unwrap() + .calls + .iter() + .any(|(_, path, _)| path == API) + ); + assert_eq!( + mutations(&state)[0].2["preconditions"], + json!({"uid":"policy-uid","resourceVersion":"10"}) + ); +} + +fn runtime_policy( + kind: &str, + sandbox: &KarsSandbox, + namespace: &Namespace, + plan: &Plan, +) -> (String, Value) { + if kind == KIND { + ( + format!("{POLICIES}/fenced"), + json!({"spec":spec(sandbox, namespace, plan).unwrap()}), + ) + } else { + ( + "/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/fenced".into(), + json!({"spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, + "policyTypes":["Egress"],"egress":plan.rules}}), + ) + } +} + +#[tokio::test] +async fn observer_api_both_policy_kinds_keep_original_namespace_uid_across_create_update_and_noop() +{ + for kind in ["NetworkPolicy", KIND] { + for operation in ["create", "update", "noop"] { + for replacement in ["before-namespace-read", "after-policy-read"] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + let (path, desired) = runtime_policy(kind, &sandbox, &namespace, &plan); + if operation != "create" { + let mut initial = desired.clone(); + if operation == "update" { + initial["spec"]["egress"] = json!([]); + } + apply_runtime(&client, &grant, &namespace, kind, "fenced", initial) + .await + .unwrap(); + } + let original = { + let mut state = state.lock().unwrap(); + let original = state.objects.get(&path).cloned(); + state.calls.clear(); + if replacement == "before-namespace-read" { + state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = + "replacement-uid".into(); + } else { + state.namespace_replacement_on_policy_read = Some(path.clone()); + } + original + }; + let error = apply_runtime(&client, &grant, &namespace, kind, "fenced", desired) + .await + .unwrap_err(); + assert!( + error.contains("namespace"), + "{kind}/{operation}/{replacement}" + ); + assert!( + mutations(&state).is_empty(), + "{kind}/{operation}/{replacement}" + ); + assert_eq!(state.lock().unwrap().objects.get(&path), original.as_ref()); + assert_eq!(namespace.uid().as_deref(), Some("runtime-uid")); + } + } + } +} + +#[tokio::test] +async fn observer_api_both_policy_kinds_require_existing_uid_rv_and_preserve_conflicted_objects() { + for kind in ["NetworkPolicy", KIND] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + let (path, desired) = runtime_policy(kind, &sandbox, &namespace, &plan); + let mut initial = desired.clone(); + initial["spec"]["egress"] = json!([]); + apply_runtime(&client, &grant, &namespace, kind, "fenced", initial) + .await + .unwrap(); + let original = state.lock().unwrap().objects[&path].clone(); + for missing in ["uid", "resourceVersion"] { + { + let mut state = state.lock().unwrap(); + state.objects.insert(path.clone(), original.clone()); + state.objects.get_mut(&path).unwrap()["metadata"][missing] = Value::Null; + state.calls.clear(); + } + assert!( + apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) + .await + .is_err(), + "{kind}/{missing}" + ); + assert!(mutations(&state).is_empty()); + } + { + let mut state = state.lock().unwrap(); + state.objects.insert(path.clone(), original.clone()); + state.replace_conflict = true; + state.calls.clear(); + } + let error = apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) + .await + .unwrap_err(); + assert!(error.contains("409")); + assert_eq!(state.lock().unwrap().objects[&path], original); + let requests = mutations(&state); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0, "PUT"); + assert_eq!(requests[0].2["metadata"]["uid"], "policy-uid"); + assert_eq!(requests[0].2["metadata"]["resourceVersion"], "10"); + assert_eq!( + requests[0].2["metadata"]["annotations"][NAMESPACE_UID], + "runtime-uid" + ); + { + let mut state = state.lock().unwrap(); + state.replace_conflict = false; + state.calls.clear(); + } + apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) + .await + .unwrap(); + assert_eq!( + state.lock().unwrap().objects[&path]["spec"], + desired["spec"] + ); + assert_eq!( + state.lock().unwrap().objects[&path]["metadata"]["uid"], + "policy-uid" + ); + } +} + +#[test] +fn observer_api_policy_does_not_change_agent_uid_1000_guard() { + let guard = crate::reconciler::build_egress_guard_command(false); + assert_eq!(guard, crate::reconciler::build_egress_guard_command(true)); + assert!(guard.contains("-m owner --uid-owner 1000 -j DROP")); + assert!(guard.contains("--dport 443 -j REDIRECT --to-port 8444")); + assert!(!guard.contains("6443")); + assert!(!guard.contains("KUBERNETES_SERVICE_HOST")); +} diff --git a/controller/src/credential_grants/operator.rs b/controller/src/credential_grants/operator.rs index e0e21f6c4..482589941 100644 --- a/controller/src/credential_grants/operator.rs +++ b/controller/src/credential_grants/operator.rs @@ -286,8 +286,8 @@ async fn publish( } pub(super) async fn revoke(client: &Client, grant: &KarsCredentialGrant) -> Result<(), String> { - super::observer_rbac::revoke(client, grant).await?; - super::observer_metadata::revoke(client, grant).await?; + let metadata = super::observer_metadata::revoke(client, grant).await; + let rbac = super::observer_rbac::revoke(client, grant).await; let workspace = grant.namespace().ok_or("Observation workspace missing")?; for sandbox in Api::<KarsSandbox>::namespaced(client.clone(), &workspace) .list(&ListParams::default()) @@ -308,7 +308,7 @@ pub(super) async fn revoke(client: &Client, grant: &KarsCredentialGrant) -> Resu retire(client, &sandbox, &namespace).await?; } } - Ok(()) + metadata.and(rbac) } async fn retire( diff --git a/controller/src/reconciler/mod.rs b/controller/src/reconciler/mod.rs index a20412ab9..88c26821b 100644 --- a/controller/src/reconciler/mod.rs +++ b/controller/src/reconciler/mod.rs @@ -50,7 +50,7 @@ pub(crate) mod trustgraph_mount; use mcp_egress::mcp_egress_rule; mod pod_spec; -mod sre_egress; +pub(crate) mod sre_egress; pub(crate) use pod_spec::{ build_egress_guard_command, build_pod_labels, build_pod_security_context, isolation_scheduling, sandbox_node_selector_from, diff --git a/controller/src/reconciler/sre_egress.rs b/controller/src/reconciler/sre_egress.rs index 7feb2e083..e2b9fb524 100644 --- a/controller/src/reconciler/sre_egress.rs +++ b/controller/src/reconciler/sre_egress.rs @@ -23,7 +23,7 @@ fn read_error(resource: &'static str, error: kube::Error) -> String { } } -pub(super) async fn rules( +pub(crate) async fn rules( client: &Client, service_host: &str, service_port: &str, diff --git a/deploy/helm/kars/templates/credential-grant-rbac.yaml b/deploy/helm/kars/templates/credential-grant-rbac.yaml index 15387ad6d..5e971e6ae 100644 --- a/deploy/helm/kars/templates/credential-grant-rbac.yaml +++ b/deploy/helm/kars/templates/credential-grant-rbac.yaml @@ -37,6 +37,11 @@ rules: - apiGroups: ["admissionregistration.k8s.io"] resources: ["validatingadmissionpolicies", "validatingadmissionpolicybindings"] verbs: ["get", "list"] + # Only the controller manages namespaced observer API policies when Cilium + # is already installed. No CiliumClusterwideNetworkPolicy or agent access. + - apiGroups: ["cilium.io"] + resources: ["ciliumnetworkpolicies"] + verbs: ["get", "list", "create", "update", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 25ba7cf9f..3254ae143 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -104,7 +104,8 @@ or legacy routes, even through the legacy loopback exception. Bridge pins the controller-issued CA and Sandbox-UID hostname, resolves only the verified Pod/ReplicaSet/Deployment lineage, and disables redirects, ambient trust roots and proxy discovery. Missing capability is an error, never a legacy fallback. -Core adds only the receiver-scoped runtime ingress policy. Existing BFF egress +Core adds the receiver-scoped runtime ingress policy and approved observer +runtime/verifier paths described below. Existing BFF egress isolation must explicitly permit that verified runtime's TCP 9447 before observation enrollment is usable. Core must not create an egress-only policy that accidentally isolates a previously unrestricted BFF and blocks its @@ -118,6 +119,40 @@ to the selected runtime namespace and Sandbox Pods. The private chart's of **existing** isolation, and accepts only explicitly reviewed target namespace names. It does not replace the existing API/provider/OIDC/GitHub egress baseline. +The observer router also requires HTTPS access to the canonical Kubernetes API +for its existing authenticated metadata checks. Only an explicitly approved +current observation target, after the runtime/controller isolation preflight, +receives this path. Its existing grant-owned runtime NetworkPolicy includes +exact API Service and ready Endpoint `/32` or `/128` destinations with their +validated HTTPS ports, using the same canonical-target validator as SRE. +No ordinary Sandbox receives this additional policy. + +When the `cilium.io/v2` API is already installed, core additionally owns a +namespace-scoped CiliumNetworkPolicy with only `toEntities: [kube-apiserver]` +and those validated TCP ports. Its selector uses source-qualified Sandbox and +namespace labels, never `pod-template-hash` (excluded from Cilium identities by +default). This handles Cilium's API entity classification; it does not install +or configure Cilium, add a CiliumClusterwideNetworkPolicy, or allow world/nodes. +A genuine discovery `404 NotFound` keeps the portable non-Cilium path. +Authorization, transport and malformed discovery errors block issuance rather +than silently treating Cilium as absent. + +The chart grants **only the controller** `get/list/create/update/delete` on the +namespaced `ciliumnetworkpolicies` resource through its existing controller +ClusterRole/Binding. These permissions are not granted to agents, Bridge, or +users; controller calls use namespaced policy APIs, not clusterwide policy +inventory. Existing namespace-label patch permission maintains a durable +`kars.azure.com/observer-api-policy=v1` cleanup index. That label grants no +network access and remains until namespace deletion, so interrupted cleanup +cannot hide a policy after other metadata has gone. Grant UID, target UID, +namespace UID, namespace ownership and generation checks authorize lifecycle +operations. Owned policy replacement/deletion uses UID/resourceVersion fences; +replacement removes stale `specs`, extra ingress and other policy extensions +instead of merging them. Removed targets, disabled/deleted grants and stale +generations revoke the CNP even after partial RBAC/NetworkPolicy cleanup. +The UID-1000 egress guard, API authentication, TLS validation and original +observer-readiness deadline are unchanged. + ### Controller privacy verification RPC Enable the approved core verifier explicitly: From 2316d9014bc4b4928217fe678bd2909fda473b1b Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 14:36:48 +0200 Subject: [PATCH 092/111] test(e2e): retain bounded port-forward failure details Record only a fixed failure category and actual exit status before removing private logs. Reap the owned child without a stale-PID cleanup attempt. Preserve the one-shot startup and all authentication, scope and cleanup assertions; do not retry an unexplained failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- tests/e2e/governed-services.sh | 27 ++++++++++++++++-- tests/e2e/governed_services_test.py | 44 +++++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/tests/e2e/governed-services.sh b/tests/e2e/governed-services.sh index 9bfb97e2d..fabffeb33 100644 --- a/tests/e2e/governed-services.sh +++ b/tests/e2e/governed-services.sh @@ -9,6 +9,7 @@ test_governed_services() ( local scratch forward_pid="" port="" token agent_token scope request_id new_scope code local sandbox_uid namespace_uid local stage=setup category=command expected_status=0 actual_status=0 + local forward_exit=-1 # Exit status has not been observed yet. local token_present=false agent_token_present=false tokens_distinct=false local sandbox_present=false namespace_present=false forward_started=false local scope_changed=false sandbox_preserved=false telemetry_scope_matches=false @@ -16,10 +17,10 @@ test_governed_services() ( exec 3>&2 exec 2>"$scratch/commands.log" governed_failure() { - printf 'GOVERNED-SERVICES-FAILURE {"stage":"%s","category":"%s","expectedHttpStatus":%s,"httpStatus":%s,"operatorTokenPresent":%s,"agentTokenPresent":%s,"tokensDistinct":%s,"sandboxUidPresent":%s,"namespaceUidPresent":%s,"forwardStarted":%s,"scopeChanged":%s,"sandboxPreserved":%s,"telemetryScopeMatches":%s}\n' \ + printf 'GOVERNED-SERVICES-FAILURE {"stage":"%s","category":"%s","expectedHttpStatus":%s,"httpStatus":%s,"operatorTokenPresent":%s,"agentTokenPresent":%s,"tokensDistinct":%s,"sandboxUidPresent":%s,"namespaceUidPresent":%s,"forwardStarted":%s,"forwardExitStatus":%s,"scopeChanged":%s,"sandboxPreserved":%s,"telemetryScopeMatches":%s}\n' \ "$stage" "$category" "$expected_status" "$actual_status" \ "$token_present" "$agent_token_present" "$tokens_distinct" \ - "$sandbox_present" "$namespace_present" "$forward_started" \ + "$sandbox_present" "$namespace_present" "$forward_started" "$forward_exit" \ "$scope_changed" "$sandbox_preserved" "$telemetry_scope_matches" >&3 } service_stage() { @@ -92,7 +93,27 @@ PY forward_pid=$! local deadline=$(($(date +%s) + 30)) while [ "$(date +%s)" -lt "$deadline" ]; do - kill -0 "$forward_pid" 2>/dev/null || { category=process-exited; return 1; } + if ! kill -0 "$forward_pid" 2>/dev/null; then + if wait "$forward_pid"; then forward_exit=0; else forward_exit=$?; fi + forward_pid="" + category=$(python3 - "$scratch/forward.log" <<'PY' +import re, sys +with open(sys.argv[1], "rb") as source: + text = source.read(65536).decode("utf-8", errors="replace") +patterns = { + "pod-not-running": r"^error: unable to forward port because pod is not running\. Current status=", + "pod-disconnected": r"^error: lost connection to pod\b", + "upgrade-failed": r"^error: error upgrading connection:", + "local-bind-failed": r"unable to listen on any of the requested ports", + "service-port-missing": r"^error: Service .+ does not have a service port ", + "forward-forbidden": r"^error: .*(?:\(Forbidden\)|forbidden:)", +} +matched = [name for name, pattern in patterns.items() if re.search(pattern, text, re.MULTILINE)] +print(matched[0] if len(matched) == 1 else "process-exited") +PY + ) || { category=classification-failed; return 1; } + return 1 + fi port=$(sed -n 's/^Forwarding from 127\.0\.0\.1:\([0-9]*\) ->.*/\1/p' "$scratch/forward.log" | head -1) [ -z "$port" ] || break sleep 1 diff --git a/tests/e2e/governed_services_test.py b/tests/e2e/governed_services_test.py index 85a62a683..89c1e7ab1 100644 --- a/tests/e2e/governed_services_test.py +++ b/tests/e2e/governed_services_test.py @@ -39,9 +39,23 @@ args = args[2:] if args[0] == "port-forward": assert "--address" in args and "127.0.0.1" in args and ":8443" in args + starts = root / "forward-start-count" + starts.write_text(str(int(starts.read_text()) + 1 if starts.exists() else 1)) (root / "forward-pid").write_text(str(os.getpid())) - if mode == "forward-exit": - sys.exit(1) + failures = { + "forward-not-running": "error: unable to forward port because pod is not running. Current status=Pending", + "forward-disconnected": "error: lost connection to pod", + "forward-upgrade": "error: error upgrading connection: private upgrade details", + "forward-bind": "error: unable to listen on any of the requested ports: private bind details", + "forward-port": "error: Service private-service does not have a service port 8443", + "forward-forbidden": "error: Error from server (Forbidden): private authorization details", + "forward-ambiguous": "error: lost connection to pod\nerror: error upgrading connection: private", + "forward-oversized": "x" * 65536 + "\nerror: lost connection to pod", + } + if mode in failures or mode == "forward-exit": + print(failures.get(mode, private), file=sys.stderr) + (root / "forward-exited").write_text("true") + sys.exit(23) def stopped(_signal, _frame): (root / "forward-stopped").write_text("true") sys.exit(0) @@ -135,11 +149,13 @@ def run_gate(self, mode): self.assertFalse(scratch.exists(), "Credential-bearing scratch files were retained") count = root / "request-count" requests = int(count.read_text()) if count.exists() else 0 - if (root / "forward-pid").exists() and mode != "forward-exit": + if (root / "forward-start-count").exists(): + self.assertEqual((root / "forward-start-count").read_text(), "1") + if (root / "forward-pid").exists() and not (root / "forward-exited").exists(): self.assertTrue((root / "forward-stopped").exists(), "Owned forward was not stopped") finally: pid_file = root / "forward-pid" - if pid_file.exists() and mode != "forward-exit" and not (root / "forward-stopped").exists(): + if pid_file.exists() and not (root / "forward-exited").exists() and not (root / "forward-stopped").exists(): try: os.kill(int(pid_file.read_text()), signal.SIGTERM) except ProcessLookupError: @@ -161,11 +177,14 @@ def failure(self, mode, stage, category, requests=None): self.assertEqual(set(fact), { "stage", "category", "expectedHttpStatus", "httpStatus", "operatorTokenPresent", "agentTokenPresent", "tokensDistinct", "sandboxUidPresent", "namespaceUidPresent", - "forwardStarted", "scopeChanged", "sandboxPreserved", "telemetryScopeMatches", + "forwardStarted", "forwardExitStatus", "scopeChanged", "sandboxPreserved", "telemetryScopeMatches", }) for key, value in fact.items(): - if key not in {"stage", "category", "expectedHttpStatus", "httpStatus"}: + if key not in {"stage", "category", "expectedHttpStatus", "httpStatus", "forwardExitStatus"}: self.assertIsInstance(value, bool) + self.assertIs(type(fact["forwardExitStatus"]), int) + self.assertGreaterEqual(fact["forwardExitStatus"], -1) + self.assertLessEqual(fact["forwardExitStatus"], 255) if requests is not None: self.assertEqual(count, requests) return fact @@ -179,6 +198,19 @@ def test_unchanged_positive_sequence_has_no_failure_diagnostic(self): def test_forward_exit_is_reported_before_private_log_cleanup(self): fact = self.failure("forward-exit", "port-forward-start", "process-exited", 0) self.assertFalse(fact["forwardStarted"]) + self.assertEqual(fact["forwardExitStatus"], 23) + + def test_known_forward_failures_are_bounded_redacted_and_not_retried(self): + for mode, category in ( + ("forward-not-running", "pod-not-running"), ("forward-disconnected", "pod-disconnected"), + ("forward-upgrade", "upgrade-failed"), ("forward-bind", "local-bind-failed"), + ("forward-port", "service-port-missing"), ("forward-forbidden", "forward-forbidden"), + ("forward-ambiguous", "process-exited"), ("forward-oversized", "process-exited"), + ): + with self.subTest(mode=mode): + fact = self.failure(mode, "port-forward-start", category, 0) + self.assertFalse(fact["forwardStarted"]) + self.assertEqual(fact["forwardExitStatus"], 23) def test_unchanged_scope_still_fails_and_cleans_up(self): fact = self.failure("same-scope", "reset-scope-change", "assertion", 8) From 934d74420e17c87ca4f7bd1145c61e35cc815d37 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 14:58:18 +0200 Subject: [PATCH 093/111] fix(controller): complete API namespace recheck and split regressions Map only a successful namespace recheck to unit, preserving validation and error propagation. Split lifecycle/fencing regressions into a shared-fixture child module so every new file meets the existing size limit. Retain all twenty regression functions and assertions; no gate exception or policy behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../observer_metadata/api_egress.rs | 1 + .../observer_metadata/api_egress/tests.rs | 360 +---------------- .../api_egress/tests/lifecycle.rs | 363 ++++++++++++++++++ 3 files changed, 366 insertions(+), 358 deletions(-) create mode 100644 controller/src/credential_grants/observer_metadata/api_egress/tests/lifecycle.rs diff --git a/controller/src/credential_grants/observer_metadata/api_egress.rs b/controller/src/credential_grants/observer_metadata/api_egress.rs index 1efcafb4b..f4298be1e 100644 --- a/controller/src/credential_grants/observer_metadata/api_egress.rs +++ b/controller/src/credential_grants/observer_metadata/api_egress.rs @@ -161,6 +161,7 @@ pub(super) async fn ensure( super::super::verify(client, grant).await?; claim::recheck(client, sandbox, namespace) .await + .map(|_| ()) .map_err(|_| "Observer API namespace changed during policy issuance".to_string()) } diff --git a/controller/src/credential_grants/observer_metadata/api_egress/tests.rs b/controller/src/credential_grants/observer_metadata/api_egress/tests.rs index 75d9ce513..372415e05 100644 --- a/controller/src/credential_grants/observer_metadata/api_egress/tests.rs +++ b/controller/src/credential_grants/observer_metadata/api_egress/tests.rs @@ -8,6 +8,8 @@ use std::{ }; use wiremock::{Mock, MockServer, ResponseTemplate}; +mod lifecycle; + const NS: &str = "/api/v1/namespaces/kars-agent"; const GRANT: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karscredentialgrants/workspace"; const API: &str = "/apis/cilium.io/v2"; @@ -522,364 +524,6 @@ async fn observer_api_missing_isolation_preflight_never_reads_targets_or_writes_ ); } -#[tokio::test] -async fn observer_api_retirement_finds_orphans_without_other_metadata_and_keeps_only_approved_generation() - { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) - .await - .unwrap(); - retire(&client, &grant, true).await.unwrap(); - assert!(state.lock().unwrap().objects.contains_key(POLICY)); - let mut next = grant.clone(); - next.metadata.generation = Some(2); - retire(&client, &next, true).await.unwrap(); - assert!(!state.lock().unwrap().objects.contains_key(POLICY)); - assert_eq!( - state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], - "v1" - ); - retire(&client, &next, true).await.unwrap(); - let deletes: Vec<_> = mutations(&state) - .into_iter() - .filter(|(method, _, _)| method == "DELETE") - .collect(); - assert_eq!(deletes.len(), 1); - assert_eq!( - deletes[0].2["preconditions"], - json!({"uid":"policy-uid","resourceVersion":"10"}) - ); -} - -#[tokio::test] -async fn observer_api_removed_disabled_and_full_revoke_remove_even_same_generation_policies() { - for mode in ["removed", "disabled", "revoke"] { - let (_server, client, state, mut grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) - .await - .unwrap(); - if mode == "removed" { - grant.spec.observation_targets.clear(); - } - if mode == "disabled" { - grant.spec.enabled = false; - } - retire(&client, &grant, mode != "revoke").await.unwrap(); - assert!( - !state.lock().unwrap().objects.contains_key(POLICY), - "{mode}" - ); - } -} - -#[tokio::test] -async fn observer_api_cleanup_conflicts_pending_deletes_and_api_errors_remain_retryable() { - for mode in ["conflict", "pending", "forbidden"] { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) - .await - .unwrap(); - { - let mut state = state.lock().unwrap(); - state.delete_conflict = mode == "conflict"; - state.retain_deleted = mode == "pending"; - if mode == "forbidden" { - state.errors.insert(POLICIES.into(), 403); - } - } - assert!(retire(&client, &grant, false).await.is_err()); - assert!(state.lock().unwrap().objects.contains_key(POLICY)); - assert_eq!( - state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], - "v1" - ); - { - let mut state = state.lock().unwrap(); - state.delete_conflict = false; - state.retain_deleted = false; - state.errors.clear(); - } - retire(&client, &grant, false).await.unwrap(); - assert!(!state.lock().unwrap().objects.contains_key(POLICY)); - } -} - -#[tokio::test] -async fn observer_api_partial_other_metadata_failure_cannot_skip_cnp_revocation() { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) - .await - .unwrap(); - state.lock().unwrap().errors.insert( - "/apis/rbac.authorization.k8s.io/v1/rolebindings".into(), - 403, - ); - assert!(super::super::revoke(&client, &grant).await.is_err()); - assert!(!state.lock().unwrap().objects.contains_key(POLICY)); - assert_eq!( - state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], - "v1" - ); - state.lock().unwrap().errors.clear(); - super::super::revoke(&client, &grant).await.unwrap(); -} - -#[tokio::test] -async fn observer_api_namespace_race_before_create_never_writes_into_the_replacement() { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - state.lock().unwrap().namespace_replacement_on_policy_read = Some(POLICY.into()); - assert!( - ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) - .await - .is_err() - ); - assert!(!state.lock().unwrap().objects.contains_key(POLICY)); - assert!( - !mutations(&state) - .iter() - .any(|(_, path, _)| path.contains("ciliumnetworkpolicies")) - ); -} - -#[tokio::test] -async fn observer_api_retirement_preserves_foreign_owners_and_namespace_replacements() { - for mode in ["grant", "owner", "target", "namespace", "workspace"] { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) - .await - .unwrap(); - { - let mut state = state.lock().unwrap(); - match mode { - "grant" => { - state.objects.get_mut(POLICY).unwrap()["metadata"]["annotations"][GRANT_OWNER] = - "foreign".into() - } - "owner" => { - state.objects.get_mut(POLICY).unwrap()["metadata"]["ownerReferences"][0]["uid"] = - "foreign".into() - } - "target" => { - state.objects.get_mut(POLICY).unwrap()["metadata"]["annotations"] - [claim::SOURCE_UID] = "foreign".into() - } - "namespace" => { - state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = "replacement".into() - } - _ => { - state.objects.get_mut(NS).unwrap()["metadata"]["annotations"] - [claim::SOURCE_NAMESPACE] = "foreign".into() - } - } - state.calls.clear(); - } - let result = retire(&client, &grant, false).await; - if mode != "workspace" { - assert!(result.is_err(), "{mode}"); - } - assert!(state.lock().unwrap().objects.contains_key(POLICY)); - assert!(mutations(&state).is_empty()); - } -} - -#[tokio::test] -async fn observer_api_ordinary_namespaces_do_not_probe_cilium_or_gain_an_index() { - let (_server, client, state, mut grant, _, _) = fixture().await; - grant.spec.observation_targets.clear(); - state.lock().unwrap().errors.insert(API.into(), 403); - retire(&client, &grant, true).await.unwrap(); - assert!(mutations(&state).is_empty()); - assert!( - !state - .lock() - .unwrap() - .calls - .iter() - .any(|(_, path, _)| path == API) - ); -} - -#[tokio::test] -async fn observer_api_portable_policy_is_revoked_when_last_target_is_removed() { - let (_server, client, state, mut grant, sandbox, namespace) = fixture().await; - let name = format!( - "{}-rpc", - policy_prefix(&grant, &sandbox.uid().unwrap()).unwrap() - ); - let path = format!("/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/{name}"); - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - apply_runtime( - &client, - &grant, - &namespace, - "NetworkPolicy", - &name, - json!({ - "spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, - "policyTypes":["Egress"],"egress":plan.rules} - }), - ) - .await - .unwrap(); - state.lock().unwrap().calls.clear(); - state.lock().unwrap().errors.insert(API.into(), 403); - grant.spec.observation_targets.clear(); - super::super::revoke_stale(&client, &grant).await.unwrap(); - assert!(!state.lock().unwrap().objects.contains_key(&path)); - assert!( - !state - .lock() - .unwrap() - .calls - .iter() - .any(|(_, path, _)| path == API) - ); - assert_eq!( - mutations(&state)[0].2["preconditions"], - json!({"uid":"policy-uid","resourceVersion":"10"}) - ); -} - -fn runtime_policy( - kind: &str, - sandbox: &KarsSandbox, - namespace: &Namespace, - plan: &Plan, -) -> (String, Value) { - if kind == KIND { - ( - format!("{POLICIES}/fenced"), - json!({"spec":spec(sandbox, namespace, plan).unwrap()}), - ) - } else { - ( - "/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/fenced".into(), - json!({"spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, - "policyTypes":["Egress"],"egress":plan.rules}}), - ) - } -} - -#[tokio::test] -async fn observer_api_both_policy_kinds_keep_original_namespace_uid_across_create_update_and_noop() -{ - for kind in ["NetworkPolicy", KIND] { - for operation in ["create", "update", "noop"] { - for replacement in ["before-namespace-read", "after-policy-read"] { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - let (path, desired) = runtime_policy(kind, &sandbox, &namespace, &plan); - if operation != "create" { - let mut initial = desired.clone(); - if operation == "update" { - initial["spec"]["egress"] = json!([]); - } - apply_runtime(&client, &grant, &namespace, kind, "fenced", initial) - .await - .unwrap(); - } - let original = { - let mut state = state.lock().unwrap(); - let original = state.objects.get(&path).cloned(); - state.calls.clear(); - if replacement == "before-namespace-read" { - state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = - "replacement-uid".into(); - } else { - state.namespace_replacement_on_policy_read = Some(path.clone()); - } - original - }; - let error = apply_runtime(&client, &grant, &namespace, kind, "fenced", desired) - .await - .unwrap_err(); - assert!( - error.contains("namespace"), - "{kind}/{operation}/{replacement}" - ); - assert!( - mutations(&state).is_empty(), - "{kind}/{operation}/{replacement}" - ); - assert_eq!(state.lock().unwrap().objects.get(&path), original.as_ref()); - assert_eq!(namespace.uid().as_deref(), Some("runtime-uid")); - } - } - } -} - -#[tokio::test] -async fn observer_api_both_policy_kinds_require_existing_uid_rv_and_preserve_conflicted_objects() { - for kind in ["NetworkPolicy", KIND] { - let (_server, client, state, grant, sandbox, namespace) = fixture().await; - let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); - let (path, desired) = runtime_policy(kind, &sandbox, &namespace, &plan); - let mut initial = desired.clone(); - initial["spec"]["egress"] = json!([]); - apply_runtime(&client, &grant, &namespace, kind, "fenced", initial) - .await - .unwrap(); - let original = state.lock().unwrap().objects[&path].clone(); - for missing in ["uid", "resourceVersion"] { - { - let mut state = state.lock().unwrap(); - state.objects.insert(path.clone(), original.clone()); - state.objects.get_mut(&path).unwrap()["metadata"][missing] = Value::Null; - state.calls.clear(); - } - assert!( - apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) - .await - .is_err(), - "{kind}/{missing}" - ); - assert!(mutations(&state).is_empty()); - } - { - let mut state = state.lock().unwrap(); - state.objects.insert(path.clone(), original.clone()); - state.replace_conflict = true; - state.calls.clear(); - } - let error = apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) - .await - .unwrap_err(); - assert!(error.contains("409")); - assert_eq!(state.lock().unwrap().objects[&path], original); - let requests = mutations(&state); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].0, "PUT"); - assert_eq!(requests[0].2["metadata"]["uid"], "policy-uid"); - assert_eq!(requests[0].2["metadata"]["resourceVersion"], "10"); - assert_eq!( - requests[0].2["metadata"]["annotations"][NAMESPACE_UID], - "runtime-uid" - ); - { - let mut state = state.lock().unwrap(); - state.replace_conflict = false; - state.calls.clear(); - } - apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) - .await - .unwrap(); - assert_eq!( - state.lock().unwrap().objects[&path]["spec"], - desired["spec"] - ); - assert_eq!( - state.lock().unwrap().objects[&path]["metadata"]["uid"], - "policy-uid" - ); - } -} - #[test] fn observer_api_policy_does_not_change_agent_uid_1000_guard() { let guard = crate::reconciler::build_egress_guard_command(false); diff --git a/controller/src/credential_grants/observer_metadata/api_egress/tests/lifecycle.rs b/controller/src/credential_grants/observer_metadata/api_egress/tests/lifecycle.rs new file mode 100644 index 000000000..401fc29b1 --- /dev/null +++ b/controller/src/credential_grants/observer_metadata/api_egress/tests/lifecycle.rs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::credential_grants::observer_metadata as metadata; + +#[tokio::test] +async fn observer_api_retirement_finds_orphans_without_other_metadata_and_keeps_only_approved_generation() + { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + retire(&client, &grant, true).await.unwrap(); + assert!(state.lock().unwrap().objects.contains_key(POLICY)); + let mut next = grant.clone(); + next.metadata.generation = Some(2); + retire(&client, &next, true).await.unwrap(); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + assert_eq!( + state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], + "v1" + ); + retire(&client, &next, true).await.unwrap(); + let deletes: Vec<_> = mutations(&state) + .into_iter() + .filter(|(method, _, _)| method == "DELETE") + .collect(); + assert_eq!(deletes.len(), 1); + assert_eq!( + deletes[0].2["preconditions"], + json!({"uid":"policy-uid","resourceVersion":"10"}) + ); +} + +#[tokio::test] +async fn observer_api_removed_disabled_and_full_revoke_remove_even_same_generation_policies() { + for mode in ["removed", "disabled", "revoke"] { + let (_server, client, state, mut grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + if mode == "removed" { + grant.spec.observation_targets.clear(); + } + if mode == "disabled" { + grant.spec.enabled = false; + } + retire(&client, &grant, mode != "revoke").await.unwrap(); + assert!( + !state.lock().unwrap().objects.contains_key(POLICY), + "{mode}" + ); + } +} + +#[tokio::test] +async fn observer_api_cleanup_conflicts_pending_deletes_and_api_errors_remain_retryable() { + for mode in ["conflict", "pending", "forbidden"] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + state.delete_conflict = mode == "conflict"; + state.retain_deleted = mode == "pending"; + if mode == "forbidden" { + state.errors.insert(POLICIES.into(), 403); + } + } + assert!(retire(&client, &grant, false).await.is_err()); + assert!(state.lock().unwrap().objects.contains_key(POLICY)); + assert_eq!( + state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], + "v1" + ); + { + let mut state = state.lock().unwrap(); + state.delete_conflict = false; + state.retain_deleted = false; + state.errors.clear(); + } + retire(&client, &grant, false).await.unwrap(); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + } +} + +#[tokio::test] +async fn observer_api_partial_other_metadata_failure_cannot_skip_cnp_revocation() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + state.lock().unwrap().errors.insert( + "/apis/rbac.authorization.k8s.io/v1/rolebindings".into(), + 403, + ); + assert!(metadata::revoke(&client, &grant).await.is_err()); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + assert_eq!( + state.lock().unwrap().objects[NS]["metadata"]["labels"][INDEX], + "v1" + ); + state.lock().unwrap().errors.clear(); + metadata::revoke(&client, &grant).await.unwrap(); +} + +#[tokio::test] +async fn observer_api_namespace_race_before_create_never_writes_into_the_replacement() { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + state.lock().unwrap().namespace_replacement_on_policy_read = Some(POLICY.into()); + assert!( + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .is_err() + ); + assert!(!state.lock().unwrap().objects.contains_key(POLICY)); + assert!( + !mutations(&state) + .iter() + .any(|(_, path, _)| path.contains("ciliumnetworkpolicies")) + ); +} + +#[tokio::test] +async fn observer_api_retirement_preserves_foreign_owners_and_namespace_replacements() { + for mode in ["grant", "owner", "target", "namespace", "workspace"] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + ensure(&client, &grant, &sandbox, &namespace, "observer-g1", &plan) + .await + .unwrap(); + { + let mut state = state.lock().unwrap(); + match mode { + "grant" => { + state.objects.get_mut(POLICY).unwrap()["metadata"]["annotations"][GRANT_OWNER] = + "foreign".into() + } + "owner" => { + state.objects.get_mut(POLICY).unwrap()["metadata"]["ownerReferences"][0]["uid"] = + "foreign".into() + } + "target" => { + state.objects.get_mut(POLICY).unwrap()["metadata"]["annotations"] + [claim::SOURCE_UID] = "foreign".into() + } + "namespace" => { + state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = "replacement".into() + } + _ => { + state.objects.get_mut(NS).unwrap()["metadata"]["annotations"] + [claim::SOURCE_NAMESPACE] = "foreign".into() + } + } + state.calls.clear(); + } + let result = retire(&client, &grant, false).await; + if mode != "workspace" { + assert!(result.is_err(), "{mode}"); + } + assert!(state.lock().unwrap().objects.contains_key(POLICY)); + assert!(mutations(&state).is_empty()); + } +} + +#[tokio::test] +async fn observer_api_ordinary_namespaces_do_not_probe_cilium_or_gain_an_index() { + let (_server, client, state, mut grant, _, _) = fixture().await; + grant.spec.observation_targets.clear(); + state.lock().unwrap().errors.insert(API.into(), 403); + retire(&client, &grant, true).await.unwrap(); + assert!(mutations(&state).is_empty()); + assert!( + !state + .lock() + .unwrap() + .calls + .iter() + .any(|(_, path, _)| path == API) + ); +} + +#[tokio::test] +async fn observer_api_portable_policy_is_revoked_when_last_target_is_removed() { + let (_server, client, state, mut grant, sandbox, namespace) = fixture().await; + let name = format!( + "{}-rpc", + policy_prefix(&grant, &sandbox.uid().unwrap()).unwrap() + ); + let path = format!("/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/{name}"); + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + apply_runtime( + &client, + &grant, + &namespace, + "NetworkPolicy", + &name, + json!({ + "spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, + "policyTypes":["Egress"],"egress":plan.rules} + }), + ) + .await + .unwrap(); + state.lock().unwrap().calls.clear(); + state.lock().unwrap().errors.insert(API.into(), 403); + grant.spec.observation_targets.clear(); + metadata::revoke_stale(&client, &grant).await.unwrap(); + assert!(!state.lock().unwrap().objects.contains_key(&path)); + assert!( + !state + .lock() + .unwrap() + .calls + .iter() + .any(|(_, path, _)| path == API) + ); + assert_eq!( + mutations(&state)[0].2["preconditions"], + json!({"uid":"policy-uid","resourceVersion":"10"}) + ); +} + +fn runtime_policy( + kind: &str, + sandbox: &KarsSandbox, + namespace: &Namespace, + plan: &Plan, +) -> (String, Value) { + if kind == KIND { + ( + format!("{POLICIES}/fenced"), + json!({"spec":spec(sandbox, namespace, plan).unwrap()}), + ) + } else { + ( + "/apis/networking.k8s.io/v1/namespaces/kars-agent/networkpolicies/fenced".into(), + json!({"spec":{"podSelector":{"matchLabels":{"kars.azure.com/sandbox":"agent"}}, + "policyTypes":["Egress"],"egress":plan.rules}}), + ) + } +} + +#[tokio::test] +async fn observer_api_both_policy_kinds_keep_original_namespace_uid_across_create_update_and_noop() +{ + for kind in ["NetworkPolicy", KIND] { + for operation in ["create", "update", "noop"] { + for replacement in ["before-namespace-read", "after-policy-read"] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + let (path, desired) = runtime_policy(kind, &sandbox, &namespace, &plan); + if operation != "create" { + let mut initial = desired.clone(); + if operation == "update" { + initial["spec"]["egress"] = json!([]); + } + apply_runtime(&client, &grant, &namespace, kind, "fenced", initial) + .await + .unwrap(); + } + let original = { + let mut state = state.lock().unwrap(); + let original = state.objects.get(&path).cloned(); + state.calls.clear(); + if replacement == "before-namespace-read" { + state.objects.get_mut(NS).unwrap()["metadata"]["uid"] = + "replacement-uid".into(); + } else { + state.namespace_replacement_on_policy_read = Some(path.clone()); + } + original + }; + let error = apply_runtime(&client, &grant, &namespace, kind, "fenced", desired) + .await + .unwrap_err(); + assert!( + error.contains("namespace"), + "{kind}/{operation}/{replacement}" + ); + assert!( + mutations(&state).is_empty(), + "{kind}/{operation}/{replacement}" + ); + assert_eq!(state.lock().unwrap().objects.get(&path), original.as_ref()); + assert_eq!(namespace.uid().as_deref(), Some("runtime-uid")); + } + } + } +} + +#[tokio::test] +async fn observer_api_both_policy_kinds_require_existing_uid_rv_and_preserve_conflicted_objects() { + for kind in ["NetworkPolicy", KIND] { + let (_server, client, state, grant, sandbox, namespace) = fixture().await; + let plan = plan(&client, "10.96.0.1", "443").await.unwrap(); + let (path, desired) = runtime_policy(kind, &sandbox, &namespace, &plan); + let mut initial = desired.clone(); + initial["spec"]["egress"] = json!([]); + apply_runtime(&client, &grant, &namespace, kind, "fenced", initial) + .await + .unwrap(); + let original = state.lock().unwrap().objects[&path].clone(); + for missing in ["uid", "resourceVersion"] { + { + let mut state = state.lock().unwrap(); + state.objects.insert(path.clone(), original.clone()); + state.objects.get_mut(&path).unwrap()["metadata"][missing] = Value::Null; + state.calls.clear(); + } + assert!( + apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) + .await + .is_err(), + "{kind}/{missing}" + ); + assert!(mutations(&state).is_empty()); + } + { + let mut state = state.lock().unwrap(); + state.objects.insert(path.clone(), original.clone()); + state.replace_conflict = true; + state.calls.clear(); + } + let error = apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) + .await + .unwrap_err(); + assert!(error.contains("409")); + assert_eq!(state.lock().unwrap().objects[&path], original); + let requests = mutations(&state); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].0, "PUT"); + assert_eq!(requests[0].2["metadata"]["uid"], "policy-uid"); + assert_eq!(requests[0].2["metadata"]["resourceVersion"], "10"); + assert_eq!( + requests[0].2["metadata"]["annotations"][NAMESPACE_UID], + "runtime-uid" + ); + { + let mut state = state.lock().unwrap(); + state.replace_conflict = false; + state.calls.clear(); + } + apply_runtime(&client, &grant, &namespace, kind, "fenced", desired.clone()) + .await + .unwrap(); + assert_eq!( + state.lock().unwrap().objects[&path]["spec"], + desired["spec"] + ); + assert_eq!( + state.lock().unwrap().objects[&path]["metadata"]["uid"], + "policy-uid" + ); + } +} From 24c85cd1f76f44c310c6bad89c5db1a9d29bbbe7 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 15:52:10 +0200 Subject: [PATCH 094/111] test(bridge): distinguish writer and private retirement baselines The real operator may replace Pods during writer retirement before recording the private-key retirement baseline. Verify that neither generation survives and bind the Qualified receipt to the original runtime, Task authorization, Deployment and admin Secret instead of requiring different phase UID sets to be identical. Preserve source/root data, key rotation and old/new-key HTTP assertions. Add positive phase-ordering and negative binding/data/auth regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 9 ++ .../native-credentials/observation_cases.py | 28 +++- .../test_late_observation_snapshot.py | 125 +++++++++++++++++- 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index 62512d2b1..d4db6732f 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -249,6 +249,15 @@ remain subject to the separate private Rust plan, and real Kind/CNI plus private adapter TLS/API lifecycle acceptance are still required. Native Kubernetes GET is name-authorized RBAC; no complete raw-GET UID-bound claim is made. +Native late-enrollment acceptance distinguishes the initial writer-retirement +Pods from the private-key retirement receipt's later baseline. The writer phase +can replace Pods before the v4 receipt is created, so those UID sets need not +match. No Pod from either captured set may remain. The qualified v4 receipt must +bind the same Sandbox, Task, Deployment, workspace, Task authorization and +original admin Secret UID. Source/bundle/projection preservation, unchanged +shared-root state, real key rotation, old-key denial and new-key acceptance +remain required; no receipt or retirement evidence is synthesized by the test. + The native observer enablement failure records `observationReadiness` alongside `metadataAtFailure` in `native.json`. Collection is read-only and bounded to the core controller and observation-target routers. Only the fixed core diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py index 85a0c976c..f8e5ccd91 100644 --- a/bridge/tests/native-credentials/observation_cases.py +++ b/bridge/tests/native-credentials/observation_cases.py @@ -124,9 +124,31 @@ def late_runtime_after(self, before): current_pods = {uid(entry) for entry in self.setup.admin.get(core(namespace, "pods"))["items"]} require(not current_pods.intersection(before["pods"]), "Old late-enrollment Pod UID survived retirement") receipt = json.loads(current_namespace["metadata"]["annotations"]["kars.azure.com/private-root-retirement"]) - require(receipt.get("version") == 4 and receipt.get("phase") == "Qualified" - and before["pods"].issubset(set(receipt.get("captured", []))), - "The real operator did not complete the captured late-runtime retirement") + retirement_error = "The real operator did not qualify the bound private-retirement receipt" + require(isinstance(receipt, dict) and type(receipt.get("version")) is int + and receipt["version"] == 4 and receipt.get("phase") == "Qualified", retirement_error) + captured = receipt.get("captured") + require(isinstance(captured, list) + and all(isinstance(value, str) and value for value in captured) + and len(set(captured)) == len(captured), retirement_error) + # Writer retirement may replace the original Pods before private-key + # retirement starts. Each phase's actual captured generation must be gone. + require(not current_pods.intersection(captured), "Private-retirement Pod UID survived retirement") + for path, expected in ( + (("runtime", "workspace"), CORE), + (("runtime", "sandbox", "name"), before["sandbox"]["metadata"]["name"]), + (("runtime", "sandbox", "uid"), uid(before["sandbox"])), + (("runtime", "task", "object", "name"), before["task"]["metadata"]["name"]), + (("runtime", "task", "object", "uid"), uid(before["task"])), + (("runtime", "task", "authorization"), before["task"].get("status", {}).get("envelopeDigest")), + (("deployment", "name"), before["deployment"]["metadata"]["name"]), + (("deployment", "uid"), uid(before["deployment"])), + (("baseline", "object", "uid"), uid(before["admin"])), + ): + actual = receipt + for field in path: + actual = actual.get(field) if isinstance(actual, dict) else None + require(isinstance(expected, str) and bool(expected) and actual == expected, retirement_error) for path, previous in before["stored"]: current = self.setup.admin.get(path) require(uid(current) == uid(previous) and current.get("data") == previous.get("data"), diff --git a/bridge/tests/native-credentials/test_late_observation_snapshot.py b/bridge/tests/native-credentials/test_late_observation_snapshot.py index 32f063d6a..232d07528 100644 --- a/bridge/tests/native-credentials/test_late_observation_snapshot.py +++ b/bridge/tests/native-credentials/test_late_observation_snapshot.py @@ -1,9 +1,12 @@ """Retain the actual governed Task bundle, not an absent legacy credentialsRef.""" +import base64 +from contextlib import nullcontext import copy +import json from types import SimpleNamespace import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import observation_cases as observation from credential_cases import SOURCE @@ -32,7 +35,8 @@ def setUp(self): self.source_path = core(CORE, "secrets", SOURCE) self.objects = { self.task_path: {"metadata": {"name": self.name, "uid": "task-uid", "annotations": { - "kars.azure.com/credential-bundle-uid": "bundle-uid"}}, "spec": {}}, + "kars.azure.com/credential-bundle-uid": "bundle-uid"}}, "spec": {}, + "status": {"envelopeDigest": "sha256:" + "a" * 64}}, self.bundle_path: {"metadata": {"uid": "bundle-uid", "ownerReferences": [copy.deepcopy(self.owner)], "annotations": {"kars.azure.com/credential-purpose": "agent-bundle-v2", "kars.azure.com/credential-target-kind": "KarsTask", @@ -47,7 +51,8 @@ def setUp(self): "kars.azure.com/private-epoch": "root-epoch"}}}, "/api/v1/namespaces/" + namespace: {"metadata": {"uid": "namespace-uid"}}, core(namespace, "pods"): {"items": [self.pod]}, - core(namespace, "secrets", "router-services-admin"): {"metadata": {"uid": "admin-uid"}}, + core(namespace, "secrets", "router-services-admin"): {"metadata": {"uid": "admin-uid"}, + "data": {"control-token": base64.b64encode(b"old-operator-token").decode()}}, resource(CORE, "deployments", "kars-controller", "/apis/apps/v1"): { "metadata": {"uid": "controller-uid", "generation": 1}, "spec": {}}, } @@ -108,6 +113,120 @@ def test_missing_or_ambiguous_projection_is_explicitly_rejected(self): with self.assertRaises(Failure): self.snapshot() + def retired(self, captured): + before = copy.deepcopy(self.snapshot()) + namespace = "kars-" + self.name + self.current_pod = copy.deepcopy(self.pod) + self.current_pod["metadata"].update(name="current-pod", uid="current-pod-uid") + self.objects[core(namespace, "pods")]["items"] = [self.current_pod] + self.objects[core(namespace, "secrets", "router-services-admin")]["data"]["control-token"] = ( + base64.b64encode(b"new-operator-token").decode()) + self.receipt = { + "version": 4, "phase": "Qualified", "captured": captured, + "runtime": {"workspace": CORE, "sandbox": {"name": self.name, "uid": "sandbox-uid"}, + "task": {"object": {"name": self.name, "uid": "task-uid"}, + "authorization": before["task"]["status"]["envelopeDigest"]}}, + "deployment": {"name": self.name, "uid": "deployment-uid"}, + "baseline": {"object": {"uid": "admin-uid"}}, + } + return before + + def after(self, before, responses=(401, 200)): + namespace = "kars-" + self.name + self.objects["/api/v1/namespaces/" + namespace]["metadata"]["annotations"] = { + "kars.azure.com/private-root-retirement": json.dumps(self.receipt)} + connections = [Mock() for _ in responses] + for connection, status in zip(connections, responses): + connection.getresponse.return_value = SimpleNamespace(status=status, read=lambda _: b"") + with patch.object(observation, "running", return_value=( + self.sandbox, self.deployment, self.current_pod)), \ + patch.object(observation, "forward", return_value=nullcontext()), \ + patch.object(observation.http.client, "HTTPConnection", side_effect=connections): + self.cases.late_runtime_after(before) + self.assertEqual([connection.request.call_args.args for connection in connections], + [("GET", "/internal/access-requests")] * 2) + self.assertEqual([connection.request.call_args.kwargs["headers"]["Authorization"] + for connection in connections], + ["Bearer old-operator-token", "Bearer new-operator-token"]) + self.assertTrue(all(connection.close.called for connection in connections)) + + def test_writer_and_private_retirement_can_capture_different_pod_generations(self): + before = self.retired(["post-writer-pod-uid"]) + self.assertEqual(before["pods"], {"pod-uid"}) + self.assertFalse(before["pods"].issubset(set(self.receipt["captured"]))) + self.after(before) + + def test_private_retirement_still_accepts_the_original_pod_generation(self): + self.after(self.retired(["pod-uid"])) + + def test_private_retirement_can_start_after_writer_retirement_left_no_live_pods(self): + self.after(self.retired([])) + + def test_neither_retirement_phase_may_leave_a_captured_pod_alive(self): + for survivor in ("pod-uid", "post-writer-pod-uid"): + with self.subTest(survivor=survivor): + self.setUp() + before = self.retired(["post-writer-pod-uid"]) + self.objects[core("kars-" + self.name, "pods")]["items"].append( + {"metadata": {"uid": survivor}}) + with self.assertRaises(Failure): + self.after(before) + + def test_private_receipt_must_bind_the_reviewed_runtime_and_original_admin_key(self): + for path, value in ( + (("version",), 3), (("phase",), "Rotating"), + (("runtime", "workspace"), "foreign"), + (("runtime", "sandbox", "uid"), "foreign"), + (("runtime", "task", "object", "uid"), "foreign"), + (("runtime", "task", "authorization"), "sha256:" + "b" * 64), + (("deployment", "uid"), "foreign"), (("baseline", "object", "uid"), "foreign"), + (("captured",), None), (("captured",), ["duplicate", "duplicate"]), + (("captured",), [None]), + ): + with self.subTest(path=path, value=value): + self.setUp() + before = self.retired(["post-writer-pod-uid"]) + target = self.receipt + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + with self.assertRaises(Failure): + self.after(before) + + def test_actual_old_key_denial_and_new_key_acceptance_remain_required(self): + for responses in ((200, 200), (401, 401)): + with self.subTest(responses=responses): + self.setUp() + with self.assertRaises(Failure): + self.after(self.retired(["post-writer-pod-uid"]), responses) + + def test_runtime_data_shared_root_and_admin_key_guards_are_preserved(self): + for fault in ("sandbox-intent", "task-intent", "source-data", "projection-uid", + "root-epoch", "root-deployment", "admin-key", "admin-uid"): + with self.subTest(fault=fault): + self.setUp() + before = self.retired(["post-writer-pod-uid"]) + if fault == "sandbox-intent": + self.sandbox["spec"]["unreviewed"] = True + elif fault == "task-intent": + self.objects[self.task_path]["spec"]["unreviewed"] = True + elif fault == "source-data": + self.objects[self.source_path]["data"]["SLACK_BOT_TOKEN"] = "changed" + elif fault == "projection-uid": + self.objects[self.projection_path]["metadata"]["uid"] = "replacement" + elif fault == "root-epoch": + self.objects["/api/v1/namespaces/" + CORE]["metadata"]["annotations"]["kars.azure.com/private-epoch"] = "changed" + elif fault == "root-deployment": + self.objects[resource(CORE, "deployments", "kars-controller", "/apis/apps/v1")]["metadata"]["generation"] += 1 + else: + admin = self.objects[core("kars-" + self.name, "secrets", "router-services-admin")] + if fault == "admin-key": + admin["data"] = copy.deepcopy(before["admin"]["data"]) + else: + admin["metadata"]["uid"] = "replacement" + with self.assertRaises(Failure): + self.after(before) + if __name__ == "__main__": unittest.main() From 4929662505d0a92348bc173e18d1d9eb57a76444 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 17:35:15 +0200 Subject: [PATCH 095/111] style(bridge): apply required Microsoft and MIT source headers Conform imported Bridge source to the existing repository copyright check and MIT license. Insert headers only; preserve all original source bytes, existing author notices, file modes and shebang positions. No runtime code, license change or gate exemption. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/bff/src/auth.rs | 3 +++ bridge/bff/src/config.rs | 3 +++ bridge/bff/src/error.rs | 3 +++ bridge/bff/src/kars/approval.rs | 3 +++ bridge/bff/src/kars/cluster.rs | 3 +++ bridge/bff/src/kars/cluster/configuration.rs | 3 +++ bridge/bff/src/kars/cluster/connections.rs | 3 +++ bridge/bff/src/kars/cluster/engineering_sources.rs | 3 +++ bridge/bff/src/kars/cluster/local_inference.rs | 3 +++ bridge/bff/src/kars/cluster/mission_records.rs | 3 +++ bridge/bff/src/kars/cluster/mission_runs.rs | 3 +++ bridge/bff/src/kars/cluster/orchestrator.rs | 3 +++ bridge/bff/src/kars/cluster/provider_tests.rs | 3 +++ bridge/bff/src/kars/cluster/providers.rs | 3 +++ bridge/bff/src/kars/cluster/resources.rs | 3 +++ bridge/bff/src/kars/cluster/sandboxes.rs | 3 +++ bridge/bff/src/kars/credential_binding_tests.rs | 3 +++ bridge/bff/src/kars/credential_contract.rs | 3 +++ bridge/bff/src/kars/credential_entrypoint_tests.rs | 3 +++ bridge/bff/src/kars/credential_handler_tests.rs | 3 +++ bridge/bff/src/kars/credential_review.rs | 3 +++ bridge/bff/src/kars/credential_review_tests.rs | 3 +++ bridge/bff/src/kars/credential_targets.rs | 3 +++ bridge/bff/src/kars/credential_tests.rs | 3 +++ bridge/bff/src/kars/credential_transport.rs | 3 +++ bridge/bff/src/kars/credentials.rs | 3 +++ bridge/bff/src/kars/credentials/integrations.rs | 3 +++ bridge/bff/src/kars/github_grants.rs | 3 +++ bridge/bff/src/kars/mod.rs | 3 +++ bridge/bff/src/kars/observation_credential_tests.rs | 3 +++ bridge/bff/src/kars/operator_credentials.rs | 3 +++ bridge/bff/src/kars/receipt.rs | 3 +++ bridge/bff/src/kars/receipt_log/tests.rs | 3 +++ bridge/bff/src/kars/sre_action.rs | 3 +++ bridge/bff/src/kars/task.rs | 3 +++ bridge/bff/src/kars/team.rs | 3 +++ bridge/bff/src/kars/workspace_credential_plan.rs | 3 +++ bridge/bff/src/lib.rs | 3 +++ bridge/bff/src/main.rs | 3 +++ bridge/bff/src/routes/approvals.rs | 3 +++ bridge/bff/src/routes/artifacts.rs | 3 +++ bridge/bff/src/routes/budgets.rs | 3 +++ bridge/bff/src/routes/channels.rs | 3 +++ bridge/bff/src/routes/compose.rs | 3 +++ bridge/bff/src/routes/compose/capability_tests.rs | 3 +++ bridge/bff/src/routes/compose/client.rs | 3 +++ bridge/bff/src/routes/compose/egress.rs | 3 +++ bridge/bff/src/routes/compose/execution.rs | 3 +++ bridge/bff/src/routes/compose/loops.rs | 3 +++ bridge/bff/src/routes/compose/mission.rs | 3 +++ bridge/bff/src/routes/compose/mission_proposal.rs | 3 +++ bridge/bff/src/routes/compose/models.rs | 3 +++ bridge/bff/src/routes/compose/prompts.rs | 3 +++ bridge/bff/src/routes/compose/routing.rs | 3 +++ bridge/bff/src/routes/compose/team.rs | 3 +++ bridge/bff/src/routes/compose/team_proposal.rs | 3 +++ bridge/bff/src/routes/compose/team_qualification.rs | 3 +++ bridge/bff/src/routes/credential_review.rs | 3 +++ bridge/bff/src/routes/digests.rs | 3 +++ bridge/bff/src/routes/efficiency.rs | 3 +++ bridge/bff/src/routes/efficiency/tests.rs | 3 +++ bridge/bff/src/routes/engineering.rs | 3 +++ bridge/bff/src/routes/engineering/config.rs | 3 +++ bridge/bff/src/routes/engineering/endpoints.rs | 3 +++ bridge/bff/src/routes/engineering/github.rs | 3 +++ bridge/bff/src/routes/engineering/intake.rs | 3 +++ bridge/bff/src/routes/engineering/queue.rs | 3 +++ bridge/bff/src/routes/engineering/remediation.rs | 3 +++ bridge/bff/src/routes/engineering/remediation_tests.rs | 3 +++ bridge/bff/src/routes/engineering/review.rs | 3 +++ bridge/bff/src/routes/engineering/synchronization.rs | 3 +++ bridge/bff/src/routes/engineering/tests.rs | 3 +++ bridge/bff/src/routes/foundry.rs | 3 +++ bridge/bff/src/routes/github.rs | 3 +++ bridge/bff/src/routes/health.rs | 3 +++ bridge/bff/src/routes/insights.rs | 3 +++ bridge/bff/src/routes/mod.rs | 3 +++ bridge/bff/src/routes/operator.rs | 3 +++ bridge/bff/src/routes/operator/additional_providers.rs | 3 +++ bridge/bff/src/routes/operator/audit.rs | 3 +++ bridge/bff/src/routes/operator/diagnostics.rs | 3 +++ bridge/bff/src/routes/operator/evals.rs | 3 +++ bridge/bff/src/routes/operator/local_inference.rs | 3 +++ bridge/bff/src/routes/operator/policies.rs | 3 +++ bridge/bff/src/routes/operator/providers.rs | 3 +++ bridge/bff/src/routes/operator/sandboxes.rs | 3 +++ bridge/bff/src/routes/operator/skills_profiles.rs | 3 +++ bridge/bff/src/routes/options.rs | 3 +++ bridge/bff/src/routes/options/palette.rs | 3 +++ bridge/bff/src/routes/options/projections.rs | 3 +++ bridge/bff/src/routes/options/qualification.rs | 3 +++ bridge/bff/src/routes/options/qualification_tests.rs | 3 +++ bridge/bff/src/routes/ownership.rs | 3 +++ bridge/bff/src/routes/receipts.rs | 3 +++ bridge/bff/src/routes/receipts/statement.rs | 3 +++ bridge/bff/src/routes/receipts/verification.rs | 3 +++ bridge/bff/src/routes/retention.rs | 3 +++ bridge/bff/src/routes/review.rs | 3 +++ bridge/bff/src/routes/run.rs | 3 +++ bridge/bff/src/routes/sre_actions.rs | 3 +++ bridge/bff/src/routes/system.rs | 3 +++ bridge/bff/src/routes/tasks.rs | 3 +++ bridge/bff/src/routes/tasks/artifacts.rs | 3 +++ bridge/bff/src/routes/tasks/creation.rs | 3 +++ bridge/bff/src/routes/tasks/diagnostics.rs | 3 +++ bridge/bff/src/routes/tasks/egress.rs | 3 +++ bridge/bff/src/routes/tasks/evidence.rs | 3 +++ bridge/bff/src/routes/tasks/fleet.rs | 3 +++ bridge/bff/src/routes/tasks/history.rs | 3 +++ bridge/bff/src/routes/tasks/lifecycle.rs | 3 +++ bridge/bff/src/routes/tasks/mapping.rs | 3 +++ bridge/bff/src/routes/tasks/models.rs | 3 +++ bridge/bff/src/routes/tasks/presentation.rs | 3 +++ bridge/bff/src/routes/tasks/queries.rs | 3 +++ bridge/bff/src/routes/tasks/tests.rs | 3 +++ bridge/bff/src/routes/teams.rs | 3 +++ bridge/bff/src/routes/teams/backlog.rs | 3 +++ bridge/bff/src/routes/teams/channels.rs | 3 +++ bridge/bff/src/routes/teams/commons.rs | 3 +++ bridge/bff/src/routes/teams/lifecycle.rs | 3 +++ bridge/bff/src/routes/teams/mutations.rs | 3 +++ bridge/bff/src/routes/teams/queries.rs | 3 +++ bridge/bff/src/routes/teams/validation.rs | 3 +++ bridge/bff/src/routes/teams_internal.rs | 3 +++ bridge/bff/src/routes/telemetry.rs | 3 +++ bridge/bff/src/routes/validate.rs | 3 +++ bridge/bff/src/routes/validate/envelope.rs | 3 +++ bridge/bff/src/routes/validate/models.rs | 3 +++ bridge/bff/src/routes/validate/network.rs | 3 +++ .../bff/src/routes/validate/qualification_requirement_tests.rs | 3 +++ bridge/bff/src/routes/validate/resources.rs | 3 +++ bridge/bff/src/state.rs | 3 +++ bridge/bff/tests/health.rs | 3 +++ bridge/bff/tests/jwt_backend.rs | 3 +++ bridge/start-bff.sh | 3 +++ bridge/teams-gateway/src/bff-client.ts | 3 +++ bridge/teams-gateway/src/cards.ts | 3 +++ bridge/teams-gateway/src/config.ts | 3 +++ bridge/teams-gateway/src/conversation-store.ts | 3 +++ bridge/teams-gateway/src/hmac.ts | 3 +++ bridge/teams-gateway/src/identity.ts | 3 +++ bridge/teams-gateway/src/log.ts | 3 +++ bridge/teams-gateway/src/main.ts | 3 +++ bridge/teams-gateway/src/watcher-types.ts | 3 +++ bridge/teams-gateway/src/watcher.ts | 3 +++ bridge/teams-gateway/tests/chart-lifecycle.test.ts | 3 +++ bridge/teams-gateway/tests/chart-upgrade.test.ts | 3 +++ bridge/teams-gateway/tests/chart.test.ts | 3 +++ bridge/teams-gateway/tests/gateway.test.ts | 3 +++ bridge/teams-gateway/tests/native-qualification.test.ts | 3 +++ bridge/teams-gateway/tests/packaging.test.ts | 3 +++ bridge/teams-gateway/vitest.config.ts | 3 +++ bridge/web/next.config.ts | 3 +++ bridge/web/src/app/api/[...path]/route.ts | 3 +++ bridge/web/src/app/api/health/route.ts | 3 +++ bridge/web/src/app/audit/layout.tsx | 3 +++ bridge/web/src/app/audit/page.tsx | 3 +++ bridge/web/src/app/auth/callback/route.ts | 3 +++ bridge/web/src/app/auth/login/route.ts | 3 +++ bridge/web/src/app/auth/logout/route.ts | 3 +++ bridge/web/src/app/auth/no-roles/page.tsx | 3 +++ bridge/web/src/app/console/access/page.tsx | 3 +++ bridge/web/src/app/console/approvals/page.tsx | 3 +++ bridge/web/src/app/console/audit/audit-receipt-row.tsx | 3 +++ bridge/web/src/app/console/audit/audit-search.tsx | 3 +++ bridge/web/src/app/console/audit/page.tsx | 3 +++ bridge/web/src/app/console/author-resource.tsx | 3 +++ bridge/web/src/app/console/capabilities/page.tsx | 3 +++ .../app/console/configuration/additional-provider-actions.ts | 3 +++ .../web/src/app/console/configuration/copilot-login-actions.ts | 3 +++ bridge/web/src/app/console/configuration/copilot-sign-in.tsx | 3 +++ bridge/web/src/app/console/configuration/credential-actions.ts | 3 +++ bridge/web/src/app/console/configuration/credential-form.tsx | 3 +++ bridge/web/src/app/console/configuration/github-app-actions.ts | 3 +++ .../src/app/console/configuration/local-inference-actions.ts | 3 +++ .../web/src/app/console/configuration/local-model-deploy.tsx | 3 +++ bridge/web/src/app/console/configuration/model-catalogue.tsx | 3 +++ bridge/web/src/app/console/configuration/page.tsx | 3 +++ bridge/web/src/app/console/configuration/provider-actions.ts | 3 +++ .../src/app/console/configuration/provider-discover-actions.ts | 3 +++ bridge/web/src/app/console/configuration/provider-wizard.tsx | 3 +++ .../src/app/console/configuration/set-default-model-actions.ts | 3 +++ .../app/console/configuration/set-default-provider-actions.ts | 3 +++ bridge/web/src/app/console/datapath/page.tsx | 3 +++ bridge/web/src/app/console/delete-resource.tsx | 3 +++ bridge/web/src/app/console/evals/eval-detail.tsx | 3 +++ bridge/web/src/app/console/evals/new-eval-form.tsx | 3 +++ bridge/web/src/app/console/evals/page.tsx | 3 +++ bridge/web/src/app/console/fleet/capacity-dashboard.tsx | 3 +++ bridge/web/src/app/console/fleet/fleet-list.tsx | 3 +++ bridge/web/src/app/console/fleet/mesh-topology.tsx | 3 +++ bridge/web/src/app/console/fleet/page.tsx | 3 +++ bridge/web/src/app/console/foundry-actions.ts | 3 +++ bridge/web/src/app/console/foundry-onboard.tsx | 3 +++ bridge/web/src/app/console/governance-actions.ts | 3 +++ bridge/web/src/app/console/inference-policy-editor.tsx | 3 +++ bridge/web/src/app/console/insights/page.tsx | 3 +++ bridge/web/src/app/console/layout.tsx | 3 +++ bridge/web/src/app/console/mcp-catalog-data.ts | 3 +++ bridge/web/src/app/console/mcp-catalog.tsx | 3 +++ bridge/web/src/app/console/mcp-profile-actions.ts | 3 +++ bridge/web/src/app/console/mcp-profiles.tsx | 3 +++ bridge/web/src/app/console/mcp-server-editor.tsx | 3 +++ bridge/web/src/app/console/operator-github-status.tsx | 3 +++ bridge/web/src/app/console/page.tsx | 3 +++ bridge/web/src/app/console/policies/page.tsx | 3 +++ bridge/web/src/app/console/policy-builder-data.ts | 3 +++ bridge/web/src/app/console/policy-builder.tsx | 3 +++ bridge/web/src/app/console/profile-editor.tsx | 3 +++ bridge/web/src/app/console/skill-approval.tsx | 3 +++ bridge/web/src/app/console/skill-submit-action.ts | 3 +++ bridge/web/src/app/console/sre-action-decision.tsx | 3 +++ bridge/web/src/app/console/sre-actions/page.tsx | 3 +++ bridge/web/src/app/console/troubleshooting/page.tsx | 3 +++ bridge/web/src/app/dex/[...path]/route.ts | 3 +++ bridge/web/src/app/inbox/approval-actions.ts | 3 +++ bridge/web/src/app/layout.tsx | 3 +++ bridge/web/src/app/page.tsx | 3 +++ bridge/web/src/app/role-actions.ts | 3 +++ bridge/web/src/app/tasks/[name]/execution-panel.tsx | 3 +++ bridge/web/src/app/tasks/[name]/launch-actions.ts | 3 +++ bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx | 3 +++ bridge/web/src/app/workspace/agents/page.tsx | 3 +++ bridge/web/src/app/workspace/artifacts/loading.tsx | 3 +++ bridge/web/src/app/workspace/artifacts/page.tsx | 3 +++ bridge/web/src/app/workspace/connections/page.tsx | 3 +++ bridge/web/src/app/workspace/inbox/loading.tsx | 3 +++ bridge/web/src/app/workspace/inbox/page.tsx | 3 +++ bridge/web/src/app/workspace/layout.tsx | 3 +++ .../web/src/app/workspace/missions/[name]/budget-recovery.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/delete-actions.ts | 3 +++ .../web/src/app/workspace/missions/[name]/delete-control.tsx | 3 +++ .../web/src/app/workspace/missions/[name]/deploy-timeline.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/egress-actions.ts | 3 +++ .../web/src/app/workspace/missions/[name]/egress-request.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/halt-button.tsx | 3 +++ .../web/src/app/workspace/missions/[name]/mission-autorun.tsx | 3 +++ .../web/src/app/workspace/missions/[name]/mission-blockers.tsx | 3 +++ .../app/workspace/missions/[name]/mission-detail-panels.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/mission-map.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/network-mode.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/org-chart.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/page.tsx | 3 +++ .../web/src/app/workspace/missions/[name]/promote-mission.tsx | 3 +++ .../web/src/app/workspace/missions/[name]/readiness-panel.tsx | 3 +++ .../src/app/workspace/missions/[name]/reliability-runner.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/review-actions.ts | 3 +++ bridge/web/src/app/workspace/missions/[name]/review-panel.tsx | 3 +++ bridge/web/src/app/workspace/missions/[name]/role-actions.ts | 3 +++ bridge/web/src/app/workspace/missions/[name]/run-actions.ts | 3 +++ bridge/web/src/app/workspace/missions/loading.tsx | 3 +++ bridge/web/src/app/workspace/missions/missions-list.tsx | 3 +++ bridge/web/src/app/workspace/missions/page.tsx | 3 +++ bridge/web/src/app/workspace/new/actions.ts | 3 +++ bridge/web/src/app/workspace/new/envelope-reveal.tsx | 3 +++ bridge/web/src/app/workspace/new/intake-flow.tsx | 3 +++ bridge/web/src/app/workspace/new/intake-flow/controls.tsx | 3 +++ bridge/web/src/app/workspace/new/intake-flow/helpers.ts | 3 +++ bridge/web/src/app/workspace/new/intake-flow/review-types.ts | 3 +++ bridge/web/src/app/workspace/new/intake-flow/review.tsx | 3 +++ bridge/web/src/app/workspace/new/page.tsx | 3 +++ bridge/web/src/app/workspace/page.tsx | 3 +++ bridge/web/src/app/workspace/skills/loading.tsx | 3 +++ bridge/web/src/app/workspace/skills/page.tsx | 3 +++ bridge/web/src/app/workspace/skills/skill-actions.ts | 3 +++ bridge/web/src/app/workspace/skills/skill-upload.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/channel-actions.ts | 3 +++ bridge/web/src/app/workspace/teams/[name]/delete-actions.ts | 3 +++ bridge/web/src/app/workspace/teams/[name]/delete-control.tsx | 3 +++ .../web/src/app/workspace/teams/[name]/engineering-actions.ts | 3 +++ .../web/src/app/workspace/teams/[name]/engineering-intake.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/page.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/promote-actions.ts | 3 +++ bridge/web/src/app/workspace/teams/[name]/promote-control.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/run-actions.ts | 3 +++ bridge/web/src/app/workspace/teams/[name]/run-control.tsx | 3 +++ .../src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/task-actions.ts | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-channels.tsx | 3 +++ .../web/src/app/workspace/teams/[name]/team-detail-panels.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-edit.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx | 3 +++ bridge/web/src/app/workspace/teams/[name]/watching-status.tsx | 3 +++ bridge/web/src/app/workspace/teams/loading.tsx | 3 +++ bridge/web/src/app/workspace/teams/new/actions.ts | 3 +++ bridge/web/src/app/workspace/teams/new/page.tsx | 3 +++ .../src/app/workspace/teams/new/team-composer-panel-types.ts | 3 +++ .../web/src/app/workspace/teams/new/team-composer-panels.tsx | 3 +++ bridge/web/src/app/workspace/teams/new/team-composer.tsx | 3 +++ bridge/web/src/app/workspace/teams/page.tsx | 3 +++ bridge/web/src/app/workspace/teams/teams-list.tsx | 3 +++ bridge/web/src/components/activity-stream.tsx | 3 +++ bridge/web/src/components/agent-graph.tsx | 3 +++ bridge/web/src/components/agent-graph/activity.ts | 3 +++ bridge/web/src/components/agent-graph/constants.ts | 3 +++ bridge/web/src/components/agent-graph/execution.ts | 3 +++ bridge/web/src/components/agent-graph/inspectors.tsx | 3 +++ bridge/web/src/components/agent-graph/layout.ts | 3 +++ bridge/web/src/components/agent-graph/types.ts | 3 +++ bridge/web/src/components/app-shell.tsx | 2 ++ bridge/web/src/components/approval-decision.tsx | 3 +++ bridge/web/src/components/approval-phase-badge.tsx | 3 +++ bridge/web/src/components/audit-report.tsx | 3 +++ bridge/web/src/components/audit-view.tsx | 3 +++ bridge/web/src/components/bar-chart.tsx | 3 +++ bridge/web/src/components/clarification-answer.tsx | 3 +++ bridge/web/src/components/compliance-pack.tsx | 3 +++ bridge/web/src/components/connect-channels.tsx | 3 +++ bridge/web/src/components/connect-github.tsx | 3 +++ bridge/web/src/components/connect-teams.tsx | 3 +++ bridge/web/src/components/console-nav.tsx | 3 +++ bridge/web/src/components/copy-digest.tsx | 3 +++ bridge/web/src/components/deliverable-view.tsx | 3 +++ bridge/web/src/components/envelope-card.tsx | 3 +++ bridge/web/src/components/envelope-digest.tsx | 3 +++ bridge/web/src/components/execution-explorer.tsx | 3 +++ bridge/web/src/components/execution-lifetime.tsx | 3 +++ bridge/web/src/components/fleet-live.tsx | 3 +++ bridge/web/src/components/honest-state.tsx | 3 +++ bridge/web/src/components/how-it-works.tsx | 3 +++ bridge/web/src/components/icon.tsx | 3 +++ bridge/web/src/components/inference-budgets.tsx | 3 +++ bridge/web/src/components/intent-entry.tsx | 3 +++ bridge/web/src/components/journey-rail.tsx | 3 +++ bridge/web/src/components/list-skeleton.tsx | 3 +++ bridge/web/src/components/live-activity-view.tsx | 3 +++ bridge/web/src/components/live-refresh.tsx | 3 +++ bridge/web/src/components/loop-designer.tsx | 3 +++ bridge/web/src/components/mermaid-diagram.tsx | 3 +++ bridge/web/src/components/mission-scorecard.tsx | 3 +++ bridge/web/src/components/mission-status.tsx | 3 +++ bridge/web/src/components/orchestration-cube.tsx | 3 +++ bridge/web/src/components/org-tree.tsx | 3 +++ bridge/web/src/components/phase-badge.tsx | 3 +++ bridge/web/src/components/preflight-check.tsx | 3 +++ bridge/web/src/components/primary-nav.tsx | 2 ++ bridge/web/src/components/provenance-overlay.tsx | 3 +++ bridge/web/src/components/provenance-story.tsx | 3 +++ bridge/web/src/components/receipt-panel.tsx | 3 +++ bridge/web/src/components/receipt-verify.tsx | 3 +++ bridge/web/src/components/repo-access.tsx | 3 +++ bridge/web/src/components/retention-policy.tsx | 3 +++ bridge/web/src/components/role-switcher.tsx | 3 +++ bridge/web/src/components/rubiks-cube.tsx | 3 +++ bridge/web/src/components/segmented-tier.tsx | 3 +++ bridge/web/src/components/skill-composer.tsx | 3 +++ bridge/web/src/components/stat-card.tsx | 3 +++ bridge/web/src/components/status-badge.tsx | 3 +++ bridge/web/src/components/surface-switcher.tsx | 3 +++ bridge/web/src/components/task-checkpoint.tsx | 3 +++ bridge/web/src/components/team-run-activity.tsx | 3 +++ bridge/web/src/components/team-run-flow.tsx | 3 +++ bridge/web/src/components/team-timing.tsx | 3 +++ bridge/web/src/components/theme-toggle.tsx | 3 +++ bridge/web/src/components/ui.tsx | 3 +++ bridge/web/src/components/use-live-trace.ts | 3 +++ bridge/web/src/components/viewport-portal.tsx | 3 +++ bridge/web/src/components/wiring-badge.tsx | 3 +++ bridge/web/src/components/workspace-nav.tsx | 3 +++ bridge/web/src/lib/auth-return.ts | 3 +++ bridge/web/src/lib/bff-contracts.ts | 3 +++ bridge/web/src/lib/bff.ts | 3 +++ bridge/web/src/lib/classify-intent.ts | 3 +++ bridge/web/src/lib/config.ts | 3 +++ bridge/web/src/lib/credential-review.ts | 3 +++ bridge/web/src/lib/format.ts | 3 +++ bridge/web/src/lib/loop-patterns.ts | 3 +++ bridge/web/src/lib/member-archetypes.ts | 3 +++ bridge/web/src/lib/oidc-config.ts | 3 +++ bridge/web/src/lib/oidc.ts | 3 +++ bridge/web/src/lib/preflight-actions.ts | 3 +++ bridge/web/src/lib/run-mission-client.ts | 3 +++ bridge/web/src/lib/session-token.ts | 3 +++ bridge/web/src/lib/session.ts | 3 +++ bridge/web/src/lib/team-run-evidence.ts | 3 +++ bridge/web/src/lib/types.ts | 3 +++ bridge/web/src/lib/types/governance.ts | 3 +++ bridge/web/src/lib/types/missions.ts | 3 +++ bridge/web/src/lib/types/operations.ts | 3 +++ bridge/web/src/lib/types/operator.ts | 3 +++ bridge/web/src/lib/types/orchestration.ts | 3 +++ bridge/web/src/lib/types/system.ts | 3 +++ bridge/web/src/lib/types/teams.ts | 3 +++ bridge/web/src/lib/types/workspace.ts | 3 +++ bridge/web/src/proxy.ts | 3 +++ 390 files changed, 1168 insertions(+) diff --git a/bridge/bff/src/auth.rs b/bridge/bff/src/auth.rs index a8b9fc20d..a890100b4 100644 --- a/bridge/bff/src/auth.rs +++ b/bridge/bff/src/auth.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — authenticated principal and persona authorization boundary. // // The Next.js web tier verifies the user's OIDC-derived `bridge-session` cookie diff --git a/bridge/bff/src/config.rs b/bridge/bff/src/config.rs index d6f48d6c7..743e7e1a8 100644 --- a/bridge/bff/src/config.rs +++ b/bridge/bff/src/config.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — runtime configuration loaded from the environment. // diff --git a/bridge/bff/src/error.rs b/bridge/bff/src/error.rs index 68b4210c8..4dd471bcd 100644 --- a/bridge/bff/src/error.rs +++ b/bridge/bff/src/error.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — typed error handling. // diff --git a/bridge/bff/src/kars/approval.rs b/bridge/bff/src/kars/approval.rs index dab5bc094..f837d4a4b 100644 --- a/bridge/bff/src/kars/approval.rs +++ b/bridge/bff/src/kars/approval.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — typed view of the `KarsApproval` CRD. // // CONTRACT OWNERSHIP: the `KarsApproval` schema is owned by core kars diff --git a/bridge/bff/src/kars/cluster.rs b/bridge/bff/src/kars/cluster.rs index e8ce9c089..a64788e74 100644 --- a/bridge/bff/src/kars/cluster.rs +++ b/bridge/bff/src/kars/cluster.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — cluster access. // // The BFF is the only process that holds a cluster client. The browser never diff --git a/bridge/bff/src/kars/cluster/configuration.rs b/bridge/bff/src/kars/cluster/configuration.rs index b15a2fe4e..5e1e57b5f 100644 --- a/bridge/bff/src/kars/cluster/configuration.rs +++ b/bridge/bff/src/kars/cluster/configuration.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::Cluster; use k8s_openapi::api::core::v1::ConfigMap; use kube::api::{Api, ListParams}; diff --git a/bridge/bff/src/kars/cluster/connections.rs b/bridge/bff/src/kars/cluster/connections.rs index bae7575d3..04c9524fb 100644 --- a/bridge/bff/src/kars/cluster/connections.rs +++ b/bridge/bff/src/kars/cluster/connections.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::Cluster; use k8s_openapi::api::core::v1::ConfigMap; use kube::ResourceExt; diff --git a/bridge/bff/src/kars/cluster/engineering_sources.rs b/bridge/bff/src/kars/cluster/engineering_sources.rs index a0e269116..e5791ed2c 100644 --- a/bridge/bff/src/kars/cluster/engineering_sources.rs +++ b/bridge/bff/src/kars/cluster/engineering_sources.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::Cluster; use k8s_openapi::api::core::v1::ConfigMap; use kube::api::{Api, ListParams}; diff --git a/bridge/bff/src/kars/cluster/local_inference.rs b/bridge/bff/src/kars/cluster/local_inference.rs index f2e79b6af..e7da06b7c 100644 --- a/bridge/bff/src/kars/cluster/local_inference.rs +++ b/bridge/bff/src/kars/cluster/local_inference.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{ Cluster, DeployActivity, DeployCondition, DeployPodState, GpuNodeSummary, LOCAL_INFERENCE_NAMESPACE, LocalDeployLiveStatus, diff --git a/bridge/bff/src/kars/cluster/mission_records.rs b/bridge/bff/src/kars/cluster/mission_records.rs index ee4f38f3e..79a1c1de4 100644 --- a/bridge/bff/src/kars/cluster/mission_records.rs +++ b/bridge/bff/src/kars/cluster/mission_records.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{Cluster, MissionOutputRecord}; use crate::providers::signing::sha256_hex; use k8s_openapi::api::core::v1::ConfigMap; diff --git a/bridge/bff/src/kars/cluster/mission_runs.rs b/bridge/bff/src/kars/cluster/mission_runs.rs index c4f938c9b..f77fe1687 100644 --- a/bridge/bff/src/kars/cluster/mission_runs.rs +++ b/bridge/bff/src/kars/cluster/mission_runs.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{AgentIdentity, Cluster, MeshRunOutcome}; use crate::providers::signing::sha256_hex; use base64::Engine as _; diff --git a/bridge/bff/src/kars/cluster/orchestrator.rs b/bridge/bff/src/kars/cluster/orchestrator.rs index dd3b00629..6e16d24d7 100644 --- a/bridge/bff/src/kars/cluster/orchestrator.rs +++ b/bridge/bff/src/kars/cluster/orchestrator.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::Cluster; use k8s_openapi::api::core::v1::Pod; use kube::api::{Api, DynamicObject, ListParams}; diff --git a/bridge/bff/src/kars/cluster/provider_tests.rs b/bridge/bff/src/kars/cluster/provider_tests.rs index 2a750efbc..4720a59e0 100644 --- a/bridge/bff/src/kars/cluster/provider_tests.rs +++ b/bridge/bff/src/kars/cluster/provider_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::mission_records::{ mission_evidence_key, mission_output_candidate, project_mission_output_record, select_mission_evidence_records, select_mission_output_records, trace_record_identity, diff --git a/bridge/bff/src/kars/cluster/providers.rs b/bridge/bff/src/kars/cluster/providers.rs index 730771941..2f957a808 100644 --- a/bridge/bff/src/kars/cluster/providers.rs +++ b/bridge/bff/src/kars/cluster/providers.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::Cluster; use kube::api::Api; diff --git a/bridge/bff/src/kars/cluster/resources.rs b/bridge/bff/src/kars/cluster/resources.rs index ca4d54835..a9f40a2f9 100644 --- a/bridge/bff/src/kars/cluster/resources.rs +++ b/bridge/bff/src/kars/cluster/resources.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::Cluster; use crate::kars::task::KarsTask; use k8s_openapi::api::core::v1::Node; diff --git a/bridge/bff/src/kars/cluster/sandboxes.rs b/bridge/bff/src/kars/cluster/sandboxes.rs index 394178614..7d2308d9e 100644 --- a/bridge/bff/src/kars/cluster/sandboxes.rs +++ b/bridge/bff/src/kars/cluster/sandboxes.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{Cluster, ContainerState, PodHealth}; use k8s_openapi::api::core::v1::{ConfigMap, Pod}; use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams}; diff --git a/bridge/bff/src/kars/credential_binding_tests.rs b/bridge/bff/src/kars/credential_binding_tests.rs index ae2cdf930..33e094f2d 100644 --- a/bridge/bff/src/kars/credential_binding_tests.rs +++ b/bridge/bff/src/kars/credential_binding_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; use std::collections::BTreeMap; diff --git a/bridge/bff/src/kars/credential_contract.rs b/bridge/bff/src/kars/credential_contract.rs index 8655c98a9..b0bea71e2 100644 --- a/bridge/bff/src/kars/credential_contract.rs +++ b/bridge/bff/src/kars/credential_contract.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use kube::api::DynamicObject; use serde::{Deserialize, Serialize}; diff --git a/bridge/bff/src/kars/credential_entrypoint_tests.rs b/bridge/bff/src/kars/credential_entrypoint_tests.rs index c293f1c99..edccdb0d5 100644 --- a/bridge/bff/src/kars/credential_entrypoint_tests.rs +++ b/bridge/bff/src/kars/credential_entrypoint_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; const FIRST: &str = "/apis/kars.azure.com/v1alpha1/namespaces/work/karsteams/first"; diff --git a/bridge/bff/src/kars/credential_handler_tests.rs b/bridge/bff/src/kars/credential_handler_tests.rs index b25a8d429..8b7b2b369 100644 --- a/bridge/bff/src/kars/credential_handler_tests.rs +++ b/bridge/bff/src/kars/credential_handler_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; use axum::{ body::{Body, to_bytes}, diff --git a/bridge/bff/src/kars/credential_review.rs b/bridge/bff/src/kars/credential_review.rs index 7ff44266f..fa81c4cee 100644 --- a/bridge/bff/src/kars/credential_review.rs +++ b/bridge/bff/src/kars/credential_review.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{ cluster::Cluster, credential_contract::{Grant, Identity, Selection, Target}, diff --git a/bridge/bff/src/kars/credential_review_tests.rs b/bridge/bff/src/kars/credential_review_tests.rs index 4daf121a6..81b60f024 100644 --- a/bridge/bff/src/kars/credential_review_tests.rs +++ b/bridge/bff/src/kars/credential_review_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; use axum::Extension; use base64::Engine; diff --git a/bridge/bff/src/kars/credential_targets.rs b/bridge/bff/src/kars/credential_targets.rs index 28e2219b5..dc7174beb 100644 --- a/bridge/bff/src/kars/credential_targets.rs +++ b/bridge/bff/src/kars/credential_targets.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{ cluster::Cluster, credential_contract::{CredentialBindings, Identity, Selection, Target}, diff --git a/bridge/bff/src/kars/credential_tests.rs b/bridge/bff/src/kars/credential_tests.rs index f433dc857..9f5c613de 100644 --- a/bridge/bff/src/kars/credential_tests.rs +++ b/bridge/bff/src/kars/credential_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::cluster::Cluster; use super::credentials::*; use axum::{ diff --git a/bridge/bff/src/kars/credential_transport.rs b/bridge/bff/src/kars/credential_transport.rs index 510afa639..3fcc2894f 100644 --- a/bridge/bff/src/kars/credential_transport.rs +++ b/bridge/bff/src/kars/credential_transport.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{cluster::Cluster, credential_contract::Identity}; use kube::{ Api, ResourceExt, diff --git a/bridge/bff/src/kars/credentials.rs b/bridge/bff/src/kars/credentials.rs index 02d6bd10f..82e91a5ee 100644 --- a/bridge/bff/src/kars/credentials.rs +++ b/bridge/bff/src/kars/credentials.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Governed credential adapter. Values stay in native, operator-authorized Secrets. use super::cluster::Cluster; diff --git a/bridge/bff/src/kars/credentials/integrations.rs b/bridge/bff/src/kars/credentials/integrations.rs index f648206fa..8a28a49ba 100644 --- a/bridge/bff/src/kars/credentials/integrations.rs +++ b/bridge/bff/src/kars/credentials/integrations.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Governed credential adapter — controller and Teams integration operations. use super::*; diff --git a/bridge/bff/src/kars/github_grants.rs b/bridge/bff/src/kars/github_grants.rs index 0b2699078..bbd736c77 100644 --- a/bridge/bff/src/kars/github_grants.rs +++ b/bridge/bff/src/kars/github_grants.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{ cluster::Cluster, credential_contract::{GitHubBinding, Identity}, diff --git a/bridge/bff/src/kars/mod.rs b/bridge/bff/src/kars/mod.rs index 12b968de4..199c52f61 100644 --- a/bridge/bff/src/kars/mod.rs +++ b/bridge/bff/src/kars/mod.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — kars cluster contract + access. pub mod approval; diff --git a/bridge/bff/src/kars/observation_credential_tests.rs b/bridge/bff/src/kars/observation_credential_tests.rs index b5f254308..85cc1b46d 100644 --- a/bridge/bff/src/kars/observation_credential_tests.rs +++ b/bridge/bff/src/kars/observation_credential_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; use base64::{Engine, engine::general_purpose::STANDARD}; diff --git a/bridge/bff/src/kars/operator_credentials.rs b/bridge/bff/src/kars/operator_credentials.rs index 6f3ef261b..fd76eb516 100644 --- a/bridge/bff/src/kars/operator_credentials.rs +++ b/bridge/bff/src/kars/operator_credentials.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Private observations are separate from legacy admin, control and App credentials. use super::{cluster::Cluster, credentials::failure}; diff --git a/bridge/bff/src/kars/receipt.rs b/bridge/bff/src/kars/receipt.rs index bb29f14a6..dd74535d6 100644 --- a/bridge/bff/src/kars/receipt.rs +++ b/bridge/bff/src/kars/receipt.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — typed view of the `KarsReceipt` CRD. // // CONTRACT OWNERSHIP: the `KarsReceipt` schema is owned by core kars diff --git a/bridge/bff/src/kars/receipt_log/tests.rs b/bridge/bff/src/kars/receipt_log/tests.rs index 14214d4ac..03134ab1c 100644 --- a/bridge/bff/src/kars/receipt_log/tests.rs +++ b/bridge/bff/src/kars/receipt_log/tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; use crate::providers::receipt::ReceiptTestSigner as SigningKey; use crate::providers::signing::sha256_hex; diff --git a/bridge/bff/src/kars/sre_action.rs b/bridge/bff/src/kars/sre_action.rs index ede3a0161..3d0ba9ccd 100644 --- a/bridge/bff/src/kars/sre_action.rs +++ b/bridge/bff/src/kars/sre_action.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — typed view of the `KarsSREAction` CRD. // // CONTRACT OWNERSHIP: the `KarsSREAction` schema is owned by core kars diff --git a/bridge/bff/src/kars/task.rs b/bridge/bff/src/kars/task.rs index 4acd1f9c6..d51145f16 100644 --- a/bridge/bff/src/kars/task.rs +++ b/bridge/bff/src/kars/task.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — typed view of the `KarsTask` CRD. // // CONTRACT OWNERSHIP: the `KarsTask` schema is owned by core kars diff --git a/bridge/bff/src/kars/team.rs b/bridge/bff/src/kars/team.rs index a4c32117e..ddb4561f4 100644 --- a/bridge/bff/src/kars/team.rs +++ b/bridge/bff/src/kars/team.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — typed view of the `KarsTeam` CRD. // // CONTRACT OWNERSHIP: the `KarsTeam` schema is owned by core kars diff --git a/bridge/bff/src/kars/workspace_credential_plan.rs b/bridge/bff/src/kars/workspace_credential_plan.rs index e9bc6012c..0d36138cb 100644 --- a/bridge/bff/src/kars/workspace_credential_plan.rs +++ b/bridge/bff/src/kars/workspace_credential_plan.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{ cluster::Cluster, credential_contract::{Identity, Selection, Target}, diff --git a/bridge/bff/src/lib.rs b/bridge/bff/src/lib.rs index 24f624cac..8d30a0100 100644 --- a/bridge/bff/src/lib.rs +++ b/bridge/bff/src/lib.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — library surface. // diff --git a/bridge/bff/src/main.rs b/bridge/bff/src/main.rs index 1e94a71af..0ae236b1a 100644 --- a/bridge/bff/src/main.rs +++ b/bridge/bff/src/main.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — secure backend-for-frontend for the kars Bridge web app. // diff --git a/bridge/bff/src/routes/approvals.rs b/bridge/bff/src/routes/approvals.rs index 3704c4115..63a670c23 100644 --- a/bridge/bff/src/routes/approvals.rs +++ b/bridge/bff/src/routes/approvals.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — Governance steering: the HITL approval inbox. // // These endpoints back the steering inbox — the fleet-wide list of human diff --git a/bridge/bff/src/routes/artifacts.rs b/bridge/bff/src/routes/artifacts.rs index e54e503f9..41ed7eeea 100644 --- a/bridge/bff/src/routes/artifacts.rs +++ b/bridge/bff/src/routes/artifacts.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — the cross-mission Artifacts index. // // The Artifacts surface (design note §16) lists the real deliverables missions diff --git a/bridge/bff/src/routes/budgets.rs b/bridge/bff/src/routes/budgets.rs index 4f74e16d5..cb7381de2 100644 --- a/bridge/bff/src/routes/budgets.rs +++ b/bridge/bff/src/routes/budgets.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — hierarchical, editable inference token budgets. // // The user asked for a real budget HIERARCHY over inference token spend: diff --git a/bridge/bff/src/routes/channels.rs b/bridge/bff/src/routes/channels.rs index f19bd8cd0..2832e9275 100644 --- a/bridge/bff/src/routes/channels.rs +++ b/bridge/bff/src/routes/channels.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — workspace-level, AGENT-AGNOSTIC communication channels. // // The user asked to move channel wiring (Telegram / Slack / Discord / WhatsApp) diff --git a/bridge/bff/src/routes/compose.rs b/bridge/bff/src/routes/compose.rs index 45cc77536..d6360e4f5 100644 --- a/bridge/bff/src/routes/compose.rs +++ b/bridge/bff/src/routes/compose.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — the launch-package orchestrator (§20 "intent → package"). // // Turns a plain-language objective into a *proposed*, fully-governed launch diff --git a/bridge/bff/src/routes/compose/capability_tests.rs b/bridge/bff/src/routes/compose/capability_tests.rs index c16a51bff..5c676760b 100644 --- a/bridge/bff/src/routes/compose/capability_tests.rs +++ b/bridge/bff/src/routes/compose/capability_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::egress::complete_egress_recommendation; use super::execution::{ apply_weighted_role_budget_floors, execution_plan_error_from_raw, parse_execution_plan, diff --git a/bridge/bff/src/routes/compose/client.rs b/bridge/bff/src/routes/compose/client.rs index 1d9909cc2..7b72efed7 100644 --- a/bridge/bff/src/routes/compose/client.rs +++ b/bridge/bff/src/routes/compose/client.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + /// Find and parse the first top-level JSON object in a model response (it may be /// fenced or prefixed with prose). Returns `None` when there's no parseable object. pub(super) fn extract_json_object(raw: &str) -> Option<serde_json::Value> { diff --git a/bridge/bff/src/routes/compose/egress.rs b/bridge/bff/src/routes/compose/egress.rs index e2f4fe8c8..063d233fb 100644 --- a/bridge/bff/src/routes/compose/egress.rs +++ b/bridge/bff/src/routes/compose/egress.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::ComposeEgress; pub(super) fn complete_egress_recommendation( diff --git a/bridge/bff/src/routes/compose/execution.rs b/bridge/bff/src/routes/compose/execution.rs index 939cd5710..0dd66c6d3 100644 --- a/bridge/bff/src/routes/compose/execution.rs +++ b/bridge/bff/src/routes/compose/execution.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::client::extract_json; use super::{ComposeDelegation, ComposeDelegationRole}; diff --git a/bridge/bff/src/routes/compose/loops.rs b/bridge/bff/src/routes/compose/loops.rs index 2edea5bf7..ca7d9c1f2 100644 --- a/bridge/bff/src/routes/compose/loops.rs +++ b/bridge/bff/src/routes/compose/loops.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::State; use serde::Serialize; diff --git a/bridge/bff/src/routes/compose/mission.rs b/bridge/bff/src/routes/compose/mission.rs index 94f73d3ce..c819e0dbf 100644 --- a/bridge/bff/src/routes/compose/mission.rs +++ b/bridge/bff/src/routes/compose/mission.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, State}; diff --git a/bridge/bff/src/routes/compose/mission_proposal.rs b/bridge/bff/src/routes/compose/mission_proposal.rs index d796c0dff..52b67f833 100644 --- a/bridge/bff/src/routes/compose/mission_proposal.rs +++ b/bridge/bff/src/routes/compose/mission_proposal.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::client::extract_json; use super::egress::complete_egress_recommendation; use super::execution::{ diff --git a/bridge/bff/src/routes/compose/models.rs b/bridge/bff/src/routes/compose/models.rs index 652b10a21..0ee8ab93f 100644 --- a/bridge/bff/src/routes/compose/models.rs +++ b/bridge/bff/src/routes/compose/models.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] diff --git a/bridge/bff/src/routes/compose/prompts.rs b/bridge/bff/src/routes/compose/prompts.rs index b8bd20dcf..590de54df 100644 --- a/bridge/bff/src/routes/compose/prompts.rs +++ b/bridge/bff/src/routes/compose/prompts.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + /// Build the system prompt enumerating the real building blocks + the strict /// JSON contract. The model is told it may ONLY use these exact identifiers, /// and is given the learned efficiency frontier so its model choice is grounded diff --git a/bridge/bff/src/routes/compose/routing.rs b/bridge/bff/src/routes/compose/routing.rs index 91b40d044..556b80220 100644 --- a/bridge/bff/src/routes/compose/routing.rs +++ b/bridge/bff/src/routes/compose/routing.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use crate::routes::options::ModelOption; pub(super) fn orchestrator_quality_score(deployment: &str) -> Option<i64> { diff --git a/bridge/bff/src/routes/compose/team.rs b/bridge/bff/src/routes/compose/team.rs index e4d53ccba..1bce401c3 100644 --- a/bridge/bff/src/routes/compose/team.rs +++ b/bridge/bff/src/routes/compose/team.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // ─── Team orchestrator: charter → org chart ────────────────────────────────── // // The symmetric counterpart to the mission orchestrator. From a standing-team diff --git a/bridge/bff/src/routes/compose/team_proposal.rs b/bridge/bff/src/routes/compose/team_proposal.rs index 51e5a8fe1..732e1e29c 100644 --- a/bridge/bff/src/routes/compose/team_proposal.rs +++ b/bridge/bff/src/routes/compose/team_proposal.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::client::extract_json; use super::egress::complete_egress_recommendation; use super::execution::parse_execution_plan; diff --git a/bridge/bff/src/routes/compose/team_qualification.rs b/bridge/bff/src/routes/compose/team_qualification.rs index ac1331ef7..1a59567e2 100644 --- a/bridge/bff/src/routes/compose/team_qualification.rs +++ b/bridge/bff/src/routes/compose/team_qualification.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::mission::option_named; use super::{ComposeTeamProposal, is_non_autonomous_harness}; diff --git a/bridge/bff/src/routes/credential_review.rs b/bridge/bff/src/routes/credential_review.rs index ff87cba04..be259b483 100644 --- a/bridge/bff/src/routes/credential_review.rs +++ b/bridge/bff/src/routes/credential_review.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::{ Json, extract::{Extension, State}, diff --git a/bridge/bff/src/routes/digests.rs b/bridge/bff/src/routes/digests.rs index f706af76f..b990d8afe 100644 --- a/bridge/bff/src/routes/digests.rs +++ b/bridge/bff/src/routes/digests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — team digests (design note §20). The standing-operation // report stream that surfaces in the steering inbox: each team publishes a // periodic digest (runs/delivered/tokens/knowledge/health), and the inbox shows diff --git a/bridge/bff/src/routes/efficiency.rs b/bridge/bff/src/routes/efficiency.rs index 8d6657016..9972e1d7e 100644 --- a/bridge/bff/src/routes/efficiency.rs +++ b/bridge/bff/src/routes/efficiency.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — cross-harness efficiency frontier (design note §3B, Pillar // B). Built entirely from the REAL per-run telemetry the router captures on // every mission run and the controller persists (mission-output ConfigMap + diff --git a/bridge/bff/src/routes/efficiency/tests.rs b/bridge/bff/src/routes/efficiency/tests.rs index f6d13a904..ae3bcb3b0 100644 --- a/bridge/bff/src/routes/efficiency/tests.rs +++ b/bridge/bff/src/routes/efficiency/tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::*; fn run( diff --git a/bridge/bff/src/routes/engineering.rs b/bridge/bff/src/routes/engineering.rs index 63f6a4f13..e89b37f08 100644 --- a/bridge/bff/src/routes/engineering.rs +++ b/bridge/bff/src/routes/engineering.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — durable GitHub engineering intake for standing teams. // // This is intentionally Bridge-owned integration workflow. Source configuration, diff --git a/bridge/bff/src/routes/engineering/config.rs b/bridge/bff/src/routes/engineering/config.rs index 5b9e2b427..b574797c5 100644 --- a/bridge/bff/src/routes/engineering/config.rs +++ b/bridge/bff/src/routes/engineering/config.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — config helpers for engineering intake. use std::collections::{BTreeMap, BTreeSet}; diff --git a/bridge/bff/src/routes/engineering/endpoints.rs b/bridge/bff/src/routes/engineering/endpoints.rs index 3ecb24f58..a6c8cc569 100644 --- a/bridge/bff/src/routes/engineering/endpoints.rs +++ b/bridge/bff/src/routes/engineering/endpoints.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — endpoints helpers for engineering intake. use axum::Json; diff --git a/bridge/bff/src/routes/engineering/github.rs b/bridge/bff/src/routes/engineering/github.rs index e6f69ea87..f817f42b8 100644 --- a/bridge/bff/src/routes/engineering/github.rs +++ b/bridge/bff/src/routes/engineering/github.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — github helpers for engineering intake. use super::{ diff --git a/bridge/bff/src/routes/engineering/intake.rs b/bridge/bff/src/routes/engineering/intake.rs index 701411ccb..f00c557bb 100644 --- a/bridge/bff/src/routes/engineering/intake.rs +++ b/bridge/bff/src/routes/engineering/intake.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — intake helpers for engineering intake. use crate::providers::signing::sha256; diff --git a/bridge/bff/src/routes/engineering/queue.rs b/bridge/bff/src/routes/engineering/queue.rs index 5d92af5ee..1c93b7ae8 100644 --- a/bridge/bff/src/routes/engineering/queue.rs +++ b/bridge/bff/src/routes/engineering/queue.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — queue helpers for engineering intake. use std::collections::BTreeMap; diff --git a/bridge/bff/src/routes/engineering/remediation.rs b/bridge/bff/src/routes/engineering/remediation.rs index 4a74d4698..daaaf693e 100644 --- a/bridge/bff/src/routes/engineering/remediation.rs +++ b/bridge/bff/src/routes/engineering/remediation.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — remediation identity and compatibility with persisted intake. use crate::providers::signing::sha256; diff --git a/bridge/bff/src/routes/engineering/remediation_tests.rs b/bridge/bff/src/routes/engineering/remediation_tests.rs index d4583de98..ea6b67dc4 100644 --- a/bridge/bff/src/routes/engineering/remediation_tests.rs +++ b/bridge/bff/src/routes/engineering/remediation_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::super::{ GithubDependabotAlert, append_bounded_tasks, dependabot_alert_task, merge_discovered_tasks, }; diff --git a/bridge/bff/src/routes/engineering/review.rs b/bridge/bff/src/routes/engineering/review.rs index a1c4a37bb..cefb98b17 100644 --- a/bridge/bff/src/routes/engineering/review.rs +++ b/bridge/bff/src/routes/engineering/review.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — review helpers for engineering intake. use std::collections::{BTreeSet, HashSet}; diff --git a/bridge/bff/src/routes/engineering/synchronization.rs b/bridge/bff/src/routes/engineering/synchronization.rs index 820726568..dd54f1b88 100644 --- a/bridge/bff/src/routes/engineering/synchronization.rs +++ b/bridge/bff/src/routes/engineering/synchronization.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — synchronization helpers for engineering intake. use std::collections::{BTreeMap, BTreeSet}; diff --git a/bridge/bff/src/routes/engineering/tests.rs b/bridge/bff/src/routes/engineering/tests.rs index b16e46b4d..d1c791ab4 100644 --- a/bridge/bff/src/routes/engineering/tests.rs +++ b/bridge/bff/src/routes/engineering/tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — engineering intake regression tests. use k8s_openapi::api::core::v1::ConfigMap; diff --git a/bridge/bff/src/routes/foundry.rs b/bridge/bff/src/routes/foundry.rs index 2b1c7d39a..995543319 100644 --- a/bridge/bff/src/routes/foundry.rs +++ b/bridge/bff/src/routes/foundry.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — operator Foundry onboarding. // // The admin connects an Azure AI Foundry project so the cluster can use Foundry diff --git a/bridge/bff/src/routes/github.rs b/bridge/bff/src/routes/github.rs index f95b55353..be6bf22b2 100644 --- a/bridge/bff/src/routes/github.rs +++ b/bridge/bff/src/routes/github.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — Connect GitHub (keyless git write, §14). // // Per-principal self-service GitHub connection. The Bridge holds the shared kars diff --git a/bridge/bff/src/routes/health.rs b/bridge/bff/src/routes/health.rs index 865a798f8..e228b3eae 100644 --- a/bridge/bff/src/routes/health.rs +++ b/bridge/bff/src/routes/health.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — health & readiness endpoints. diff --git a/bridge/bff/src/routes/insights.rs b/bridge/bff/src/routes/insights.rs index 217a1f1c1..7056a7df1 100644 --- a/bridge/bff/src/routes/insights.rs +++ b/bridge/bff/src/routes/insights.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — Insights / efficiency metrics API. // // HONESTY CONTRACT (design note §24, UX honesty grammar): diff --git a/bridge/bff/src/routes/mod.rs b/bridge/bff/src/routes/mod.rs index 8a7f03194..d55d13c7a 100644 --- a/bridge/bff/src/routes/mod.rs +++ b/bridge/bff/src/routes/mod.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — route module aggregation. diff --git a/bridge/bff/src/routes/operator.rs b/bridge/bff/src/routes/operator.rs index 288363165..579efa92d 100644 --- a/bridge/bff/src/routes/operator.rs +++ b/bridge/bff/src/routes/operator.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — Operator Console API. // diff --git a/bridge/bff/src/routes/operator/additional_providers.rs b/bridge/bff/src/routes/operator/additional_providers.rs index 567280cbe..28f4331e0 100644 --- a/bridge/bff/src/routes/operator/additional_providers.rs +++ b/bridge/bff/src/routes/operator/additional_providers.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/audit.rs b/bridge/bff/src/routes/operator/audit.rs index 92a589415..1a82ccbff 100644 --- a/bridge/bff/src/routes/operator/audit.rs +++ b/bridge/bff/src/routes/operator/audit.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/diagnostics.rs b/bridge/bff/src/routes/operator/diagnostics.rs index dca45aa11..55de5f46c 100644 --- a/bridge/bff/src/routes/operator/diagnostics.rs +++ b/bridge/bff/src/routes/operator/diagnostics.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/evals.rs b/bridge/bff/src/routes/operator/evals.rs index 14585c511..503003f91 100644 --- a/bridge/bff/src/routes/operator/evals.rs +++ b/bridge/bff/src/routes/operator/evals.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/local_inference.rs b/bridge/bff/src/routes/operator/local_inference.rs index 9208bfaec..165feeeb7 100644 --- a/bridge/bff/src/routes/operator/local_inference.rs +++ b/bridge/bff/src/routes/operator/local_inference.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/policies.rs b/bridge/bff/src/routes/operator/policies.rs index a99d35768..c8b518825 100644 --- a/bridge/bff/src/routes/operator/policies.rs +++ b/bridge/bff/src/routes/operator/policies.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/providers.rs b/bridge/bff/src/routes/operator/providers.rs index b43032bfa..8c77e28a2 100644 --- a/bridge/bff/src/routes/operator/providers.rs +++ b/bridge/bff/src/routes/operator/providers.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/sandboxes.rs b/bridge/bff/src/routes/operator/sandboxes.rs index 14e80caf7..b60cc86e3 100644 --- a/bridge/bff/src/routes/operator/sandboxes.rs +++ b/bridge/bff/src/routes/operator/sandboxes.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/operator/skills_profiles.rs b/bridge/bff/src/routes/operator/skills_profiles.rs index 9ee4d531f..a43455866 100644 --- a/bridge/bff/src/routes/operator/skills_profiles.rs +++ b/bridge/bff/src/routes/operator/skills_profiles.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/options.rs b/bridge/bff/src/routes/options.rs index 9570eb2e0..9972a419d 100644 --- a/bridge/bff/src/routes/options.rs +++ b/bridge/bff/src/routes/options.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — launch-package options. // // The editable launch package (§20 of the design note) must be composed from diff --git a/bridge/bff/src/routes/options/palette.rs b/bridge/bff/src/routes/options/palette.rs index 168812f08..46f6bacc3 100644 --- a/bridge/bff/src/routes/options/palette.rs +++ b/bridge/bff/src/routes/options/palette.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — launch options palette. use axum::Json; diff --git a/bridge/bff/src/routes/options/projections.rs b/bridge/bff/src/routes/options/projections.rs index e3e7c2f6f..077c5de49 100644 --- a/bridge/bff/src/routes/options/projections.rs +++ b/bridge/bff/src/routes/options/projections.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — launch options projections. use kube::core::DynamicObject; diff --git a/bridge/bff/src/routes/options/qualification.rs b/bridge/bff/src/routes/options/qualification.rs index 4e429b0e3..c1d21cd7e 100644 --- a/bridge/bff/src/routes/options/qualification.rs +++ b/bridge/bff/src/routes/options/qualification.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — launch options qualification. use super::{Options, QualifiedResource, QualifiedResourceSelection, QualifiedRoute, RefOption}; diff --git a/bridge/bff/src/routes/options/qualification_tests.rs b/bridge/bff/src/routes/options/qualification_tests.rs index a71e31285..08e00fe47 100644 --- a/bridge/bff/src/routes/options/qualification_tests.rs +++ b/bridge/bff/src/routes/options/qualification_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — launch qualification regression tests. use super::{ diff --git a/bridge/bff/src/routes/ownership.rs b/bridge/bff/src/routes/ownership.rs index c3f6a30a9..2b702f2a6 100644 --- a/bridge/bff/src/routes/ownership.rs +++ b/bridge/bff/src/routes/ownership.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use kube::ResourceExt; use crate::auth::Principal; diff --git a/bridge/bff/src/routes/receipts.rs b/bridge/bff/src/routes/receipts.rs index 0093428f5..6159a00d7 100644 --- a/bridge/bff/src/routes/receipts.rs +++ b/bridge/bff/src/routes/receipts.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — Governance Receipt API. // // Read endpoints project the signed predicate, never unsigned claim echoes. diff --git a/bridge/bff/src/routes/receipts/statement.rs b/bridge/bff/src/routes/receipts/statement.rs index 60eb4fbb8..18e824318 100644 --- a/bridge/bff/src/routes/receipts/statement.rs +++ b/bridge/bff/src/routes/receipts/statement.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde_json::Value; diff --git a/bridge/bff/src/routes/receipts/verification.rs b/bridge/bff/src/routes/receipts/verification.rs index 143b7d0a6..da402fa86 100644 --- a/bridge/bff/src/routes/receipts/verification.rs +++ b/bridge/bff/src/routes/receipts/verification.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — receipt verification, extracted without changing wire formats. use super::*; diff --git a/bridge/bff/src/routes/retention.rs b/bridge/bff/src/routes/retention.rs index 101ab54f7..1663f1bf1 100644 --- a/bridge/bff/src/routes/retention.rs +++ b/bridge/bff/src/routes/retention.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — cluster-wide mission/team-run retention policy. // // Kars intentionally keeps mission/team-run records (KarsTask CRs) after diff --git a/bridge/bff/src/routes/review.rs b/bridge/bff/src/routes/review.rs index b69a85401..1e9704887 100644 --- a/bridge/bff/src/routes/review.rs +++ b/bridge/bff/src/routes/review.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — artifact review loop (design note §16). // // A deliverable isn't done until a human accepts it. This surface turns the diff --git a/bridge/bff/src/routes/run.rs b/bridge/bff/src/routes/run.rs index dddf30203..c86701067 100644 --- a/bridge/bff/src/routes/run.rs +++ b/bridge/bff/src/routes/run.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — mission run (drive a real governed model run, capture it). // // This makes a "mission" actually DO something instead of a sandbox sitting diff --git a/bridge/bff/src/routes/sre_actions.rs b/bridge/bff/src/routes/sre_actions.rs index 79f2e831d..023d0b4ab 100644 --- a/bridge/bff/src/routes/sre_actions.rs +++ b/bridge/bff/src/routes/sre_actions.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — the kars-sre self-remediation approval surface. // // Backs the operator "SRE Actions" console page: the kars-sre agent diff --git a/bridge/bff/src/routes/system.rs b/bridge/bff/src/routes/system.rs index 781e854b5..d6fb177dc 100644 --- a/bridge/bff/src/routes/system.rs +++ b/bridge/bff/src/routes/system.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — system / wiring introspection. // // Delivery Constraint #5 (honest wiring visibility): the product must never diff --git a/bridge/bff/src/routes/tasks.rs b/bridge/bff/src/routes/tasks.rs index d073118ac..a8eec6bee 100644 --- a/bridge/bff/src/routes/tasks.rs +++ b/bridge/bff/src/routes/tasks.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — task API DTOs + handlers. // // These endpoints are the browser's only way to touch KarsTask resources. diff --git a/bridge/bff/src/routes/tasks/artifacts.rs b/bridge/bff/src/routes/tasks/artifacts.rs index 0023a328a..fc5c6a409 100644 --- a/bridge/bff/src/routes/tasks/artifacts.rs +++ b/bridge/bff/src/routes/tasks/artifacts.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::extract::{Extension, Path, State}; use crate::auth::Principal; diff --git a/bridge/bff/src/routes/tasks/creation.rs b/bridge/bff/src/routes/tasks/creation.rs index 42b313aac..2311900da 100644 --- a/bridge/bff/src/routes/tasks/creation.rs +++ b/bridge/bff/src/routes/tasks/creation.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, Path, State}; use kube::ResourceExt; diff --git a/bridge/bff/src/routes/tasks/diagnostics.rs b/bridge/bff/src/routes/tasks/diagnostics.rs index 4a93e1b9a..be30f98ea 100644 --- a/bridge/bff/src/routes/tasks/diagnostics.rs +++ b/bridge/bff/src/routes/tasks/diagnostics.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, Path, State}; diff --git a/bridge/bff/src/routes/tasks/egress.rs b/bridge/bff/src/routes/tasks/egress.rs index 3f99fcb07..e3882f436 100644 --- a/bridge/bff/src/routes/tasks/egress.rs +++ b/bridge/bff/src/routes/tasks/egress.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, Path, State}; use serde::Deserialize; diff --git a/bridge/bff/src/routes/tasks/evidence.rs b/bridge/bff/src/routes/tasks/evidence.rs index 7cf16d1f3..556348c28 100644 --- a/bridge/bff/src/routes/tasks/evidence.rs +++ b/bridge/bff/src/routes/tasks/evidence.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{ MissionArtifactDto, MissionResultDto, TaskAssignmentEventDto, TeamCollaborationEventDto, TeamRolePlanDto, diff --git a/bridge/bff/src/routes/tasks/fleet.rs b/bridge/bff/src/routes/tasks/fleet.rs index 8d0768a65..02958035f 100644 --- a/bridge/bff/src/routes/tasks/fleet.rs +++ b/bridge/bff/src/routes/tasks/fleet.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, State}; use kube::ResourceExt; diff --git a/bridge/bff/src/routes/tasks/history.rs b/bridge/bff/src/routes/tasks/history.rs index 850baa2c7..75e6fede8 100644 --- a/bridge/bff/src/routes/tasks/history.rs +++ b/bridge/bff/src/routes/tasks/history.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use crate::auth::Principal; diff --git a/bridge/bff/src/routes/tasks/lifecycle.rs b/bridge/bff/src/routes/tasks/lifecycle.rs index c829b0a33..569692da1 100644 --- a/bridge/bff/src/routes/tasks/lifecycle.rs +++ b/bridge/bff/src/routes/tasks/lifecycle.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, Path, State}; use kube::ResourceExt; diff --git a/bridge/bff/src/routes/tasks/mapping.rs b/bridge/bff/src/routes/tasks/mapping.rs index c6ae50e8b..da24fe626 100644 --- a/bridge/bff/src/routes/tasks/mapping.rs +++ b/bridge/bff/src/routes/tasks/mapping.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use kube::ResourceExt; use serde::Serialize; diff --git a/bridge/bff/src/routes/tasks/models.rs b/bridge/bff/src/routes/tasks/models.rs index 88fa6f734..028a5d416 100644 --- a/bridge/bff/src/routes/tasks/models.rs +++ b/bridge/bff/src/routes/tasks/models.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use serde::{Deserialize, Serialize}; use super::{PullRequestRef, SubAgentDto}; diff --git a/bridge/bff/src/routes/tasks/presentation.rs b/bridge/bff/src/routes/tasks/presentation.rs index c13138887..e067955d1 100644 --- a/bridge/bff/src/routes/tasks/presentation.rs +++ b/bridge/bff/src/routes/tasks/presentation.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::{NO_CHANGE_SENTINEL, RunBlockedDto, looks_scaffolded}; /// Classify a transport-`ok` run whose body is really a STOP condition (not a diff --git a/bridge/bff/src/routes/tasks/queries.rs b/bridge/bff/src/routes/tasks/queries.rs index 5ae4f4406..72a10f7fe 100644 --- a/bridge/bff/src/routes/tasks/queries.rs +++ b/bridge/bff/src/routes/tasks/queries.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use axum::Json; use axum::extract::{Extension, Path, State}; use kube::ResourceExt; diff --git a/bridge/bff/src/routes/tasks/tests.rs b/bridge/bff/src/routes/tasks/tests.rs index ddd55de14..5716002b9 100644 --- a/bridge/bff/src/routes/tasks/tests.rs +++ b/bridge/bff/src/routes/tasks/tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use super::BlueprintDto; use super::ExecutionPhaseDto; use super::ExecutionPlanDto; diff --git a/bridge/bff/src/routes/teams.rs b/bridge/bff/src/routes/teams.rs index 31e4dd7d1..64cbb7850 100644 --- a/bridge/bff/src/routes/teams.rs +++ b/bridge/bff/src/routes/teams.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // kars Bridge BFF — Teams API DTOs + handlers. // diff --git a/bridge/bff/src/routes/teams/backlog.rs b/bridge/bff/src/routes/teams/backlog.rs index 82afb1e88..1a767ef30 100644 --- a/bridge/bff/src/routes/teams/backlog.rs +++ b/bridge/bff/src/routes/teams/backlog.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/teams/channels.rs b/bridge/bff/src/routes/teams/channels.rs index 155575605..f9bc7cc7e 100644 --- a/bridge/bff/src/routes/teams/channels.rs +++ b/bridge/bff/src/routes/teams/channels.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/teams/commons.rs b/bridge/bff/src/routes/teams/commons.rs index bad63abd9..8f2227b97 100644 --- a/bridge/bff/src/routes/teams/commons.rs +++ b/bridge/bff/src/routes/teams/commons.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/teams/lifecycle.rs b/bridge/bff/src/routes/teams/lifecycle.rs index 29456c069..ab4d04d68 100644 --- a/bridge/bff/src/routes/teams/lifecycle.rs +++ b/bridge/bff/src/routes/teams/lifecycle.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/teams/mutations.rs b/bridge/bff/src/routes/teams/mutations.rs index 80c27ee30..e6727827b 100644 --- a/bridge/bff/src/routes/teams/mutations.rs +++ b/bridge/bff/src/routes/teams/mutations.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/teams/queries.rs b/bridge/bff/src/routes/teams/queries.rs index 8d85cf3db..1761f135e 100644 --- a/bridge/bff/src/routes/teams/queries.rs +++ b/bridge/bff/src/routes/teams/queries.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use axum::Json; diff --git a/bridge/bff/src/routes/teams/validation.rs b/bridge/bff/src/routes/teams/validation.rs index 11ca1d915..0c5c984a0 100644 --- a/bridge/bff/src/routes/teams/validation.rs +++ b/bridge/bff/src/routes/teams/validation.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. use crate::error::{AppError, AppResult}; diff --git a/bridge/bff/src/routes/teams_internal.rs b/bridge/bff/src/routes/teams_internal.rs index c97d2cf34..bf1aae57e 100644 --- a/bridge/bff/src/routes/teams_internal.rs +++ b/bridge/bff/src/routes/teams_internal.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — Internal Teams gateway endpoints. // // Authenticated by X-Teams-Internal-Secret. The gateway sends the Entra subject; diff --git a/bridge/bff/src/routes/telemetry.rs b/bridge/bff/src/routes/telemetry.rs index 211232bd9..e964893e3 100644 --- a/bridge/bff/src/routes/telemetry.rs +++ b/bridge/bff/src/routes/telemetry.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — live telemetry stream (§8). Streams the WHOLE agent tree's // real per-round / per-tool activity as it happens, so the activity stream and // the expanding flow graph tick in flight instead of only at delivery. diff --git a/bridge/bff/src/routes/validate.rs b/bridge/bff/src/routes/validate.rs index 1c85e1972..50f7c817d 100644 --- a/bridge/bff/src/routes/validate.rs +++ b/bridge/bff/src/routes/validate.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — pre-flight validation gate (design note §20). // // The riskiest moment is the handoff from an edited package to a running agent diff --git a/bridge/bff/src/routes/validate/envelope.rs b/bridge/bff/src/routes/validate/envelope.rs index 700225599..13b96879b 100644 --- a/bridge/bff/src/routes/validate/envelope.rs +++ b/bridge/bff/src/routes/validate/envelope.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — envelope pre-flight checks. use super::{Check, CheckStatus}; diff --git a/bridge/bff/src/routes/validate/models.rs b/bridge/bff/src/routes/validate/models.rs index 4924051a1..0c3ad6915 100644 --- a/bridge/bff/src/routes/validate/models.rs +++ b/bridge/bff/src/routes/validate/models.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — models pre-flight checks. use super::{Check, CheckStatus}; diff --git a/bridge/bff/src/routes/validate/network.rs b/bridge/bff/src/routes/validate/network.rs index 904e9bc55..070bc71de 100644 --- a/bridge/bff/src/routes/validate/network.rs +++ b/bridge/bff/src/routes/validate/network.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — network pre-flight checks. use std::time::Duration; diff --git a/bridge/bff/src/routes/validate/qualification_requirement_tests.rs b/bridge/bff/src/routes/validate/qualification_requirement_tests.rs index c7e638f98..ae18f52e1 100644 --- a/bridge/bff/src/routes/validate/qualification_requirement_tests.rs +++ b/bridge/bff/src/routes/validate/qualification_requirement_tests.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — qualification requirement regression tests. use super::{blueprint_to_dto, qualification_requirements}; diff --git a/bridge/bff/src/routes/validate/resources.rs b/bridge/bff/src/routes/validate/resources.rs index e2089c1a8..24ef77a93 100644 --- a/bridge/bff/src/routes/validate/resources.rs +++ b/bridge/bff/src/routes/validate/resources.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — resources pre-flight checks. use super::network::{resolves, url_host}; diff --git a/bridge/bff/src/state.rs b/bridge/bff/src/state.rs index 37a91d704..74971e67e 100644 --- a/bridge/bff/src/state.rs +++ b/bridge/bff/src/state.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge BFF — shared application state. // // Holds the optional cluster handle. Cluster connectivity is *optional* at diff --git a/bridge/bff/tests/health.rs b/bridge/bff/tests/health.rs index 6d9dd1bc6..e8559dca3 100644 --- a/bridge/bff/tests/health.rs +++ b/bridge/bff/tests/health.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Copyright (c) Pal Lakatos-Toth. // Integration tests for the kars Bridge BFF router. Exercises the public // HTTP surface in-process (no socket bind) via tower's oneshot. These run diff --git a/bridge/bff/tests/jwt_backend.rs b/bridge/bff/tests/jwt_backend.rs index 5db84d2dc..508e3afed 100644 --- a/bridge/bff/tests/jwt_backend.rs +++ b/bridge/bff/tests/jwt_backend.rs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + use std::io::Write; use std::process::{Command, Stdio}; diff --git a/bridge/start-bff.sh b/bridge/start-bff.sh index 14647b0f2..411d12428 100755 --- a/bridge/start-bff.sh +++ b/bridge/start-bff.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Run the BFF in the foreground. Existing listeners are never stopped or adopted. set -euo pipefail cd "$(dirname "$0")/bff" diff --git a/bridge/teams-gateway/src/bff-client.ts b/bridge/teams-gateway/src/bff-client.ts index 592ebb2fc..24bd185ea 100644 --- a/bridge/teams-gateway/src/bff-client.ts +++ b/bridge/teams-gateway/src/bff-client.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { TeamsGatewayConfig } from "./config.js"; import type { CardVerdict } from "./cards.js"; import type { ResolvedPrincipal } from "./identity.js"; diff --git a/bridge/teams-gateway/src/cards.ts b/bridge/teams-gateway/src/cards.ts index 033f5b587..0d094db82 100644 --- a/bridge/teams-gateway/src/cards.ts +++ b/bridge/teams-gateway/src/cards.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { IAdaptiveCard } from "@microsoft/teams.cards"; export interface ApprovalEvent { diff --git a/bridge/teams-gateway/src/config.ts b/bridge/teams-gateway/src/config.ts index d3ddde82a..e26e7d7d0 100644 --- a/bridge/teams-gateway/src/config.ts +++ b/bridge/teams-gateway/src/config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — Teams Gateway: configuration (fail-closed). // // All identity and routing configuration is required. The gateway refuses to diff --git a/bridge/teams-gateway/src/conversation-store.ts b/bridge/teams-gateway/src/conversation-store.ts index 7c00d50fe..52cd4898e 100644 --- a/bridge/teams-gateway/src/conversation-store.ts +++ b/bridge/teams-gateway/src/conversation-store.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { CoreV1Api, KubeConfig, diff --git a/bridge/teams-gateway/src/hmac.ts b/bridge/teams-gateway/src/hmac.ts index c5da7c68e..c9b13a142 100644 --- a/bridge/teams-gateway/src/hmac.ts +++ b/bridge/teams-gateway/src/hmac.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — Teams Gateway: HMAC-SHA256 authentication for internal endpoints. import { createHmac, timingSafeEqual } from "node:crypto"; diff --git a/bridge/teams-gateway/src/identity.ts b/bridge/teams-gateway/src/identity.ts index 7905507a8..a17df64dd 100644 --- a/bridge/teams-gateway/src/identity.ts +++ b/bridge/teams-gateway/src/identity.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { TeamsGatewayConfig } from "./config.js"; import { log } from "./log.js"; diff --git a/bridge/teams-gateway/src/log.ts b/bridge/teams-gateway/src/log.ts index 4486e8a08..c4b3cd2ab 100644 --- a/bridge/teams-gateway/src/log.ts +++ b/bridge/teams-gateway/src/log.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — Teams Gateway: structured logging with automatic secret redaction. export type LogLevel = "info" | "warn" | "error" | "debug"; diff --git a/bridge/teams-gateway/src/main.ts b/bridge/teams-gateway/src/main.ts index 62dad34e8..45d8d47af 100644 --- a/bridge/teams-gateway/src/main.ts +++ b/bridge/teams-gateway/src/main.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { pathToFileURL } from "node:url"; import { App } from "@microsoft/teams.apps"; diff --git a/bridge/teams-gateway/src/watcher-types.ts b/bridge/teams-gateway/src/watcher-types.ts index 6ea402913..cb385edd2 100644 --- a/bridge/teams-gateway/src/watcher-types.ts +++ b/bridge/teams-gateway/src/watcher-types.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // GatewayWatcher wire and collaborator types; no runtime initialization. export interface Metadata { diff --git a/bridge/teams-gateway/src/watcher.ts b/bridge/teams-gateway/src/watcher.ts index 6875aa2bd..68f7e46dc 100644 --- a/bridge/teams-gateway/src/watcher.ts +++ b/bridge/teams-gateway/src/watcher.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { CustomObjectsApi, KubeConfig, Watch } from "@kubernetes/client-node"; import type { App } from "@microsoft/teams.apps"; import { diff --git a/bridge/teams-gateway/tests/chart-lifecycle.test.ts b/bridge/teams-gateway/tests/chart-lifecycle.test.ts index b3d0165ca..637dc71be 100644 --- a/bridge/teams-gateway/tests/chart-lifecycle.test.ts +++ b/bridge/teams-gateway/tests/chart-lifecycle.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; diff --git a/bridge/teams-gateway/tests/chart-upgrade.test.ts b/bridge/teams-gateway/tests/chart-upgrade.test.ts index 9f409c6f1..ee5362145 100644 --- a/bridge/teams-gateway/tests/chart-upgrade.test.ts +++ b/bridge/teams-gateway/tests/chart-upgrade.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { copyFileSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; diff --git a/bridge/teams-gateway/tests/chart.test.ts b/bridge/teams-gateway/tests/chart.test.ts index 3cb6c0db4..fbb2fb225 100644 --- a/bridge/teams-gateway/tests/chart.test.ts +++ b/bridge/teams-gateway/tests/chart.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; diff --git a/bridge/teams-gateway/tests/gateway.test.ts b/bridge/teams-gateway/tests/gateway.test.ts index 7d415fbb2..0f3392ce4 100644 --- a/bridge/teams-gateway/tests/gateway.test.ts +++ b/bridge/teams-gateway/tests/gateway.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { afterEach, describe, expect, it, vi } from "vitest"; import { BffClient, diff --git a/bridge/teams-gateway/tests/native-qualification.test.ts b/bridge/teams-gateway/tests/native-qualification.test.ts index 82d45f964..807c879ee 100644 --- a/bridge/teams-gateway/tests/native-qualification.test.ts +++ b/bridge/teams-gateway/tests/native-qualification.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { execFileSync } from "node:child_process"; import { readFileSync, readdirSync } from "node:fs"; import { describe, expect, it } from "vitest"; diff --git a/bridge/teams-gateway/tests/packaging.test.ts b/bridge/teams-gateway/tests/packaging.test.ts index 7e467f6b5..f84c647e0 100644 --- a/bridge/teams-gateway/tests/packaging.test.ts +++ b/bridge/teams-gateway/tests/packaging.test.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; diff --git a/bridge/teams-gateway/vitest.config.ts b/bridge/teams-gateway/vitest.config.ts index 19384e80f..922bf7645 100644 --- a/bridge/teams-gateway/vitest.config.ts +++ b/bridge/teams-gateway/vitest.config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { defineConfig } from "vitest/config"; export default defineConfig({ diff --git a/bridge/web/next.config.ts b/bridge/web/next.config.ts index 381dccbc0..a5230046f 100644 --- a/bridge/web/next.config.ts +++ b/bridge/web/next.config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { NextConfig } from "next"; // NOTE: the same-origin /api/* proxy to the BFF is handled at RUNTIME in diff --git a/bridge/web/src/app/api/[...path]/route.ts b/bridge/web/src/app/api/[...path]/route.ts index e540a80f1..a8b59b807 100644 --- a/bridge/web/src/app/api/[...path]/route.ts +++ b/bridge/web/src/app/api/[...path]/route.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — runtime same-origin /api/* proxy to the BFF. // // Why a route handler (not a next.config rewrite): a standalone build freezes a diff --git a/bridge/web/src/app/api/health/route.ts b/bridge/web/src/app/api/health/route.ts index e54277de0..3938f4870 100644 --- a/bridge/web/src/app/api/health/route.ts +++ b/bridge/web/src/app/api/health/route.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Liveness/readiness endpoint. Intentionally does NO upstream (BFF) work so the // kubelet probe reflects "this web server can accept traffic", not "the BFF is // reachable" — the SSR pages (e.g. /workspace) do a BFF round-trip and are far diff --git a/bridge/web/src/app/audit/layout.tsx b/bridge/web/src/app/audit/layout.tsx index 401870b88..4266d7cd1 100644 --- a/bridge/web/src/app/audit/layout.tsx +++ b/bridge/web/src/app/audit/layout.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — the dedicated Auditor surface. A THIRD product surface, separate // from the employee Workspace and the operator Console: entirely read-only, with // no policy/skill/fleet write-controls and an auditor identity. An auditor diff --git a/bridge/web/src/app/audit/page.tsx b/bridge/web/src/app/audit/page.tsx index ee747dd84..d232e2c65 100644 --- a/bridge/web/src/app/audit/page.tsx +++ b/bridge/web/src/app/audit/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — Auditor surface home. The read-only tamper-evident record and // independent verification, rendered from the shared AuditView (identical to the // Console's audit page, minus every operator write-control). diff --git a/bridge/web/src/app/auth/callback/route.ts b/bridge/web/src/app/auth/callback/route.ts index f45ae0a5a..8303a6b09 100644 --- a/bridge/web/src/app/auth/callback/route.ts +++ b/bridge/web/src/app/auth/callback/route.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — SSO callback. // // GET /auth/callback: completes the OIDC Authorization Code + PKCE flow — diff --git a/bridge/web/src/app/auth/login/route.ts b/bridge/web/src/app/auth/login/route.ts index a3c4d6e85..0e181b7c3 100644 --- a/bridge/web/src/app/auth/login/route.ts +++ b/bridge/web/src/app/auth/login/route.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — SSO login entry point. // // GET /auth/login: when SSO is configured, starts a real OIDC Authorization diff --git a/bridge/web/src/app/auth/logout/route.ts b/bridge/web/src/app/auth/logout/route.ts index 72ac31bae..7074eff81 100644 --- a/bridge/web/src/app/auth/logout/route.ts +++ b/bridge/web/src/app/auth/logout/route.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — SSO logout. // // POST /auth/logout: clears the Bridge's own session cookie. Also redirects diff --git a/bridge/web/src/app/auth/no-roles/page.tsx b/bridge/web/src/app/auth/no-roles/page.tsx index ed7e75633..817c8a4e9 100644 --- a/bridge/web/src/app/auth/no-roles/page.tsx +++ b/bridge/web/src/app/auth/no-roles/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — honest landing for a real SSO login that mapped to zero // roles. Fail-closed by design (lib/oidc.ts rolesFromClaims): an // unrecognized or absent role claim never grants a default role. diff --git a/bridge/web/src/app/console/access/page.tsx b/bridge/web/src/app/console/access/page.tsx index ff9f03b67..08611cf36 100644 --- a/bridge/web/src/app/console/access/page.tsx +++ b/bridge/web/src/app/console/access/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Access & roles. The multi-user surface: the four // differentiated permission sets, what each can do, the current principal, and an // honest disclosure that there is no SSO yet (the real boundary is the Bridge's diff --git a/bridge/web/src/app/console/approvals/page.tsx b/bridge/web/src/app/console/approvals/page.tsx index f4453918e..94822b5ae 100644 --- a/bridge/web/src/app/console/approvals/page.tsx +++ b/bridge/web/src/app/console/approvals/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Approvals. Operator-side governance gates: // temporary egress widenings (EgressApproval) the platform team grants, and a // pointer to fleet-wide steering decisions. Real reads. diff --git a/bridge/web/src/app/console/audit/audit-receipt-row.tsx b/bridge/web/src/app/console/audit/audit-receipt-row.tsx index a4006221f..cd8216dc5 100644 --- a/bridge/web/src/app/console/audit/audit-receipt-row.tsx +++ b/bridge/web/src/app/console/audit/audit-receipt-row.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — a single auditable receipt row. Collapsed it diff --git a/bridge/web/src/app/console/audit/audit-search.tsx b/bridge/web/src/app/console/audit/audit-search.tsx index 9b43b0695..3041f7ee0 100644 --- a/bridge/web/src/app/console/audit/audit-search.tsx +++ b/bridge/web/src/app/console/audit/audit-search.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — audit search. The auditor's investigative diff --git a/bridge/web/src/app/console/audit/page.tsx b/bridge/web/src/app/console/audit/page.tsx index fc1f84410..df97ead33 100644 --- a/bridge/web/src/app/console/audit/page.tsx +++ b/bridge/web/src/app/console/audit/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Audit. The auditor's working surface, rendered // from the shared AuditView so the Console and the dedicated /audit surface // never drift. A chain-integrity verdict, then every Governance Receipt as an diff --git a/bridge/web/src/app/console/author-resource.tsx b/bridge/web/src/app/console/author-resource.tsx index 2ee4addec..3dc8e5956 100644 --- a/bridge/web/src/app/console/author-resource.tsx +++ b/bridge/web/src/app/console/author-resource.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useActionState, useState } from "react"; diff --git a/bridge/web/src/app/console/capabilities/page.tsx b/bridge/web/src/app/console/capabilities/page.tsx index 7e157c2c9..7ab11d47d 100644 --- a/bridge/web/src/app/console/capabilities/page.tsx +++ b/bridge/web/src/app/console/capabilities/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Agent capabilities. What teams/agents may be // granted: versioned skills, ready-made team profiles, MCP services (bounded // by a tool policy), and per-agent runtime credentials. Split out of the diff --git a/bridge/web/src/app/console/configuration/additional-provider-actions.ts b/bridge/web/src/app/console/configuration/additional-provider-actions.ts index 84d502a4c..2c1e95117 100644 --- a/bridge/web/src/app/console/configuration/additional-provider-actions.ts +++ b/bridge/web/src/app/console/configuration/additional-provider-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/configuration/copilot-login-actions.ts b/bridge/web/src/app/console/configuration/copilot-login-actions.ts index 1c7fd4894..6e36f847e 100644 --- a/bridge/web/src/app/console/configuration/copilot-login-actions.ts +++ b/bridge/web/src/app/console/configuration/copilot-login-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/configuration/copilot-sign-in.tsx b/bridge/web/src/app/console/configuration/copilot-sign-in.tsx index 8e0a2d5cc..81ffdc5db 100644 --- a/bridge/web/src/app/console/configuration/copilot-sign-in.tsx +++ b/bridge/web/src/app/console/configuration/copilot-sign-in.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useEffect, useRef, useState, useTransition } from "react"; diff --git a/bridge/web/src/app/console/configuration/credential-actions.ts b/bridge/web/src/app/console/configuration/credential-actions.ts index 6d7a46610..2b5c7b964 100644 --- a/bridge/web/src/app/console/configuration/credential-actions.ts +++ b/bridge/web/src/app/console/configuration/credential-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { BffError, putCredential, reviewCredential } from "@/lib/bff"; diff --git a/bridge/web/src/app/console/configuration/credential-form.tsx b/bridge/web/src/app/console/configuration/credential-form.tsx index 80522a55a..8ed081b9e 100644 --- a/bridge/web/src/app/console/configuration/credential-form.tsx +++ b/bridge/web/src/app/console/configuration/credential-form.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useActionState, useState } from "react"; diff --git a/bridge/web/src/app/console/configuration/github-app-actions.ts b/bridge/web/src/app/console/configuration/github-app-actions.ts index 47500d145..cf9a4f3d6 100644 --- a/bridge/web/src/app/console/configuration/github-app-actions.ts +++ b/bridge/web/src/app/console/configuration/github-app-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; // kars Bridge Operator Console — GitHub App self-service setup. Replaces the diff --git a/bridge/web/src/app/console/configuration/local-inference-actions.ts b/bridge/web/src/app/console/configuration/local-inference-actions.ts index f0b4ae4c5..9c0fcfe3d 100644 --- a/bridge/web/src/app/console/configuration/local-inference-actions.ts +++ b/bridge/web/src/app/console/configuration/local-inference-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/configuration/local-model-deploy.tsx b/bridge/web/src/app/console/configuration/local-model-deploy.tsx index 5a07d56e5..2bc5e0242 100644 --- a/bridge/web/src/app/console/configuration/local-model-deploy.tsx +++ b/bridge/web/src/app/console/configuration/local-model-deploy.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Deploy a local (in-cluster / AI Runway) model — used in the Model catalogue, diff --git a/bridge/web/src/app/console/configuration/model-catalogue.tsx b/bridge/web/src/app/console/configuration/model-catalogue.tsx index 30198766a..985e85461 100644 --- a/bridge/web/src/app/console/configuration/model-catalogue.tsx +++ b/bridge/web/src/app/console/configuration/model-catalogue.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Model catalogue — every model across all connected providers, tagged with diff --git a/bridge/web/src/app/console/configuration/page.tsx b/bridge/web/src/app/console/configuration/page.tsx index 01734aad1..140ada7d3 100644 --- a/bridge/web/src/app/console/configuration/page.tsx +++ b/bridge/web/src/app/console/configuration/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Configuration. The hub for the CLUSTER this // runs on: the inference provider, Azure AI Foundry connection, the models it // serves, cluster add-ons (SRE agent, Headlamp), and the platform GitHub App. diff --git a/bridge/web/src/app/console/configuration/provider-actions.ts b/bridge/web/src/app/console/configuration/provider-actions.ts index 98b8591a5..2bdf9f62a 100644 --- a/bridge/web/src/app/console/configuration/provider-actions.ts +++ b/bridge/web/src/app/console/configuration/provider-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { BffError, putProvider } from "@/lib/bff"; diff --git a/bridge/web/src/app/console/configuration/provider-discover-actions.ts b/bridge/web/src/app/console/configuration/provider-discover-actions.ts index 8cd2ca94d..8da9a0744 100644 --- a/bridge/web/src/app/console/configuration/provider-discover-actions.ts +++ b/bridge/web/src/app/console/configuration/provider-discover-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; // kars Bridge Operator Console — live model discovery, server-side. Client diff --git a/bridge/web/src/app/console/configuration/provider-wizard.tsx b/bridge/web/src/app/console/configuration/provider-wizard.tsx index 1de5ff608..85abbd590 100644 --- a/bridge/web/src/app/console/configuration/provider-wizard.tsx +++ b/bridge/web/src/app/console/configuration/provider-wizard.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — the ONE inference-provider wizard. Merges diff --git a/bridge/web/src/app/console/configuration/set-default-model-actions.ts b/bridge/web/src/app/console/configuration/set-default-model-actions.ts index 03a0fe2d4..d570470e3 100644 --- a/bridge/web/src/app/console/configuration/set-default-model-actions.ts +++ b/bridge/web/src/app/console/configuration/set-default-model-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/configuration/set-default-provider-actions.ts b/bridge/web/src/app/console/configuration/set-default-provider-actions.ts index 7eaff756c..4f32e4ee9 100644 --- a/bridge/web/src/app/console/configuration/set-default-provider-actions.ts +++ b/bridge/web/src/app/console/configuration/set-default-provider-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/datapath/page.tsx b/bridge/web/src/app/console/datapath/page.tsx index 3b534487e..4096fd680 100644 --- a/bridge/web/src/app/console/datapath/page.tsx +++ b/bridge/web/src/app/console/datapath/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Datapath witness. // // Surfaces the OPTIONAL eBPF (Inspektor Gadget) datapath-completeness witness: diff --git a/bridge/web/src/app/console/delete-resource.tsx b/bridge/web/src/app/console/delete-resource.tsx index d2d696c0d..fb74fc4e7 100644 --- a/bridge/web/src/app/console/delete-resource.tsx +++ b/bridge/web/src/app/console/delete-resource.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — per-item delete/revoke control. A two-step diff --git a/bridge/web/src/app/console/evals/eval-detail.tsx b/bridge/web/src/app/console/evals/eval-detail.tsx index 459c1bca3..9f9447b86 100644 --- a/bridge/web/src/app/console/evals/eval-detail.tsx +++ b/bridge/web/src/app/console/evals/eval-detail.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — detailed eval report. Expands an eval to show diff --git a/bridge/web/src/app/console/evals/new-eval-form.tsx b/bridge/web/src/app/console/evals/new-eval-form.tsx index 423efd010..74c98a6fd 100644 --- a/bridge/web/src/app/console/evals/new-eval-form.tsx +++ b/bridge/web/src/app/console/evals/new-eval-form.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — configure + launch a safety eval. Operator-only: diff --git a/bridge/web/src/app/console/evals/page.tsx b/bridge/web/src/app/console/evals/page.tsx index 9fd179434..089f29c1b 100644 --- a/bridge/web/src/app/console/evals/page.tsx +++ b/bridge/web/src/app/console/evals/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Safety evals (KarsEval). // // The quality/safety lifecycle: each KarsEval replays a curated adversarial diff --git a/bridge/web/src/app/console/fleet/capacity-dashboard.tsx b/bridge/web/src/app/console/fleet/capacity-dashboard.tsx index 185f3be0e..a510bafea 100644 --- a/bridge/web/src/app/console/fleet/capacity-dashboard.tsx +++ b/bridge/web/src/app/console/fleet/capacity-dashboard.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { Section } from "@/components/ui"; import type { ClusterCapacity, Sandbox } from "@/lib/types"; diff --git a/bridge/web/src/app/console/fleet/fleet-list.tsx b/bridge/web/src/app/console/fleet/fleet-list.tsx index eae206d80..dc1242dc3 100644 --- a/bridge/web/src/app/console/fleet/fleet-list.tsx +++ b/bridge/web/src/app/console/fleet/fleet-list.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — the sandbox fleet as a filterable working diff --git a/bridge/web/src/app/console/fleet/mesh-topology.tsx b/bridge/web/src/app/console/fleet/mesh-topology.tsx index e3a629e02..ecd4d12a8 100644 --- a/bridge/web/src/app/console/fleet/mesh-topology.tsx +++ b/bridge/web/src/app/console/fleet/mesh-topology.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — Mesh topology, as a real node-link graph. Each diff --git a/bridge/web/src/app/console/fleet/page.tsx b/bridge/web/src/app/console/fleet/page.tsx index 60b2f0c22..4209e7e95 100644 --- a/bridge/web/src/app/console/fleet/page.tsx +++ b/bridge/web/src/app/console/fleet/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Sandboxes. Every sandbox (lead + spawned // sub-agents), with phase, runtime, isolation, parent, and the conditions table // for troubleshooting. Real reads from KarsSandbox. The list itself is a diff --git a/bridge/web/src/app/console/foundry-actions.ts b/bridge/web/src/app/console/foundry-actions.ts index df3af83d0..15b8984b8 100644 --- a/bridge/web/src/app/console/foundry-actions.ts +++ b/bridge/web/src/app/console/foundry-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/foundry-onboard.tsx b/bridge/web/src/app/console/foundry-onboard.tsx index 36cc2ba7a..29d2a776e 100644 --- a/bridge/web/src/app/console/foundry-onboard.tsx +++ b/bridge/web/src/app/console/foundry-onboard.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Operator Foundry onboarding — URL-first + guided, animated discovery. diff --git a/bridge/web/src/app/console/governance-actions.ts b/bridge/web/src/app/console/governance-actions.ts index a65842955..b0ab8dac6 100644 --- a/bridge/web/src/app/console/governance-actions.ts +++ b/bridge/web/src/app/console/governance-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/inference-policy-editor.tsx b/bridge/web/src/app/console/inference-policy-editor.tsx index ea6125453..e31089e97 100644 --- a/bridge/web/src/app/console/inference-policy-editor.tsx +++ b/bridge/web/src/app/console/inference-policy-editor.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — visual InferencePolicy editor. Replaces the diff --git a/bridge/web/src/app/console/insights/page.tsx b/bridge/web/src/app/console/insights/page.tsx index 11d106fff..abce4cee5 100644 --- a/bridge/web/src/app/console/insights/page.tsx +++ b/bridge/web/src/app/console/insights/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Insights. Fleet-wide efficiency + governance, // graphical. Structural facts are real; runtime token/latency render the // honest "needs a real run" state, never fabricated zeros. diff --git a/bridge/web/src/app/console/layout.tsx b/bridge/web/src/app/console/layout.tsx index da3d01991..87b3a267a 100644 --- a/bridge/web/src/app/console/layout.tsx +++ b/bridge/web/src/app/console/layout.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — the platform/SRE shell. // // Dense, information-first chrome. Unlike the Workspace, Kubernetes context is diff --git a/bridge/web/src/app/console/mcp-catalog-data.ts b/bridge/web/src/app/console/mcp-catalog-data.ts index d4281f947..c530615bf 100644 --- a/bridge/web/src/app/console/mcp-catalog-data.ts +++ b/bridge/web/src/app/console/mcp-catalog-data.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — curated catalog of popular MCP servers, so an operator can add a // connected service in one click instead of hand-authoring a McpServer spec. // diff --git a/bridge/web/src/app/console/mcp-catalog.tsx b/bridge/web/src/app/console/mcp-catalog.tsx index 990b7f4a5..6d6ebbf90 100644 --- a/bridge/web/src/app/console/mcp-catalog.tsx +++ b/bridge/web/src/app/console/mcp-catalog.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — MCP catalog picker. Managed entries create a typed McpServer diff --git a/bridge/web/src/app/console/mcp-profile-actions.ts b/bridge/web/src/app/console/mcp-profile-actions.ts index ddadf3644..ee47654d4 100644 --- a/bridge/web/src/app/console/mcp-profile-actions.ts +++ b/bridge/web/src/app/console/mcp-profile-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/console/mcp-profiles.tsx b/bridge/web/src/app/console/mcp-profiles.tsx index 3e0ccdfe8..1ade2c790 100644 --- a/bridge/web/src/app/console/mcp-profiles.tsx +++ b/bridge/web/src/app/console/mcp-profiles.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Operator MCP profiles — curate named, vetted bundles of McpServers so users diff --git a/bridge/web/src/app/console/mcp-server-editor.tsx b/bridge/web/src/app/console/mcp-server-editor.tsx index 8a58a5ceb..9ac61e4bd 100644 --- a/bridge/web/src/app/console/mcp-server-editor.tsx +++ b/bridge/web/src/app/console/mcp-server-editor.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — visual McpServer editor. Replaces the raw- diff --git a/bridge/web/src/app/console/operator-github-status.tsx b/bridge/web/src/app/console/operator-github-status.tsx index 488d3f3ae..f4fb48dcd 100644 --- a/bridge/web/src/app/console/operator-github-status.tsx +++ b/bridge/web/src/app/console/operator-github-status.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — GitHub App self-service setup. Operators diff --git a/bridge/web/src/app/console/page.tsx b/bridge/web/src/app/console/page.tsx index 3db7fb205..a8774c479 100644 --- a/bridge/web/src/app/console/page.tsx +++ b/bridge/web/src/app/console/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Fleet Health (shift-triage home). // Sandbox phase counts, the degraded list, and substrate scope. Operator // truth: zeros are legitimate and useful here (operators count resources). diff --git a/bridge/web/src/app/console/policies/page.tsx b/bridge/web/src/app/console/policies/page.tsx index 36d9c702c..3cc2325d5 100644 --- a/bridge/web/src/app/console/policies/page.tsx +++ b/bridge/web/src/app/console/policies/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — Policies. Inventory of the governance config: // connected MCP servers, tool policies, inference policies, and temporary // egress approvals. All real reads via the operator API. diff --git a/bridge/web/src/app/console/policy-builder-data.ts b/bridge/web/src/app/console/policy-builder-data.ts index bcd70b6ef..dc5ad9285 100644 --- a/bridge/web/src/app/console/policy-builder-data.ts +++ b/bridge/web/src/app/console/policy-builder-data.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — structured AGT tool-policy builder. Instead of hand-writing the // agentmesh PolicyEngine YAML (the "embarrassing" part), the operator toggles // capability presets and adds optional custom allow/deny rules; this module diff --git a/bridge/web/src/app/console/policy-builder.tsx b/bridge/web/src/app/console/policy-builder.tsx index c3375982e..9dcad9f5e 100644 --- a/bridge/web/src/app/console/policy-builder.tsx +++ b/bridge/web/src/app/console/policy-builder.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — structured AGT tool-policy builder. Toggle capability presets, diff --git a/bridge/web/src/app/console/profile-editor.tsx b/bridge/web/src/app/console/profile-editor.tsx index 8d866af25..650c998e7 100644 --- a/bridge/web/src/app/console/profile-editor.tsx +++ b/bridge/web/src/app/console/profile-editor.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — visual KarsProfile (team profile) editor. diff --git a/bridge/web/src/app/console/skill-approval.tsx b/bridge/web/src/app/console/skill-approval.tsx index d96559b09..a61103f81 100644 --- a/bridge/web/src/app/console/skill-approval.tsx +++ b/bridge/web/src/app/console/skill-approval.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Operator skill-admission control. The trust gate the user described: diff --git a/bridge/web/src/app/console/skill-submit-action.ts b/bridge/web/src/app/console/skill-submit-action.ts index 2d360a7ed..059cf5f11 100644 --- a/bridge/web/src/app/console/skill-submit-action.ts +++ b/bridge/web/src/app/console/skill-submit-action.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; // kars Bridge Operator Console — skill submission (server action). Same diff --git a/bridge/web/src/app/console/sre-action-decision.tsx b/bridge/web/src/app/console/sre-action-decision.tsx index 9ecd98ec0..bd9b31506 100644 --- a/bridge/web/src/app/console/sre-action-decision.tsx +++ b/bridge/web/src/app/console/sre-action-decision.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars-SRE remediation-proposal approve/reject control. Mirrors SkillApproval: diff --git a/bridge/web/src/app/console/sre-actions/page.tsx b/bridge/web/src/app/console/sre-actions/page.tsx index e9fc957c7..2ea7831e8 100644 --- a/bridge/web/src/app/console/sre-actions/page.tsx +++ b/bridge/web/src/app/console/sre-actions/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Operator Console — SRE Actions. The kars-sre agent's // self-remediation proposal surface: it diagnoses a workload incident and // proposes ONE typed fix (KarsSREAction, closed action set); an operator diff --git a/bridge/web/src/app/console/troubleshooting/page.tsx b/bridge/web/src/app/console/troubleshooting/page.tsx index 3b8c1a471..7e4a1854b 100644 --- a/bridge/web/src/app/console/troubleshooting/page.tsx +++ b/bridge/web/src/app/console/troubleshooting/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — System / wiring view. Delivery Constraint #5: the product // never hides an un-wired gap behind a finished screen. This page shows the // true, cluster-read status of every stage of the governed-agent pipeline. diff --git a/bridge/web/src/app/dex/[...path]/route.ts b/bridge/web/src/app/dex/[...path]/route.ts index 51edcbe21..88a80bd8e 100644 --- a/bridge/web/src/app/dex/[...path]/route.ts +++ b/bridge/web/src/app/dex/[...path]/route.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — same-origin OIDC IdP proxy (/dex/* → in-cluster Dex). // // Why: colleagues reach the Bridge over a single `kubectl port-forward diff --git a/bridge/web/src/app/inbox/approval-actions.ts b/bridge/web/src/app/inbox/approval-actions.ts index 0171f02dc..6ee39eb57 100644 --- a/bridge/web/src/app/inbox/approval-actions.ts +++ b/bridge/web/src/app/inbox/approval-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — approval decision server action (the steering primitive). "use server"; diff --git a/bridge/web/src/app/layout.tsx b/bridge/web/src/app/layout.tsx index 65ec0571e..8ed362064 100644 --- a/bridge/web/src/app/layout.tsx +++ b/bridge/web/src/app/layout.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; diff --git a/bridge/web/src/app/page.tsx b/bridge/web/src/app/page.tsx index 17982a6c2..b44fc8974 100644 --- a/bridge/web/src/app/page.tsx +++ b/bridge/web/src/app/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { redirect } from "next/navigation"; // The product opens on the Workspace (the employee surface). Operators switch diff --git a/bridge/web/src/app/role-actions.ts b/bridge/web/src/app/role-actions.ts index ddbe834ad..c97ffbb49 100644 --- a/bridge/web/src/app/role-actions.ts +++ b/bridge/web/src/app/role-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; // kars Bridge — set the DEV role-simulation cookie. No SSO yet, so this lets one diff --git a/bridge/web/src/app/tasks/[name]/execution-panel.tsx b/bridge/web/src/app/tasks/[name]/execution-panel.tsx index f18956f0f..409f98842 100644 --- a/bridge/web/src/app/tasks/[name]/execution-panel.tsx +++ b/bridge/web/src/app/tasks/[name]/execution-panel.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — execution panel. The §20 launch control + honest live diff --git a/bridge/web/src/app/tasks/[name]/launch-actions.ts b/bridge/web/src/app/tasks/[name]/launch-actions.ts index 18407a9ce..5732d7adc 100644 --- a/bridge/web/src/app/tasks/[name]/launch-actions.ts +++ b/bridge/web/src/app/tasks/[name]/launch-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — launch / un-launch server action (the §20 gate). "use server"; diff --git a/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx b/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx index a06030b5c..d93277a51 100644 --- a/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx +++ b/bridge/web/src/app/tasks/[name]/task-approvals-panel.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — task-scoped approvals panel. Shows the human decisions gating // this task (the steering surface, in the task's own context), with inline // approve/deny for any still pending. diff --git a/bridge/web/src/app/workspace/agents/page.tsx b/bridge/web/src/app/workspace/agents/page.tsx index 51fecf932..90d34bc90 100644 --- a/bridge/web/src/app/workspace/agents/page.tsx +++ b/bridge/web/src/app/workspace/agents/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Active agents. The plain answer to "what is working // right now, and what just finished?" Sourced from real run telemetry (not idle // pods): live runs pulse with what they're doing this second; recent runs show diff --git a/bridge/web/src/app/workspace/artifacts/loading.tsx b/bridge/web/src/app/workspace/artifacts/loading.tsx index 2a4a12d65..f4ae45f06 100644 --- a/bridge/web/src/app/workspace/artifacts/loading.tsx +++ b/bridge/web/src/app/workspace/artifacts/loading.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { ListSkeleton } from "@/components/list-skeleton"; export default function Loading() { diff --git a/bridge/web/src/app/workspace/artifacts/page.tsx b/bridge/web/src/app/workspace/artifacts/page.tsx index 61b95d7bd..8e7b32d57 100644 --- a/bridge/web/src/app/workspace/artifacts/page.tsx +++ b/bridge/web/src/app/workspace/artifacts/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Artifacts. The cross-mission deliverable index. // // Lists the REAL deliverables missions have produced — the files captured by diff --git a/bridge/web/src/app/workspace/connections/page.tsx b/bridge/web/src/app/workspace/connections/page.tsx index 3ab39aa4b..99acbbdd6 100644 --- a/bridge/web/src/app/workspace/connections/page.tsx +++ b/bridge/web/src/app/workspace/connections/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Connections. Where a USER connects their own GitHub // repos so their agents can open pull requests (keyless). Operator-level App // setup lives in the Console; each user's GitHub connection is isolated. diff --git a/bridge/web/src/app/workspace/inbox/loading.tsx b/bridge/web/src/app/workspace/inbox/loading.tsx index 2a4a12d65..f4ae45f06 100644 --- a/bridge/web/src/app/workspace/inbox/loading.tsx +++ b/bridge/web/src/app/workspace/inbox/loading.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { ListSkeleton } from "@/components/list-skeleton"; export default function Loading() { diff --git a/bridge/web/src/app/workspace/inbox/page.tsx b/bridge/web/src/app/workspace/inbox/page.tsx index f17575d1d..1eb8a8af0 100644 --- a/bridge/web/src/app/workspace/inbox/page.tsx +++ b/bridge/web/src/app/workspace/inbox/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Inbox. The fleet-wide decision queue. Every card // answers what/by-which-role/why/impact before the buttons (no rubber-stamping). diff --git a/bridge/web/src/app/workspace/layout.tsx b/bridge/web/src/app/workspace/layout.tsx index 0c213c2bf..dc77b2d7d 100644 --- a/bridge/web/src/app/workspace/layout.tsx +++ b/bridge/web/src/app/workspace/layout.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — the employee shell. // // Consumer-grade chrome: warm, generous spacing, the product identity, the diff --git a/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx b/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx index 94f98ab8b..e136c802e 100644 --- a/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/budget-recovery.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useState, useTransition } from "react"; diff --git a/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts b/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts index 8b7734faf..b89cb5e16 100644 --- a/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts +++ b/bridge/web/src/app/workspace/missions/[name]/delete-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — "Delete mission" server action. Permanently removes a mission // via the BFF (which deletes the KarsTask and sweeps its deliverable, files, // trace, and review record). Destructive and irreversible; the control gates it diff --git a/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx b/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx index b3deaf596..136620129 100644 --- a/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/delete-control.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — "Delete mission" control. Deleting a mission is destructive: it diff --git a/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx b/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx index 8fc0872c2..088d9b00e 100644 --- a/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/deploy-timeline.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — the live Deploy timeline (design note FL/REQ15: // "dynamically watch agents deploy"). The journey rail shows the high-level // beat; this fills the Build→Run gap with the granular, real provisioning diff --git a/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts b/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts index 2c11570a5..adbfdcc34 100644 --- a/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts +++ b/bridge/web/src/app/workspace/missions/[name]/egress-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — request temporary website access for a mission. // // Files an EgressApproval the controller reconciles through human approval; the diff --git a/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx b/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx index aebbb34b1..70514955f 100644 --- a/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/egress-request.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Mission "request website access" form. The agent's egress is default-deny; diff --git a/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx b/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx index 02e291d97..331d2c0a4 100644 --- a/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/halt-button.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — governed emergency-stop ("red button"). One click halts a diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx index 14259e2e1..921cea4a9 100644 --- a/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/mission-autorun.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — first-run auto-kickoff. diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx index 8e2f796a8..bbafa6550 100644 --- a/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/mission-blockers.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Mission blockers ("what the agent got stuck on"). Built from the diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx index 115c277d6..6422c279e 100644 --- a/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/mission-detail-panels.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Mission detail server presentation; page fetching and routing stay in ./page. import Link from "next/link"; diff --git a/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx b/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx index bf1d0c43f..871c00427 100644 --- a/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/mission-map.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — the live mission map (design note §4). A single // at-a-glance view of a governed run: the delegation tree (principal + reports // + sub-agents) with each node's authority tier, the live token burn against diff --git a/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx b/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx index 764f446ae..4f0e29db1 100644 --- a/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/network-mode.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Network mode (learning → enforced). Surfaces the sandbox's REAL diff --git a/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx b/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx index 1f826a162..b442375a3 100644 --- a/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/org-chart.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — the org chart: principal + delegated roles, and an diff --git a/bridge/web/src/app/workspace/missions/[name]/page.tsx b/bridge/web/src/app/workspace/missions/[name]/page.tsx index 28d55c995..07cc35f3b 100644 --- a/bridge/web/src/app/workspace/missions/[name]/page.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Mission detail (the live mission canvas). // // The user projection of a governed task: the objective, an always-visible diff --git a/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx b/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx index 8d1c57ba5..a64b9d599 100644 --- a/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/promote-mission.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Per-mission autonomy promotion (§12). Requests a higher tier; the controller diff --git a/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx b/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx index 22d5240cf..db9105934 100644 --- a/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/readiness-panel.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Pre-flight access readiness (design note FL5). // // Before (and during) a run, the operator must be able to see — at a glance — diff --git a/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx b/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx index 0e5d1059c..1f08dccd0 100644 --- a/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/reliability-runner.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Reliability runner — the pass^k trigger. Runs a delivered mission's EXACT diff --git a/bridge/web/src/app/workspace/missions/[name]/review-actions.ts b/bridge/web/src/app/workspace/missions/[name]/review-actions.ts index 76d89ff7b..cac4e4b21 100644 --- a/bridge/web/src/app/workspace/missions/[name]/review-actions.ts +++ b/bridge/web/src/app/workspace/missions/[name]/review-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — artifact review server action (§16). request_changes re-drives // the producing task on the reviewer's delta. "use server"; diff --git a/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx b/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx index c55396389..cfb1f17f0 100644 --- a/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx +++ b/bridge/web/src/app/workspace/missions/[name]/review-panel.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — the artifact review loop (§16). A reviewer accepts a diff --git a/bridge/web/src/app/workspace/missions/[name]/role-actions.ts b/bridge/web/src/app/workspace/missions/[name]/role-actions.ts index f8065235f..35c84486a 100644 --- a/bridge/web/src/app/workspace/missions/[name]/role-actions.ts +++ b/bridge/web/src/app/workspace/missions/[name]/role-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — add a delegated role to a mission (the §12 org chart). // // A "role" is a child KarsTask whose authority is a verified subset of the diff --git a/bridge/web/src/app/workspace/missions/[name]/run-actions.ts b/bridge/web/src/app/workspace/missions/[name]/run-actions.ts index 649f07b71..1ec8ef442 100644 --- a/bridge/web/src/app/workspace/missions/[name]/run-actions.ts +++ b/bridge/web/src/app/workspace/missions/[name]/run-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — reliability runner (pass^k) server action. // // Replicates a delivered mission's EXACT package k times so the efficiency diff --git a/bridge/web/src/app/workspace/missions/loading.tsx b/bridge/web/src/app/workspace/missions/loading.tsx index 2a4a12d65..f4ae45f06 100644 --- a/bridge/web/src/app/workspace/missions/loading.tsx +++ b/bridge/web/src/app/workspace/missions/loading.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { ListSkeleton } from "@/components/list-skeleton"; export default function Loading() { diff --git a/bridge/web/src/app/workspace/missions/missions-list.tsx b/bridge/web/src/app/workspace/missions/missions-list.tsx index 0871e3a98..80f405bf6 100644 --- a/bridge/web/src/app/workspace/missions/missions-list.tsx +++ b/bridge/web/src/app/workspace/missions/missions-list.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — the missions list as a filterable surface (audit f7): diff --git a/bridge/web/src/app/workspace/missions/page.tsx b/bridge/web/src/app/workspace/missions/page.tsx index 5ef0e9bad..7bb868d0a 100644 --- a/bridge/web/src/app/workspace/missions/page.tsx +++ b/bridge/web/src/app/workspace/missions/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Missions list. Plain-language projection of the task // fleet, filterable by what the user cares about (running / ready / blocked). diff --git a/bridge/web/src/app/workspace/new/actions.ts b/bridge/web/src/app/workspace/new/actions.ts index 8aac66302..99d9f669c 100644 --- a/bridge/web/src/app/workspace/new/actions.ts +++ b/bridge/web/src/app/workspace/new/actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — mission intake server action. // // Creates a governed mission from the reviewed package. The user never sees a diff --git a/bridge/web/src/app/workspace/new/envelope-reveal.tsx b/bridge/web/src/app/workspace/new/envelope-reveal.tsx index f5136b2e3..3b6ee92d7 100644 --- a/bridge/web/src/app/workspace/new/envelope-reveal.tsx +++ b/bridge/web/src/app/workspace/new/envelope-reveal.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Composer reveal. The moment that makes kars legible: after an diff --git a/bridge/web/src/app/workspace/new/intake-flow.tsx b/bridge/web/src/app/workspace/new/intake-flow.tsx index 0f66a0f9e..a9b7fdf0f 100644 --- a/bridge/web/src/app/workspace/new/intake-flow.tsx +++ b/bridge/web/src/app/workspace/new/intake-flow.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — mission intake → editable launch package → launch. diff --git a/bridge/web/src/app/workspace/new/intake-flow/controls.tsx b/bridge/web/src/app/workspace/new/intake-flow/controls.tsx index 83922737e..3fba2ccc2 100644 --- a/bridge/web/src/app/workspace/new/intake-flow/controls.tsx +++ b/bridge/web/src/app/workspace/new/intake-flow/controls.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type * as React from "react"; import { useState } from "react"; import { useFormStatus } from "react-dom"; diff --git a/bridge/web/src/app/workspace/new/intake-flow/helpers.ts b/bridge/web/src/app/workspace/new/intake-flow/helpers.ts index 18aa0166f..ec5589e0f 100644 --- a/bridge/web/src/app/workspace/new/intake-flow/helpers.ts +++ b/bridge/web/src/app/workspace/new/intake-flow/helpers.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export function modelKey(provider: string, deployment: string) { return `${provider}::${deployment}`; } diff --git a/bridge/web/src/app/workspace/new/intake-flow/review-types.ts b/bridge/web/src/app/workspace/new/intake-flow/review-types.ts index b6313f74e..c0f4f8e1e 100644 --- a/bridge/web/src/app/workspace/new/intake-flow/review-types.ts +++ b/bridge/web/src/app/workspace/new/intake-flow/review-types.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { Dispatch, SetStateAction } from "react"; import type { Blueprint, diff --git a/bridge/web/src/app/workspace/new/intake-flow/review.tsx b/bridge/web/src/app/workspace/new/intake-flow/review.tsx index 805c43a9e..3d0b0cea7 100644 --- a/bridge/web/src/app/workspace/new/intake-flow/review.tsx +++ b/bridge/web/src/app/workspace/new/intake-flow/review.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { SegmentedTier } from "@/components/segmented-tier"; import { OrchestrationCube } from "@/components/orchestration-cube"; import { Icon } from "@/components/icon"; diff --git a/bridge/web/src/app/workspace/new/page.tsx b/bridge/web/src/app/workspace/new/page.tsx index 9d4eeb65e..6f9da643b 100644 --- a/bridge/web/src/app/workspace/new/page.tsx +++ b/bridge/web/src/app/workspace/new/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — New mission (intake → editable package → launch). import { IntakeFlow } from "./intake-flow"; diff --git a/bridge/web/src/app/workspace/page.tsx b/bridge/web/src/app/workspace/page.tsx index 625341d66..d76cd8a32 100644 --- a/bridge/web/src/app/workspace/page.tsx +++ b/bridge/web/src/app/workspace/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Home. Action-led, not a stat dashboard: the single // "Start a mission" entry point + your in-flight missions + anything waiting on // you. Honest empty state on a fresh cluster (no zeroed cards that imply diff --git a/bridge/web/src/app/workspace/skills/loading.tsx b/bridge/web/src/app/workspace/skills/loading.tsx index 2a4a12d65..f4ae45f06 100644 --- a/bridge/web/src/app/workspace/skills/loading.tsx +++ b/bridge/web/src/app/workspace/skills/loading.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { ListSkeleton } from "@/components/list-skeleton"; export default function Loading() { diff --git a/bridge/web/src/app/workspace/skills/page.tsx b/bridge/web/src/app/workspace/skills/page.tsx index 4ad400a2d..159b8d709 100644 --- a/bridge/web/src/app/workspace/skills/page.tsx +++ b/bridge/web/src/app/workspace/skills/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Skills. The USER side of the skill trust gate: upload // a skill package, watch it move through operator review, and see which skills // are approved + usable to assign to a task or team. Uploading proposes diff --git a/bridge/web/src/app/workspace/skills/skill-actions.ts b/bridge/web/src/app/workspace/skills/skill-actions.ts index 5b9b133ba..6657cd2eb 100644 --- a/bridge/web/src/app/workspace/skills/skill-actions.ts +++ b/bridge/web/src/app/workspace/skills/skill-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; // kars Bridge Workspace — user skill submission (server action). A team member diff --git a/bridge/web/src/app/workspace/skills/skill-upload.tsx b/bridge/web/src/app/workspace/skills/skill-upload.tsx index 063895914..d79d6660d 100644 --- a/bridge/web/src/app/workspace/skills/skill-upload.tsx +++ b/bridge/web/src/app/workspace/skills/skill-upload.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — skill upload. Thin wrapper around the shared diff --git a/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts b/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts index 9a849c751..bcec5ce3b 100644 --- a/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts +++ b/bridge/web/src/app/workspace/teams/[name]/channel-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — team communication-channel server actions. Tokens are sent to // the BFF (which stores them only in a K8s Secret) and never returned to the // browser. GET/state reports enablement plus route-qualification status. diff --git a/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts b/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts index 6f07b9cb2..86f1dd79c 100644 --- a/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts +++ b/bridge/web/src/app/workspace/teams/[name]/delete-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — "Delete team" server action. Permanently removes a standing // team via the BFF (which deletes the KarsTeam and sweeps its runs, member // sandboxes, shared memory, task backlog, and channel secret). Destructive and diff --git a/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx b/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx index 1997623c4..045e6271a 100644 --- a/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/delete-control.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — "Delete team" control. A standing team is long-lived and owns diff --git a/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts b/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts index 42a690ce9..bbac5de95 100644 --- a/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts +++ b/bridge/web/src/app/workspace/teams/[name]/engineering-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { revalidatePath } from "next/cache"; diff --git a/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx b/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx index 478bdeec1..456fe718e 100644 --- a/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/engineering-intake.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useState, useTransition } from "react"; diff --git a/bridge/web/src/app/workspace/teams/[name]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/page.tsx index eb09855c1..1d17dfb27 100644 --- a/bridge/web/src/app/workspace/teams/[name]/page.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Team detail. The standing org's command surface: // its charter, who it watches and on what cadence, its org chart (principal + // roster, each a verified subset of the team's authority), and the live diff --git a/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts b/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts index 574d3b0bd..4a7b39349 100644 --- a/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts +++ b/bridge/web/src/app/workspace/teams/[name]/promote-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — team promotion server action (§12). Records a requested higher // tier; the controller opens a human approval and widens the envelope only on // approval (the BFF never raises the envelope directly). diff --git a/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx b/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx index fec4693bb..18a48bdc4 100644 --- a/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/promote-control.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — team promotion control (§12). Requests a higher autonomy tier; diff --git a/bridge/web/src/app/workspace/teams/[name]/run-actions.ts b/bridge/web/src/app/workspace/teams/[name]/run-actions.ts index 0b2ed752a..13b8dd8b8 100644 --- a/bridge/web/src/app/workspace/teams/[name]/run-actions.ts +++ b/bridge/web/src/app/workspace/teams/[name]/run-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — "Run now" server action. Triggers an immediate team run by // setting the controller's `run-now` annotation via the BFF. The controller // mints one taskforce run under the normal readiness gates and clears the diff --git a/bridge/web/src/app/workspace/teams/[name]/run-control.tsx b/bridge/web/src/app/workspace/teams/[name]/run-control.tsx index 7c73ccddb..958af0f02 100644 --- a/bridge/web/src/app/workspace/teams/[name]/run-control.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/run-control.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — "Run now" control. Triggers an immediate team run so a team diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx index 8ffe21af5..ca8dbc80c 100644 --- a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/halt-button.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useRouter } from "next/navigation"; diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx index ab6cd1bf9..0d73d3173 100644 --- a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import Link from "next/link"; import { DeliverableBody } from "@/components/deliverable-view"; import { HonestState } from "@/components/honest-state"; diff --git a/bridge/web/src/app/workspace/teams/[name]/task-actions.ts b/bridge/web/src/app/workspace/teams/[name]/task-actions.ts index 69822514b..cb65f4af8 100644 --- a/bridge/web/src/app/workspace/teams/[name]/task-actions.ts +++ b/bridge/web/src/app/workspace/teams/[name]/task-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — team task backlog server actions. Add/remove discrete tasks on a // standing team; the controller drains the oldest `pending` task on its next run // (cadence or Run now) and marks it `done` when that run delivers. diff --git a/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx b/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx index b70cf3a75..4516bc7f5 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-channels.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — team communication channels. Part of a standing team's envelope: diff --git a/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx b/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx index 64f22b325..d6046296e 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-detail-panels.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import Link from "next/link"; import type { ReactNode } from "react"; import type { TeamDetail } from "@/lib/types"; diff --git a/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx b/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx index aede22da6..9c47189e0 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-edit.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useState, useTransition } from "react"; diff --git a/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx b/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx index bd8d3102f..902ee07ba 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-ledger.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — team activity ledger list. Client component so the operator can diff --git a/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx b/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx index 91123643f..c76b97890 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-outcomes.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import Link from "next/link"; diff --git a/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx b/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx index 725dfeb99..90673775f 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-roster-edit.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — edit a standing team's org post-create (REQ13 "edit everything"). diff --git a/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx b/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx index 908b0ca33..ba9951400 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-tabs.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Team console tabs. Tames the standing-team monolith: instead of diff --git a/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx b/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx index b196364d9..0450057fd 100644 --- a/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/team-tasks.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — team task backlog. A standing team is a persistent org you diff --git a/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx b/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx index 17c120837..fdc60fdbf 100644 --- a/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/watching-status.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — a team's live "watching" status. Shows a calm, diff --git a/bridge/web/src/app/workspace/teams/loading.tsx b/bridge/web/src/app/workspace/teams/loading.tsx index 2a4a12d65..f4ae45f06 100644 --- a/bridge/web/src/app/workspace/teams/loading.tsx +++ b/bridge/web/src/app/workspace/teams/loading.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { ListSkeleton } from "@/components/list-skeleton"; export default function Loading() { diff --git a/bridge/web/src/app/workspace/teams/new/actions.ts b/bridge/web/src/app/workspace/teams/new/actions.ts index 1f9779fdf..37e0aacfd 100644 --- a/bridge/web/src/app/workspace/teams/new/actions.ts +++ b/bridge/web/src/app/workspace/teams/new/actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use server"; import { redirect } from "next/navigation"; diff --git a/bridge/web/src/app/workspace/teams/new/page.tsx b/bridge/web/src/app/workspace/teams/new/page.tsx index d36936264..ee68ad8e8 100644 --- a/bridge/web/src/app/workspace/teams/new/page.tsx +++ b/bridge/web/src/app/workspace/teams/new/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — New team. Server shell that loads the real cluster building // blocks (models, harnesses) so the org-chart composer can offer per-role // harness + model choices. When ?profile=<name> is present, the team is diff --git a/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts b/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts index 99767f867..7ccc2c8e9 100644 --- a/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts +++ b/bridge/web/src/app/workspace/teams/new/team-composer-panel-types.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { Dispatch, SetStateAction } from "react"; import type { Options } from "@/lib/types"; diff --git a/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx b/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx index 24e9ed90d..a8c0efb83 100644 --- a/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx +++ b/bridge/web/src/app/workspace/teams/new/team-composer-panels.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { Icon } from "@/components/icon"; diff --git a/bridge/web/src/app/workspace/teams/new/team-composer.tsx b/bridge/web/src/app/workspace/teams/new/team-composer.tsx index 1b1cdb8fe..a62f86228 100644 --- a/bridge/web/src/app/workspace/teams/new/team-composer.tsx +++ b/bridge/web/src/app/workspace/teams/new/team-composer.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Team composer. Intent → a visual org chart. The user gives a diff --git a/bridge/web/src/app/workspace/teams/page.tsx b/bridge/web/src/app/workspace/teams/page.tsx index 459d423d3..4a28f9060 100644 --- a/bridge/web/src/app/workspace/teams/page.tsx +++ b/bridge/web/src/app/workspace/teams/page.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — Teams list. Standing orgs that run continuously // under a charter — distinct from Missions (finite task forces). Each Team's // charter loop mints task-force work on a cadence (autonomous monitoring). diff --git a/bridge/web/src/app/workspace/teams/teams-list.tsx b/bridge/web/src/app/workspace/teams/teams-list.tsx index 69de709b8..acb8cce5e 100644 --- a/bridge/web/src/app/workspace/teams/teams-list.tsx +++ b/bridge/web/src/app/workspace/teams/teams-list.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import Link from "next/link"; diff --git a/bridge/web/src/components/activity-stream.tsx b/bridge/web/src/components/activity-stream.tsx index f968d69e6..59331bc19 100644 --- a/bridge/web/src/components/activity-stream.tsx +++ b/bridge/web/src/components/activity-stream.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — live activity stream. // // The plan's Mission Map right-rail: the real tool-call / round trace and token diff --git a/bridge/web/src/components/agent-graph.tsx b/bridge/web/src/components/agent-graph.tsx index 452c77494..5ea792b42 100644 --- a/bridge/web/src/components/agent-graph.tsx +++ b/bridge/web/src/components/agent-graph.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useEffect, useMemo, useState } from "react"; diff --git a/bridge/web/src/components/agent-graph/activity.ts b/bridge/web/src/components/agent-graph/activity.ts index c25c8e696..699d7a346 100644 --- a/bridge/web/src/components/agent-graph/activity.ts +++ b/bridge/web/src/components/agent-graph/activity.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { ActivityEvent } from "@/lib/types"; import type { AgentAction, AgentExecution, ToolEvent } from "./types"; diff --git a/bridge/web/src/components/agent-graph/constants.ts b/bridge/web/src/components/agent-graph/constants.ts index abb3d5745..e0da4413b 100644 --- a/bridge/web/src/components/agent-graph/constants.ts +++ b/bridge/web/src/components/agent-graph/constants.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export const AGENT_WIDTH = 248; export const AGENT_HEIGHT = 128; diff --git a/bridge/web/src/components/agent-graph/execution.ts b/bridge/web/src/components/agent-graph/execution.ts index 70db068b5..09cedbd5a 100644 --- a/bridge/web/src/components/agent-graph/execution.ts +++ b/bridge/web/src/components/agent-graph/execution.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { ActivityEvent, SubAgent } from "@/lib/types"; import { actionFromEvent, destinationsFrom, normalize } from "./activity"; import type { AgentExecution, ToolEvent } from "./types"; diff --git a/bridge/web/src/components/agent-graph/inspectors.tsx b/bridge/web/src/components/agent-graph/inspectors.tsx index 039343bdd..efd7a7ef6 100644 --- a/bridge/web/src/components/agent-graph/inspectors.tsx +++ b/bridge/web/src/components/agent-graph/inspectors.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type * as React from "react"; import { Icon } from "@/components/icon"; import type { AgentIdentity, Receipt } from "@/lib/types"; diff --git a/bridge/web/src/components/agent-graph/layout.ts b/bridge/web/src/components/agent-graph/layout.ts index 1c41cb2b0..d2eb12f73 100644 --- a/bridge/web/src/components/agent-graph/layout.ts +++ b/bridge/web/src/components/agent-graph/layout.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { normalize } from "./activity"; import { ACTION_LIMIT, diff --git a/bridge/web/src/components/agent-graph/types.ts b/bridge/web/src/components/agent-graph/types.ts index a5a3e7348..d3d6c2dd6 100644 --- a/bridge/web/src/components/agent-graph/types.ts +++ b/bridge/web/src/components/agent-graph/types.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { ActivityEvent } from "@/lib/types"; export type ToolEvent = Extract<ActivityEvent, { kind: "tool" }>; diff --git a/bridge/web/src/components/app-shell.tsx b/bridge/web/src/components/app-shell.tsx index e69de29bb..fc36ab244 100644 --- a/bridge/web/src/components/app-shell.tsx +++ b/bridge/web/src/components/app-shell.tsx @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. diff --git a/bridge/web/src/components/approval-decision.tsx b/bridge/web/src/components/approval-decision.tsx index 147829949..d724d83bc 100644 --- a/bridge/web/src/components/approval-decision.tsx +++ b/bridge/web/src/components/approval-decision.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — approval decision controls (Approve / Deny). diff --git a/bridge/web/src/components/approval-phase-badge.tsx b/bridge/web/src/components/approval-phase-badge.tsx index 58e8c1056..d51be2400 100644 --- a/bridge/web/src/components/approval-phase-badge.tsx +++ b/bridge/web/src/components/approval-phase-badge.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — approval phase badge. export function ApprovalPhaseBadge({ phase }: { phase: string }) { diff --git a/bridge/web/src/components/audit-report.tsx b/bridge/web/src/components/audit-report.tsx index 13a964db9..bbcd753aa 100644 --- a/bridge/web/src/components/audit-report.tsx +++ b/bridge/web/src/components/audit-report.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Downloadable audit report. Compiles the REAL governance proofs already on diff --git a/bridge/web/src/components/audit-view.tsx b/bridge/web/src/components/audit-view.tsx index 27675d9ea..dd7625419 100644 --- a/bridge/web/src/components/audit-view.tsx +++ b/bridge/web/src/components/audit-view.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — the shared Auditor view. Rendered both inside the Operator // Console (/console/audit) and on the dedicated read-only Auditor surface // (/audit), so the two never drift. It is entirely read-only: a chain-integrity diff --git a/bridge/web/src/components/bar-chart.tsx b/bridge/web/src/components/bar-chart.tsx index 6e48d3985..9d70887c2 100644 --- a/bridge/web/src/components/bar-chart.tsx +++ b/bridge/web/src/components/bar-chart.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — simple, dependency-free horizontal bar chart for count data. // Renders real values only; an empty series renders nothing (caller shows the // honest empty state). diff --git a/bridge/web/src/components/clarification-answer.tsx b/bridge/web/src/components/clarification-answer.tsx index 2f34a6896..07ea10aa0 100644 --- a/bridge/web/src/components/clarification-answer.tsx +++ b/bridge/web/src/components/clarification-answer.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — clarification answer control. A team run asked the human a diff --git a/bridge/web/src/components/compliance-pack.tsx b/bridge/web/src/components/compliance-pack.tsx index 0dbca1063..d9ed27a06 100644 --- a/bridge/web/src/components/compliance-pack.tsx +++ b/bridge/web/src/components/compliance-pack.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — compliance evidence pack. The mission's signed Governance diff --git a/bridge/web/src/components/connect-channels.tsx b/bridge/web/src/components/connect-channels.tsx index f4b9bffde..41f0fb2e2 100644 --- a/bridge/web/src/components/connect-channels.tsx +++ b/bridge/web/src/components/connect-channels.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — workspace communication channels (agent-agnostic). Configured on diff --git a/bridge/web/src/components/connect-github.tsx b/bridge/web/src/components/connect-github.tsx index a65213494..f31c7a39b 100644 --- a/bridge/web/src/components/connect-github.tsx +++ b/bridge/web/src/components/connect-github.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Each signed-in user connects an installation of the admin-configured shared diff --git a/bridge/web/src/components/connect-teams.tsx b/bridge/web/src/components/connect-teams.tsx index 73f95cf11..2d5104ad7 100644 --- a/bridge/web/src/components/connect-teams.tsx +++ b/bridge/web/src/components/connect-teams.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Microsoft Teams channel configuration. diff --git a/bridge/web/src/components/console-nav.tsx b/bridge/web/src/components/console-nav.tsx index 3ef17f1b4..97cc69309 100644 --- a/bridge/web/src/components/console-nav.tsx +++ b/bridge/web/src/components/console-nav.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — primary navigation (platform/SRE surface). diff --git a/bridge/web/src/components/copy-digest.tsx b/bridge/web/src/components/copy-digest.tsx index c413a8d5a..278d7a8af 100644 --- a/bridge/web/src/components/copy-digest.tsx +++ b/bridge/web/src/components/copy-digest.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — copyable digest block. diff --git a/bridge/web/src/components/deliverable-view.tsx b/bridge/web/src/components/deliverable-view.tsx index 6436cfa90..413d4c910 100644 --- a/bridge/web/src/components/deliverable-view.tsx +++ b/bridge/web/src/components/deliverable-view.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — smart deliverable renderer. Turns an agent's raw markdown output // into a well-formatted, TYPED document: it classifies what the agent produced // (report / recommendation / action plan / note), lifts a summary into a diff --git a/bridge/web/src/components/envelope-card.tsx b/bridge/web/src/components/envelope-card.tsx index 40bd22301..f501cc932 100644 --- a/bridge/web/src/components/envelope-card.tsx +++ b/bridge/web/src/components/envelope-card.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — trust-envelope visualization. // // Renders the authority a task holds as a precise, scannable card: the diff --git a/bridge/web/src/components/envelope-digest.tsx b/bridge/web/src/components/envelope-digest.tsx index b3f2388a2..1e1f55631 100644 --- a/bridge/web/src/components/envelope-digest.tsx +++ b/bridge/web/src/components/envelope-digest.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — render an envelope digest as a verifiable, copyable // monospace chip. The digest is the value a Governance Receipt binds to, so // it is presented as evidence, not decoration. diff --git a/bridge/web/src/components/execution-explorer.tsx b/bridge/web/src/components/execution-explorer.tsx index 1fbbaaed7..5c6565c38 100644 --- a/bridge/web/src/components/execution-explorer.tsx +++ b/bridge/web/src/components/execution-explorer.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useState } from "react"; diff --git a/bridge/web/src/components/execution-lifetime.tsx b/bridge/web/src/components/execution-lifetime.tsx index 74ae945cf..9ba1e855b 100644 --- a/bridge/web/src/components/execution-lifetime.tsx +++ b/bridge/web/src/components/execution-lifetime.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useMemo, useState } from "react"; diff --git a/bridge/web/src/components/fleet-live.tsx b/bridge/web/src/components/fleet-live.tsx index 20c753243..561d0a892 100644 --- a/bridge/web/src/components/fleet-live.tsx +++ b/bridge/web/src/components/fleet-live.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — fleet live telemetry. The "at scale" view: instead of diff --git a/bridge/web/src/components/honest-state.tsx b/bridge/web/src/components/honest-state.tsx index 233583cb7..dd55821c8 100644 --- a/bridge/web/src/components/honest-state.tsx +++ b/bridge/web/src/components/honest-state.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — the honesty grammar. // // A single, reusable component for the three distinct "no data" situations the diff --git a/bridge/web/src/components/how-it-works.tsx b/bridge/web/src/components/how-it-works.tsx index 8fcc07783..59027f358 100644 --- a/bridge/web/src/components/how-it-works.tsx +++ b/bridge/web/src/components/how-it-works.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import Link from "next/link"; /// The three-beat "how kars Bridge works" explainer. Extracted so it can be diff --git a/bridge/web/src/components/icon.tsx b/bridge/web/src/components/icon.tsx index 9346bbb44..4c51a39c8 100644 --- a/bridge/web/src/components/icon.tsx +++ b/bridge/web/src/components/icon.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — a small, dependency-free line-icon set. Emoji render // differently per-OS, can't inherit color/size, and read as amateur; these are // consistent 1.5px-stroke glyphs that inherit `currentColor` and align to a diff --git a/bridge/web/src/components/inference-budgets.tsx b/bridge/web/src/components/inference-budgets.tsx index c2e8926a1..bf23287a7 100644 --- a/bridge/web/src/components/inference-budgets.tsx +++ b/bridge/web/src/components/inference-budgets.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — hierarchical, EDITABLE inference token budgets. diff --git a/bridge/web/src/components/intent-entry.tsx b/bridge/web/src/components/intent-entry.tsx index ba502147d..c3a356cb3 100644 --- a/bridge/web/src/components/intent-entry.tsx +++ b/bridge/web/src/components/intent-entry.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Unified intent-first intake. One box: describe the outcome. Bridge classifies diff --git a/bridge/web/src/components/journey-rail.tsx b/bridge/web/src/components/journey-rail.tsx index 3540945a8..1509d6d31 100644 --- a/bridge/web/src/components/journey-rail.tsx +++ b/bridge/web/src/components/journey-rail.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — the Journey rail. ONE lifecycle spine, rendered identically // across the product so every surface tells the same story: a unit of work // (mission or team) always moves through Describe → Compose → Review → Launch → diff --git a/bridge/web/src/components/list-skeleton.tsx b/bridge/web/src/components/list-skeleton.tsx index cd9cd87ee..46da3009c 100644 --- a/bridge/web/src/components/list-skeleton.tsx +++ b/bridge/web/src/components/list-skeleton.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { Skeleton } from "@/components/ui"; /// A shared loading scaffold for the workspace list pages (missions, teams, diff --git a/bridge/web/src/components/live-activity-view.tsx b/bridge/web/src/components/live-activity-view.tsx index 732d66a9c..82c9fd831 100644 --- a/bridge/web/src/components/live-activity-view.tsx +++ b/bridge/web/src/components/live-activity-view.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — the shared live Activity view. Opens ONE SSE connection (via diff --git a/bridge/web/src/components/live-refresh.tsx b/bridge/web/src/components/live-refresh.tsx index 3f330f161..664063e0f 100644 --- a/bridge/web/src/components/live-refresh.tsx +++ b/bridge/web/src/components/live-refresh.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — live mission canvas. diff --git a/bridge/web/src/components/loop-designer.tsx b/bridge/web/src/components/loop-designer.tsx index 8eaa053fd..8b356387b 100644 --- a/bridge/web/src/components/loop-designer.tsx +++ b/bridge/web/src/components/loop-designer.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Loop Designer. The authoring surface for 2026 loop engineering: diff --git a/bridge/web/src/components/mermaid-diagram.tsx b/bridge/web/src/components/mermaid-diagram.tsx index f44f764f7..51c2f22f7 100644 --- a/bridge/web/src/components/mermaid-diagram.tsx +++ b/bridge/web/src/components/mermaid-diagram.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useEffect, useId, useRef, useState } from "react"; diff --git a/bridge/web/src/components/mission-scorecard.tsx b/bridge/web/src/components/mission-scorecard.tsx index b2b299f08..9b975d0e1 100644 --- a/bridge/web/src/components/mission-scorecard.tsx +++ b/bridge/web/src/components/mission-scorecard.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — mission scorecard (graphical, honest). // // The efficiency numbers the plan promises to deliver to USERS. Structural diff --git a/bridge/web/src/components/mission-status.tsx b/bridge/web/src/components/mission-status.tsx index 7880f2532..5e7bcbae1 100644 --- a/bridge/web/src/components/mission-status.tsx +++ b/bridge/web/src/components/mission-status.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge Workspace — mission status projection. // // Projects operator governance vocabulary (Ready/Degraded/Pending + execution diff --git a/bridge/web/src/components/orchestration-cube.tsx b/bridge/web/src/components/orchestration-cube.tsx index 00ecf9696..bf3d6a324 100644 --- a/bridge/web/src/components/orchestration-cube.tsx +++ b/bridge/web/src/components/orchestration-cube.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // Orchestration cube — the visual spine of the compose/validate/execute flow. diff --git a/bridge/web/src/components/org-tree.tsx b/bridge/web/src/components/org-tree.tsx index f6b972a32..c03501e61 100644 --- a/bridge/web/src/components/org-tree.tsx +++ b/bridge/web/src/components/org-tree.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — a real org chart. Nodes, drawn reporting-line edges, and live diff --git a/bridge/web/src/components/phase-badge.tsx b/bridge/web/src/components/phase-badge.tsx index e551caa83..be72f761b 100644 --- a/bridge/web/src/components/phase-badge.tsx +++ b/bridge/web/src/components/phase-badge.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — map a KarsTask phase to a status-badge tone. import { StatusBadge } from "@/components/status-badge"; diff --git a/bridge/web/src/components/preflight-check.tsx b/bridge/web/src/components/preflight-check.tsx index bc26761c1..938518da7 100644 --- a/bridge/web/src/components/preflight-check.tsx +++ b/bridge/web/src/components/preflight-check.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — shared pre-flight check. The SAME honest, cluster-grounded diff --git a/bridge/web/src/components/primary-nav.tsx b/bridge/web/src/components/primary-nav.tsx index e69de29bb..fc36ab244 100644 --- a/bridge/web/src/components/primary-nav.tsx +++ b/bridge/web/src/components/primary-nav.tsx @@ -0,0 +1,2 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. diff --git a/bridge/web/src/components/provenance-overlay.tsx b/bridge/web/src/components/provenance-overlay.tsx index bd6e20cca..d7371596a 100644 --- a/bridge/web/src/components/provenance-overlay.tsx +++ b/bridge/web/src/components/provenance-overlay.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Provenance Overlay. A standalone, slide-over lineage trail that diff --git a/bridge/web/src/components/provenance-story.tsx b/bridge/web/src/components/provenance-story.tsx index be64e3c26..4b6358d26 100644 --- a/bridge/web/src/components/provenance-story.tsx +++ b/bridge/web/src/components/provenance-story.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Provenance Story. The detailed, plain-language answer to "how diff --git a/bridge/web/src/components/receipt-panel.tsx b/bridge/web/src/components/receipt-panel.tsx index b9a691712..bc21bdc2f 100644 --- a/bridge/web/src/components/receipt-panel.tsx +++ b/bridge/web/src/components/receipt-panel.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Governance Receipt evidence panel (the auditor's moment). diff --git a/bridge/web/src/components/receipt-verify.tsx b/bridge/web/src/components/receipt-verify.tsx index 06e04e417..f699d2dcb 100644 --- a/bridge/web/src/components/receipt-verify.tsx +++ b/bridge/web/src/components/receipt-verify.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — in-browser receipt verification for the mission surface. The diff --git a/bridge/web/src/components/repo-access.tsx b/bridge/web/src/components/repo-access.tsx index 99da454a7..fff333ffd 100644 --- a/bridge/web/src/components/repo-access.tsx +++ b/bridge/web/src/components/repo-access.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — Repository access (keyless git write) for a mission or team. diff --git a/bridge/web/src/components/retention-policy.tsx b/bridge/web/src/components/retention-policy.tsx index b2e09f847..e140eeb1a 100644 --- a/bridge/web/src/components/retention-policy.tsx +++ b/bridge/web/src/components/retention-policy.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Operator Console — mission/team-run retention policy. diff --git a/bridge/web/src/components/role-switcher.tsx b/bridge/web/src/components/role-switcher.tsx index 50c4ee5c0..73932e892 100644 --- a/bridge/web/src/components/role-switcher.tsx +++ b/bridge/web/src/components/role-switcher.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — the multi-user surface. Shows the current principal + primary diff --git a/bridge/web/src/components/rubiks-cube.tsx b/bridge/web/src/components/rubiks-cube.tsx index 8120a1a74..f5178443b 100644 --- a/bridge/web/src/components/rubiks-cube.tsx +++ b/bridge/web/src/components/rubiks-cube.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // A real Rubik's cube rendered in CSS 3D — six faces, each a 3×3 grid of diff --git a/bridge/web/src/components/segmented-tier.tsx b/bridge/web/src/components/segmented-tier.tsx index 900151da2..f3f81bc75 100644 --- a/bridge/web/src/components/segmented-tier.tsx +++ b/bridge/web/src/components/segmented-tier.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — segmented autonomy-tier control. diff --git a/bridge/web/src/components/skill-composer.tsx b/bridge/web/src/components/skill-composer.tsx index 0a0960501..6fb3d1508 100644 --- a/bridge/web/src/components/skill-composer.tsx +++ b/bridge/web/src/components/skill-composer.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — shared skill-package composer. A guided visual form (name, diff --git a/bridge/web/src/components/stat-card.tsx b/bridge/web/src/components/stat-card.tsx index 2c6ca3d77..51b4d651f 100644 --- a/bridge/web/src/components/stat-card.tsx +++ b/bridge/web/src/components/stat-card.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — compact metric tile for the command-center + console overviews. // Shares the premium value-first treatment of `ui.tsx`'s <Stat/> so KPI tiles // look identical in the Workspace and the Operator Console (one visual diff --git a/bridge/web/src/components/status-badge.tsx b/bridge/web/src/components/status-badge.tsx index cfc4fb2b8..a388c7a9e 100644 --- a/bridge/web/src/components/status-badge.tsx +++ b/bridge/web/src/components/status-badge.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — status badge. A small, accessible indicator used across // the trust/verification surfaces. diff --git a/bridge/web/src/components/surface-switcher.tsx b/bridge/web/src/components/surface-switcher.tsx index 6f51e9e27..63dc904a1 100644 --- a/bridge/web/src/components/surface-switcher.tsx +++ b/bridge/web/src/components/surface-switcher.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — surface switcher. diff --git a/bridge/web/src/components/task-checkpoint.tsx b/bridge/web/src/components/task-checkpoint.tsx index 67e9e68d8..de0c28e13 100644 --- a/bridge/web/src/components/task-checkpoint.tsx +++ b/bridge/web/src/components/task-checkpoint.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { TaskCheckpoint } from "@/lib/types"; const TONE: Record<TaskCheckpoint["status"], string> = { diff --git a/bridge/web/src/components/team-run-activity.tsx b/bridge/web/src/components/team-run-activity.tsx index 81071cbdc..b4e6a1fed 100644 --- a/bridge/web/src/components/team-run-activity.tsx +++ b/bridge/web/src/components/team-run-activity.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { TeamRunEvidence } from "@/lib/team-run-evidence"; function label(value: string): string { diff --git a/bridge/web/src/components/team-run-flow.tsx b/bridge/web/src/components/team-run-flow.tsx index 1947d2ba7..942dc18e3 100644 --- a/bridge/web/src/components/team-run-flow.tsx +++ b/bridge/web/src/components/team-run-flow.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useMemo, useState } from "react"; diff --git a/bridge/web/src/components/team-timing.tsx b/bridge/web/src/components/team-timing.tsx index 6c8b06bc7..50ee075dd 100644 --- a/bridge/web/src/components/team-timing.tsx +++ b/bridge/web/src/components/team-timing.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useEffect, useState } from "react"; diff --git a/bridge/web/src/components/theme-toggle.tsx b/bridge/web/src/components/theme-toggle.tsx index 958fba9e0..296b8fc4d 100644 --- a/bridge/web/src/components/theme-toggle.tsx +++ b/bridge/web/src/components/theme-toggle.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — theme toggle. The palette already ships light + dark token sets diff --git a/bridge/web/src/components/ui.tsx b/bridge/web/src/components/ui.tsx index ebb558bf0..3d53fdf22 100644 --- a/bridge/web/src/components/ui.tsx +++ b/bridge/web/src/components/ui.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — shared UI primitives. One consistent visual language so every // page stops being an undifferentiated stack of gray boxes. Hierarchy comes // from these: PageHeader (eyebrow + title + lead), Section (titled block), diff --git a/bridge/web/src/components/use-live-trace.ts b/bridge/web/src/components/use-live-trace.ts index fdee0a431..34934e347 100644 --- a/bridge/web/src/components/use-live-trace.ts +++ b/bridge/web/src/components/use-live-trace.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge — shared live-activity stream hook. diff --git a/bridge/web/src/components/viewport-portal.tsx b/bridge/web/src/components/viewport-portal.tsx index bd02c1d9c..a1d44f966 100644 --- a/bridge/web/src/components/viewport-portal.tsx +++ b/bridge/web/src/components/viewport-portal.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; import { useEffect, type ReactNode } from "react"; diff --git a/bridge/web/src/components/wiring-badge.tsx b/bridge/web/src/components/wiring-badge.tsx index 5ddb49c30..bd18b8db8 100644 --- a/bridge/web/src/components/wiring-badge.tsx +++ b/bridge/web/src/components/wiring-badge.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — wiring-status badge. Communicates honest implementation // status: live (green), partial (amber), not wired (neutral/dashed). diff --git a/bridge/web/src/components/workspace-nav.tsx b/bridge/web/src/components/workspace-nav.tsx index 67009271c..bc4d910a7 100644 --- a/bridge/web/src/components/workspace-nav.tsx +++ b/bridge/web/src/components/workspace-nav.tsx @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + "use client"; // kars Bridge Workspace — primary navigation (employee surface). diff --git a/bridge/web/src/lib/auth-return.ts b/bridge/web/src/lib/auth-return.ts index 8b5f84843..be58c5014 100644 --- a/bridge/web/src/lib/auth-return.ts +++ b/bridge/web/src/lib/auth-return.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export function safeReturnTo(value: string | null | undefined, fallback = "/workspace"): string { const candidate = value?.trim(); if (!candidate || !candidate.startsWith("/") || candidate.startsWith("//") || candidate.includes("\\")) { diff --git a/bridge/web/src/lib/bff-contracts.ts b/bridge/web/src/lib/bff-contracts.ts index c1522e11f..88c22b659 100644 --- a/bridge/web/src/lib/bff-contracts.ts +++ b/bridge/web/src/lib/bff-contracts.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Server-side BFF client contracts; transport and authentication remain in ./bff. diff --git a/bridge/web/src/lib/bff.ts b/bridge/web/src/lib/bff.ts index bd1cafbf1..a504ed52d 100644 --- a/bridge/web/src/lib/bff.ts +++ b/bridge/web/src/lib/bff.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — server-side BFF client. // // This module runs only on the Next.js server. It is the single place the diff --git a/bridge/web/src/lib/classify-intent.ts b/bridge/web/src/lib/classify-intent.ts index a4279dfdc..a6cb88b37 100644 --- a/bridge/web/src/lib/classify-intent.ts +++ b/bridge/web/src/lib/classify-intent.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Intent classifier for the unified intent-first intake. // // One intent box decides whether the work is a MISSION (a focused, one-off task diff --git a/bridge/web/src/lib/config.ts b/bridge/web/src/lib/config.ts index 8262285a0..a7f19edab 100644 --- a/bridge/web/src/lib/config.ts +++ b/bridge/web/src/lib/config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — runtime configuration. // // All values are read server-side. The browser never receives cluster diff --git a/bridge/web/src/lib/credential-review.ts b/bridge/web/src/lib/credential-review.ts index 1a3e97b53..3fa530f19 100644 --- a/bridge/web/src/lib/credential-review.ts +++ b/bridge/web/src/lib/credential-review.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export type CredentialKind = "KarsSandbox" | "KarsTask" | "KarsTeam"; export interface CredentialInput { kind: CredentialKind; diff --git a/bridge/web/src/lib/format.ts b/bridge/web/src/lib/format.ts index 89bdf5f37..90332d5a6 100644 --- a/bridge/web/src/lib/format.ts +++ b/bridge/web/src/lib/format.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — value formatting helpers. Disciplined, audit-friendly // rendering of machine values (counts, budgets, money). diff --git a/bridge/web/src/lib/loop-patterns.ts b/bridge/web/src/lib/loop-patterns.ts index 80184a38a..45a8907f0 100644 --- a/bridge/web/src/lib/loop-patterns.ts +++ b/bridge/web/src/lib/loop-patterns.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — Loop engineering catalog (2026). // // "Loop engineering" is the 2026 discipline that supersedes one-shot prompt diff --git a/bridge/web/src/lib/member-archetypes.ts b/bridge/web/src/lib/member-archetypes.ts index 2fb1f7ab1..5dd53c1f4 100644 --- a/bridge/web/src/lib/member-archetypes.ts +++ b/bridge/web/src/lib/member-archetypes.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — reusable team MEMBER archetypes. // // Pre-defined role templates ("a Rust engineer", "a Financial analyst") an diff --git a/bridge/web/src/lib/oidc-config.ts b/bridge/web/src/lib/oidc-config.ts index 0ad731af1..66029a2cf 100644 --- a/bridge/web/src/lib/oidc-config.ts +++ b/bridge/web/src/lib/oidc-config.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — OIDC SSO configuration (server-only). // // Generic, config-only OIDC Authorization Code + PKCE client: point it at any diff --git a/bridge/web/src/lib/oidc.ts b/bridge/web/src/lib/oidc.ts index a7a3efa1f..1b2c489f8 100644 --- a/bridge/web/src/lib/oidc.ts +++ b/bridge/web/src/lib/oidc.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — OIDC Authorization Code + PKCE client (server-only). // // Standards-compliant against any OIDC-conformant IdP: discovery document, diff --git a/bridge/web/src/lib/preflight-actions.ts b/bridge/web/src/lib/preflight-actions.ts index eb34777bb..7d58ec135 100644 --- a/bridge/web/src/lib/preflight-actions.ts +++ b/bridge/web/src/lib/preflight-actions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — shared pre-flight validation action. Validates a launch package // (mission OR team) against the live cluster: model served, tool policy compiled, // MCP servers reconciled + endpoints resolve, egress hosts resolve, budget/tier diff --git a/bridge/web/src/lib/run-mission-client.ts b/bridge/web/src/lib/run-mission-client.ts index ee1c6a9b3..38dcff2b4 100644 --- a/bridge/web/src/lib/run-mission-client.ts +++ b/bridge/web/src/lib/run-mission-client.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + export async function runMissionClient( namespace: string, name: string, diff --git a/bridge/web/src/lib/session-token.ts b/bridge/web/src/lib/session-token.ts index 19a944cf0..3a9b37edb 100644 --- a/bridge/web/src/lib/session-token.ts +++ b/bridge/web/src/lib/session-token.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — signed session cookie (server-only). // // A real OIDC login mints one of these: a compact, HS256-signed JWT (never diff --git a/bridge/web/src/lib/session.ts b/bridge/web/src/lib/session.ts index 6828efc9b..1c8e17c5e 100644 --- a/bridge/web/src/lib/session.ts +++ b/bridge/web/src/lib/session.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — server-side session/RBAC resolution. // // Roles come from, in priority order: diff --git a/bridge/web/src/lib/team-run-evidence.ts b/bridge/web/src/lib/team-run-evidence.ts index a644f82a6..716c1a471 100644 --- a/bridge/web/src/lib/team-run-evidence.ts +++ b/bridge/web/src/lib/team-run-evidence.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { MissionArtifact, TaskDetail, TeamDetail, TeamRole } from "./types"; export interface CollaborationEvent { diff --git a/bridge/web/src/lib/types.ts b/bridge/web/src/lib/types.ts index 70f485c75..ccf9d6aa1 100644 --- a/bridge/web/src/lib/types.ts +++ b/bridge/web/src/lib/types.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge web — shared types mirroring the BFF API DTOs. // The BFF (Rust) owns these shapes; keep field names in sync with // bff/src/routes/ and bff/src/kars/. Domain modules keep this public barrel stable. diff --git a/bridge/web/src/lib/types/governance.ts b/bridge/web/src/lib/types/governance.ts index 73eefa808..7bd08c95f 100644 --- a/bridge/web/src/lib/types/governance.ts +++ b/bridge/web/src/lib/types/governance.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + diff --git a/bridge/web/src/lib/types/missions.ts b/bridge/web/src/lib/types/missions.ts index 2ef2dec75..1d1b9d812 100644 --- a/bridge/web/src/lib/types/missions.ts +++ b/bridge/web/src/lib/types/missions.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { MissionDelegation } from "./orchestration"; import type { PullRequestRef } from "./workspace"; // kars Bridge web — shared types mirroring the BFF API DTOs. diff --git a/bridge/web/src/lib/types/operations.ts b/bridge/web/src/lib/types/operations.ts index 91bbe2659..5b0b5fee3 100644 --- a/bridge/web/src/lib/types/operations.ts +++ b/bridge/web/src/lib/types/operations.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // ─── Insights / scorecard (real + honest) ─────────────────────────────────── diff --git a/bridge/web/src/lib/types/operator.ts b/bridge/web/src/lib/types/operator.ts index 6135e0b89..20364b3fa 100644 --- a/bridge/web/src/lib/types/operator.ts +++ b/bridge/web/src/lib/types/operator.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // ─── Operator Console projections (real CRD reads) ────────────────────────── diff --git a/bridge/web/src/lib/types/orchestration.ts b/bridge/web/src/lib/types/orchestration.ts index 242c51350..bb7a9acc2 100644 --- a/bridge/web/src/lib/types/orchestration.ts +++ b/bridge/web/src/lib/types/orchestration.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { BlueprintEgress, BlueprintModel, ExecutionPlan } from "./missions"; import type { EngineeringSignal } from "./teams"; diff --git a/bridge/web/src/lib/types/system.ts b/bridge/web/src/lib/types/system.ts index 712be7dae..5296e6494 100644 --- a/bridge/web/src/lib/types/system.ts +++ b/bridge/web/src/lib/types/system.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // ─── System / wiring ───────────────────────────────────────────────────────── diff --git a/bridge/web/src/lib/types/teams.ts b/bridge/web/src/lib/types/teams.ts index e4ba73430..b8c8f417f 100644 --- a/bridge/web/src/lib/types/teams.ts +++ b/bridge/web/src/lib/types/teams.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import type { ExecutionPlan } from "./missions"; import type { PullRequestRef } from "./workspace"; diff --git a/bridge/web/src/lib/types/workspace.ts b/bridge/web/src/lib/types/workspace.ts index a548a5892..8962ef21f 100644 --- a/bridge/web/src/lib/types/workspace.ts +++ b/bridge/web/src/lib/types/workspace.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // ─── Artifacts index (cross-mission deliverables, §16) ─────────────────────── diff --git a/bridge/web/src/proxy.ts b/bridge/web/src/proxy.ts index 428bc9d82..24c7d6566 100644 --- a/bridge/web/src/proxy.ts +++ b/bridge/web/src/proxy.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Bridge — server-side RBAC enforcement at the edge. // // The Operator Console UI disables admin-only controls, but that is cosmetic: a From 5f634ae828ab6454d45b94e62fb24d81e7cb2f93 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 19:04:15 +0200 Subject: [PATCH 096/111] fix(ci): verify bounded tool downloads before qualification Preserve pinned Kind, kubectl and metrics-server versions. Validate official checksums before executable publication, bound HTTPS acquisition and transient retries, and separate metrics manifest acquisition from one-shot Kubernetes apply. Keep global deadlines, integrity checks and runtime assertions; exercise real partial-transfer cleanup and exact topology preservation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 13 +- ci/acquire_test_tools.py | 176 ++++++++++ ci/tests/acquisition_test.py | 519 ++++++++++++++++++++++++++++++ tests/e2e/kind-config.yaml | 3 + tests/e2e/sre_authority/common.py | 6 + tests/e2e/sre_authority/proxy.py | 35 +- 6 files changed, 745 insertions(+), 7 deletions(-) create mode 100644 ci/acquire_test_tools.py create mode 100644 ci/tests/acquisition_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52bb19ea3..02785e835 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: CI on: @@ -710,12 +713,14 @@ jobs: # save cost. save-if: false + - name: Test bounded CI dependency acquisition + run: python3 -m unittest discover -s ci/tests -p acquisition_test.py + - name: Install kind if: steps.paths.outputs.run == 'true' - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 - with: - install_only: true - version: v0.24.0 + # Official v0.24.0 checksum; bounded HTTPS acquisition, no tool-cache + # fallback or implicit kubectl install. Cluster lifecycle is unchanged. + run: python3 ci/acquire_test_tools.py kind - name: Install kubectl if: steps.paths.outputs.run == 'true' diff --git a/ci/acquire_test_tools.py b/ci/acquire_test_tools.py new file mode 100644 index 000000000..c7ebc064d --- /dev/null +++ b/ci/acquire_test_tools.py @@ -0,0 +1,176 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""CI-only acquisition of pinned Kind and the real E2E metrics manifest. + +Curl gets at most three attempts per asset, 10s to connect and 30s per attempt, +within a shared caller deadline. Only transient transport failures, 429 and 5xx +retry (1s/2s backoff). HTTP bodies and subprocess errors are never diagnostics. +Kind uses its official release checksum, not a cache or an unchecked fallback. +Metrics retains its existing HTTPS provenance; no new digest is asserted. +Its caller shares the original 90s fetch/apply budget. +""" + +import argparse +import hashlib +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +from time import monotonic, sleep +import uuid + +KIND_VERSION = "v0.24.0" +KIND_RELEASE = f"https://github.com/kubernetes-sigs/kind/releases/download/{KIND_VERSION}" +METRICS_URL = ( + "https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.2/components.yaml" +) +ATTEMPTS = 3 +CONNECT_SECONDS = 10 +ATTEMPT_SECONDS = 30 +KIND_SECONDS = 120 +TRANSIENT_CURL = frozenset((5, 6, 7, 16, 18, 28, 52, 55, 56, 92)) + + +class AcquisitionError(Exception): + """The message is a fixed category, never external error text.""" + + +def remaining(deadline): + value = deadline - monotonic() + if value <= 0: + raise AcquisitionError("deadline") + return value + + +def download(url, destination, deadline, max_bytes): + destination = Path(destination) + partial = destination.with_name(destination.name + ".part") + if destination.exists(): + raise AcquisitionError("local-io") + # All callers supply a unique, private directory. Exclusivity prevents + # adopting another invocation's partial file or exposing it as a result. + descriptor = os.open(partial, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(descriptor) + try: + for attempt in range(ATTEMPTS): + budget = remaining(deadline) + timeout = min(ATTEMPT_SECONDS, budget) + try: + result = subprocess.run( + ["curl", "--disable", "--proto", "=https", "--proto-redir", "=https", + "--location", "--max-redirs", "5", "--fail", "--silent", "--show-error", + "--connect-timeout", str(min(CONNECT_SECONDS, timeout)), + "--max-time", str(timeout), "--retry", "0", + "--max-filesize", str(max_bytes), "--output", str(partial), + "--write-out", "%{http_code}", url], + capture_output=True, timeout=budget, + ) + except subprocess.TimeoutExpired: + raise AcquisitionError("deadline") from None + except OSError: + raise AcquisitionError("local-io") from None + status = int(result.stdout) if re.fullmatch(rb"[1-5][0-9]{2}", result.stdout) else None + retry = False + if status is not None and not 200 <= status < 300: + category = f"http-{status}" + retry = (status == 429 or status >= 500) and result.returncode in (0, 22) + elif result.returncode: + category = "timeout" if result.returncode == 28 else "transport" + retry = result.returncode in TRANSIENT_CURL + elif status is None: + category = "invalid-status" + elif not 0 < partial.stat().st_size <= max_bytes: + category = "invalid-size" + else: + remaining(deadline) + partial.rename(destination) + return + if not retry or attempt == ATTEMPTS - 1: + raise AcquisitionError(category) + delay = attempt + 1 + if remaining(deadline) <= delay: + raise AcquisitionError("deadline") + # Do not retain or append an error body/partial transfer on retry. + partial.write_bytes(b"") + sleep(delay) + finally: + partial.unlink(missing_ok=True) + + +def checksum_for(data, filename): + match = re.fullmatch(rb"([0-9a-fA-F]{64}) [ *]" + re.escape(filename.encode("ascii")) + rb"\n?", data) + if not match: + raise AcquisitionError("checksum-format") + return match[1].decode("ascii").lower() + + +def install_kind(work, github_path): + if platform.system() != "Linux": + raise AcquisitionError("unsupported-platform") + arch = {"x86_64": "amd64", "aarch64": "arm64"}.get(platform.machine()) + if not arch: + raise AcquisitionError("unsupported-platform") + filename = f"kind-linux-{arch}" + directory = Path(work).resolve() / (".ci-kind-" + uuid.uuid4().hex) + directory.mkdir(mode=0o700) + checksum = directory / (filename + ".sha256sum") + binary = directory / "kind" + published = False + deadline = monotonic() + KIND_SECONDS + try: + download(f"{KIND_RELEASE}/{filename}.sha256sum", checksum, deadline, 1024) + expected = checksum_for(checksum.read_bytes(), filename) + download(f"{KIND_RELEASE}/{filename}", binary, deadline, 50 * 1024 * 1024) + digest = hashlib.sha256() + with binary.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != expected: + raise AcquisitionError("checksum-mismatch") + remaining(deadline) + binary.chmod(0o700) + # No binary execution, chmod, PATH publication or cache reuse precedes + # the exact-filename checksum and digest checks. + with Path(github_path).open("a", encoding="utf-8") as output: + output.write(str(directory) + "\n") + published = True + return binary + finally: + checksum.unlink(missing_ok=True) + if not published: + binary.unlink(missing_ok=True) + directory.rmdir() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="asset", required=True) + subparsers.add_parser("kind") + metrics = subparsers.add_parser("metrics") + metrics.add_argument("--destination", type=Path, required=True) + metrics.add_argument("--budget", type=float, required=True) + args = parser.parse_args() + try: + if args.asset == "kind": + github_path = os.environ.get("GITHUB_PATH") + if os.environ.get("GITHUB_ACTIONS") != "true" or not github_path: + raise AcquisitionError("ci-environment") + install_kind(Path.cwd(), github_path) + else: + if not 0 < args.budget <= 90: + raise AcquisitionError("deadline") + download(METRICS_URL, args.destination, monotonic() + args.budget, 1024 * 1024) + except AcquisitionError as error: + print(f"CI-ACQUISITION-FAILURE {error}", file=sys.stderr) + return 1 + except OSError: + print("CI-ACQUISITION-FAILURE local-io", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/tests/acquisition_test.py b/ci/tests/acquisition_test.py new file mode 100644 index 000000000..37aaeea34 --- /dev/null +++ b/ci/tests/acquisition_test.py @@ -0,0 +1,519 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Offline subprocess fixtures; these do not claim a live Kind/API result.""" + +import contextlib +from concurrent.futures import ThreadPoolExecutor +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import time +import unittest +from unittest.mock import Mock, patch +import uuid + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "tests/e2e")) +from ci import acquire_test_tools as acquisition +from sre_authority.common import Harness, command_error_category +from sre_authority import proxy + +PRIVATE = "fixture-PAT-must-not-leak https://private.invalid/?token=fixture-secret" +BINARY = "#!/bin/sh\nprintf 'UNVERIFIED-EXECUTION' >&2\nexit 99\n" +SHA = hashlib.sha256(BINARY.encode()).hexdigest() +CHECKSUM = SHA + " kind-linux-amd64\n" +MANIFEST = "apiVersion: v1\nkind: List\nitems: []\n" + +FAKE_CURL = r''' +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +import json +import os +from pathlib import Path +import sys +import time + +plan = Path(os.environ["ACQUISITION_FIXTURE"]) +responses = json.loads(plan.read_text()) +log = plan.with_suffix(".calls") +calls = json.loads(log.read_text()) if log.exists() else [] +args = sys.argv[1:] +output = Path(args[args.index("--output") + 1]) +path_file = Path(os.environ["GITHUB_PATH"]) +calls.append({"args": args, "mode": output.stat().st_mode & 0o777, + "directoryMode": output.parent.stat().st_mode & 0o777, + "previousSize": output.stat().st_size, "path": path_file.read_text()}) +response = responses[min(len(calls) - 1, len(responses) - 1)] +time.sleep(response.get("startup_sleep", 0)) +log.write_text(json.dumps(calls)) +output.write_text(response.get("body", "")) +time.sleep(response.get("sleep", 0)) +sys.stdout.write(response.get("status", "200")) +sys.stderr.write(response.get("stderr", "")) +sys.exit(response.get("exit", 0)) +''' + + +class AcquisitionTests(unittest.TestCase): + def setUp(self): + self.work = ROOT / (".ci-acquisition-test-" + uuid.uuid4().hex) + self.work.mkdir(mode=0o700) + self.addCleanup(shutil.rmtree, self.work) + self.tools = self.work / "tools" + self.tools.mkdir(mode=0o700) + curl = self.tools / "curl" + curl.write_text("#!" + sys.executable + "\n" + FAKE_CURL) + curl.chmod(0o700) + self.plan = self.work / "responses.json" + self.github_path = self.work / "github-path" + self.github_path.write_text("") + self.environment = patch.dict(os.environ, { + "PATH": str(self.tools) + os.pathsep + os.environ.get("PATH", ""), + "ACQUISITION_FIXTURE": str(self.plan), "GITHUB_PATH": str(self.github_path), + "GITHUB_ACTIONS": "true", "PYTHONDONTWRITEBYTECODE": "1", + }) + self.environment.start() + self.addCleanup(self.environment.stop) + self.destination = self.work / "asset" + + def responses(self, *values): + self.plan.write_text(json.dumps(values)) + self.plan.with_suffix(".calls").unlink(missing_ok=True) + + def calls(self): + log = self.plan.with_suffix(".calls") + return json.loads(log.read_text()) if log.exists() else [] + + def download(self, seconds=15, max_bytes=1024): + acquisition.download("https://public.invalid/asset", self.destination, + time.monotonic() + seconds, max_bytes) + + def assert_no_download(self): + self.assertFalse(self.destination.exists()) + self.assertFalse(self.destination.with_suffix(".part").exists()) + + def install(self): + with patch.object(acquisition.platform, "system", return_value="Linux"), \ + patch.object(acquisition.platform, "machine", return_value="x86_64"): + return acquisition.install_kind(self.work, self.github_path) + + def harness(self, seconds=650): + h = Harness.__new__(Harness) + h.root, h.work, h.phase = ROOT, self.work, "acquisition-test" + h.deadline = time.monotonic() + seconds + h.get = Mock(return_value=None) + h.k = Mock(return_value=subprocess.CompletedProcess([], 0, "", "")) + h.poll = Mock() + return h + + def assert_metrics_clean(self): + self.assertEqual(list(self.work.glob("metrics-*")), []) + + def test_checksum_requires_one_exact_official_filename_and_hex_digest(self): + for data in ( + "<html>" + PRIVATE + "</html>", "", SHA, SHA + " kind-linux-arm64\n", + SHA + " ./kind-linux-amd64\n", CHECKSUM + CHECKSUM, + CHECKSUM + PRIVATE, "x" * 64 + " kind-linux-amd64\n", + SHA + "\tkind-linux-amd64\n", SHA + " kind-linux-amd64.extra\n", + PRIVATE + "\n" + CHECKSUM, SHA + " kind-linux-amd64\n\n", + ): + with self.subTest(checksum_case=data[:8]): + self.responses({"body": data}) + category = "checksum-format" if data else "invalid-size" + with self.assertRaisesRegex(acquisition.AcquisitionError, "^" + category + "$"): + self.install() + self.assertEqual(len(self.calls()), 1) + self.assertEqual(self.github_path.read_text(), "") + self.assertEqual(list(self.work.glob(".ci-kind-*")), []) + self.assertEqual(acquisition.checksum_for(CHECKSUM.encode(), "kind-linux-amd64"), SHA) + self.assertEqual(acquisition.checksum_for( + (SHA.upper() + " *kind-linux-amd64").encode(), "kind-linux-amd64"), SHA) + + def test_binary_mismatch_never_chmods_publishes_executes_or_retries(self): + self.responses({"body": CHECKSUM}, {"body": "<html>" + PRIVATE + "</html>"}) + with patch.object(Path, "chmod") as chmod, \ + self.assertRaisesRegex(acquisition.AcquisitionError, "^checksum-mismatch$"): + self.install() + chmod.assert_not_called() + self.assertEqual(len(self.calls()), 2) + self.assertEqual(self.github_path.read_text(), "") + self.assertEqual(list(self.work.glob(".ci-kind-*")), []) + + def test_verified_kind_has_unique_private_install_without_cache_or_execution(self): + other = self.work / ".ci-kind-not-owned" + other.mkdir() + (other / "kind").write_text("do not use or remove") + self.responses({"body": CHECKSUM}, {"body": BINARY}, + {"body": CHECKSUM}, {"body": BINARY}) + first = self.install() + second = self.install() + self.assertNotEqual(first, second) + self.assertEqual(first.read_text(), BINARY) + self.assertEqual(stat.S_IMODE(first.stat().st_mode), 0o700) + self.assertEqual(list(first.parent.iterdir()), [first]) + self.assertEqual(self.github_path.read_text().splitlines(), + [str(first.parent), str(second.parent)]) + self.assertEqual((other / "kind").read_text(), "do not use or remove") + self.assertEqual(len(self.calls()), 4) + for call in self.calls()[:2]: + self.assertEqual((call["mode"], call["directoryMode"], call["path"]), (0o600, 0o700, "")) + self.assertEqual([call["args"][-1] for call in self.calls()[:2]], [ + acquisition.KIND_RELEASE + "/kind-linux-amd64.sha256sum", + acquisition.KIND_RELEASE + "/kind-linux-amd64", + ]) + + def test_publication_failure_cleans_verified_binary_not_other_files(self): + self.responses({"body": CHECKSUM}, {"body": BINARY}) + self.github_path = self.work / "missing-parent" / "path" + # The fixture's path stays valid; only the installer's publication fails. + with self.assertRaises(OSError): + self.install() + self.assertEqual(list(self.work.glob(".ci-kind-*")), []) + + def test_kind_checksum_binary_and_verification_share_one_total_deadline(self): + clock = [0] + deadlines = [] + + def fetch(url, destination, deadline, _max_bytes): + deadlines.append(deadline) + checksum = url.endswith(".sha256sum") + destination.write_text(CHECKSUM if checksum else BINARY) + clock[0] = 80 if checksum else 121 + + with patch.object(acquisition, "monotonic", side_effect=lambda: clock[0]), \ + patch.object(acquisition, "download", side_effect=fetch), \ + patch.object(Path, "chmod") as chmod, \ + self.assertRaisesRegex(acquisition.AcquisitionError, "^deadline$"): + self.install() + self.assertEqual(deadlines, [120, 120]) + chmod.assert_not_called() + self.assertEqual(self.github_path.read_text(), "") + self.assertEqual(list(self.work.glob(".ci-kind-*")), []) + + def test_wrong_platform_fails_before_download_or_path_publication(self): + for system, machine in (("Darwin", "arm64"), ("Linux", "riscv64")): + with self.subTest(system=system, machine=machine), \ + patch.object(acquisition.platform, "system", return_value=system), \ + patch.object(acquisition.platform, "machine", return_value=machine), \ + self.assertRaisesRegex(acquisition.AcquisitionError, "^unsupported-platform$"): + acquisition.install_kind(self.work, self.github_path) + self.assertEqual(self.calls(), []) + self.assertEqual(self.github_path.read_text(), "") + + def test_arm64_uses_matching_official_checksum_and_binary(self): + self.responses({"body": SHA + " kind-linux-arm64\n"}, {"body": BINARY}) + with patch.object(acquisition.platform, "system", return_value="Linux"), \ + patch.object(acquisition.platform, "machine", return_value="aarch64"): + binary = acquisition.install_kind(self.work, self.github_path) + self.assertEqual(binary.read_text(), BINARY) + self.assertTrue(all(call["args"][-1].split("/")[-1].startswith("kind-linux-arm64") + for call in self.calls())) + + def test_permanent_http_failures_never_retry_or_publish_partial_body(self): + for status in ("401", "403", "404", "408", "410", "422"): + with self.subTest(status=status): + self.responses({"status": status, "exit": 22, "body": PRIVATE, "stderr": PRIVATE}) + with patch.object(acquisition, "sleep") as sleep, \ + self.assertRaisesRegex(acquisition.AcquisitionError, "^http-" + status + "$"): + self.download() + self.assertEqual(len(self.calls()), 1) + sleep.assert_not_called() + self.assert_no_download() + + def test_transient_http_and_transport_failures_retry_from_empty_file(self): + failures = [{"status": str(status), "exit": 22} for status in (429, 500, 502, 503, 504, 599)] + failures += [{"status": "000", "exit": code} for code in acquisition.TRANSIENT_CURL] + failures += [{"status": "200", "exit": 18}] + for failure in failures: + with self.subTest(failure=failure): + self.responses({**failure, "body": PRIVATE, "stderr": PRIVATE}, {"body": "complete"}) + with patch.object(acquisition, "sleep") as sleep: + self.download() + self.assertEqual(self.destination.read_text(), "complete") + self.destination.unlink() + self.assertEqual(len(self.calls()), 2) + self.assertEqual(self.calls()[1]["previousSize"], 0) + sleep.assert_called_once_with(1) + + def test_retry_exhaustion_is_three_attempts_with_only_one_two_second_backoffs(self): + for response, category in (({"status": "503", "exit": 22}, "http-503"), + ({"status": "000", "exit": 28}, "timeout"), + ({"status": "000", "exit": 7}, "transport")): + with self.subTest(category=category): + self.responses({**response, "body": PRIVATE}) + with patch.object(acquisition, "sleep") as sleep, \ + self.assertRaisesRegex(acquisition.AcquisitionError, "^" + category + "$"): + self.download() + self.assertEqual(len(self.calls()), 3) + self.assertEqual([call.args[0] for call in sleep.call_args_list], [1, 2]) + self.assert_no_download() + + def test_permanent_curl_failures_and_malformed_status_never_retry(self): + for response, category in ( + ({"status": "000", "exit": 60}, "transport"), + ({"status": "000", "exit": 23}, "transport"), + ({"status": "000", "exit": 1}, "transport"), + ({"status": "302", "exit": 1}, "http-302"), + ({"status": "200 " + PRIVATE}, "invalid-status"), + ({"status": "000"}, "invalid-status"), + ): + with self.subTest(category=category): + self.responses({**response, "body": PRIVATE, "stderr": PRIVATE}) + with self.assertRaisesRegex(acquisition.AcquisitionError, "^" + category + "$"): + self.download() + self.assertEqual(len(self.calls()), 1) + self.assert_no_download() + + def test_curl_arguments_enforce_https_redirects_http_failures_and_both_time_bounds(self): + self.responses({"body": "asset"}) + self.download(seconds=12) + args = self.calls()[0]["args"] + self.assertEqual(args[0], "--disable") + for flag, value in (("--proto", "=https"), ("--proto-redir", "=https"), + ("--max-redirs", "5"), ("--retry", "0"), ("--max-filesize", "1024")): + self.assertEqual(args[args.index(flag) + 1], value) + for flag in ("--fail", "--location", "--silent", "--show-error"): + self.assertIn(flag, args) + self.assertLessEqual(float(args[args.index("--connect-timeout") + 1]), 10) + self.assertLessEqual(float(args[args.index("--max-time") + 1]), 12) + self.assertNotIn("--insecure", args) + self.assertEqual(stat.S_IMODE(self.destination.stat().st_mode), 0o600) + + def test_real_subprocess_deadline_kills_partial_transfer_without_retry(self): + self.responses({"body": PRIVATE, "startup_sleep": 0.3, "sleep": 30}) + partial = self.destination.with_suffix(".part") + processes = [] + popen = subprocess.Popen + + def record_process(*args, **kwargs): + process = popen(*args, **kwargs) + processes.append(process) + return process + + start = time.monotonic() + with patch.object(acquisition.subprocess, "Popen", side_effect=record_process), \ + patch.object(acquisition, "sleep") as backoff, \ + ThreadPoolExecutor(max_workers=1) as executor: + # Observe actual partial bytes within the startup allowance, while + # the unchanged downloader enforces its real five-second deadline. + result = executor.submit(self.download, seconds=5) + while not (partial.exists() and partial.read_bytes() == PRIVATE.encode()): + self.assertFalse(result.done(), "Download ended before writing partial data") + self.assertLess(time.monotonic() - start, 3, "Fixture startup timed out") + time.sleep(0.01) + self.assertLess(time.monotonic() - start, 3, "Fixture startup timed out") + self.assertEqual(len(processes), 1) + self.assertIsNone(processes[0].poll(), "Fixture must still be transferring") + self.assertEqual(len(self.calls()), 1) + with self.assertRaisesRegex(acquisition.AcquisitionError, "^deadline$"): + result.result(timeout=7 - (time.monotonic() - start)) + self.assertGreaterEqual(time.monotonic() - start, 5) + self.assertLess(time.monotonic() - start, 7) + self.assertIsNotNone(processes[0].returncode) + self.assertNotEqual(processes[0].returncode, 0) + backoff.assert_not_called() + self.assertEqual(len(self.calls()), 1) + self.assert_no_download() + + def test_deadline_prevents_initial_attempt_and_excess_backoff(self): + self.responses({"body": PRIVATE, "status": "503", "exit": 22}) + with self.assertRaisesRegex(acquisition.AcquisitionError, "^deadline$"): + self.download(seconds=-1) + self.assertEqual(self.calls(), []) + self.assert_no_download() + with patch.object(acquisition, "sleep") as sleep, \ + self.assertRaisesRegex(acquisition.AcquisitionError, "^deadline$"): + self.download(seconds=0.5) + sleep.assert_not_called() + self.assertEqual(len(self.calls()), 1) + self.assert_no_download() + + def test_empty_or_oversize_body_is_not_a_success_or_retry(self): + for body in ("", "too large"): + with self.subTest(body=body): + self.responses({"body": body}) + with self.assertRaisesRegex(acquisition.AcquisitionError, "^invalid-size$"): + self.download(max_bytes=2) + self.assertEqual(len(self.calls()), 1) + self.assert_no_download() + + def test_existing_files_are_not_adopted_overwritten_or_cleaned(self): + for target in (self.destination, self.destination.with_suffix(".part")): + with self.subTest(target=target.name): + target.write_text("owned by another invocation") + with self.assertRaises((acquisition.AcquisitionError, FileExistsError)): + self.download() + self.assertEqual(target.read_text(), "owned by another invocation") + self.assertEqual(self.calls(), []) + target.unlink() + + def test_child_cli_diagnostics_do_not_leak_private_body_stderr_or_urls(self): + self.responses({"status": "403", "exit": 22, "body": PRIVATE, "stderr": PRIVATE}) + result = subprocess.run( + [sys.executable, str(ROOT / "ci/acquire_test_tools.py"), "metrics", + "--destination", str(self.destination), "--budget", "3"], + capture_output=True, text=True, timeout=5, + ) + self.assertEqual(result.returncode, 1) + self.assertEqual(result.stdout, "") + self.assertEqual(result.stderr, "CI-ACQUISITION-FAILURE http-403\n") + self.assert_no_download() + + def test_kind_cli_requires_ci_environment_before_acquisition(self): + with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}), \ + patch.object(sys, "argv", ["acquire_test_tools.py", "kind"]), \ + contextlib.redirect_stderr(io.StringIO()) as stderr: + self.assertEqual(acquisition.main(), 1) + self.assertEqual(stderr.getvalue(), "CI-ACQUISITION-FAILURE ci-environment\n") + self.assertEqual(self.calls(), []) + + def test_metrics_uses_local_private_manifest_and_retains_patch_rollout_and_poll(self): + self.responses({"body": MANIFEST}) + h = self.harness() + + def kubectl(*args, **kwargs): + if args[0] == "apply": + manifest = Path(args[2]) + self.assertTrue(manifest.is_relative_to(self.work)) + self.assertEqual(manifest.read_text(), MANIFEST) + self.assertEqual(stat.S_IMODE(manifest.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(manifest.parent.stat().st_mode), 0o700) + self.assertLess(kwargs["timeout"], 90) + self.assertEqual(kwargs["expected"], None) + return subprocess.CompletedProcess([], 0, "", "") + + h.k.side_effect = kubectl + proxy.install_metrics(h) + self.assertEqual([call.args[0] for call in h.k.call_args_list], ["apply", "patch", "rollout"]) + self.assertIn("--kubelet-insecure-tls", h.k.call_args_list[1].args[-1]) + self.assertEqual(h.k.call_args_list[2].kwargs["timeout"], 130) + h.poll.assert_called_once() + self.assertEqual(self.calls()[0]["args"][-1], acquisition.METRICS_URL) + self.assert_metrics_clean() + + def test_metrics_download_failure_never_calls_kubernetes_or_leaks_child_data(self): + self.responses({"status": "404", "exit": 22, "body": PRIVATE, "stderr": PRIVATE}) + h = self.harness() + with self.assertRaisesRegex( + AssertionError, "^Metrics manifest download failed; category=ci-acquisition:http-404$" + ): + proxy.install_metrics(h) + h.k.assert_not_called() + h.poll.assert_not_called() + self.assertEqual(len(self.calls()), 1) + self.assert_metrics_clean() + + def test_metrics_apply_failure_is_separate_sanitized_and_never_retried(self): + self.responses({"body": MANIFEST}) + h = self.harness() + h.k.return_value = subprocess.CompletedProcess( + [], 1, PRIVATE, "Error from server (Forbidden): " + PRIVATE) + with self.assertRaisesRegex( + AssertionError, "^Metrics manifest Kubernetes apply failed; category=Forbidden$" + ): + proxy.install_metrics(h) + h.k.assert_called_once() + self.assertEqual(h.k.call_args.args[0], "apply") + h.poll.assert_not_called() + self.assertEqual(len(self.calls()), 1) + self.assert_metrics_clean() + + def test_metrics_apply_timeout_never_retries_mutations(self): + self.responses({"body": MANIFEST}) + h = self.harness() + h.k.side_effect = AssertionError("Command exceeded its bounded timeout at apply_metrics") + with self.assertRaisesRegex(AssertionError, "bounded timeout at apply_metrics"): + proxy.install_metrics(h) + h.k.assert_called_once() + self.assert_metrics_clean() + + def test_metrics_parent_timeout_cleans_partial_download_and_never_applies(self): + self.responses({"body": PRIVATE, "sleep": 3}) + h = self.harness(seconds=0.3) + start = time.monotonic() + with self.assertRaisesRegex(AssertionError, "bounded timeout|ci-acquisition:deadline"): + proxy.install_metrics(h) + self.assertLess(time.monotonic() - start, 2) + h.k.assert_not_called() + self.assert_metrics_clean() + + def test_metrics_fetch_and_apply_share_original_ninety_seconds_and_phase_deadline(self): + for phase_budget, spent in ((650, 7), (30, 7), (30, 30), (650, 90)): + with self.subTest(phase_budget=phase_budget, spent=spent): + h = self.harness() + h.deadline = phase_budget + clock = [0] + + def fetch(args, **kwargs): + self.assertEqual(kwargs["timeout"], min(90, phase_budget)) + self.assertEqual(float(args[-1]), min(90, phase_budget)) + Path(args[args.index("--destination") + 1]).write_text(MANIFEST) + clock[0] = spent + return subprocess.CompletedProcess([], 0, "", "") + + h.run = Mock(side_effect=fetch) + with patch.object(proxy.time, "monotonic", side_effect=lambda: clock[0]): + if spent >= min(90, phase_budget): + with self.assertRaisesRegex(AssertionError, "Kubernetes apply exceeded"): + proxy.install_metrics(h) + h.k.assert_not_called() + else: + proxy.install_metrics(h) + self.assertEqual(h.k.call_args_list[0].kwargs["timeout"], + min(90, phase_budget) - spent) + self.assert_metrics_clean() + + def test_existing_metrics_service_preserves_no_install_behavior(self): + h = self.harness() + h.get.return_value = {"metadata": {"name": "v1beta1.metrics.k8s.io"}} + h.run = Mock() + proxy.install_metrics(h) + h.run.assert_not_called() + h.k.assert_not_called() + h.poll.assert_called_once() + + def test_acquisition_classification_accepts_only_fixed_categories(self): + prefix = "CI-ACQUISITION-FAILURE " + self.assertEqual(command_error_category(PRIVATE + "\n" + prefix + "http-403\n" + PRIVATE), + "ci-acquisition:http-403") + for text in (prefix + PRIVATE, prefix + "http-403 " + PRIVATE, prefix + "http-999"): + self.assertEqual(command_error_category(text), "unclassified") + self.assertEqual(command_error_category(prefix + "deadline\n" + prefix + "http-503"), + "ci-acquisition:ambiguous") + + def test_ci_wires_tests_before_install_without_changing_other_tool_or_cluster_versions(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text() + e2e = workflow.split("\n e2e-kind:", 1)[1].split("\n bench-regression:", 1)[0] + tests = "python3 -m unittest discover -s ci/tests -p acquisition_test.py" + installer = "python3 ci/acquire_test_tools.py kind" + self.assertLess(e2e.index(tests), e2e.index(installer)) + self.assertNotIn("helm/kind-action@", e2e) + self.assertIn("version: v1.30.5", e2e) + self.assertIn("azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310", e2e) + self.assertIn("make test-e2e", e2e) + self.assertEqual(workflow.count("helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc"), 2) + self.assertEqual(acquisition.KIND_VERSION, "v0.24.0") + self.assertEqual(acquisition.METRICS_URL, + "https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.2/components.yaml") + self.assertEqual((ROOT / "tests/e2e/kind-config.yaml").read_bytes(), + b"# Copyright (c) Microsoft Corporation.\n" + b"# Licensed under the MIT License.\n\n" + b"kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n" + b" - role: control-plane\n - role: worker\n" + b" labels:\n kars.azure.com/pool: sandbox\n") + self.assertIn('kind create cluster --name "$CLUSTER_NAME" --config "$SCRIPT_DIR/kind-config.yaml"', + (ROOT / "tests/e2e/run.sh").read_text()) + self.assertIn(" - name: Test bounded CI dependency acquisition\n" + " run: " + tests, e2e) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/e2e/kind-config.yaml b/tests/e2e/kind-config.yaml index cd3b64335..3f406cc11 100644 --- a/tests/e2e/kind-config.yaml +++ b/tests/e2e/kind-config.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 nodes: diff --git a/tests/e2e/sre_authority/common.py b/tests/e2e/sre_authority/common.py index 42ef16a9e..fe0f204d9 100644 --- a/tests/e2e/sre_authority/common.py +++ b/tests/e2e/sre_authority/common.py @@ -64,6 +64,12 @@ def command_site(): def command_error_category(stderr): + acquisition = re.findall( + r"^CI-ACQUISITION-FAILURE (http-[1-5][0-9]{2}|deadline|timeout|transport|" + r"invalid-status|invalid-size|local-io|checksum-format|checksum-mismatch|" + r"unsupported-platform|ci-environment)$", stderr, re.MULTILINE) + if acquisition: + return "ci-acquisition:" + (acquisition[0] if len(set(acquisition)) == 1 else "ambiguous") stages = { "registrar", "controller-review", "release-inventory", "prerequisite-chart-render", "action-schema-review", "helm-compatibility", "action-schema-migration", diff --git a/tests/e2e/sre_authority/proxy.py b/tests/e2e/sre_authority/proxy.py index 0a9fdb6d7..3add8ca7a 100644 --- a/tests/e2e/sre_authority/proxy.py +++ b/tests/e2e/sre_authority/proxy.py @@ -6,9 +6,11 @@ import os import re import sys +import time import types +import uuid -from .common import AGENT, EPOCH, OPERATORS, PRIVATE, RUNTIME, STANDIN, SYSTEM, require +from .common import AGENT, EPOCH, OPERATORS, PRIVATE, RUNTIME, STANDIN, SYSTEM, command_error_category, require from .admission import runtime_denials from .credential_paths import token_secret_denials @@ -16,12 +18,39 @@ SA_PATH = "/var/run/secrets/kubernetes.io/serviceaccount" +def download_metrics(h, manifest, deadline): + budget = deadline - time.monotonic() + require(budget > 0, "Metrics manifest download exceeded its bounded deadline") + result = h.run([sys.executable, str(h.root / "ci/acquire_test_tools.py"), "metrics", + "--destination", str(manifest), "--budget", str(budget)], + timeout=budget, expected=None) + require(result.returncode == 0, + "Metrics manifest download failed; category=" + command_error_category(result.stderr)) + + +def apply_metrics(h, manifest, deadline): + budget = deadline - time.monotonic() + require(budget > 0, "Metrics manifest Kubernetes apply exceeded its bounded deadline") + result = h.k("apply", "-f", str(manifest), timeout=budget, expected=None) + require(result.returncode == 0, + "Metrics manifest Kubernetes apply failed; category=" + command_error_category(result.stderr)) + + def install_metrics(h): # Real metrics-server on the disposable Kind cluster. This is not a fake # metrics API and does not change any production chart or SRE policy. if not h.get("apiservice", "v1beta1.metrics.k8s.io"): - h.k("apply", "-f", "https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.7.2/components.yaml", - timeout=90) + deadline = min(h.deadline, time.monotonic() + 90) + work = h.work / ("metrics-" + uuid.uuid4().hex) + work.mkdir(mode=0o700) + manifest = work / "components.yaml" + try: + download_metrics(h, manifest, deadline) + apply_metrics(h, manifest, deadline) + finally: + manifest.unlink(missing_ok=True) + manifest.with_name("components.yaml.part").unlink(missing_ok=True) + work.rmdir() h.k("patch", "deployment", "metrics-server", "-n", "kube-system", "--type=json", "-p", json.dumps([{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--kubelet-insecure-tls"}])) From 191a320291aa5431dc4b481e2bf5f03ac004deca Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 19:04:22 +0200 Subject: [PATCH 097/111] chore(repo): enforce format-safe Microsoft MIT attribution Cover all tracked first-party comment-capable formats while preserving source bytes, directives, frontmatter, modes and existing notices. Explicitly account for strict data, legal files, generated artifacts and upstream ownership without corrupting payloads or changing licensing. Restrict generated coverage to reviewed exact paths; reject unknown authored formats. Keep existing checker/applier entrypoints and test enforcement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .agt-sdk/.gitignore | 3 + .cargo/audit.toml | 3 + .dockerignore | 3 + .github/CODEOWNERS | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 3 + .github/ISSUE_TEMPLATE/config.yml | 3 + .github/ISSUE_TEMPLATE/feature_request.yml | 3 + .github/ISSUE_TEMPLATE/security_report.yml | 3 + .github/codeql-config.yml | 3 + .github/copilot-instructions.md | 3 + .github/dependabot.yml | 3 + .github/pipelines/esrp-publish.yml | 3 + .github/pull_request_template.md | 3 + .github/skills/agt-e2e-encryption/SKILL.md | 3 + .github/skills/kars-deployment/SKILL.md | 3 + .github/workflows/blocklist-refresh.yml | 3 + .github/workflows/bridge-ci.yml | 3 + .github/workflows/bridge-native.yml | 3 + .github/workflows/check-agt-released.yml | 3 + .github/workflows/ci-gates.yml | 8 +- .github/workflows/codeql.yml | 3 + .github/workflows/dependency-review.yml | 3 + .github/workflows/image-cache-publish.yml | 3 + .github/workflows/image-sign-sbom.yml | 3 + .github/workflows/perf-nightly.yml | 3 + .github/workflows/release-internal.yml | 3 + .github/workflows/release-public-interim.yml | 3 + .github/workflows/release.yml | 3 + .github/workflows/scorecard.yml | 3 + .github/workflows/secret-scanning.yml | 3 + .gitignore | 3 + CHANGELOG.md | 3 + CODE_OF_CONDUCT.md | 3 + CONTRIBUTING.md | 61 +- Cargo.toml | 3 + Makefile | 3 + README.md | 3 + SECURITY.md | 3 + SUPPORT.md | 3 + TRADEMARKS.md | 3 + a2a-gateway/Cargo.toml | 3 + a2a-gateway/Dockerfile | 3 + azure.yaml | 3 + bridge/.env.example | 3 + bridge/.gitignore | 3 + bridge/Makefile | 3 + bridge/README.md | 3 + bridge/bff/.dockerignore | 3 + bridge/bff/Cargo.toml | 3 + bridge/bff/Dockerfile | 3 + bridge/deploy/helm/kars-bridge/Chart.yaml | 3 + bridge/deploy/helm/kars-bridge/README.md | 3 + .../helm/kars-bridge/templates/NOTES.txt | 3 +- .../helm/kars-bridge/templates/_helpers.tpl | 3 +- .../helm/kars-bridge/templates/bff.yaml | 3 +- .../kars-bridge/templates/idp-secret.yaml | 3 +- .../helm/kars-bridge/templates/idp.yaml | 3 +- .../helm/kars-bridge/templates/ingress.yaml | 3 +- .../helm/kars-bridge/templates/namespace.yaml | 3 +- .../kars-bridge/templates/networkpolicy.yaml | 3 +- .../templates/observation-egress.yaml | 3 +- .../helm/kars-bridge/templates/rbac.yaml | 3 +- .../kars-bridge/templates/teams-gateway.yaml | 3 +- .../helm/kars-bridge/templates/web.yaml | 3 +- .../deploy/helm/kars-bridge/values-kind.yaml | 3 + bridge/deploy/helm/kars-bridge/values.yaml | 3 + bridge/deploy/rbac.yaml | 3 + bridge/docs/README.md | 3 + bridge/docs/SUMMARY.md | 3 + bridge/docs/approvals-egress.md | 3 + bridge/docs/architecture.md | 3 + bridge/docs/compatibility.md | 3 + bridge/docs/connections.md | 3 + bridge/docs/contributing.md | 3 + bridge/docs/deployment.md | 3 + bridge/docs/evidence-compliance.md | 3 + bridge/docs/glossary.md | 3 + bridge/docs/governed-credentials.md | 3 + bridge/docs/identity.md | 3 + bridge/docs/inference-budgets.md | 3 + bridge/docs/local-inference.md | 3 + bridge/docs/mcp-servers.md | 3 + bridge/docs/missions-and-teams.md | 3 + bridge/docs/observability.md | 3 + bridge/docs/operations.md | 3 + bridge/docs/providers.md | 3 + bridge/docs/quickstart.md | 3 + bridge/docs/rbac.md | 3 + bridge/docs/skills.md | 3 + bridge/docs/team-workflows.md | 3 + bridge/docs/troubleshooting.md | 3 + bridge/teams-gateway/.dockerignore | 3 + bridge/teams-gateway/.gitignore | 3 + bridge/teams-gateway/Dockerfile | 3 + .../legacy-namespace-chart/Chart.yaml | 3 + .../templates/namespace.yaml | 3 +- .../tests/fixtures/values-10505214.yaml | 3 + .../tests/native-credentials/Dockerfile.bff | 3 + .../tests/native-credentials/Dockerfile.probe | 3 + .../native-credentials/Dockerfile.runtime | 3 + .../native-credentials/admission_cases.py | 3 + .../tests/native-credentials/api-values.yaml | 3 + bridge/tests/native-credentials/api_gate.py | 3 + .../api_outcome_diagnostics.py | 3 + .../native-credentials/audit-policy.yaml | 3 + bridge/tests/native-credentials/boot.py | 3 + .../native-credentials/credential_cases.py | 3 + .../credential_diagnostics.py | 3 + .../native-credentials/credential_review.py | 3 + bridge/tests/native-credentials/enrollment.py | 3 + .../grant_continuity_case.py | 3 + .../tests/native-credentials/kind_config.py | 3 + .../native-credentials/lifecycle_cases.py | 3 + .../tests/native-credentials/loaded_images.py | 3 + bridge/tests/native-credentials/native_api.py | 3 + .../native-credentials/observation_cases.py | 3 + .../observation_diagnostics.py | 3 + .../observer_cilium_diagnostics.py | 3 + .../observer_network_diagnostics.py | 3 + .../observer_packet_diagnostics.py | 3 + .../operator_diagnostics.py | 3 + .../tests/native-credentials/private_tls.py | 3 + bridge/tests/native-credentials/run.py | 3 + .../tests/native-credentials/runtime_probe.py | 3 + .../tests/native-credentials/runtime_state.py | 3 + .../native-credentials/schema_preparation.py | 3 + .../native-credentials/source_revision.py | 3 + .../template_diagnostics.py | 3 + .../test_cilium_baseline_witness.py | 3 + .../test_cilium_status_schema.py | 3 + .../test_credential_diagnostics.py | 3 + .../test_credential_review.py | 3 + .../test_credential_target_startup.py | 3 + .../native-credentials/test_enrollment.py | 3 + .../test_late_observation_snapshot.py | 3 + .../test_observation_diagnostics.py | 3 + .../test_observer_cilium_diagnostics.py | 3 + .../test_observer_cilium_selector.py | 3 + .../test_observer_network_diagnostics.py | 3 + .../test_observer_packet_diagnostics.py | 3 + .../test_operator_diagnostics.py | 3 + .../test_schema_preparation.py | 3 + .../test_source_revision.py | 3 + .../test_template_diagnostics.py | 3 + bridge/web/.dockerignore | 3 + bridge/web/.gitignore | 3 + bridge/web/AGENTS.md | 3 + bridge/web/CLAUDE.md | 3 + bridge/web/Dockerfile | 3 + bridge/web/README.md | 3 + bridge/web/eslint.config.mjs | 3 + bridge/web/postcss.config.mjs | 3 + bridge/web/src/app/globals.css | 3 + bridge/web/tests/credential-review.test.mjs | 3 + bridge/web/tests/proxy-routes.test.mjs | 3 + bridge/web/tests/team-run-links.test.mjs | 3 + ci/bench_regression.py | 3 + ci/check-copyright-headers.sh | 57 +- ci/copyright-coverage.json | 177 ++++++ ci/copyright_headers.py | 317 ++++++++++ ci/loc-budget.yaml | 3 + ci/tests/copyright_headers_test.py | 540 ++++++++++++++++++ cli/README.md | 3 + cli/profiles/agt/kars-default.yaml | 3 + cli/profiles/agt/kars-offload.yaml | 3 + cli/src/testing/README.md | 3 + .../01-chat-completion-happy-path.yaml | 3 + .../02-content-filter-propagation.yaml | 3 + .../scenarios/03-rate-limit-passthrough.yaml | 3 + conformance-runner/Cargo.toml | 3 + controller/Cargo.toml | 3 + controller/Dockerfile | 3 + controller/Dockerfile.multistage | 3 + deny.toml | 3 + deploy/agentmesh-agt.yaml | 3 + deploy/agentmesh-ingress.yaml | 3 + deploy/bicep/main.bicep | 3 + .../bicep/modules/acr-pull-assignment.bicep | 3 + deploy/bicep/modules/acr.bicep | 3 + deploy/bicep/modules/aks.bicep | 3 + deploy/bicep/modules/keyvault.bicep | 3 + deploy/bicep/modules/monitor.bicep | 3 + deploy/bicep/modules/openai.bicep | 3 + deploy/bicep/modules/sandbox-rbac.bicep | 3 + .../bicep/standalone/controller-acrpull.bicep | 3 + deploy/helm/kars/Chart.yaml | 3 + deploy/helm/kars/README.md | 3 + .../kars/templates/_credential-grants.tpl | 3 +- .../templates/a2a-gateway-deployment.yaml | 3 +- .../admission-content-safety-floor.yaml | 3 +- .../admission-dev-only-label-immutable.yaml | 3 +- .../admission-envelope-write-lock.yaml | 3 +- .../admission-no-public-router-exposure.yaml | 3 +- .../templates/admission-null-provider.yaml | 3 +- .../templates/admission-pod-exec-ban.yaml | 3 +- .../admission-sandbox-posture-lock.yaml | 3 +- .../admission-seccomp-auto-stamp.yaml | 3 +- .../admission-task-namespace-floor.yaml | 3 +- deploy/helm/kars/templates/agentmesh.yaml | 3 +- .../templates/auth-sidecar-deployment.yaml | 3 +- .../templates/auth-sidecar-networkpolicy.yaml | 3 +- .../kars/templates/auth-sidecar-service.yaml | 3 +- .../auth-sidecar-serviceaccount.yaml | 3 +- .../cilium-a2a-gateway-to-router.yaml | 3 +- .../kars/templates/controller-deployment.yaml | 3 +- deploy/helm/kars/templates/crd-a2aagent.yaml | 3 +- .../kars/templates/crd-egressapproval.yaml | 3 +- .../kars/templates/crd-inferencepolicy.yaml | 3 +- .../helm/kars/templates/crd-karsapproval.yaml | 3 +- .../kars/templates/crd-karsauthconfig.yaml | 3 +- .../kars/templates/crd-karsbudgetaccount.yaml | 3 +- .../templates/crd-karscredentialgrant.yaml | 3 +- deploy/helm/kars/templates/crd-karseval.yaml | 3 +- .../helm/kars/templates/crd-karsmemory.yaml | 3 +- .../helm/kars/templates/crd-karsprofile.yaml | 3 +- .../helm/kars/templates/crd-karsreceipt.yaml | 3 +- deploy/helm/kars/templates/crd-karsskill.yaml | 3 +- .../kars/templates/crd-karssreaction.yaml | 3 +- .../templates/crd-karssreregistration.yaml | 3 +- deploy/helm/kars/templates/crd-karstask.yaml | 3 +- deploy/helm/kars/templates/crd-karsteam.yaml | 3 +- deploy/helm/kars/templates/crd-mcpserver.yaml | 3 +- .../helm/kars/templates/crd-toolpolicy.yaml | 3 +- .../helm/kars/templates/crd-trustgraph.yaml | 3 +- deploy/helm/kars/templates/crd.yaml | 3 +- .../templates/credential-grant-admission.yaml | 3 +- .../kars/templates/credential-grant-rbac.yaml | 3 +- .../credential-namespace-admission.yaml | 3 +- .../credential-reader-admission.yaml | 3 +- .../credential-rebind-admission.yaml | 3 +- .../templates/credential-store-admission.yaml | 3 +- .../templates/inference-budget-admission.yaml | 3 +- .../helm/kars/templates/inference-budget.yaml | 3 +- .../helm/kars/templates/inspektor-gadget.yaml | 3 +- deploy/helm/kars/templates/namespace.yaml | 3 +- .../kars/templates/observation-privacy.yaml | 3 +- .../operator-default-deny-networkpolicy.yaml | 3 +- .../kars/templates/private-consumption.yaml | 3 +- deploy/helm/kars/templates/rbac.yaml | 3 +- .../kars/templates/seccomp-installer.yaml | 3 +- .../templates/signer-policy-configmap.yaml | 3 +- .../templates/sre-authority-admission.yaml | 3 +- .../templates/sre-authority-consumers.yaml | 3 +- .../kars/templates/sre-authority-rbac.yaml | 3 +- deploy/helm/kars/templates/sre.yaml | 3 +- .../kars/templates/toolpolicy-default.yaml | 3 +- deploy/helm/kars/values-existing-aks.yaml | 3 + deploy/helm/kars/values-generic.yaml | 3 + deploy/helm/kars/values-local-dev.yaml | 3 + deploy/helm/kars/values.yaml | 3 + .../monitoring/agentmesh-json-exporter.yaml | 3 + deploy/monitoring/dashboards.md | 3 + .../grafana-dashboard-configmap.yaml | 3 + .../monitoring/podmonitor-sandbox-router.yaml | 3 + deploy/security/notation-ratify.md | 3 + docker-compose.dev.yml | 3 + docs/README.md | 3 + docs/SUMMARY.md | 3 + docs/adr/0001-a2a-ingress-front-edge.md | 3 + docs/adr/0002-inference-endpoint-sourcing.md | 3 + docs/adr/README.md | 3 + docs/agent-identity.md | 3 + docs/api/conditions.md | 3 + docs/api/crd-reference.md | 3 + docs/api/karseval.md | 3 + docs/api/lifecycle.md | 3 + docs/api/policy-canonical-format.md | 3 + docs/architecture-diagrams.md | 3 + docs/architecture.md | 3 + docs/architecture/a2a-gateway.md | 3 + docs/architecture/agt-boundary.md | 3 + .../entra-agent-id/01-runtime-token-flow.md | 3 + .../entra-agent-id/05-security-alignment.md | 3 + .../entra-agent-id/06-mesh-trust-design.md | 3 + docs/architecture/entra-agent-id/README.md | 3 + docs/blueprints/00-index.md | 3 + docs/blueprints/01-developer-inner-loop.md | 3 + docs/blueprints/02-local-k8s-dev-loop.md | 3 + docs/blueprints/03-enterprise-self-hosted.md | 3 + docs/blueprints/04-managed-public-offload.md | 3 + docs/blueprints/05-cross-org-federation.md | 3 + docs/blueprints/06-sovereign-airgapped.md | 3 + docs/channels-plugins.md | 3 + docs/cli-reference.md | 3 + docs/compliance.md | 3 + docs/egress-proxy.md | 3 + docs/examples.md | 3 + docs/getting-started.md | 3 + docs/github-services.md | 3 + docs/governed-inference-budgets.md | 3 + docs/governed-services.md | 3 + docs/hermes-plugin.md | 3 + docs/how-to/credential-sources.md | 3 + docs/how-to/governed-credential-grants.md | 3 + docs/how-to/helm-installation.md | 3 + docs/how-to/namespace-ownership.md | 3 + docs/how-to/sre-authority.md | 3 + docs/local-inference.md | 3 + docs/maturity.md | 3 + docs/mcp.md | 3 + docs/mesh-plugin.md | 3 + docs/multi-tenant.md | 3 + docs/openclaw-plugin.md | 3 + docs/operations/README.md | 3 + docs/operations/a2a-gateway.md | 3 + docs/operations/branch-protection.md | 3 + docs/operations/byo-strict.md | 3 + docs/operations/chaos-tier.md | 3 + docs/operations/gitops.md | 3 + docs/operations/helm-packaging.md | 3 + docs/operations/image-versioning.md | 3 + docs/operations/secret-rotation.md | 3 + docs/operations/supply-chain.md | 3 + docs/operations/upgrades.md | 3 + docs/operator-tui.md | 3 + docs/permissions.md | 3 + docs/quickstart.md | 3 + docs/roadmap.md | 3 + docs/runbooks/hermes-troubleshooting.md | 3 + docs/runtimes.md | 3 + docs/runtimes/CONTRACT.md | 3 + ...-06-27-foundry-memory-mcp-accept-header.md | 3 + .../2026-06-27-kars-upgrade-flow-fixes.md | 3 + ...-06-29-egress-learn-enforce-flow-repair.md | 3 + ...-06-29-upgrade-changelog-impact-confirm.md | 3 + .../2026-06-30-mcp-out-of-the-box.md | 3 + ...2026-08-24-dependency-security-baseline.md | 3 + .../2026-08-25-langgraph-runtime-alias.md | 3 + .../2026-08-25-multi-provider-guardrails.md | 3 + .../2026-09-03-core-governance-apis.md | 3 + .../2026-09-03-standing-team-control-plane.md | 3 + .../2026-09-04-existing-aks-adoption.md | 3 + .../2026-09-07-credential-sources.md | 3 + .../2026-09-07-inference-local-failover.md | 3 + .../2026-09-07-sandbox-namespace-ownership.md | 3 + .../2026-09-08-github-services.md | 3 + .../2026-09-08-governed-credential-grants.md | 3 + .../2026-09-08-governed-inference-budgets.md | 3 + .../2026-09-08-governed-router-services.md | 3 + .../security-audits/2026-09-08-managed-mcp.md | 3 + .../2026-09-08-sre-authority-prerequisite.md | 3 + .../2026-09-10-evaluator-runner-contract.md | 3 + .../2026-09-11-bridge-application.md | 3 + .../2026-09-11-evaluator-evidence-parity.md | 3 + .../2026-09-11-receipt-log-parity.md | 3 + docs/security-audits/README.md | 3 + docs/security-audits/_template.md | 3 + docs/security-mcp-top10.md | 3 + docs/security-validation.md | 3 + docs/security.md | 3 + docs/security/crd-trust-model.md | 3 + docs/security/red-team.md | 3 + docs/security/stride.md | 3 + docs/security/supply-chain-posture.md | 3 + docs/site/README.md | 3 + docs/site/book.toml | 3 + docs/site/theme/css/custom.css | 3 + docs/site/theme/index.hbs | 3 +- docs/tutorials/managed-mcp.md | 3 + docs/upstream-alignment.md | 3 + docs/use-cases.md | 3 + docs/use-cases/exec-brief-walkthrough.md | 3 + eval-corpus/Cargo.toml | 3 + examples/README.md | 3 + examples/basic-agent/README.md | 3 + examples/basic-agent/clawsandbox.yaml | 3 + examples/byo-quickstart/README.md | 3 + examples/byo-quickstart/app/requirements.txt | 3 + .../k8s/clawsandbox-strict-demo.yaml | 3 + examples/byo-quickstart/k8s/clawsandbox.yaml | 3 + examples/confidential-agent/README.md | 3 + examples/confidential-agent/clawsandbox.yaml | 3 + examples/demo-clawshield/README.md | 3 + .../demo-clawshield/contoso-bank-agent.yaml | 3 + .../demo-clawshield/fabrikam-legal-agent.yaml | 3 + .../northwind-trade-agent.yaml | 3 + examples/demo-clawshield/poisoned-document.md | 3 + examples/full-stack-demo/README.md | 3 + examples/full-stack-demo/demo.yaml | 3 + examples/hermes-quickstart/README.md | 3 + examples/hermes-quickstart/karssandbox.yaml | 3 + examples/lethal-trifecta-demo/README.md | 3 + examples/lethal-trifecta-demo/WALKTHROUGH.md | 3 + .../bait/poisoned-skill.md | 3 + .../scenarios/00-namespaces.yaml | 3 + .../scenarios/01-naked-claw.yaml | 3 + .../scenarios/02-kars-sandbox.yaml | 3 + .../scenarios/03-bait-server.yaml | 3 + examples/maf-quickstart/README.md | 3 + examples/maf-quickstart/clawsandbox.yaml | 3 + examples/openai-agents-quickstart/README.md | 3 + .../openai-agents-quickstart/clawsandbox.yaml | 3 + .../playwright-mcp/00-playwright-mcp.yaml | 3 + examples/playwright-mcp/01-mcpserver.yaml | 3 + examples/playwright-mcp/02-karssandbox.yaml | 3 + examples/playwright-mcp/README.md | 3 + examples/telegram-agent/README.md | 3 + examples/telegram-agent/clawsandbox.yaml | 3 + inference-router/Cargo.toml | 3 + inference-router/Dockerfile | 3 + inference-router/Dockerfile.dev | 3 + inference-router/Dockerfile.multistage | 3 + inference-router/fuzz/.gitignore | 3 + inference-router/fuzz/Cargo.toml | 3 + inference-router/fuzz/README.md | 3 + .../tests/fixtures/foundry/README.md | 3 + kars-a2a-core/Cargo.toml | 3 + mesh-plugin/.gitignore | 3 + mesh-plugin/README.md | 3 + .../nemoclaw/policies/presets/kars-mesh.yaml | 3 + mesh-plugin/skills/mesh-federation/SKILL.md | 3 + osv-scanner.toml | 3 + runtimes/.gitignore | 3 + runtimes/agt-mesh-python/README.md | 3 + runtimes/anthropic/README.md | 3 + runtimes/hermes/README.md | 3 + runtimes/hermes/pyproject.toml | 3 + .../src/kars_runtime_hermes/__init__.py | 3 + .../kars_runtime_hermes/plugin/__init__.py | 3 + .../kars_runtime_hermes/plugin/discover.py | 3 + .../src/kars_runtime_hermes/plugin/foundry.py | 3 + .../kars_runtime_hermes/plugin/governance.py | 3 + .../src/kars_runtime_hermes/plugin/handoff.py | 3 + .../kars_runtime_hermes/plugin/http_fetch.py | 3 + .../src/kars_runtime_hermes/plugin/mesh.py | 3 + .../kars_runtime_hermes/plugin/plugin.yaml | 3 + .../plugin/router_client.py | 3 + .../src/kars_runtime_hermes/plugin/spawn.py | 3 + .../kars_runtime_hermes/plugin/telemetry.py | 3 + .../tests/test_file_transfer_unconditional.py | 3 + .../hermes/tests/test_foundry_http_fetch.py | 3 + runtimes/hermes/tests/test_foundry_native.py | 3 + runtimes/hermes/tests/test_governance.py | 3 + runtimes/hermes/tests/test_handoff.py | 3 + .../hermes/tests/test_mesh_transfer_file.py | 3 + runtimes/hermes/tests/test_mesh_worker.py | 3 + runtimes/hermes/tests/test_package_shape.py | 3 + runtimes/hermes/tests/test_peer_roster.py | 3 + runtimes/hermes/tests/test_router_client.py | 3 + runtimes/hermes/tests/test_spawn_discover.py | 3 + runtimes/hermes/tests/test_telemetry.py | 3 + runtimes/langgraph-ts/README.md | 3 + runtimes/langgraph/README.md | 3 + runtimes/maf-python/README.md | 3 + runtimes/openai-agents/README.md | 3 + runtimes/openclaw/.gitignore | 3 + .../openclaw/skills/agt-governance/SKILL.md | 3 + .../openclaw/skills/foundry-agents/SKILL.md | 3 + .../openclaw/skills/foundry-code/SKILL.md | 3 + .../skills/foundry-conversations/SKILL.md | 3 + .../skills/foundry-deployments/SKILL.md | 3 + .../skills/foundry-evaluations/SKILL.md | 3 + .../skills/foundry-knowledge/SKILL.md | 3 + .../openclaw/skills/foundry-memory/SKILL.md | 3 + .../skills/foundry-web-search/SKILL.md | 3 + runtimes/openclaw/skills/kars-spawn/SKILL.md | 3 + runtimes/pydantic-ai/README.md | 3 + sandbox-images/anthropic/Dockerfile | 3 + .../anthropic/default-agent/main.py | 3 + sandbox-images/conformance-runner/Dockerfile | 3 + sandbox-images/hermes/Dockerfile | 3 + sandbox-images/hermes/default-agent/main.py | 3 + sandbox-images/langgraph-ts/Dockerfile | 3 + sandbox-images/langgraph/Dockerfile | 3 + .../langgraph/default-agent/main.py | 3 + sandbox-images/maf-python/Dockerfile | 3 + .../maf-python/default-agent/main.py | 3 + sandbox-images/nemoclaw/Dockerfile | 3 + sandbox-images/openai-agents/Dockerfile | 3 + .../openai-agents/default-agent/main.py | 3 + sandbox-images/openclaw/Dockerfile | 3 + sandbox-images/openclaw/Dockerfile.base | 3 + sandbox-images/pydantic-ai/Dockerfile | 3 + .../pydantic-ai/default-agent/main.py | 3 + scripts/apply-copyright-headers.sh | 57 +- scripts/showcase/README.md | 3 + tests/chaos/Cargo.toml | 3 + tests/chaos/README.md | 3 + tests/cncf-conformance/CONFORMANCE-REPORT.md | 3 + tests/cncf-conformance/Cargo.toml | 3 + tests/compat/README.md | 3 + .../fixtures/null-provider-devonly-ok.yaml | 3 + .../fixtures/null-provider-prod-denied.yaml | 3 + tests/conformance/README.md | 3 + tests/conformance/fixtures/README.md | 3 + tests/e2e-manual/README.md | 3 + tests/e2e/Dockerfile.sandbox-stub | 3 + .../interop/manifests/aks-hermes-bidi-2.yaml | 3 + tests/k6/README.md | 3 + tools/README.md | 3 + tools/demo/README.md | 3 + tools/demo/act2/agent-a-research.yaml | 3 + .../demo/act2/demo-1-minimal-summarizer.yaml | 3 + .../demo/act2/demo-2-governed-translator.yaml | 3 + tools/demo/act2/demo-3-mesh-analyst.yaml | 3 + tools/demo/act2/platform-hardening-quota.yaml | 3 + tools/demo/act2/runbook.md | 3 + tools/demo/scenarios/01-sandbox.yaml | 3 + tools/demo/scenarios/02-toolpolicy.yaml | 3 + tools/demo/scenarios/03-egress-approval.yaml | 3 + tools/demo/scenarios/04-claweval.yaml | 3 + tools/drift/README.md | 3 + tools/drift/allowlist-q1.txt | 3 + tools/drift/drift.py | 3 + tools/e2e-harness/README.md | 3 + .../exec-brief-hermes-single/README.md | 3 + .../manifests/00-namespace.yaml | 3 + .../manifests/01-inferencepolicy.yaml | 3 + .../manifests/02-toolpolicy.yaml | 3 + .../manifests/03-clawmemory.yaml | 3 + .../manifests/04-mcpserver.yaml | 3 + .../manifests/05-clawsandbox.yaml | 3 + .../scenarios/exec-brief-hermes/README.md | 3 + .../manifests/00-namespace.yaml | 3 + .../manifests/01-inferencepolicy.yaml | 3 + .../manifests/02-toolpolicy.yaml | 3 + .../manifests/05-clawsandbox.yaml | 3 + .../exec-brief/manifests/00-namespace.yaml | 3 + .../manifests/01-inferencepolicy.yaml | 3 + .../exec-brief/manifests/02-toolpolicy.yaml | 3 + .../exec-brief/manifests/03-clawmemory.yaml | 3 + .../exec-brief/manifests/04-mcpserver.yaml | 3 + .../exec-brief/manifests/05-clawsandbox.yaml | 3 + .../scenarios/mesh-roundtrip-hermes/README.md | 3 + .../manifests/00-namespaces.yaml | 3 + .../manifests/01-inferencepolicies.yaml | 3 + .../manifests/02-toolpolicies.yaml | 3 + .../manifests/05-sandboxes.yaml | 3 + tools/headlamp-plugin/.gitignore | 3 + tools/headlamp-plugin/README.md | 3 + tools/item-manifest/.gitignore | 3 + tools/item-manifest/Cargo.toml | 3 + tools/item-manifest/README.md | 3 + 533 files changed, 2608 insertions(+), 187 deletions(-) create mode 100644 ci/copyright-coverage.json create mode 100644 ci/copyright_headers.py create mode 100644 ci/tests/copyright_headers_test.py diff --git a/.agt-sdk/.gitignore b/.agt-sdk/.gitignore index 39df43b70..28ceb0c29 100644 --- a/.agt-sdk/.gitignore +++ b/.agt-sdk/.gitignore @@ -1,2 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + *.tgz *.tar.gz diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 586008a9f..743bf73c5 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # cargo-audit configuration # See: https://docs.rs/cargo-audit/latest/cargo_audit/#configuration diff --git a/.dockerignore b/.dockerignore index 42e6dc4fb..506807f5c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + target/ cli/node_modules/ .git/ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b33da617b..45e9bbb5c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Code Owners # Each line is a file pattern followed by one or more owners. # diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c26c7d7cf..0bf6fab44 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Bug Report description: Report a bug in Kars body: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 320f7b5a2..0a11100df 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + blank_issues_enabled: true contact_links: - name: Security Vulnerabilities (Critical/High) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index e51431092..0fc66cef0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Feature Request description: Suggest a new feature for Kars body: diff --git a/.github/ISSUE_TEMPLATE/security_report.yml b/.github/ISSUE_TEMPLATE/security_report.yml index ab0043f94..71724edb2 100644 --- a/.github/ISSUE_TEMPLATE/security_report.yml +++ b/.github/ISSUE_TEMPLATE/security_report.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Security Issue description: Report a security vulnerability (for critical vulnerabilities, use MSRC per SECURITY.md) body: diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml index ddcc4a6ee..c8cc96949 100644 --- a/.github/codeql-config.yml +++ b/.github/codeql-config.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: "Kars CodeQL Config" # No vendored AgentMesh source is present after the Phase 5.2 AGT-only migration. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ded40b23d..78ea8712f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars — Copilot Instructions ## What is Kars? diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 511f80d29..1a3f4bfda 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + version: 2 updates: # Rust / Cargo diff --git a/.github/pipelines/esrp-publish.yml b/.github/pipelines/esrp-publish.yml index 375dfd399..adfd8331e 100644 --- a/.github/pipelines/esrp-publish.yml +++ b/.github/pipelines/esrp-publish.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # --------------------------------------------------------- # Azure DevOps Pipeline: Unified ESRP Release Publishing — kars # diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 57d57fafb..51c979d5c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + ## Summary <!-- Brief description of changes --> diff --git a/.github/skills/agt-e2e-encryption/SKILL.md b/.github/skills/agt-e2e-encryption/SKILL.md index 104f7ceea..3d4b1758f 100644 --- a/.github/skills/agt-e2e-encryption/SKILL.md +++ b/.github/skills/agt-e2e-encryption/SKILL.md @@ -1,6 +1,9 @@ --- description: "Kars AGT E2E encryption skill — how the Signal Protocol inter-agent messaging works, how to debug it, and what was patched." --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # AGT E2E Encrypted Inter-Agent Communication diff --git a/.github/skills/kars-deployment/SKILL.md b/.github/skills/kars-deployment/SKILL.md index f2fdfb4f1..91eb48902 100644 --- a/.github/skills/kars-deployment/SKILL.md +++ b/.github/skills/kars-deployment/SKILL.md @@ -1,6 +1,9 @@ --- description: "Kars deployment and infrastructure skill — how to deploy, build images, manage AKS, and troubleshoot." --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Deployment & Infrastructure diff --git a/.github/workflows/blocklist-refresh.yml b/.github/workflows/blocklist-refresh.yml index b884f42f4..dc41c1851 100644 --- a/.github/workflows/blocklist-refresh.yml +++ b/.github/workflows/blocklist-refresh.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Refresh Blocklist Seed on: diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index 44c495c62..bb516c05e 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Bridge CI on: diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml index be2ccb96a..47a38718c 100644 --- a/.github/workflows/bridge-native.yml +++ b/.github/workflows/bridge-native.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Bridge native qualification on: diff --git a/.github/workflows/check-agt-released.yml b/.github/workflows/check-agt-released.yml index 0ed9a189c..a174c3c86 100644 --- a/.github/workflows/check-agt-released.yml +++ b/.github/workflows/check-agt-released.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Check AGT Released # Runs daily to detect when Microsoft AGT publishes a release that diff --git a/.github/workflows/ci-gates.yml b/.github/workflows/ci-gates.yml index 10370a4b9..a805fcc3b 100644 --- a/.github/workflows/ci-gates.yml +++ b/.github/workflows/ci-gates.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: ci-gates on: @@ -83,5 +86,8 @@ jobs: no-null-provider-prod) ./ci/no-null-provider-prod.sh ;; security-audit-required) ./ci/security-audit-required.sh ;; a2a-module-isolation) ./ci/a2a-module-isolation.sh ;; - copyright-headers) ./ci/check-copyright-headers.sh ;; + copyright-headers) + python3 ci/tests/copyright_headers_test.py + ./ci/check-copyright-headers.sh + ;; esac diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b10997fda..7b369ad52 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: CodeQL on: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 5b6a726aa..aeca417ab 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Dependency Review on: diff --git a/.github/workflows/image-cache-publish.yml b/.github/workflows/image-cache-publish.yml index 8ad3caa08..534f8767c 100644 --- a/.github/workflows/image-cache-publish.yml +++ b/.github/workflows/image-cache-publish.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Image Cache Publish # Publishes runtime container images (controller, inference-router, diff --git a/.github/workflows/image-sign-sbom.yml b/.github/workflows/image-sign-sbom.yml index f03e8806a..39ead62a3 100644 --- a/.github/workflows/image-sign-sbom.yml +++ b/.github/workflows/image-sign-sbom.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Image Build, Sign & SBOM on: diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 9fc608ad5..2e136b28d 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Perf Nightly (k6) # Phase 2 S16. Wall-clock perf smoke against the inference router. diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml index 09ff15e2f..200547c80 100644 --- a/.github/workflows/release-internal.yml +++ b/.github/workflows/release-internal.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Internal Release (private, wall-off) # Cuts a REAL release end-to-end with EVERY artefact stored behind the wall. diff --git a/.github/workflows/release-public-interim.yml b/.github/workflows/release-public-interim.yml index b14b06045..aa0027eda 100644 --- a/.github/workflows/release-public-interim.yml +++ b/.github/workflows/release-public-interim.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Public Release (GHCR + GitHub Release) # ───────────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca42a2de4..e7003acdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Release on: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6b591043b..8040c3ea7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: OpenSSF Scorecard # OpenSSF Scorecard runs weekly + on push to main, but ONLY when the diff --git a/.github/workflows/secret-scanning.yml b/.github/workflows/secret-scanning.yml index 849a0618a..1bf54f60a 100644 --- a/.github/workflows/secret-scanning.yml +++ b/.github/workflows/secret-scanning.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: Secret Scanning on: diff --git a/.gitignore b/.gitignore index f990cf503..a37de9568 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## diff --git a/CHANGELOG.md b/CHANGELOG.md index b62f8e810..c3b042bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Changelog All notable changes to kars will be documented in this file. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6cae41068..9faca38cc 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Microsoft Open Source Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e57761a8..90854690e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Contributing to kars 👋 **Welcome — and thank you!** Whether you're fixing a typo, adding a plugin, or shipping your first-ever open-source pull request, you're exactly the kind of person this project is for. We're genuinely glad you're here. @@ -45,7 +48,7 @@ git checkout -b my-first-contribution make test && make lint # keep it green ``` -Add the two-line copyright header to any **new** file you create (details in [Code Style](#-code-style)). +Apply copyright coverage to every **new** file you create (format-safe rules in [Code Style](#-code-style)). ### 4. Open your PR 🎉 @@ -209,16 +212,64 @@ Credentials live in a K8s secret named `<sandbox-name>-credentials` in the sandb ### Copyright headers -Every kars-authored source file (`.rs`, `.ts`, `.tsx`, `.js`, `.sh`) **must** begin with the two-line Microsoft + MIT copyright header: +The Microsoft + MIT policy applies repository-wide, including Bridge, documentation, +configuration, templates and scripts. Every Kars-authored file that safely supports +comments **must** carry the two-line notice in its format's comment syntax: ``` // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. ``` -(Use `#` instead of `//` for shell scripts. For shell scripts with a shebang, the shebang stays on line 1 and the header follows on lines 2–3.) - -The CI gate `ci/check-copyright-headers.sh` enforces this on every PR. Add the header to any new file before opening your PR. Vendored code under `vendor/` is excluded — don't add Microsoft headers there. +Use `//` for Rust, TypeScript/JavaScript (including TSX and MJS), and Bicep; +`#` for shell/Python, YAML/TOML, Dockerfiles, Makefiles, ignore files, CODEOWNERS +and environment examples; HTML comments for Markdown; CSS block comments for CSS; +and Handlebars comments for `.hbs`. Helm templates (including template YAML and +`NOTES.txt`) use Go-template comments with **no surrounding output whitespace**, +not YAML comments that can interact with `{{- ... -}}` trimming. + +Use `scripts/apply-copyright-headers.sh` rather than rewriting files manually. +It preserves original body bytes, line endings, file modes, author notices, +shebangs, Python encoding cookies, Docker parser directives, Markdown frontmatter, +CSS charset directives and frontend directive prologues. It is idempotent. +Both existing commands share `ci/copyright_headers.py` (Python standard library). +`ci/check-copyright-headers.sh` checks **every tracked file**, and fails on unknown +formats, missing notices or unsafe inputs. Run the format regression tests with +`python3 ci/tests/copyright_headers_test.py`. + +Some files cannot safely receive literal comments. `ci/copyright-coverage.json` +explicitly records coverage under the existing root `LICENSE`/`NOTICE`: strict +JSON, lockfiles, binary/image/presentation assets, managed drawings, recordings, +encoded certificate fixtures, empty markers, literal prompt inputs and the +Helm policy embedded verbatim into a resource string remain +byte-identical. This is license coverage, **not a claim that binaries have text +headers**. Vendored packages, upstream assets, generated output and third-party +license texts retain their own ownership and notices; do not prepend Microsoft +ownership to them or replace original attribution. The policy does not relicense +third-party content. New special formats require a reviewed rule, not a blanket +directory exemption for first-party sources. + +Generated status is never inferred from names such as `build`, `target`, `dist`, +`coverage`, `.turbo`, `node_modules`, or the `.d.ts` suffix. Generated coverage +requires an exact reviewed file entry with producer/provenance and a notice +reference in `ci/copyright-coverage.json`. The current entries are only +`tools/headlamp-plugin/dist/main.js` and `tools/headlamp-plugin/dist/package.json`. +Handwritten sources in output-named directories and authored declarations need +normal headers; unknown first-party formats still fail. + +The embedded Helm AGT policy has a raw-byte digest contract: the controller +publishes its exact bytes as `agt-profile.yaml`, and the router confirms a +length-prefixed SHA-256 over the filename and body. Adding a YAML comment would +change that digest even if the parsed policy were identical, so this exact +payload is explicitly covered without a literal header. + +The checker prints coverage totals, including non-header categories. Pass +`--verbose` to list every non-header path and reason, or +`--report copyright-report.json` for a complete machine-readable inventory. +The applier accepts the same options; its report includes insertion offsets, +lengths and before/after SHA-256 hashes. Reports are local artifacts, not source +files to commit. Optional repository-relative paths limit a local check/apply; +CI invokes the checker without paths, so there are no silent extension omissions. ### File size guidelines diff --git a/Cargo.toml b/Cargo.toml index 5ee5128a6..00edc4b49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [workspace] resolver = "2" members = [ diff --git a/Makefile b/Makefile index 97fd06bd0..9cc394cf0 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Makefile # Usage: make build | make test | make lint | make images | make clean diff --git a/README.md b/README.md index bbf927284..ed8e2fb07 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + <div align="center"> <img src="docs/assets/logo.png" alt="kars logo" width="128" /> diff --git a/SECURITY.md b/SECURITY.md index ab9a859fb..98d44ddd7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + <!-- BEGIN MICROSOFT SECURITY.MD V1.0.0 BLOCK --> ## Security diff --git a/SUPPORT.md b/SUPPORT.md index effa32e24..2b0ccae29 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Support ## How to file issues and get help diff --git a/TRADEMARKS.md b/TRADEMARKS.md index d89261e0d..52a03d227 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Trademarks This project may contain trademarks or logos for projects, products, or services. diff --git a/a2a-gateway/Cargo.toml b/a2a-gateway/Cargo.toml index e0a10aeab..3c50a8c13 100644 --- a/a2a-gateway/Cargo.toml +++ b/a2a-gateway/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-a2a-gateway" description = "Public-ingress A2A 1.0.0 edge for kars — TLS termination, JWS verification, mTLS to inference-router. Phase 2 S3.5 (ADR-0001 #4)." diff --git a/a2a-gateway/Dockerfile b/a2a-gateway/Dockerfile index 93d1153bc..34c14975b 100644 --- a/a2a-gateway/Dockerfile +++ b/a2a-gateway/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars A2A Gateway — distroless (build-once pattern) # # Public A2A 1.0.0 ingress edge. Built from a pre-compiled binary diff --git a/azure.yaml b/azure.yaml index 3c1391ea3..27fea745f 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars - Azure Developer CLI (azd) template # Deploy with: azd init --template Azure/kars && azd up diff --git a/bridge/.env.example b/bridge/.env.example index 3e0bab584..b2ee92dd1 100644 --- a/bridge/.env.example +++ b/bridge/.env.example @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge local configuration. Copy to .env and adjust as needed. # Never commit real secrets. diff --git a/bridge/.gitignore b/bridge/.gitignore index 9823b0267..0f9194ecd 100644 --- a/bridge/.gitignore +++ b/bridge/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Rust (BFF) /bff/target/ **/*.rs.bk diff --git a/bridge/Makefile b/bridge/Makefile index f9140fb11..2a6a37a80 100644 --- a/bridge/Makefile +++ b/bridge/Makefile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + .PHONY: dev bff web check check-bff check-web check-gateway install images image-gateway images-push helm-lint helm-test helm-install helm-install-kind # Run BFF + web together (Ctrl-C stops both). diff --git a/bridge/README.md b/bridge/README.md index 09371b46a..b7699ff8e 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Bridge **Mission control for governed agent work.** diff --git a/bridge/bff/.dockerignore b/bridge/bff/.dockerignore index 8432daef9..333ca9d81 100644 --- a/bridge/bff/.dockerignore +++ b/bridge/bff/.dockerignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + target/ .git/ *.md diff --git a/bridge/bff/Cargo.toml b/bridge/bff/Cargo.toml index 48400d736..a1fa2fd50 100644 --- a/bridge/bff/Cargo.toml +++ b/bridge/bff/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-bridge-bff" version = "0.1.0" diff --git a/bridge/bff/Dockerfile b/bridge/bff/Dockerfile index df51c2568..9b4ba7247 100644 --- a/bridge/bff/Dockerfile +++ b/bridge/bff/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge BFF — container image. Multi-stage: build the Rust binary against a # glibc base, then ship it on a slim Debian runtime. Cloud-agnostic: the image # runs identically on AKS, EKS, GKE, and local kind. diff --git a/bridge/deploy/helm/kars-bridge/Chart.yaml b/bridge/deploy/helm/kars-bridge/Chart.yaml index c2695e12e..88ec58698 100644 --- a/bridge/deploy/helm/kars-bridge/Chart.yaml +++ b/bridge/deploy/helm/kars-bridge/Chart.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: v2 name: kars-bridge description: >- diff --git a/bridge/deploy/helm/kars-bridge/README.md b/bridge/deploy/helm/kars-bridge/README.md index e790f7b7d..2099986e4 100644 --- a/bridge/deploy/helm/kars-bridge/README.md +++ b/bridge/deploy/helm/kars-bridge/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Deploying kars Bridge kars Bridge is an **additive** layer on top of [kars](https://github.com/Azure/kars): diff --git a/bridge/deploy/helm/kars-bridge/templates/NOTES.txt b/bridge/deploy/helm/kars-bridge/templates/NOTES.txt index 004ee3bf1..4b7817490 100644 --- a/bridge/deploy/helm/kars-bridge/templates/NOTES.txt +++ b/bridge/deploy/helm/kars-bridge/templates/NOTES.txt @@ -1,4 +1,5 @@ -kars Bridge {{ .Chart.AppVersion }} installed in namespace {{ include "kars-bridge.namespace" . }} (release {{ .Release.Name }}). +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}kars Bridge {{ .Chart.AppVersion }} installed in namespace {{ include "kars-bridge.namespace" . }} (release {{ .Release.Name }}). This is an ADDITIVE layer on top of kars. It expects the kars CRDs + controller to already be present in the cluster. Install a compatible full Kars runtime from diff --git a/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl b/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl index 248e02d4d..1d7d3202d 100644 --- a/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl +++ b/bridge/deploy/helm/kars-bridge/templates/_helpers.tpl @@ -1,4 +1,5 @@ -{{/* Common labels + names for the kars Bridge chart. */}} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{/* Common labels + names for the kars Bridge chart. */}} {{- define "kars-bridge.labels" -}} app.kubernetes.io/name: kars-bridge app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/bridge/deploy/helm/kars-bridge/templates/bff.yaml b/bridge/deploy/helm/kars-bridge/templates/bff.yaml index dddb68a6b..85beecd00 100644 --- a/bridge/deploy/helm/kars-bridge/templates/bff.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/bff.yaml @@ -1,4 +1,5 @@ -apiVersion: apps/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: apps/v1 kind: Deployment metadata: name: kars-bridge-bff diff --git a/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml b/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml index 79715d48d..8b8b8e45a 100644 --- a/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/idp-secret.yaml @@ -1,4 +1,5 @@ -{{- if .Values.idp.enabled }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.idp.enabled }} {{- $ns := include "kars-bridge.namespace" . }} {{- $existing := (lookup "v1" "Secret" $ns .Values.idp.secretName) }} {{- $client := .Values.idp.clientSecret }} diff --git a/bridge/deploy/helm/kars-bridge/templates/idp.yaml b/bridge/deploy/helm/kars-bridge/templates/idp.yaml index cf1262cc2..f6306027f 100644 --- a/bridge/deploy/helm/kars-bridge/templates/idp.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/idp.yaml @@ -1,4 +1,5 @@ -{{- if .Values.idp.enabled }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.idp.enabled }} # kars Bridge — in-cluster IdP (Dex) for multi-user SSO. # # WHY THIS EXISTS: colleagues reach the Bridge over a single diff --git a/bridge/deploy/helm/kars-bridge/templates/ingress.yaml b/bridge/deploy/helm/kars-bridge/templates/ingress.yaml index 10706b037..2e101cabb 100644 --- a/bridge/deploy/helm/kars-bridge/templates/ingress.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/ingress.yaml @@ -1,4 +1,5 @@ -{{- if .Values.ingress.enabled }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: diff --git a/bridge/deploy/helm/kars-bridge/templates/namespace.yaml b/bridge/deploy/helm/kars-bridge/templates/namespace.yaml index aea69115a..2be80964f 100644 --- a/bridge/deploy/helm/kars-bridge/templates/namespace.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/namespace.yaml @@ -1,4 +1,5 @@ -{{- $namespace := include "kars-bridge.namespace" . }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $namespace := include "kars-bridge.namespace" . }} {{- $retainOwnedNamespace := false }} {{- $existing := dict }} {{- if .Release.IsUpgrade }} diff --git a/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml b/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml index 3fc5858cd..f08ba0a2c 100644 --- a/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/networkpolicy.yaml @@ -1,4 +1,5 @@ -{{- if .Values.networkPolicy.create }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.networkPolicy.create }} # kars Bridge — additive NetworkPolicies. # # kars applies a `kars-system-default-deny` policy that selects EVERY pod in the diff --git a/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml b/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml index 69702f46e..3580225c0 100644 --- a/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/observation-egress.yaml @@ -1,4 +1,5 @@ -{{- $networkPolicy := .Values.networkPolicy | default dict }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $networkPolicy := .Values.networkPolicy | default dict }} {{- $observations := $networkPolicy.observations | default dict }} {{- if ($observations.enabled | default false) }} {{- if not ($observations.existingIsolationConfirmed | default false) }} diff --git a/bridge/deploy/helm/kars-bridge/templates/rbac.yaml b/bridge/deploy/helm/kars-bridge/templates/rbac.yaml index b7ed2f6f3..7aa7b1eca 100644 --- a/bridge/deploy/helm/kars-bridge/templates/rbac.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/rbac.yaml @@ -1,4 +1,5 @@ -{{- if .Values.rbac.create }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.rbac.create }} # kars Bridge — least-privilege RBAC for the BFF ServiceAccount. The REAL # authorization boundary includes Kubernetes RBAC and the core grant admission # policies, not only BFF code. This ClusterRole has no Secret mutation or diff --git a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml index c7e21972b..6886d9da9 100644 --- a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml @@ -1,4 +1,5 @@ -# kars Bridge — Teams Gateway: dedicated ServiceAccount, RBAC, Deployment, +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars Bridge — Teams Gateway: dedicated ServiceAccount, RBAC, Deployment, # Service, NetworkPolicy, and optional Ingress for Teams webhook callbacks. # Credentials live in a DEDICATED Secret (kars-bridge-teams), NEVER in the # workspace-channels Secret that gets propagated to sandbox pods. diff --git a/bridge/deploy/helm/kars-bridge/templates/web.yaml b/bridge/deploy/helm/kars-bridge/templates/web.yaml index 90bfc05cf..38fd62ee3 100644 --- a/bridge/deploy/helm/kars-bridge/templates/web.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/web.yaml @@ -1,4 +1,5 @@ -apiVersion: apps/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: apps/v1 kind: Deployment metadata: name: kars-bridge-web diff --git a/bridge/deploy/helm/kars-bridge/values-kind.yaml b/bridge/deploy/helm/kars-bridge/values-kind.yaml index 29e3c61b7..04ccc562d 100644 --- a/bridge/deploy/helm/kars-bridge/values-kind.yaml +++ b/bridge/deploy/helm/kars-bridge/values-kind.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge — values overlay for local kind clusters. # helm install kars-bridge deploy/helm/kars-bridge -n kars-system -f deploy/helm/kars-bridge/values-kind.yaml # Load the images into kind first: diff --git a/bridge/deploy/helm/kars-bridge/values.yaml b/bridge/deploy/helm/kars-bridge/values.yaml index de9795a10..57cb4c8f3 100644 --- a/bridge/deploy/helm/kars-bridge/values.yaml +++ b/bridge/deploy/helm/kars-bridge/values.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge Helm values. # # The Bridge is ADDITIVE: it deploys on top of an existing kars install (kars diff --git a/bridge/deploy/rbac.yaml b/bridge/deploy/rbac.yaml index 19e1d1de0..414050e1b 100644 --- a/bridge/deploy/rbac.yaml +++ b/bridge/deploy/rbac.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge — least-privilege RBAC for the BFF ServiceAccount. # # The Bridge is a privileged Kubernetes API client: it authors envelope CRDs diff --git a/bridge/docs/README.md b/bridge/docs/README.md index 95219dc3b..df6c9749b 100644 --- a/bridge/docs/README.md +++ b/bridge/docs/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Bridge documentation Kars Bridge turns Kars APIs into a governed human workflow for agent missions diff --git a/bridge/docs/SUMMARY.md b/bridge/docs/SUMMARY.md index d6ca6a98a..d4f286d59 100644 --- a/bridge/docs/SUMMARY.md +++ b/bridge/docs/SUMMARY.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Bridge documentation - [Documentation home](README.md) diff --git a/bridge/docs/approvals-egress.md b/bridge/docs/approvals-egress.md index 22f52531b..6e3ed3588 100644 --- a/bridge/docs/approvals-egress.md +++ b/bridge/docs/approvals-egress.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Approvals and egress Bridge presents governance requests in a shared inbox. Approval is a diff --git a/bridge/docs/architecture.md b/bridge/docs/architecture.md index 8793854dc..3c6c85f45 100644 --- a/bridge/docs/architecture.md +++ b/bridge/docs/architecture.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Architecture kars Bridge is a **thin product layer** over the kars substrate. It owns the diff --git a/bridge/docs/compatibility.md b/bridge/docs/compatibility.md index fcf39fa43..706298f59 100644 --- a/bridge/docs/compatibility.md +++ b/bridge/docs/compatibility.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Compatibility Bridge is version-coupled to Kars APIs, but is not a required core component. diff --git a/bridge/docs/connections.md b/bridge/docs/connections.md index c589fb2e6..bb417e4a8 100644 --- a/bridge/docs/connections.md +++ b/bridge/docs/connections.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Connections The **Connections** tab is where a signed-in user connects services for work diff --git a/bridge/docs/contributing.md b/bridge/docs/contributing.md index fc32cfaad..b22604c82 100644 --- a/bridge/docs/contributing.md +++ b/bridge/docs/contributing.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Contributing documentation Bridge documentation should be usable by someone without access to session diff --git a/bridge/docs/deployment.md b/bridge/docs/deployment.md index cc8274c97..dd595a411 100644 --- a/bridge/docs/deployment.md +++ b/bridge/docs/deployment.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Deployment Kars Bridge is an additive Helm release installed into a compatible Kars diff --git a/bridge/docs/evidence-compliance.md b/bridge/docs/evidence-compliance.md index 4345182d1..e9f477ad3 100644 --- a/bridge/docs/evidence-compliance.md +++ b/bridge/docs/evidence-compliance.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Evidence, receipts, and compliance views Bridge presents Kars evidence; it does not turn evidence mappings into a diff --git a/bridge/docs/glossary.md b/bridge/docs/glossary.md index f4b78baf4..7f33e9dd6 100644 --- a/bridge/docs/glossary.md +++ b/bridge/docs/glossary.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Glossary | Term | Meaning | diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index d4db6732f..e7a882089 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Bridge governed credential adapter Bridge source is being integrated into **Azure/kars:kars-bridge** as an optional diff --git a/bridge/docs/identity.md b/bridge/docs/identity.md index 7421cb535..27cc5a6c2 100644 --- a/bridge/docs/identity.md +++ b/bridge/docs/identity.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Identity and sign-in Bridge supports standards-based OIDC and an optional in-cluster Dex deployment. diff --git a/bridge/docs/inference-budgets.md b/bridge/docs/inference-budgets.md index 852810108..9ee531c48 100644 --- a/bridge/docs/inference-budgets.md +++ b/bridge/docs/inference-budgets.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Inference budgets Bridge adds a **hierarchy** over inference token spend, above the per-sandbox diff --git a/bridge/docs/local-inference.md b/bridge/docs/local-inference.md index 31f94710d..6adeb98c0 100644 --- a/bridge/docs/local-inference.md +++ b/bridge/docs/local-inference.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Local inference with AI Runway Bridge discovers and manages Kars-compatible in-cluster model deployments diff --git a/bridge/docs/mcp-servers.md b/bridge/docs/mcp-servers.md index 2b75c099e..88cc63e58 100644 --- a/bridge/docs/mcp-servers.md +++ b/bridge/docs/mcp-servers.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # MCP servers Bridge manages the Kars `McpServer` catalog and exposes two distinct modes. diff --git a/bridge/docs/missions-and-teams.md b/bridge/docs/missions-and-teams.md index 25d9af2d8..42565743b 100644 --- a/bridge/docs/missions-and-teams.md +++ b/bridge/docs/missions-and-teams.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Missions & teams Bridge runs work as either a one-shot **mission** or a standing **team**, both on diff --git a/bridge/docs/observability.md b/bridge/docs/observability.md index 75692c43e..6166367f4 100644 --- a/bridge/docs/observability.md +++ b/bridge/docs/observability.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Observability Bridge surfaces three complementary views of a run and the fleet, all from real diff --git a/bridge/docs/operations.md b/bridge/docs/operations.md index fb6256abb..c7bdca4e2 100644 --- a/bridge/docs/operations.md +++ b/bridge/docs/operations.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Operations Bridge is stateless application code over Kubernetes-resident Kars resources. diff --git a/bridge/docs/providers.md b/bridge/docs/providers.md index 758c0ca42..e4fea233c 100644 --- a/bridge/docs/providers.md +++ b/bridge/docs/providers.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Providers and model routing Bridge separates provider connections from per-mission model policy. diff --git a/bridge/docs/quickstart.md b/bridge/docs/quickstart.md index 500536d38..dd5389e7f 100644 --- a/bridge/docs/quickstart.md +++ b/bridge/docs/quickstart.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Private-preview quickstart This quickstart assumes access to the private Bridge images and a compatible diff --git a/bridge/docs/rbac.md b/bridge/docs/rbac.md index da39a0662..c59bbaecd 100644 --- a/bridge/docs/rbac.md +++ b/bridge/docs/rbac.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Access and roles Bridge has two authorization layers: diff --git a/bridge/docs/skills.md b/bridge/docs/skills.md index 752350984..ee988295c 100644 --- a/bridge/docs/skills.md +++ b/bridge/docs/skills.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Skills Skills are versioned packages that extend an agent with instructions, diff --git a/bridge/docs/team-workflows.md b/bridge/docs/team-workflows.md index 06612f0bf..078dcd6a0 100644 --- a/bridge/docs/team-workflows.md +++ b/bridge/docs/team-workflows.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Team workflows: from intent to reviewed outcome This guide ties together the Bridge concepts that appear across the Workspace: diff --git a/bridge/docs/troubleshooting.md b/bridge/docs/troubleshooting.md index bf2555fd9..456ea7d2b 100644 --- a/bridge/docs/troubleshooting.md +++ b/bridge/docs/troubleshooting.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Troubleshooting ## First checks diff --git a/bridge/teams-gateway/.dockerignore b/bridge/teams-gateway/.dockerignore index 9f0e441c2..d88970280 100644 --- a/bridge/teams-gateway/.dockerignore +++ b/bridge/teams-gateway/.dockerignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + node_modules dist .git diff --git a/bridge/teams-gateway/.gitignore b/bridge/teams-gateway/.gitignore index 1eae0cf67..a34edf11a 100644 --- a/bridge/teams-gateway/.gitignore +++ b/bridge/teams-gateway/.gitignore @@ -1,2 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + dist/ node_modules/ diff --git a/bridge/teams-gateway/Dockerfile b/bridge/teams-gateway/Dockerfile index 409c0ecae..97f69202b 100644 --- a/bridge/teams-gateway/Dockerfile +++ b/bridge/teams-gateway/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + FROM node:22-slim AS builder WORKDIR /app COPY package.json package-lock.json ./ diff --git a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml index bb03447b3..6c969c670 100644 --- a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml +++ b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/Chart.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: v2 name: legacy-bridge-namespace version: 0.1.0 diff --git a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml index 67da82901..6aec11491 100644 --- a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml +++ b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml @@ -1,4 +1,5 @@ -apiVersion: v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: v1 kind: Namespace metadata: name: {{ .Release.Namespace }} diff --git a/bridge/teams-gateway/tests/fixtures/values-10505214.yaml b/bridge/teams-gateway/tests/fixtures/values-10505214.yaml index 09ea1c58f..635cb46f7 100644 --- a/bridge/teams-gateway/tests/fixtures/values-10505214.yaml +++ b/bridge/teams-gateway/tests/fixtures/values-10505214.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge Helm values. # # The Bridge is ADDITIVE: it deploys on top of an existing kars install (kars diff --git a/bridge/tests/native-credentials/Dockerfile.bff b/bridge/tests/native-credentials/Dockerfile.bff index e90ed015e..a7637e520 100644 --- a/bridge/tests/native-credentials/Dockerfile.bff +++ b/bridge/tests/native-credentials/Dockerfile.bff @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Hosted build-once test packaging; private binaries never leave this runner. FROM mcr.microsoft.com/azurelinux/distroless/base:3.0 COPY kars-bridge-bff /usr/local/bin/kars-bridge-bff diff --git a/bridge/tests/native-credentials/Dockerfile.probe b/bridge/tests/native-credentials/Dockerfile.probe index 77e0e998c..e4a37e988 100644 --- a/bridge/tests/native-credentials/Dockerfile.probe +++ b/bridge/tests/native-credentials/Dockerfile.probe @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Test-only unauthenticated network peer; no cluster or observer credentials. FROM python:3.12.11-alpine3.22 USER 10001:10001 diff --git a/bridge/tests/native-credentials/Dockerfile.runtime b/bridge/tests/native-credentials/Dockerfile.runtime index 09cfdeb8c..d5b50f5ff 100644 --- a/bridge/tests/native-credentials/Dockerfile.runtime +++ b/bridge/tests/native-credentials/Dockerfile.runtime @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Controlled test runtime, extending the exact core egress-guard fixture. # No private image is published and no production runtime is replaced. FROM kars-native-runtime-base:latest diff --git a/bridge/tests/native-credentials/admission_cases.py b/bridge/tests/native-credentials/admission_cases.py index 1005cc192..ab55444d6 100644 --- a/bridge/tests/native-credentials/admission_cases.py +++ b/bridge/tests/native-credentials/admission_cases.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Actual CEL evaluation with explicit setup-admin authority, not BFF issuance.""" import base64 diff --git a/bridge/tests/native-credentials/api-values.yaml b/bridge/tests/native-credentials/api-values.yaml index b4e7dfdb4..09903d16b 100644 --- a/bridge/tests/native-credentials/api-values.yaml +++ b/bridge/tests/native-credentials/api-values.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + controller: replicas: 0 inferenceRouter: diff --git a/bridge/tests/native-credentials/api_gate.py b/bridge/tests/native-credentials/api_gate.py index 3a56f8aec..f39ca2476 100644 --- a/bridge/tests/native-credentials/api_gate.py +++ b/bridge/tests/native-credentials/api_gate.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Real API prerequisite, deliberately independent of builds and SRE migration. There are no runtime credentials in this lane. Schema/admission failures are diff --git a/bridge/tests/native-credentials/api_outcome_diagnostics.py b/bridge/tests/native-credentials/api_outcome_diagnostics.py index 36a0ca58e..8260f518c 100644 --- a/bridge/tests/native-credentials/api_outcome_diagnostics.py +++ b/bridge/tests/native-credentials/api_outcome_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Case-bounded metadata audit outcomes for two UID-proven native actors.""" from collections import Counter diff --git a/bridge/tests/native-credentials/audit-policy.yaml b/bridge/tests/native-credentials/audit-policy.yaml index fde2573bd..a559fd2f7 100644 --- a/bridge/tests/native-credentials/audit-policy.yaml +++ b/bridge/tests/native-credentials/audit-policy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: audit.k8s.io/v1 kind: Policy omitStages: diff --git a/bridge/tests/native-credentials/boot.py b/bridge/tests/native-credentials/boot.py index 43d496f94..a2eaf577a 100644 --- a/bridge/tests/native-credentials/boot.py +++ b/bridge/tests/native-credentials/boot.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Administrative fixture setup, kept separate from the native BFF actor.""" import base64 diff --git a/bridge/tests/native-credentials/credential_cases.py b/bridge/tests/native-credentials/credential_cases.py index 4db1787c3..57dfa89d7 100644 --- a/bridge/tests/native-credentials/credential_cases.py +++ b/bridge/tests/native-credentials/credential_cases.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Public BFF entrypoints under actual native writer RBAC and API audit.""" import base64 diff --git a/bridge/tests/native-credentials/credential_diagnostics.py b/bridge/tests/native-credentials/credential_diagnostics.py index 784a80f4d..b2a5913f1 100644 --- a/bridge/tests/native-credentials/credential_diagnostics.py +++ b/bridge/tests/native-credentials/credential_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Secret-free failure categories and private-scope metadata booleans.""" import re diff --git a/bridge/tests/native-credentials/credential_review.py b/bridge/tests/native-credentials/credential_review.py index 5dff42e5e..48c0fa746 100644 --- a/bridge/tests/native-credentials/credential_review.py +++ b/bridge/tests/native-credentials/credential_review.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Explicit operator metadata review/resubmission, not transport or status retries.""" import copy diff --git a/bridge/tests/native-credentials/enrollment.py b/bridge/tests/native-credentials/enrollment.py index 6a65ec54b..dc4af75f5 100644 --- a/bridge/tests/native-credentials/enrollment.py +++ b/bridge/tests/native-credentials/enrollment.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Exercise the shipped operator preview/apply path, never fabricated activation.""" import json diff --git a/bridge/tests/native-credentials/grant_continuity_case.py b/bridge/tests/native-credentials/grant_continuity_case.py index c69c85dc4..9ed272757 100644 --- a/bridge/tests/native-credentials/grant_continuity_case.py +++ b/bridge/tests/native-credentials/grant_continuity_case.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Real operator updates must preserve other workspaces, not just render valid grants.""" import copy diff --git a/bridge/tests/native-credentials/kind_config.py b/bridge/tests/native-credentials/kind_config.py index 88cdfe314..fe0ad9ea4 100644 --- a/bridge/tests/native-credentials/kind_config.py +++ b/bridge/tests/native-credentials/kind_config.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Generate only a disposable Kind topology with enforced CNI and metadata audit.""" import json diff --git a/bridge/tests/native-credentials/lifecycle_cases.py b/bridge/tests/native-credentials/lifecycle_cases.py index 12f0f04ed..28325064e 100644 --- a/bridge/tests/native-credentials/lifecycle_cases.py +++ b/bridge/tests/native-credentials/lifecycle_cases.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Real consumer lifecycle, namespace-resource continuity and ephemeral workspaces.""" import base64 diff --git a/bridge/tests/native-credentials/loaded_images.py b/bridge/tests/native-credentials/loaded_images.py index 0791f99fb..f1a312a1b 100644 --- a/bridge/tests/native-credentials/loaded_images.py +++ b/bridge/tests/native-credentials/loaded_images.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Pin Kind-imported Docker images using containerd's actual manifest identity.""" import json diff --git a/bridge/tests/native-credentials/native_api.py b/bridge/tests/native-credentials/native_api.py index a025148ce..0a4efc42b 100644 --- a/bridge/tests/native-credentials/native_api.py +++ b/bridge/tests/native-credentials/native_api.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Bounded real Kubernetes clients; actor contexts never inherit admin keys.""" import base64 diff --git a/bridge/tests/native-credentials/observation_cases.py b/bridge/tests/native-credentials/observation_cases.py index f8e5ccd91..1f6845ce6 100644 --- a/bridge/tests/native-credentials/observation_cases.py +++ b/bridge/tests/native-credentials/observation_cases.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Fresh no-registration TLS proofs and real CNI paths, with no legacy fallback.""" import base64 diff --git a/bridge/tests/native-credentials/observation_diagnostics.py b/bridge/tests/native-credentials/observation_diagnostics.py index 6f64d007a..8dc6c14ae 100644 --- a/bridge/tests/native-credentials/observation_diagnostics.py +++ b/bridge/tests/native-credentials/observation_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Failure-only projection of fixed core stages; raw logs never enter evidence.""" import json diff --git a/bridge/tests/native-credentials/observer_cilium_diagnostics.py b/bridge/tests/native-credentials/observer_cilium_diagnostics.py index 75e605838..cb679aca8 100644 --- a/bridge/tests/native-credentials/observer_cilium_diagnostics.py +++ b/bridge/tests/native-credentials/observer_cilium_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Read-only, fixed-field Cilium 1.18.5 witnesses for the observer experiment.""" import copy diff --git a/bridge/tests/native-credentials/observer_network_diagnostics.py b/bridge/tests/native-credentials/observer_network_diagnostics.py index f70221499..dcd58ccbc 100644 --- a/bridge/tests/native-credentials/observer_network_diagnostics.py +++ b/bridge/tests/native-credentials/observer_network_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Failure-only exact API egress experiment; never changes acceptance results.""" import copy diff --git a/bridge/tests/native-credentials/observer_packet_diagnostics.py b/bridge/tests/native-credentials/observer_packet_diagnostics.py index 6e89ff8b0..e88d16438 100644 --- a/bridge/tests/native-credentials/observer_packet_diagnostics.py +++ b/bridge/tests/native-credentials/observer_packet_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Bounded, read-only Cilium monitor evidence, never an acceptance oracle. Wire contract (v1.18.5): pkg/monitor/{datapath_drop,datapath_trace,dissect}.go. diff --git a/bridge/tests/native-credentials/operator_diagnostics.py b/bridge/tests/native-credentials/operator_diagnostics.py index af1328ca7..bfee42341 100644 --- a/bridge/tests/native-credentials/operator_diagnostics.py +++ b/bridge/tests/native-credentials/operator_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Project only fixed categories and allowlisted source locations from CLI errors.""" import json diff --git a/bridge/tests/native-credentials/private_tls.py b/bridge/tests/native-credentials/private_tls.py index 74f9d6157..2e5aca02c 100644 --- a/bridge/tests/native-credentials/private_tls.py +++ b/bridge/tests/native-credentials/private_tls.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Pinned local TLS transport for API tests, not NetworkPolicy evidence.""" from contextlib import contextmanager diff --git a/bridge/tests/native-credentials/run.py b/bridge/tests/native-credentials/run.py index 4398c1d46..40f13783d 100644 --- a/bridge/tests/native-credentials/run.py +++ b/bridge/tests/native-credentials/run.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Hosted-only native acceptance. Each emitted marker records an actual outcome.""" import json diff --git a/bridge/tests/native-credentials/runtime_probe.py b/bridge/tests/native-credentials/runtime_probe.py index 89e62ce16..4887d63c7 100644 --- a/bridge/tests/native-credentials/runtime_probe.py +++ b/bridge/tests/native-credentials/runtime_probe.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Test-runtime self-observation; no exec, Secret disclosure, or arbitrary IO.""" from http.server import BaseHTTPRequestHandler, HTTPServer diff --git a/bridge/tests/native-credentials/runtime_state.py b/bridge/tests/native-credentials/runtime_state.py index cabe19376..d905034c3 100644 --- a/bridge/tests/native-credentials/runtime_state.py +++ b/bridge/tests/native-credentials/runtime_state.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Observe the controlled runtime over the existing permitted gateway port.""" import http.client diff --git a/bridge/tests/native-credentials/schema_preparation.py b/bridge/tests/native-credentials/schema_preparation.py index 182a2a788..95008b435 100644 --- a/bridge/tests/native-credentials/schema_preparation.py +++ b/bridge/tests/native-credentials/schema_preparation.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Use the exact core operator's schema lifecycle before installing its policies.""" import json diff --git a/bridge/tests/native-credentials/source_revision.py b/bridge/tests/native-credentials/source_revision.py index bf0e70af1..c537144ff 100644 --- a/bridge/tests/native-credentials/source_revision.py +++ b/bridge/tests/native-credentials/source_revision.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Bind Bridge/core qualification to the same checked-out monorepo commit.""" import os diff --git a/bridge/tests/native-credentials/template_diagnostics.py b/bridge/tests/native-credentials/template_diagnostics.py index bade5bc02..6142235c0 100644 --- a/bridge/tests/native-credentials/template_diagnostics.py +++ b/bridge/tests/native-credentials/template_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Failure-only template comparisons; neither template values nor hashes are published.""" import json diff --git a/bridge/tests/native-credentials/test_cilium_baseline_witness.py b/bridge/tests/native-credentials/test_cilium_baseline_witness.py index 5162679dc..c9788ed80 100644 --- a/bridge/tests/native-credentials/test_cilium_baseline_witness.py +++ b/bridge/tests/native-credentials/test_cilium_baseline_witness.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Controlled formats and failure checkpoints, not live Cilium qualification.""" import json diff --git a/bridge/tests/native-credentials/test_cilium_status_schema.py b/bridge/tests/native-credentials/test_cilium_status_schema.py index 2582c9bf7..167c6bb64 100644 --- a/bridge/tests/native-credentials/test_cilium_status_schema.py +++ b/bridge/tests/native-credentials/test_cilium_status_schema.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Tagged status-field controls; no live Cilium or Kubernetes qualification.""" import copy diff --git a/bridge/tests/native-credentials/test_credential_diagnostics.py b/bridge/tests/native-credentials/test_credential_diagnostics.py index 78e68ab5a..6f2c00b36 100644 --- a/bridge/tests/native-credentials/test_credential_diagnostics.py +++ b/bridge/tests/native-credentials/test_credential_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import copy import json import types diff --git a/bridge/tests/native-credentials/test_credential_review.py b/bridge/tests/native-credentials/test_credential_review.py index 557063ca9..a0ff5cc42 100644 --- a/bridge/tests/native-credentials/test_credential_review.py +++ b/bridge/tests/native-credentials/test_credential_review.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Operator protocol orchestration tests, not native delivery evidence.""" import copy diff --git a/bridge/tests/native-credentials/test_credential_target_startup.py b/bridge/tests/native-credentials/test_credential_target_startup.py index 7f0f2ac9f..777fade72 100644 --- a/bridge/tests/native-credentials/test_credential_target_startup.py +++ b/bridge/tests/native-credentials/test_credential_target_startup.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Initial ownership must settle before reviewing a credential write.""" import copy diff --git a/bridge/tests/native-credentials/test_enrollment.py b/bridge/tests/native-credentials/test_enrollment.py index 55f6c430e..7ed037532 100644 --- a/bridge/tests/native-credentials/test_enrollment.py +++ b/bridge/tests/native-credentials/test_enrollment.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Operator enrollment transport checks, not live native authority evidence.""" import copy diff --git a/bridge/tests/native-credentials/test_late_observation_snapshot.py b/bridge/tests/native-credentials/test_late_observation_snapshot.py index 232d07528..29e921b6c 100644 --- a/bridge/tests/native-credentials/test_late_observation_snapshot.py +++ b/bridge/tests/native-credentials/test_late_observation_snapshot.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Retain the actual governed Task bundle, not an absent legacy credentialsRef.""" import base64 diff --git a/bridge/tests/native-credentials/test_observation_diagnostics.py b/bridge/tests/native-credentials/test_observation_diagnostics.py index b268dc6c7..85284c77a 100644 --- a/bridge/tests/native-credentials/test_observation_diagnostics.py +++ b/bridge/tests/native-credentials/test_observation_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import copy from datetime import datetime, timedelta, timezone import json diff --git a/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py b/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py index 7621fccff..a55e8d897 100644 --- a/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py +++ b/bridge/tests/native-credentials/test_observer_cilium_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import copy import json import subprocess diff --git a/bridge/tests/native-credentials/test_observer_cilium_selector.py b/bridge/tests/native-credentials/test_observer_cilium_selector.py index 5b6ba550b..331d5f916 100644 --- a/bridge/tests/native-credentials/test_observer_cilium_selector.py +++ b/bridge/tests/native-credentials/test_observer_cilium_selector.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Effective identity-label selection; the original Pod rollout fences remain.""" import copy diff --git a/bridge/tests/native-credentials/test_observer_network_diagnostics.py b/bridge/tests/native-credentials/test_observer_network_diagnostics.py index 7e9d0ddc4..a98243c97 100644 --- a/bridge/tests/native-credentials/test_observer_network_diagnostics.py +++ b/bridge/tests/native-credentials/test_observer_network_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import contextlib import copy import io diff --git a/bridge/tests/native-credentials/test_observer_packet_diagnostics.py b/bridge/tests/native-credentials/test_observer_packet_diagnostics.py index 398585b4d..435952202 100644 --- a/bridge/tests/native-credentials/test_observer_packet_diagnostics.py +++ b/bridge/tests/native-credentials/test_observer_packet_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import copy from datetime import datetime, timedelta, timezone import json diff --git a/bridge/tests/native-credentials/test_operator_diagnostics.py b/bridge/tests/native-credentials/test_operator_diagnostics.py index 9fb0eae4c..cb406622f 100644 --- a/bridge/tests/native-credentials/test_operator_diagnostics.py +++ b/bridge/tests/native-credentials/test_operator_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Run real subprocess failures and prove no CLI body is published.""" import io diff --git a/bridge/tests/native-credentials/test_schema_preparation.py b/bridge/tests/native-credentials/test_schema_preparation.py index 4b70119dd..c7459cf85 100644 --- a/bridge/tests/native-credentials/test_schema_preparation.py +++ b/bridge/tests/native-credentials/test_schema_preparation.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Schema-preparation orchestration tests, not live discovery evidence.""" import io diff --git a/bridge/tests/native-credentials/test_source_revision.py b/bridge/tests/native-credentials/test_source_revision.py index 8dfac45f2..199cd019f 100644 --- a/bridge/tests/native-credentials/test_source_revision.py +++ b/bridge/tests/native-credentials/test_source_revision.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Qualification follows one immutable monorepo revision, never a stale core pin.""" import os diff --git a/bridge/tests/native-credentials/test_template_diagnostics.py b/bridge/tests/native-credentials/test_template_diagnostics.py index d5d1485db..064785f31 100644 --- a/bridge/tests/native-credentials/test_template_diagnostics.py +++ b/bridge/tests/native-credentials/test_template_diagnostics.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Prove template drift diagnostics retain only fixed, identity-bound facts.""" import copy diff --git a/bridge/web/.dockerignore b/bridge/web/.dockerignore index 17f335f0e..b8ab76d55 100644 --- a/bridge/web/.dockerignore +++ b/bridge/web/.dockerignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + node_modules/ .next/ .git/ diff --git a/bridge/web/.gitignore b/bridge/web/.gitignore index 5ef6a5207..d7d25f02b 100644 --- a/bridge/web/.gitignore +++ b/bridge/web/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies diff --git a/bridge/web/AGENTS.md b/bridge/web/AGENTS.md index 8bd0e3908..5dd86fb92 100644 --- a/bridge/web/AGENTS.md +++ b/bridge/web/AGENTS.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + <!-- BEGIN:nextjs-agent-rules --> # This is NOT the Next.js you know diff --git a/bridge/web/CLAUDE.md b/bridge/web/CLAUDE.md index 43c994c2d..de0423f88 100644 --- a/bridge/web/CLAUDE.md +++ b/bridge/web/CLAUDE.md @@ -1 +1,4 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + @AGENTS.md diff --git a/bridge/web/Dockerfile b/bridge/web/Dockerfile index 4e15a97b9..c555d795c 100644 --- a/bridge/web/Dockerfile +++ b/bridge/web/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Bridge web (Next.js) — container image. Multi-stage standalone build, so # the runtime image ships only server.js + the minimal traced node_modules. # Cloud-agnostic: runs identically on AKS, EKS, GKE, and local kind. diff --git a/bridge/web/README.md b/bridge/web/README.md index 37e12e33e..a0eaf815f 100644 --- a/bridge/web/README.md +++ b/bridge/web/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Bridge web The Next.js application provides three persona-scoped products: diff --git a/bridge/web/eslint.config.mjs b/bridge/web/eslint.config.mjs index 05e726d1b..b5d921c6a 100644 --- a/bridge/web/eslint.config.mjs +++ b/bridge/web/eslint.config.mjs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; diff --git a/bridge/web/postcss.config.mjs b/bridge/web/postcss.config.mjs index 61e36849c..21ee3921b 100644 --- a/bridge/web/postcss.config.mjs +++ b/bridge/web/postcss.config.mjs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + const config = { plugins: { "@tailwindcss/postcss": {}, diff --git a/bridge/web/src/app/globals.css b/bridge/web/src/app/globals.css index 2420ad9b3..e703648b2 100644 --- a/bridge/web/src/app/globals.css +++ b/bridge/web/src/app/globals.css @@ -1,3 +1,6 @@ +/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */ + @import "tailwindcss"; /* diff --git a/bridge/web/tests/credential-review.test.mjs b/bridge/web/tests/credential-review.test.mjs index 053037a4a..ada18465c 100644 --- a/bridge/web/tests/credential-review.test.mjs +++ b/bridge/web/tests/credential-review.test.mjs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import assert from "node:assert/strict"; import { test } from "node:test"; import { diff --git a/bridge/web/tests/proxy-routes.test.mjs b/bridge/web/tests/proxy-routes.test.mjs index 27612d7d6..7ba54e77d 100644 --- a/bridge/web/tests/proxy-routes.test.mjs +++ b/bridge/web/tests/proxy-routes.test.mjs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; diff --git a/bridge/web/tests/team-run-links.test.mjs b/bridge/web/tests/team-run-links.test.mjs index c08ab2ee9..d667f9099 100644 --- a/bridge/web/tests/team-run-links.test.mjs +++ b/bridge/web/tests/team-run-links.test.mjs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; diff --git a/ci/bench_regression.py b/ci/bench_regression.py index 140aa49b2..a3d964616 100755 --- a/ci/bench_regression.py +++ b/ci/bench_regression.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Bench-regression gate (Phase 2 S16). Reads a baseline JSON file with structure: diff --git a/ci/check-copyright-headers.sh b/ci/check-copyright-headers.sh index 70360287e..eb042425c 100755 --- a/ci/check-copyright-headers.sh +++ b/ci/check-copyright-headers.sh @@ -1,58 +1,7 @@ #!/usr/bin/env bash # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# ci/check-copyright-headers.sh — enforces OSPO finding CODE-COPYRIGHT-HDRS. -# -# Every kars-authored source file (.rs, .ts, .tsx, .js, .sh) must carry -# the two-line Microsoft + MIT copyright header at the top of the file. -# -# Excludes: -# - vendor/ (upstream code; own licenses in THIRD_PARTY_NOTICES.txt) -# - node_modules/ (installed deps) -# - dist/ (compiled output) -# - build/ (compiled output) -# - target/ (Rust build artifacts) -# - .turbo/ (turbo cache) -# - coverage/ (test coverage reports) -# - *.d.ts (generated TypeScript declarations) -# -# Exit codes: -# 0 — all files have the header -# 1 — one or more files are missing the header (list printed to stderr) +# Check every tracked file; explicit data/upstream coverage is reported separately. set -euo pipefail - -MISSING=() - -while IFS= read -r file; do - # Check first 5 lines for the copyright marker - if ! head -5 "$file" | grep -qE '^(//|#) *Copyright \(c\) Microsoft Corporation'; then - MISSING+=("$file") - fi -done < <( - git ls-files \ - | grep -E '\.(rs|ts|tsx|js|sh)$' \ - | grep -v '^vendor/' \ - | grep -v 'node_modules/' \ - | grep -v '/dist/' \ - | grep -v '^target/' \ - | grep -v '/build/' \ - | grep -v '\.d\.ts$' \ - | grep -v '\.turbo/' \ - | grep -v '/coverage/' \ - | grep -v '^docs/site/mermaid' -) - -if [ "${#MISSING[@]}" -gt 0 ]; then - echo "❌ Missing Microsoft + MIT copyright header in ${#MISSING[@]} file(s):" >&2 - for f in "${MISSING[@]}"; do - echo " $f" >&2 - done - echo "" >&2 - echo "Every kars-authored source file must begin with:" >&2 - echo " // Copyright (c) Microsoft Corporation." >&2 - echo " // Licensed under the MIT License." >&2 - echo "(or # … for shell/Python files)" >&2 - exit 1 -fi - -echo "✅ All $(git ls-files | grep -E '\.(rs|ts|tsx|js|sh)$' | grep -v '^vendor/' | grep -v 'node_modules/' | grep -v '/dist/' | grep -v '^target/' | grep -v '/build/' | grep -v '\.d\.ts$' | grep -v '\.turbo/' | grep -v '/coverage/' | grep -v '^docs/site/mermaid' | wc -l | tr -d ' ') source files carry the Microsoft + MIT copyright header." +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "$ROOT/ci/copyright_headers.py" check "$@" diff --git a/ci/copyright-coverage.json b/ci/copyright-coverage.json new file mode 100644 index 000000000..9df40b7d2 --- /dev/null +++ b/ci/copyright-coverage.json @@ -0,0 +1,177 @@ +{ + "version": 1, + "formats": { + ".json": { + "category": "repository-license", + "reason": "Strict JSON cannot contain comments; preserve manifests, lock data and fixtures byte-for-byte.", + "notice": "LICENSE" + }, + ".excalidraw": { + "category": "repository-license", + "reason": "Managed diagram JSON is unchanged; do not add comments or rewrite drawing content.", + "notice": "LICENSE" + }, + ".png": { + "category": "repository-license", + "reason": "Binary image; no literal text header.", + "notice": "LICENSE" + }, + ".gif": { + "category": "repository-license", + "reason": "Binary animation; no literal text header.", + "notice": "LICENSE" + }, + ".ico": { + "category": "repository-license", + "reason": "Binary icon; no literal text header.", + "notice": "LICENSE" + }, + ".pptx": { + "category": "repository-license", + "reason": "Presentation archive; retain original bytes, no literal text header or metadata rewrite.", + "notice": "LICENSE" + }, + ".svg": { + "category": "repository-license", + "reason": "Image asset kept byte-identical; upstream assets have separate path-specific ownership coverage.", + "notice": "LICENSE" + }, + ".cast": { + "category": "repository-license", + "reason": "Asciinema JSON-lines recording; comments would invalidate recorded data.", + "notice": "LICENSE" + } + }, + "files": { + "tools/headlamp-plugin/dist/main.js": { + "category": "generated", + "reason": "Reviewed Headlamp bundle produced by headlamp-plugin build in tools/headlamp-plugin/package.json; preserve bundled source/dependency notices and artifact bytes.", + "notice": "NOTICE" + }, + "tools/headlamp-plugin/dist/package.json": { + "category": "generated", + "reason": "Reviewed packaged Headlamp manifest emitted with the bundle by the build script in tools/headlamp-plugin/package.json; preserve generated output and dependency licensing.", + "notice": "NOTICE" + }, + "LICENSE": { + "category": "legal", + "reason": "Canonical Microsoft MIT license text; never prepend or replace legal notices.", + "notice": "LICENSE" + }, + "NOTICE": { + "category": "legal", + "reason": "Third-party attribution document; preserve all original notices.", + "notice": "NOTICE" + }, + "THIRD_PARTY_NOTICES.txt": { + "category": "legal", + "reason": "Third-party license texts are not Microsoft-authored source.", + "notice": "THIRD_PARTY_NOTICES.txt" + }, + "Cargo.lock": { + "category": "repository-license", + "reason": "Generated dependency lockfile; no dependency/checksum changes for copyright policy.", + "notice": "LICENSE" + }, + "bridge/bff/Cargo.lock": { + "category": "repository-license", + "reason": "Generated dependency lockfile; no dependency/checksum changes for copyright policy.", + "notice": "LICENSE" + }, + ".agt-sdk/.keep": { + "category": "repository-license", + "reason": "Empty directory marker; preserve emptiness.", + "notice": "LICENSE" + }, + "runtimes/wheels/.gitkeep": { + "category": "repository-license", + "reason": "Empty directory marker; preserve emptiness.", + "notice": "LICENSE" + }, + "a2a-gateway/testdata/test-cert.pem": { + "category": "repository-license", + "reason": "Encoded certificate fixture; preserve signed bytes and parser input.", + "notice": "LICENSE" + }, + "a2a-gateway/testdata/test-key.pem": { + "category": "repository-license", + "reason": "Encoded test-key fixture; preserve parser input (not a production credential).", + "notice": "LICENSE" + }, + "docs/llms.txt": { + "category": "repository-license", + "reason": "Machine-consumed documentation index; preserve literal input text.", + "notice": "LICENSE" + }, + "deploy/helm/kars/files/kars-default-agt-profile.yaml": { + "category": "repository-license", + "reason": "Embedded verbatim by Helm .Files.Get as ToolPolicy.agtProfile.inline. Its raw bytes feed the controller/router agt-profile.yaml digest contract; preserve the rendered payload and digest, not just parsed YAML.", + "notice": "LICENSE" + }, + "tools/e2e-harness/scenarios/exec-brief-hermes-single/prompt.txt": { + "category": "repository-license", + "reason": "Literal agent prompt fixture; comments would change the tested input.", + "notice": "LICENSE" + }, + "tools/e2e-harness/scenarios/exec-brief-hermes/prompt.txt": { + "category": "repository-license", + "reason": "Literal agent prompt fixture; comments would change the tested input.", + "notice": "LICENSE" + }, + "tools/e2e-harness/scenarios/exec-brief/prompt.txt": { + "category": "repository-license", + "reason": "Literal agent prompt fixture; comments would change the tested input.", + "notice": "LICENSE" + }, + "tools/e2e-harness/scenarios/mesh-roundtrip-hermes/prompt.txt": { + "category": "repository-license", + "reason": "Literal agent prompt fixture; comments would change the tested input.", + "notice": "LICENSE" + }, + "cli/blocklists/seed-domains.txt": { + "category": "third-party", + "reason": "Generated OISD/URLhaus feed data plus local entries; retain source attribution, not blanket Microsoft ownership.", + "notice": "NOTICE" + }, + "docs/site/mermaid-init.js": { + "category": "third-party", + "reason": "Upstream mdBook Mermaid initializer carries its own MPL-2.0 notice.", + "notice": "NOTICE" + }, + "docs/site/mermaid.min.js": { + "category": "third-party", + "reason": "Bundled upstream Mermaid distribution; retain upstream licensing, do not rewrite minified code.", + "notice": "NOTICE" + }, + "bridge/web/public/file.svg": { + "category": "third-party", + "reason": "Next.js scaffold asset; retain upstream provenance, no Microsoft ownership assertion.", + "notice": "NOTICE" + }, + "bridge/web/public/globe.svg": { + "category": "third-party", + "reason": "Next.js scaffold asset; retain upstream provenance, no Microsoft ownership assertion.", + "notice": "NOTICE" + }, + "bridge/web/public/next.svg": { + "category": "third-party", + "reason": "Next.js logo from scaffold; no Microsoft ownership or trademark assertion.", + "notice": "NOTICE" + }, + "bridge/web/public/vercel.svg": { + "category": "third-party", + "reason": "Vercel logo from scaffold; no Microsoft ownership or trademark assertion.", + "notice": "NOTICE" + }, + "bridge/web/public/window.svg": { + "category": "third-party", + "reason": "Next.js scaffold asset; retain upstream provenance, no Microsoft ownership assertion.", + "notice": "NOTICE" + }, + "bridge/web/src/app/favicon.ico": { + "category": "third-party", + "reason": "Next.js scaffold icon; preserve asset pending any independent provenance review, no Microsoft ownership assertion.", + "notice": "NOTICE" + } + } +} diff --git a/ci/copyright_headers.py b/ci/copyright_headers.py new file mode 100644 index 000000000..bb7b59548 --- /dev/null +++ b/ci/copyright_headers.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Check/apply repository licensing coverage without rewriting file bodies.""" + +import argparse +import codecs +from collections import Counter +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import re +import stat +import subprocess +import sys +import tokenize + + +COPYRIGHT = "Copyright (c) Microsoft Corporation." +LICENSE = "Licensed under the MIT License." +POLICY_PATH = "ci/copyright-coverage.json" +STYLES = { + "slash": f"// {COPYRIGHT}\n// {LICENSE}\n\n", + "hash": f"# {COPYRIGHT}\n# {LICENSE}\n\n", + "html": f"<!-- {COPYRIGHT}\n{LICENSE} -->\n\n", + "css": f"/* {COPYRIGHT}\n{LICENSE} */\n\n", + # No whitespace outside either template comment: rendering stays identical, + # including when the original template begins with a whitespace-trimming tag. + "helm": f"{{{{/* {COPYRIGHT}\n{LICENSE} */}}}}", + "handlebars": f"{{{{!-- {COPYRIGHT}\n{LICENSE} --}}}}", +} +SUFFIX_STYLES = { + **dict.fromkeys((".rs", ".ts", ".tsx", ".js", ".mjs", ".bicep"), "slash"), + **dict.fromkeys((".sh", ".py", ".toml", ".yaml", ".yml"), "hash"), + ".md": "html", + ".css": "css", + ".hbs": "handlebars", + ".tpl": "helm", +} +HASH_NAMES = { + ".gitignore", ".dockerignore", "Makefile", "CODEOWNERS", ".env.example", + "requirements.txt", +} +DOCKER_DIRECTIVE = re.compile(rb"^[ \t]*#[ \t]*(syntax|escape|check)[ \t]*=", re.I) +ENCODING_COOKIE = re.compile(rb"^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)") + + +class CoverageError(ValueError): + """A file cannot be classified or safely changed.""" + + +def load_policy(root): + policy = json.loads((root / POLICY_PATH).read_bytes()) + if not isinstance(policy, dict) or set(policy) != {"version", "formats", "files"} or policy["version"] != 1: + raise CoverageError("unsupported coverage policy schema") + categories = {"repository-license", "third-party", "legal"} + for group in ("formats", "files"): + allowed_categories = categories | ({"generated"} if group == "files" else set()) + if not isinstance(policy[group], dict): + raise CoverageError(f"{group} must be an object") + for key, rule in policy[group].items(): + if ( + not isinstance(rule, dict) + or set(rule) != {"category", "reason", "notice"} + or not isinstance(rule["category"], str) + or rule["category"] not in allowed_categories + or not isinstance(rule["reason"], str) + or not rule["reason"].strip() + or not isinstance(rule["notice"], str) + or rule["notice"] not in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.txt") + ): + raise CoverageError(f"invalid coverage rule: {key}") + if group == "files": + path = PurePosixPath(key) + if path.is_absolute() or ".." in path.parts or str(path) != key: + raise CoverageError(f"invalid coverage path: {key}") + elif not key.startswith(".") or "/" in key: + raise CoverageError(f"invalid coverage extension: {key}") + for notice in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.txt"): + if not (root / notice).is_file(): + raise CoverageError(f"missing license/notice document: {notice}") + return policy + + +def classification(path, policy): + p = PurePosixPath(path) + if path in policy["files"]: + return dict(policy["files"][path]) + if p.parts[0] == "vendor": + return { + "category": "third-party", "notice": "NOTICE", + "reason": "Vendored inputs retain their upstream/package licenses and checksums.", + } + if "templates" in p.parts and p.suffix in (".yaml", ".yml", ".tpl", ".txt"): + return {"category": "header", "style": "helm"} + if p.name.startswith("Dockerfile") and (p.name == "Dockerfile" or p.name[10:11] == "."): + return {"category": "header", "style": "hash"} + if p.name in HASH_NAMES or path == "tools/drift/allowlist-q1.txt": + return {"category": "header", "style": "hash"} + if p.suffix in SUFFIX_STYLES: + return {"category": "header", "style": SUFFIX_STYLES[p.suffix]} + if p.suffix in policy["formats"]: + return dict(policy["formats"][p.suffix]) + raise CoverageError("unknown format: add a safe comment style or an explicit reviewed coverage rule") + + +def anchor(path, data): + """Return an insertion point after syntax that must remain at the start.""" + p = PurePosixPath(path) + start = len(codecs.BOM_UTF8) if data.startswith(codecs.BOM_UTF8) else 0 + body = data[start:] + lines = body.splitlines(keepends=True) + shebang = lines and lines[0].startswith(b"#!") + if p.suffix == ".rs" and body.startswith(b"#!["): + shebang = False + count = 1 if shebang else 0 + if p.suffix == ".py": + try: + encoding, detected_lines = tokenize.detect_encoding(io.BytesIO(data).readline) + data.decode(encoding) + except (SyntaxError, UnicodeError, LookupError) as exc: + raise CoverageError(f"invalid Python encoding: {exc}") from exc + for i, line in enumerate(lines[:len(detected_lines)]): + if ENCODING_COOKIE.match(line): + count = max(count, i + 1) + else: + try: + data.decode("utf-8-sig") + except UnicodeError as exc: + raise CoverageError("commentable files must be UTF-8 (Python cookies are supported)") from exc + if b"\0" in data: + raise CoverageError("binary content in a commentable format") + if p.name == "Dockerfile" or p.name.startswith("Dockerfile."): + count = 0 + for line in lines: + if not DOCKER_DIRECTIVE.match(line): + break + count += 1 + if p.suffix == ".md" and lines and lines[0].strip() in (b"---", b"+++"): + delimiter = lines[0].strip() + endings = (delimiter, b"...") if delimiter == b"---" else (delimiter,) + for i, line in enumerate(lines[1:], 1): + if line.strip() in endings: + count = i + 1 + break + else: + raise CoverageError("unterminated Markdown frontmatter") + if p.suffix == ".css" and body.startswith(b'@charset "'): + match = re.match(rb'@charset "[^"\r\n]+";', body) + if not match: + raise CoverageError("invalid CSS charset directive") + # A CSS comment may immediately follow the semicolon, even on one line. + return start + match.end() + if count and not lines[count - 1].endswith(b"\n"): + raise CoverageError("leading directive has no newline; terminate it before applying a header") + return start + sum(map(len, lines[:count])) + + +def header_for(style, data): + first_newline = data.find(b"\n") + newline = "\r\n" if first_newline > 0 and data[first_newline - 1:first_newline] == b"\r" else "\n" + return STYLES[style].replace("\n", newline).encode("ascii") + + +def has_header(data, offset, style): + prefix = data[offset:].replace(b"\r\n", b"\n") + expected = STYLES[style].encode("ascii").rstrip(b"\n") + prefix = prefix.lstrip(b"\n") + if style in ("hash", "slash"): + marker = b"#" if style == "hash" else b"//" + lines = prefix.splitlines()[:5] + # Legacy LOC annotations can separate the notices. Both must be exact + # comment lines in the leading preamble, never executable/string text. + if not lines or lines[0] != marker + b" " + COPYRIGHT.encode("ascii"): + return False + for line in lines[1:]: + if line == marker + b" " + LICENSE.encode("ascii"): + return True + if line.strip() and not line.startswith(marker): + return False + return False + if style == "html": + alternative = f"<!--\n{COPYRIGHT}\n{LICENSE}\n-->".encode("ascii") + if prefix.startswith(alternative): + return True + return prefix.startswith(expected) and ( + style in ("helm", "handlebars", "html", "css") + or len(prefix) == len(expected) + or prefix[len(expected):len(expected) + 1] == b"\n" + ) + + +def insertion(path, data, style): + offset = anchor(path, data) + if has_header(data, offset, style): + return offset, b"" + if PurePosixPath(path).suffix == ".rs": + # Older fuzz targets put their existing notice after #![no_main]. + # Preserve it without treating new Rust attributes as executable shebangs. + attribute = re.match(rb"#!\[[^\r\n]*\]\r?\n", data[offset:]) + if attribute and has_header(data, offset + attribute.end(), style): + return offset + attribute.end(), b"" + return offset, header_for(style, data) + + +def tracked_files(root): + result = subprocess.check_output(["git", "ls-files", "-z"], cwd=root) + return sorted(set(result.decode("utf-8").split("\0")) - {""}) + + +def file_bytes(root, name): + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or str(path) != name: + raise CoverageError("path must be repository-relative and normalized") + target = root + for part in path.parts: + target /= part + if target.is_symlink(): + raise CoverageError("symlink requires explicit human review; never follow it") + mode = target.stat().st_mode + if not stat.S_ISREG(mode): + raise CoverageError("not a regular file") + return target.read_bytes(), mode + + +def process(root, paths, policy, apply=False): + records, changes = [], [] + for name in sorted(set(paths)): + record = {"path": name} + try: + data, mode = file_bytes(root, name) + record.update(classification(name, policy)) + if record["category"] == "header": + offset, header = insertion(name, data, record["style"]) + record["status"] = "missing" if header else "present" + if header: + record.update({ + "offset": offset, "inserted_bytes": len(header), + "before_sha256": hashlib.sha256(data).hexdigest(), + "after_sha256": hashlib.sha256(data[:offset] + header + data[offset:]).hexdigest(), + }) + changes.append((name, data, mode, offset, header, record)) + else: + record["status"] = "covered-without-header" + except (CoverageError, OSError, UnicodeError) as exc: + record.update(status="error", error=str(exc)) + records.append(record) + # Fail closed, before writing any file, if coverage is incomplete/unsafe. + if apply and not any(r["status"] == "error" for r in records): + for name, data, mode, offset, header, record in changes: + current, current_mode = file_bytes(root, name) + if current != data or current_mode != mode: + raise CoverageError(f"{name}: changed during inspection; nothing should overwrite another editor") + for name, data, mode, offset, header, record in changes: + target = root / name + target.write_bytes(data[:offset] + header + data[offset:]) + if target.stat().st_mode != mode: + raise CoverageError(f"{name}: file mode changed") + record["status"] = "applied" + return records + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "apply")) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) + parser.add_argument("--report", type=Path, help="write exhaustive per-file JSON coverage (relative to root)") + parser.add_argument("--verbose", action="store_true", help="list every non-header coverage exception") + parser.add_argument("paths", nargs="*", help="explicit paths; default: ALL git-tracked files") + args = parser.parse_args(argv) + root = args.root.resolve() + try: + if args.report: + report = args.report + if report.is_absolute() or ".." in report.parts: + raise CoverageError("report must be a repository-relative path") + target = root + for part in report.parts: + target /= part + if target.is_symlink(): + raise CoverageError("report path must not contain symlinks") + if target.exists(): + raise CoverageError("report already exists; choose a new path") + if not target.parent.is_dir(): + raise CoverageError("report parent directory does not exist") + policy = load_policy(root) + records = process(root, args.paths or tracked_files(root), policy, args.command == "apply") + counts = Counter(r["status"] for r in records) + categories = Counter(r.get("category", "unknown") for r in records) + if args.report: + with (root / report).open("x", encoding="utf-8") as output: + json.dump({ + "counts": dict(counts), "categories": dict(categories), "files": records, + }, output, indent=2) + output.write("\n") + for record in records: + if record["status"] in ("missing", "error"): + print(f"{record['path']}: {record.get('error', 'missing Microsoft + MIT header')}", file=sys.stderr) + elif args.verbose and record["status"] == "covered-without-header": + print(f"{record['path']}: {record['category']} via {record['notice']}: {record['reason']}") + print( + f"Copyright coverage: {len(records)} files; " + f"{counts['present']} headers present, {counts['applied']} applied, " + f"{counts['covered-without-header']} explicit non-header coverage, " + f"{counts['missing']} missing, {counts['error']} errors." + ) + print("Coverage categories: " + ", ".join(f"{k}={v}" for k, v in sorted(categories.items()))) + print("Non-header coverage retains LICENSE/NOTICE and original ownership; use --verbose or --report for paths.") + return 1 if counts["missing"] or counts["error"] else 0 + except (CoverageError, OSError, ValueError, subprocess.CalledProcessError) as exc: + print(f"Copyright coverage error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/loc-budget.yaml b/ci/loc-budget.yaml index ae1f5ce69..4952ab2ba 100644 --- a/ci/loc-budget.yaml +++ b/ci/loc-budget.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # LOC budget — per internal Phase 1 plan §4.2. # # `ci/check-loc.sh` enforces: diff --git a/ci/tests/copyright_headers_test.py b/ci/tests/copyright_headers_test.py new file mode 100644 index 000000000..6420cc256 --- /dev/null +++ b/ci/tests/copyright_headers_test.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +"""Format and insertion-only regressions; scratch files stay in the checkout.""" + +import ast +import codecs +import contextlib +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import shutil +import subprocess +import sys +import unittest +import uuid + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("copyright_headers", ROOT / "ci/copyright_headers.py") +headers = importlib.util.module_from_spec(spec) +spec.loader.exec_module(headers) + + +class HeaderTests(unittest.TestCase): + def setUp(self): + self.root = ROOT / (".copyright-test-" + uuid.uuid4().hex) + self.root.mkdir() + self.addCleanup(shutil.rmtree, self.root) + self.policy = headers.load_policy(ROOT) + + def write(self, name, data): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + def apply(self, name, data, expected_offset=None): + rule = headers.classification(name, self.policy) + offset, block = headers.insertion(name, data, rule["style"]) + if expected_offset is not None: + self.assertEqual(offset, expected_offset) + self.assertTrue(block) + result = data[:offset] + block + data[offset:] + self.assertEqual(result[:offset] + result[offset + len(block):], data) + self.assertEqual(headers.insertion(name, result, rule["style"])[1], b"") + self.assertIn(headers.COPYRIGHT.encode(), block) + self.assertIn(headers.LICENSE.encode(), block) + return result + + def test_all_commentable_formats(self): + fixtures = { + "a.rs": b"//! Crate docs\nfn main() {}\n", + "a.ts": b"/// <reference lib=\"dom\" />\nexport {};\n", + "a.tsx": b"'use client';\nexport const A = () => <p />;\n", + "a.js": b'"use strict";\nconst x = 1;', + "a.mjs": b'"use server";\nexport const x = 1;\n', + "a.bicep": b"param location string = 'westus'\n", + "a.sh": b"printf 'unchanged\\n'\n", + "a.py": b'"""Module docs."""\nx = 1\n', + "a.toml": b"[package]\nname = 'fixture'\n", + "a.yaml": b"---\nname: value\n", + "a.yml": b"on: [push]\njobs: {}\n", + "a.md": b"# Title\n\nText\n", + "a.css": b'@import "theme.css";\na { color: red; }\n', + "a.hbs": b"<!DOCTYPE html>\n<p>{{name}}</p>\n", + "a.tpl": b'{{- define "test" -}}test{{- end -}}\n', + "Dockerfile": b"FROM scratch\n", + "Dockerfile.probe": b"FROM scratch\n", + ".gitignore": b"!important\nbuild/\n", + ".dockerignore": b"node_modules\n", + "Makefile": b"test:\n\t@printf 'ok\\n'\n", + ".github/CODEOWNERS": b"* @Azure/kars\n", + ".env.example": b"EMPTY=\nVALUE=test\n", + "requirements.txt": b"example==1.0\n", + "tools/drift/allowlist-q1.txt": b"allowed_function\n", + } + for path, body in fixtures.items(): + with self.subTest(path=path): + self.apply(path, body, 0) + + def test_shebangs_and_encoding_cookies(self): + fixtures = [ + ("a.sh", b"#!/bin/sh\nprintf ok"), + ("a.mjs", b"#!/usr/bin/env node\n'use strict';\n"), + ("a.py", b"#!/usr/bin/env python3\n# coding: latin-1\nx = '\xe9'\n"), + ("a.py", b"# coding=latin-1\nx = '\xe9'\n"), + ("a.py", b"# Original attribution\n# -*- coding: latin-1 -*-\nx = '\xe9'\n"), + ("a.py", b"#!/usr/bin/python3\n\nx = 1\n"), + ] + for name, body in fixtures: + with self.subTest(body=body): + expected_lines = 2 if b"coding" in body.splitlines()[1] else 1 + prefix = b"".join(body.splitlines(keepends=True)[:expected_lines]) + after = self.apply(name, body, len(prefix)) + if name.endswith(".py"): + self.assertEqual(ast.dump(ast.parse(body)), ast.dump(ast.parse(after))) + # A cookie-looking comment after executable code is not an encoding + # declaration; the header must still precede the executable statement. + self.apply("a.py", b"x = 1\n# coding: utf-8\n", 0) + + def test_bom_crlf_and_no_final_newline(self): + for body in ( + codecs.BOM_UTF8 + b'"use client";\r\nexport {};', + b"const x = 1;\r\n\r\n", + b"const x = 1;", + b"", + ): + with self.subTest(body=body): + after = self.apply("a.ts", body) + if body.startswith(codecs.BOM_UTF8): + self.assertTrue(after.startswith(codecs.BOM_UTF8)) + if b"\r\n" in body: + self.assertNotIn(b"\n", after.replace(b"\r\n", b"")) + self.assertTrue(after.endswith(body[3:] if body.startswith(codecs.BOM_UTF8) else body)) + + def test_rust_inner_attributes_are_not_shebangs(self): + self.apply("a.rs", b"#![no_std]", 0) + self.apply("a.rs", b"#![deny(unsafe_code)]\n//! Crate docs\n", 0) + existing = b"#![no_main]\n" + headers.STYLES["slash"].encode() + b"use libfuzzer_sys::fuzz_target;\n" + self.assertEqual(headers.insertion("a.rs", existing, "slash")[1], b"") + + def test_docker_directives_preserved(self): + for directives in ( + b"# syntax=docker/dockerfile:1.7\n", + b"# syntax=docker/dockerfile:1.7\r\n# escape=`\r\n# check=skip=JSONArgsRecommended\r\n", + b"# SYNTAX=docker/dockerfile:1\n# ESCAPE=\\\n", + ): + after = self.apply("Dockerfile.dev", directives + b"\nFROM scratch\n", len(directives)) + self.assertTrue(after.startswith(directives)) + self.apply("Dockerfile", b"# explanation\n# syntax=not-a-directive\nFROM scratch\n", 0) + + def test_markdown_frontmatter_and_existing_html_notice(self): + for prefix in ( + b"---\nname: skill\nmetadata: {a: b}\n---\n", + b"---\r\nname: skill\r\n...\r\n", + b"+++\nname = 'skill'\n+++\n", + ): + self.apply("SKILL.md", prefix + b"\n# Heading\n", len(prefix)) + body = f"<!--\n{headers.COPYRIGHT}\n{headers.LICENSE}\n-->\n\n# Title".encode() + self.assertEqual(headers.insertion("a.md", body, "html")[1], b"") + + def test_css_charset_and_import(self): + prefix = b'@charset "UTF-8";' + self.apply("a.css", prefix + b'\n@import "theme.css";\n', len(prefix)) + + def test_template_headers_never_emit_or_trim_whitespace(self): + for path in ("chart/templates/config.yaml", "chart/templates/NOTES.txt", "a.tpl", "a.hbs"): + for body in ( + b'{{- if .Values.enabled -}}\nkey: value\n{{- end -}}\n', + b' leading whitespace\n{{- /* existing comment */ -}}\n', + b"plain text with trailing spaces \n\n", + ): + with self.subTest(path=path, body=body): + after = self.apply(path, body, 0) + self.assertTrue(after.endswith(body)) + marker = b"--}}" if path.endswith(".hbs") else b"*/}}" + self.assertEqual(after.split(marker, 1)[1], body) + + def test_original_attribution_and_legacy_annotation_preserved(self): + body = b"// Copyright (c) 2026 Original Author\n// SPDX-License-Identifier: MIT\nfn main() {}\n" + after = self.apply("a.rs", body) + self.assertEqual(after.count(b"Original Author"), 1) + self.assertTrue(after.endswith(body)) + legacy = ( + f"// {headers.COPYRIGHT}\n// ci:loc-ok existing annotation\n\n" + f"// {headers.LICENSE}\n\nfn main() {{}}\n" + ).encode() + self.assertEqual(headers.insertion("a.rs", legacy, "slash")[1], b"") + + def test_both_notices_required_in_leading_comments(self): + for body in ( + f"// {headers.COPYRIGHT}\nfn main() {{}}\n".encode(), + f'const text = "// {headers.COPYRIGHT}\\n// {headers.LICENSE}";\n'.encode(), + f"fn main() {{}}\n// {headers.COPYRIGHT}\n// {headers.LICENSE}\n".encode(), + f"// {headers.COPYRIGHT}\nfn main() {{}}\n// {headers.LICENSE}\n".encode(), + ): + self.assertTrue(headers.insertion("a.rs", body, "slash")[1]) + + def test_unknown_and_unsafe_formats_fail(self): + for name in ("unknown.conf", "new.txt", "new.lock", "own.whl", "unknown", "a.cjs"): + with self.subTest(name=name): + with self.assertRaises(headers.CoverageError): + headers.classification(name, self.policy) + for name, data in ( + ("a.md", b"---\nname: unfinished\n"), + ("a.py", b"# coding: not-an-encoding\n"), + ("a.sh", b"#!/bin/sh"), + ("Dockerfile", b"# syntax=docker/dockerfile:1"), + ("a.ts", b"\x00binary"), + ("a.yaml", b"\xffinvalid"), + ("a.css", b'@charset "UTF-8"'), + ): + with self.subTest(name=name, data=data): + with self.assertRaises(headers.CoverageError): + headers.insertion(name, data, headers.classification(name, self.policy)["style"]) + + def test_non_header_coverage_never_changes_bytes(self): + fixtures = { + "data.json": b'{"signature":"unchanged"}\n', + "Cargo.lock": b"# generated\nversion = 4\n", + "drawing.excalidraw": b'{"type":"excalidraw"}', + "asset.png": b"\x89PNG\r\n\x00", + "asset.gif": b"GIF89a\x00", + "asset.ico": b"\x00icon", + "asset.svg": b"<svg/>", + "slide.pptx": b"PK\x00", + "record.cast": b'{"version":2}\n[1.0,"o","record"]\n', + "a2a-gateway/testdata/test-cert.pem": b"certificate bytes", + "vendor/sandbox-wheels/external.whl": b"PK\x00", + "vendor/agt/external.tgz": b"\x1f\x8barchive", + "vendor/agt/SHA256SUMS": b"original digest external.tgz\n", + "vendor/external.rs": b"// Upstream copyright\n", + "docs/site/mermaid-init.js": b"// MPL upstream\n", + "bridge/web/public/next.svg": b"<svg/>", + "bridge/web/src/app/favicon.ico": b"\0icon", + "cli/blocklists/seed-domains.txt": b"# upstream\nexample.test\n", + "deploy/helm/kars/files/kars-default-agt-profile.yaml": b"name: literal-embedded-value\n", + "tools/e2e-harness/scenarios/exec-brief/prompt.txt": b"Prompt input.\n", + "tools/headlamp-plugin/dist/main.js": b"minified();", + "tools/headlamp-plugin/dist/package.json": b'{"name":"generated-manifest"}\n', + "LICENSE": b"Original legal text\n", + "NOTICE": b"Original attribution\n", + "THIRD_PARTY_NOTICES.txt": b"Original third-party license\n", + } + for path, data in fixtures.items(): + self.write(path, data) + results = headers.process(self.root, list(fixtures), self.policy, apply=True) + self.assertTrue(all(r["status"] == "covered-without-header" for r in results)) + for path, data in fixtures.items(): + self.assertEqual((self.root / path).read_bytes(), data) + with self.assertRaises(headers.CoverageError): + headers.classification("bridge/unknown-format.xyz", self.policy) + + def test_reported_generated_bypasses_fail_closed(self): + fixtures = { + "cli/src/build/handwritten.ts": b'"use client";\nexport const value = 1;\n', + "cli/src/authored.d.ts": b'/// <reference lib="dom" />\nexport declare const value: string;\n', + "ci/tests/coverage/unknown.newformat": b"unrecognized first-party input\n", + } + for name, body in fixtures.items(): + self.write(name, body) + results = headers.process(self.root, list(fixtures), self.policy, apply=True) + by_path = {r["path"]: r for r in results} + for name in list(fixtures)[:2]: + self.assertEqual(by_path[name]["category"], "header") + self.assertEqual(by_path[name]["status"], "missing") + unknown = by_path["ci/tests/coverage/unknown.newformat"] + self.assertEqual(unknown["status"], "error") + self.assertIn("unknown format", unknown["error"]) + for name, body in fixtures.items(): + self.assertEqual((self.root / name).read_bytes(), body) + sources = list(fixtures)[:2] + results = headers.process(self.root, sources, self.policy, apply=True) + self.assertTrue(all(r["status"] == "applied" for r in results)) + for name in sources: + self.assertEqual( + (self.root / name).read_bytes(), + headers.STYLES["slash"].encode() + fixtures[name], + ) + again = headers.process(self.root, sources, self.policy, apply=True) + self.assertTrue(all(r["status"] == "present" for r in again)) + + def test_output_directory_names_never_imply_generated_coverage(self): + directories = ("build", "target", "dist", "coverage", ".turbo", "node_modules") + for directory in directories: + for prefix in ("", "cli/src/", "ci/tests/fixtures/"): + with self.subTest(directory=directory, prefix=prefix): + for filename in ("handwritten.ts", "authored.d.ts"): + path = f"{prefix}{directory}/{filename}" + self.assertEqual(headers.classification(path, self.policy)["category"], "header") + self.apply(path, b"export declare const value: string;\n", 0) + with self.assertRaises(headers.CoverageError): + headers.classification(f"{prefix}{directory}/unknown.newformat", self.policy) + self.assertEqual( + headers.classification(f"{prefix}{directory}/data.json", self.policy)["category"], + "repository-license", + ) + nested = "cli/src/" + "/".join(directories) + "/handwritten.ts" + self.assertEqual(headers.classification(nested, self.policy)["category"], "header") + self.apply(nested, b"export const value = 1;\n", 0) + + def test_generated_coverage_requires_exact_reviewed_paths(self): + expected = {"tools/headlamp-plugin/dist/main.js", "tools/headlamp-plugin/dist/package.json"} + generated = {p for p, r in self.policy["files"].items() if r["category"] == "generated"} + self.assertEqual(generated, expected) + for name in expected: + rule = headers.classification(name, self.policy) + self.assertEqual(rule["notice"], "NOTICE") + self.assertIn("tools/headlamp-plugin/package.json", rule["reason"]) + for name in ( + "tools/headlamp-plugin/dist/authored.ts", + "tools/headlamp-plugin/dist/authored.d.ts", + "examples/tools/headlamp-plugin/dist/main.js", + "tools/headlamp-plugin/dist/nested/main.js", + ): + self.assertEqual(headers.classification(name, self.policy)["category"], "header") + with self.assertRaises(headers.CoverageError): + headers.classification("tools/headlamp-plugin/dist/main.js.newformat", self.policy) + name = "ci/tests/fixtures/generated/types.d.ts" + self.policy["files"][name] = { + "category": "generated", "notice": "NOTICE", + "reason": "Reviewed declaration fixture emitted by this test generator.", + } + body = b"declare const generated: string;\n" + path = self.write(name, body) + for _ in range(2): + result, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(result["status"], "covered-without-header") + self.assertEqual(path.read_bytes(), body) + + def test_generated_rules_cannot_be_format_wide(self): + for name in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.txt"): + self.write(name, b"notice\n") + policy_path = self.write(headers.POLICY_PATH, json.dumps(self.policy).encode()) + headers.load_policy(self.root) + self.policy["formats"][".d.ts"] = { + "category": "generated", "notice": "NOTICE", "reason": "Not a reviewed exact file.", + } + policy_path.write_text(json.dumps(self.policy)) + with self.assertRaises(headers.CoverageError): + headers.load_policy(self.root) + + def test_verbatim_helm_policy_preserves_raw_byte_digest(self): + name = "deploy/helm/kars/files/kars-default-agt-profile.yaml" + original = (ROOT / name).read_bytes() + path = self.write(name, original) + + def digest(body): + # The controller and router use this filename/body wire contract. + filename = b"agt-profile.yaml" + canonical = ( + len(filename).to_bytes(8, "big") + filename + + len(body).to_bytes(8, "big") + body + ) + return hashlib.sha256(canonical).hexdigest() + + expected = digest(original) + result, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(result["status"], "covered-without-header") + self.assertEqual(path.read_bytes(), original) + self.assertEqual(digest(path.read_bytes()), expected) + self.assertNotEqual(digest(headers.header_for("hash", original) + original), expected) + + def test_process_preserves_mode_and_proves_insertion(self): + body = b"#!/bin/sh\r\nprintf 'same\\n'\r\n" + path = self.write("space in name.sh", body) + path.chmod(0o751) + result, = headers.process(self.root, ["space in name.sh"], self.policy, apply=True) + after = path.read_bytes() + self.assertEqual(result["status"], "applied") + self.assertEqual(path.stat().st_mode & 0o777, 0o751) + self.assertEqual(result["before_sha256"], hashlib.sha256(body).hexdigest()) + self.assertEqual(result["after_sha256"], hashlib.sha256(after).hexdigest()) + offset, length = result["offset"], result["inserted_bytes"] + self.assertEqual(after[:offset] + after[offset + length:], body) + again, = headers.process(self.root, ["space in name.sh"], self.policy, apply=True) + self.assertEqual(again["status"], "present") + self.assertEqual(path.read_bytes(), after) + + def test_errors_prevent_partial_application(self): + good = self.write("good.py", b"value = 1\n") + self.write("unknown.format", b"unknown\n") + results = headers.process(self.root, ["good.py", "unknown.format"], self.policy, apply=True) + self.assertEqual(good.read_bytes(), b"value = 1\n") + self.assertEqual({r["status"] for r in results}, {"missing", "error"}) + self.write("linked.py", b"value = 2\n") + (self.root / "link.py").symlink_to("linked.py") + for name in ("missing.py", "link.py", "../escape.py"): + result, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(result["status"], "error") + + def test_policy_schema_errors(self): + for name in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.txt"): + self.write(name, b"notice\n") + policy_path = self.write(headers.POLICY_PATH, b"{}") + for value in (b"{}", b"null", b"[]"): + policy_path.write_bytes(value) + with self.assertRaises(headers.CoverageError): + headers.load_policy(self.root) + policy = json.loads(json.dumps(self.policy)) + policy["files"]["escape"] = {"category": "ignored"} + policy_path.write_text(json.dumps(policy)) + with self.assertRaises(headers.CoverageError): + headers.load_policy(self.root) + + def test_cli_tracks_every_file_reports_exceptions_and_is_idempotent(self): + for name in ("LICENSE", "NOTICE", "THIRD_PARTY_NOTICES.txt"): + self.write(name, b"notice\n") + self.write(headers.POLICY_PATH, json.dumps(self.policy).encode()) + self.write("new.py", b"value = 1\n") + self.write("data.json", b"{}") + subprocess.run(["git", "init", "-q", str(self.root)], check=True) + subprocess.run(["git", "add", "."], cwd=self.root, check=True) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(headers.main(["check", "--root", str(self.root)]), 1) + self.assertEqual(headers.main(["apply", "--root", str(self.root), "--report", "report.json"]), 0) + self.assertEqual(headers.main(["check", "--root", str(self.root)]), 0) + self.assertEqual(headers.main(["apply", "--root", str(self.root)]), 0) + self.assertEqual(headers.main(["apply", "--root", str(self.root), "--report", "../escape.json"]), 2) + self.assertEqual(headers.main(["apply", "--root", str(self.root), "--report", "report.json"]), 2) + self.assertEqual(headers.main(["apply", "--root", str(self.root), "--report", "missing/report.json"]), 2) + report = json.loads((self.root / "report.json").read_bytes()) + self.assertEqual(len(report["files"]), 6) + self.assertEqual(report["counts"]["applied"], 1) + self.assertEqual(report["counts"]["covered-without-header"], 5) + self.write("ci/tests/coverage/unknown.newformat", b"must not be silently ignored\n") + subprocess.run(["git", "add", "ci/tests/coverage/unknown.newformat"], cwd=self.root, check=True) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(headers.main(["check", "--root", str(self.root)]), 1) + + def test_python_ast_and_toml_parser_equivalence(self): + import tomllib + python = b'#!/usr/bin/python3\n"""Module docs."""\nfrom __future__ import annotations\nx: int = 1\n' + after = self.apply("a.py", python) + self.assertEqual(ast.dump(ast.parse(python)), ast.dump(ast.parse(after))) + toml = b"[package]\nname = 'fixture'\nitems = ['a', 'b']\n" + self.assertEqual(tomllib.loads(toml.decode()), tomllib.loads(self.apply("a.toml", toml).decode())) + + @unittest.skipUnless(shutil.which("node"), "Node not installed") + def test_javascript_directives_remain_executable_prologue(self): + for directive in (b'"use client";\n', b'"use server";\n'): + body = b'#!/usr/bin/env node\n' + directive + b'"use strict";\nconsole.log((function () { return this === undefined; })());\n' + path = self.write("directive.mjs", body) + before = subprocess.check_output(["node", str(path)]) + path.write_bytes(self.apply("directive.mjs", body)) + after = subprocess.check_output(["node", str(path)]) + self.assertEqual(before, b"true\n") + self.assertEqual(before, after) + + @unittest.skipUnless(shutil.which("node"), "Node not installed") + def test_frontend_ast_and_css_parser_equivalence_when_installed(self): + program = r""" +const fs = require("node:fs"); +let ts, css; +try { + const paths = [process.argv[1]]; + ts = require(require.resolve("typescript", { paths })); + css = require(require.resolve("postcss", { paths })); +} catch { process.exit(77); } +const fixtures = JSON.parse(fs.readFileSync(0, "utf8")); +function structure(node) { + return [node.kind, node.text ?? null, node.getChildren().map(structure)]; +} +for (const [name, before, after] of fixtures) { + let original, updated; + if (name.endsWith(".css")) { + function cssStructure(node) { + if (node.type === "comment") return null; + return [node.type, node.name, node.params, node.selector, node.prop, + node.value, node.important, node.nodes?.map(cssStructure).filter(Boolean)]; + } + original = cssStructure(css.parse(before)); + updated = cssStructure(css.parse(after)); + } else { + function parse(text) { + const source = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true); + if (source.parseDiagnostics.length) throw new Error("invalid fixture"); + return [source.statements.map(structure), + source.libReferenceDirectives.map(reference => reference.fileName)]; + } + original = parse(before); + updated = parse(after); + } + if (JSON.stringify(original) !== JSON.stringify(updated)) throw new Error(name); +} +""" + fixtures = [] + for name, body in ( + ("client.tsx", b'"use client";\nexport const App = () => <p>Hello</p>;\n'), + ("server.ts", b'"use server";\nexport async function action() { return 1; }\n'), + ("refs.ts", b'/// <reference lib="dom" />\nexport {};\n'), + ("authored.d.ts", b'/// <reference lib="dom" />\nexport declare const value: string;\n'), + ("strict.js", b'"use strict";\nfunction value() { return this; }\n'), + ("import.css", b'@import "theme.css";\np { color: red !important; }\n'), + ("charset.css", b'@charset "UTF-8";\n@import "theme.css";\n'), + ): + fixtures.append([name, body.decode(), self.apply(name, body).decode()]) + result = subprocess.run( + ["node", "-e", program, str(ROOT / "cli")], + input=json.dumps(fixtures).encode(), capture_output=True, + ) + if result.returncode == 77: + self.skipTest("Existing TypeScript/PostCSS dependencies are not installed") + self.assertEqual(result.returncode, 0, result.stderr.decode()) + + def test_yaml_parser_equivalence_when_installed(self): + try: + import yaml + except ImportError: + self.skipTest("PyYAML is not installed") + body = b"---\non: [push]\njobs: {}\n---\nvalue: |\n exact string\n" + self.assertEqual( + list(yaml.safe_load_all(body)), + list(yaml.safe_load_all(self.apply("workflow.yml", body))), + ) + + @unittest.skipUnless(shutil.which("make"), "Make not installed") + def test_make_execution_equivalence(self): + body = b"all:\n\t@printf 'unchanged\\n'\n" + path = self.write("Makefile", body) + before = subprocess.check_output(["make", "-s", "-f", str(path)]) + path.write_bytes(self.apply("Makefile", body)) + self.assertEqual(subprocess.check_output(["make", "-s", "-f", str(path)]), before) + + @unittest.skipUnless(shutil.which("bash"), "Bash not installed") + def test_shell_execution_equivalence(self): + body = b"#!/bin/sh\nvalue='literal'\nprintf '%s\\n' \"$value\"\n" + path = self.write("test.sh", body) + before = subprocess.check_output(["bash", str(path)]) + path.write_bytes(self.apply("test.sh", body)) + subprocess.run(["bash", "-n", str(path)], check=True) + self.assertEqual(subprocess.check_output(["bash", str(path)]), before) + + @unittest.skipUnless(shutil.which("helm"), "Helm not installed") + def test_real_helm_render_equivalence_including_trimmed_comments(self): + fixtures = { + "chart/Chart.yaml": b"apiVersion: v2\nname: fixture\nversion: 0.1.0\n", + "chart/values.yaml": b"enabled: true\n", + "chart/templates/_helpers.tpl": b'{{- define "fixture.name" -}}example{{- end -}}\n', + "chart/templates/config.yaml": ( + b'{{- if .Values.enabled -}}\napiVersion: v1\nkind: ConfigMap\n' + b'metadata:\n name: {{ include "fixture.name" . }}\n' + b'data:\n value: unchanged\n{{- end -}}\n' + ), + "chart/templates/NOTES.txt": b' Installed {{ include "fixture.name" . }}.\n', + } + for path, data in fixtures.items(): + self.write(path, data) + command = ["helm", "template", "fixture", str(self.root / "chart"), "--render-subchart-notes"] + before = subprocess.check_output(command) + for path, data in fixtures.items(): + (self.root / path).write_bytes(self.apply(path, data)) + self.assertEqual(subprocess.check_output(command), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/cli/README.md b/cli/README.md index 57c207355..bde2b2ef1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars CLI — `@kars-runtime/cli` The command-line interface for **[kars](https://github.com/Azure/kars)** — a diff --git a/cli/profiles/agt/kars-default.yaml b/cli/profiles/agt/kars-default.yaml index 1a43943d1..ffa3cd27e 100644 --- a/cli/profiles/agt/kars-default.yaml +++ b/cli/profiles/agt/kars-default.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars AGT Governance Policy — agentmesh PolicyEngine format # # Uses agentmesh PolicyEngine action-pattern matching (glob: shell:*, inference:*). diff --git a/cli/profiles/agt/kars-offload.yaml b/cli/profiles/agt/kars-offload.yaml index 917fb166e..eb25cb1d8 100644 --- a/cli/profiles/agt/kars-offload.yaml +++ b/cli/profiles/agt/kars-offload.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars AGT Governance Policy — OFFLOAD profile # # Applied only to cloud-offload sandboxes created via federation from an diff --git a/cli/src/testing/README.md b/cli/src/testing/README.md index 349899935..53dfa427c 100644 --- a/cli/src/testing/README.md +++ b/cli/src/testing/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # In-process fake router (CLI-side) Groundwork for the local dev-loop plan (plan items T1 / T4 / T5). diff --git a/cli/src/testing/scenarios/01-chat-completion-happy-path.yaml b/cli/src/testing/scenarios/01-chat-completion-happy-path.yaml index eeb1ed701..720641b15 100644 --- a/cli/src/testing/scenarios/01-chat-completion-happy-path.yaml +++ b/cli/src/testing/scenarios/01-chat-completion-happy-path.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Scenario 01 — chat-completion happy path # # Fires a canonical Foundry chat completion against the FakeRouter using the diff --git a/cli/src/testing/scenarios/02-content-filter-propagation.yaml b/cli/src/testing/scenarios/02-content-filter-propagation.yaml index dabdbe21e..5c58cf636 100644 --- a/cli/src/testing/scenarios/02-content-filter-propagation.yaml +++ b/cli/src/testing/scenarios/02-content-filter-propagation.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Scenario 02 — content-filter propagation # # The router's job is to pass through Foundry prompt_filter_results so that the diff --git a/cli/src/testing/scenarios/03-rate-limit-passthrough.yaml b/cli/src/testing/scenarios/03-rate-limit-passthrough.yaml index f2e0c41ee..48af8fdff 100644 --- a/cli/src/testing/scenarios/03-rate-limit-passthrough.yaml +++ b/cli/src/testing/scenarios/03-rate-limit-passthrough.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Scenario 03 — rate-limit surface passthrough # # Foundry emits HTTP 429 with a `retry_after_seconds` field when a deployment diff --git a/conformance-runner/Cargo.toml b/conformance-runner/Cargo.toml index 76c2252d6..99ff56280 100644 --- a/conformance-runner/Cargo.toml +++ b/conformance-runner/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-conformance-runner" description = "In-cluster runner that replays signed KarsEval corpora against the live inference router and emits per-case verdicts. Consumed by the KarsEval reconciler (slice 6.3) which launches one runner Pod per scheduled run; the binary is endpoint-agnostic and CR-agnostic — it reads a corpus path + router base URL, writes a JSON report, exits non-zero on judge failures." diff --git a/controller/Cargo.toml b/controller/Cargo.toml index 97f2361e9..4ed4cee5a 100644 --- a/controller/Cargo.toml +++ b/controller/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-controller" description = "Kubernetes operator for kars — manages KarsSandbox CRDs, sandbox lifecycle, policy enforcement, and Azure service connectors" diff --git a/controller/Dockerfile b/controller/Dockerfile index 03fd650d2..4d3403ce7 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Controller — distroless (build-once pattern) # # Built from a pre-compiled binary produced by the `build-rust` CI job diff --git a/controller/Dockerfile.multistage b/controller/Dockerfile.multistage index 8cc2708a8..accdec8d3 100644 --- a/controller/Dockerfile.multistage +++ b/controller/Dockerfile.multistage @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Controller — multi-stage Rust build (Azure Linux) ARG AZURELINUX_BASE=mcr.microsoft.com/azurelinux/base/core:3.0@sha256:35149ae8dd179684f969944f54a337c665a64e702486154eb44253fb39c2505b diff --git a/deny.toml b/deny.toml index e8cf13b9c..0e1359021 100644 --- a/deny.toml +++ b/deny.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # cargo-deny configuration — S17 supply-chain row. # # Enforced by `.github/workflows/ci.yml :: cargo-deny` (required PR row; diff --git a/deploy/agentmesh-agt.yaml b/deploy/agentmesh-agt.yaml index f0ee99aca..01b53a50b 100644 --- a/deploy/agentmesh-agt.yaml +++ b/deploy/agentmesh-agt.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # AGT upstream relay + registry — Phase 4 of the agentmesh provider swap. # # This manifest deploys the upstream Microsoft Agent Governance Toolkit diff --git a/deploy/agentmesh-ingress.yaml b/deploy/agentmesh-ingress.yaml index b38311d68..e13360bcd 100644 --- a/deploy/agentmesh-ingress.yaml +++ b/deploy/agentmesh-ingress.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # AgentMesh Global Ingress — AGIC (Application Gateway Ingress Controller) # # Exposes relay (WebSocket) and registry (HTTP) publicly with: diff --git a/deploy/bicep/main.bicep b/deploy/bicep/main.bicep index 983d7a276..2d176cfbd 100644 --- a/deploy/bicep/main.bicep +++ b/deploy/bicep/main.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars Infrastructure - Main Bicep Template // Deploys: AKS (Azure Linux) + ACR + Key Vault + Azure OpenAI + Monitor diff --git a/deploy/bicep/modules/acr-pull-assignment.bicep b/deploy/bicep/modules/acr-pull-assignment.bicep index 7958600d0..7119727b0 100644 --- a/deploy/bicep/modules/acr-pull-assignment.bicep +++ b/deploy/bicep/modules/acr-pull-assignment.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Reusable, **idempotent** AcrPull role assignment scoped to an ACR. // // `principalId` is a STRING parameter (legal in a roleAssignment `name`, unlike diff --git a/deploy/bicep/modules/acr.bicep b/deploy/bicep/modules/acr.bicep index 6a4bb9445..90b230bb8 100644 --- a/deploy/bicep/modules/acr.bicep +++ b/deploy/bicep/modules/acr.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars - ACR Module @description('ACR name (must be globally unique, alphanumeric)') diff --git a/deploy/bicep/modules/aks.bicep b/deploy/bicep/modules/aks.bicep index 6abe34d1a..43bbb1d75 100644 --- a/deploy/bicep/modules/aks.bicep +++ b/deploy/bicep/modules/aks.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars - AKS Module // Deploys AKS cluster with Azure Linux node pools // Governance: Azure Policy add-on (no Defender for Cloud required) diff --git a/deploy/bicep/modules/keyvault.bicep b/deploy/bicep/modules/keyvault.bicep index 344bb73d0..786847451 100644 --- a/deploy/bicep/modules/keyvault.bicep +++ b/deploy/bicep/modules/keyvault.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars - Key Vault Module @description('Key Vault name') diff --git a/deploy/bicep/modules/monitor.bicep b/deploy/bicep/modules/monitor.bicep index 98c78aaeb..e98543055 100644 --- a/deploy/bicep/modules/monitor.bicep +++ b/deploy/bicep/modules/monitor.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars - Azure Monitor Module @description('Resource name prefix') diff --git a/deploy/bicep/modules/openai.bicep b/deploy/bicep/modules/openai.bicep index c9b10ab33..806983b40 100644 --- a/deploy/bicep/modules/openai.bicep +++ b/deploy/bicep/modules/openai.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // kars - Azure OpenAI Module @description('Azure OpenAI account name') diff --git a/deploy/bicep/modules/sandbox-rbac.bicep b/deploy/bicep/modules/sandbox-rbac.bicep index 0df621122..6da4a1461 100644 --- a/deploy/bicep/modules/sandbox-rbac.bicep +++ b/deploy/bicep/modules/sandbox-rbac.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Sandbox + kubelet RBAC for the AKS cluster, with **idempotent** role- // assignment names. // diff --git a/deploy/bicep/standalone/controller-acrpull.bicep b/deploy/bicep/standalone/controller-acrpull.bicep index 3176583ae..104689ff2 100644 --- a/deploy/bicep/standalone/controller-acrpull.bicep +++ b/deploy/bicep/standalone/controller-acrpull.bicep @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + // Standalone Bicep that grants the controller workload identity // AcrPull on a specific ACR. // diff --git a/deploy/helm/kars/Chart.yaml b/deploy/helm/kars/Chart.yaml index 3746033fd..1d026e278 100644 --- a/deploy/helm/kars/Chart.yaml +++ b/deploy/helm/kars/Chart.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: v2 name: kars description: kars - Enterprise-grade OpenClaw sandbox orchestrator for AKS diff --git a/deploy/helm/kars/README.md b/deploy/helm/kars/README.md index 729e438ba..0849a3673 100644 --- a/deploy/helm/kars/README.md +++ b/deploy/helm/kars/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Helm chart This chart installs the Kars CRDs, controller, RBAC, admission controls, diff --git a/deploy/helm/kars/templates/_credential-grants.tpl b/deploy/helm/kars/templates/_credential-grants.tpl index c4669ab17..0747cb8a9 100644 --- a/deploy/helm/kars/templates/_credential-grants.tpl +++ b/deploy/helm/kars/templates/_credential-grants.tpl @@ -1,4 +1,5 @@ -{{- define "kars.credentialIdentitySchema" -}} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- define "kars.credentialIdentitySchema" -}} type: object required: [name, uid] properties: diff --git a/deploy/helm/kars/templates/a2a-gateway-deployment.yaml b/deploy/helm/kars/templates/a2a-gateway-deployment.yaml index b825059ac..a18e6cd2b 100644 --- a/deploy/helm/kars/templates/a2a-gateway-deployment.yaml +++ b/deploy/helm/kars/templates/a2a-gateway-deployment.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 2 S3.5 — public-ingress A2A gateway (ADR-0001 #4). Rendered only when `.Values.a2aGateway.enabled` is true. The diff --git a/deploy/helm/kars/templates/admission-content-safety-floor.yaml b/deploy/helm/kars/templates/admission-content-safety-floor.yaml index fa458a223..90e5e46c5 100644 --- a/deploy/helm/kars/templates/admission-content-safety-floor.yaml +++ b/deploy/helm/kars/templates/admission-content-safety-floor.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 2 / S7.F (implementation-plan.md §10.4 #4 — VAP/MAP expansion beyond the Phase 1 core set). diff --git a/deploy/helm/kars/templates/admission-dev-only-label-immutable.yaml b/deploy/helm/kars/templates/admission-dev-only-label-immutable.yaml index f79e2612a..cd2169618 100644 --- a/deploy/helm/kars/templates/admission-dev-only-label-immutable.yaml +++ b/deploy/helm/kars/templates/admission-dev-only-label-immutable.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 1 deliverable (implementation-plan.md §7 item 13 / "core VAP set"). ValidatingAdmissionPolicy that prevents removal of the diff --git a/deploy/helm/kars/templates/admission-envelope-write-lock.yaml b/deploy/helm/kars/templates/admission-envelope-write-lock.yaml index 86f76b562..53b712c8b 100644 --- a/deploy/helm/kars/templates/admission-envelope-write-lock.yaml +++ b/deploy/helm/kars/templates/admission-envelope-write-lock.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Envelope-write lockdown (design note arch-D / §7). ValidatingAdmissionPolicy that makes a KarsTask / KarsTeam's *governance* diff --git a/deploy/helm/kars/templates/admission-no-public-router-exposure.yaml b/deploy/helm/kars/templates/admission-no-public-router-exposure.yaml index f84ca2ae9..d451da9dd 100644 --- a/deploy/helm/kars/templates/admission-no-public-router-exposure.yaml +++ b/deploy/helm/kars/templates/admission-no-public-router-exposure.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 1 deliverable (implementation-plan.md §7 / ADR-0001 D2 + D3). ValidatingAdmissionPolicy that enforces the "router is never publicly diff --git a/deploy/helm/kars/templates/admission-null-provider.yaml b/deploy/helm/kars/templates/admission-null-provider.yaml index 74983dd1e..ef8534a52 100644 --- a/deploy/helm/kars/templates/admission-null-provider.yaml +++ b/deploy/helm/kars/templates/admission-null-provider.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 0 deliverable (implementation-plan.md §6 item 6 + §0.2 #9). ValidatingAdmissionPolicy that rejects any KarsSandbox / McpServer / diff --git a/deploy/helm/kars/templates/admission-pod-exec-ban.yaml b/deploy/helm/kars/templates/admission-pod-exec-ban.yaml index 06be4f7a1..322f4b21a 100644 --- a/deploy/helm/kars/templates/admission-pod-exec-ban.yaml +++ b/deploy/helm/kars/templates/admission-pod-exec-ban.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 1 deliverable (implementation-plan.md §7 item 13 / "core VAP set"). ValidatingAdmissionPolicy that rejects kubectl exec / attach into diff --git a/deploy/helm/kars/templates/admission-sandbox-posture-lock.yaml b/deploy/helm/kars/templates/admission-sandbox-posture-lock.yaml index b03d20073..75c17fddd 100644 --- a/deploy/helm/kars/templates/admission-sandbox-posture-lock.yaml +++ b/deploy/helm/kars/templates/admission-sandbox-posture-lock.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 1 deliverable (implementation-plan.md §7 item 13 / "core VAP set"). ValidatingAdmissionPolicy that blocks posture *downgrades* on pods diff --git a/deploy/helm/kars/templates/admission-seccomp-auto-stamp.yaml b/deploy/helm/kars/templates/admission-seccomp-auto-stamp.yaml index a99d22f55..f45139e3b 100644 --- a/deploy/helm/kars/templates/admission-seccomp-auto-stamp.yaml +++ b/deploy/helm/kars/templates/admission-seccomp-auto-stamp.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 1 deliverable (implementation-plan.md §7 item 13 / "core MAP set"). MutatingAdmissionPolicy that auto-stamps the kars-strict seccomp diff --git a/deploy/helm/kars/templates/admission-task-namespace-floor.yaml b/deploy/helm/kars/templates/admission-task-namespace-floor.yaml index dbdb0b29e..c49f177a8 100644 --- a/deploy/helm/kars/templates/admission-task-namespace-floor.yaml +++ b/deploy/helm/kars/templates/admission-task-namespace-floor.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* kars Bridge completeness floor (design note §24b, roadmap item 4). ValidatingAdmissionPolicy that enforces the *create-time* completeness diff --git a/deploy/helm/kars/templates/agentmesh.yaml b/deploy/helm/kars/templates/agentmesh.yaml index ef6b2e19f..192119dc1 100644 --- a/deploy/helm/kars/templates/agentmesh.yaml +++ b/deploy/helm/kars/templates/agentmesh.yaml @@ -1,4 +1,5 @@ -{{- $mesh := .Values.agentMesh | default dict }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $mesh := .Values.agentMesh | default dict }} {{- if $mesh.enabled }} {{- $namespace := $mesh.namespace | default "agentmesh" }} {{- if ne $namespace "agentmesh" }} diff --git a/deploy/helm/kars/templates/auth-sidecar-deployment.yaml b/deploy/helm/kars/templates/auth-sidecar-deployment.yaml index 37d08ef18..9c1fe3d45 100644 --- a/deploy/helm/kars/templates/auth-sidecar-deployment.yaml +++ b/deploy/helm/kars/templates/auth-sidecar-deployment.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Shared Microsoft Entra SDK auth-sidecar Deployment. Runs ONCE per cluster (2 replicas for HA). All sandbox inference- diff --git a/deploy/helm/kars/templates/auth-sidecar-networkpolicy.yaml b/deploy/helm/kars/templates/auth-sidecar-networkpolicy.yaml index 407b90c8f..1406e0884 100644 --- a/deploy/helm/kars/templates/auth-sidecar-networkpolicy.yaml +++ b/deploy/helm/kars/templates/auth-sidecar-networkpolicy.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* NetworkPolicy gating ingress to the shared auth-sidecar. Trust-boundary control: **only pods in sandbox namespaces, labeled diff --git a/deploy/helm/kars/templates/auth-sidecar-service.yaml b/deploy/helm/kars/templates/auth-sidecar-service.yaml index 4eebe4337..8c61b2fa8 100644 --- a/deploy/helm/kars/templates/auth-sidecar-service.yaml +++ b/deploy/helm/kars/templates/auth-sidecar-service.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* ClusterIP Service exposing the shared auth-sidecar. Stable DNS: `entra-auth-sidecar.<ns>.svc:5000`. Sandbox inference- diff --git a/deploy/helm/kars/templates/auth-sidecar-serviceaccount.yaml b/deploy/helm/kars/templates/auth-sidecar-serviceaccount.yaml index e6d3b5201..82d316a1f 100644 --- a/deploy/helm/kars/templates/auth-sidecar-serviceaccount.yaml +++ b/deploy/helm/kars/templates/auth-sidecar-serviceaccount.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* ServiceAccount for the shared Microsoft Entra SDK auth-sidecar. The sidecar runs ONCE per cluster (Helm-managed Deployment, 2 replicas diff --git a/deploy/helm/kars/templates/cilium-a2a-gateway-to-router.yaml b/deploy/helm/kars/templates/cilium-a2a-gateway-to-router.yaml index 6a8aa6028..7d8881d1a 100644 --- a/deploy/helm/kars/templates/cilium-a2a-gateway-to-router.yaml +++ b/deploy/helm/kars/templates/cilium-a2a-gateway-to-router.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Phase 1 deliverable (ADR-0001 D3 — Cilium L7 defense in depth). CiliumClusterwideNetworkPolicy that pins the inbound path for the diff --git a/deploy/helm/kars/templates/controller-deployment.yaml b/deploy/helm/kars/templates/controller-deployment.yaml index e7caf2a85..07e675db7 100644 --- a/deploy/helm/kars/templates/controller-deployment.yaml +++ b/deploy/helm/kars/templates/controller-deployment.yaml @@ -1,4 +1,5 @@ -{{- $localInference := .Values.localInference | default dict }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $localInference := .Values.localInference | default dict }} {{- $privacyRpc := .Values.observationPrivacyRpc | default dict }} apiVersion: apps/v1 kind: Deployment diff --git a/deploy/helm/kars/templates/crd-a2aagent.yaml b/deploy/helm/kars/templates/crd-a2aagent.yaml index 621448de8..c9a94b516 100644 --- a/deploy/helm/kars/templates/crd-a2aagent.yaml +++ b/deploy/helm/kars/templates/crd-a2aagent.yaml @@ -1,4 +1,5 @@ -# kars A2AAgent CRD +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars A2AAgent CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/a2a_agent.rs` plus the CEL rules in diff --git a/deploy/helm/kars/templates/crd-egressapproval.yaml b/deploy/helm/kars/templates/crd-egressapproval.yaml index 17b4a568a..db12b644b 100644 --- a/deploy/helm/kars/templates/crd-egressapproval.yaml +++ b/deploy/helm/kars/templates/crd-egressapproval.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-inferencepolicy.yaml b/deploy/helm/kars/templates/crd-inferencepolicy.yaml index e4df5cd89..659c4233a 100644 --- a/deploy/helm/kars/templates/crd-inferencepolicy.yaml +++ b/deploy/helm/kars/templates/crd-inferencepolicy.yaml @@ -1,4 +1,5 @@ -# kars InferencePolicy CRD +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars InferencePolicy CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/inference_policy.rs` plus the CEL rules in diff --git a/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml index 9cbc3ba67..134844f49 100644 --- a/deploy/helm/kars/templates/crd-karsapproval.yaml +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsauthconfig.yaml b/deploy/helm/kars/templates/crd-karsauthconfig.yaml index fbb924e9c..71aea2018 100644 --- a/deploy/helm/kars/templates/crd-karsauthconfig.yaml +++ b/deploy/helm/kars/templates/crd-karsauthconfig.yaml @@ -1,4 +1,5 @@ -# kars KarsAuthConfig CRD +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars KarsAuthConfig CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/auth_config.rs`. diff --git a/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml b/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml index 7d3655307..02287db10 100644 --- a/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml +++ b/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml @@ -1,4 +1,5 @@ -# Copyright (c) Microsoft Corporation. +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition diff --git a/deploy/helm/kars/templates/crd-karscredentialgrant.yaml b/deploy/helm/kars/templates/crd-karscredentialgrant.yaml index a2eab284c..1f743fec0 100644 --- a/deploy/helm/kars/templates/crd-karscredentialgrant.yaml +++ b/deploy/helm/kars/templates/crd-karscredentialgrant.yaml @@ -1,4 +1,5 @@ -apiVersion: apiextensions.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: karscredentialgrants.kars.azure.com diff --git a/deploy/helm/kars/templates/crd-karseval.yaml b/deploy/helm/kars/templates/crd-karseval.yaml index 5ced904fb..0a2421f2d 100644 --- a/deploy/helm/kars/templates/crd-karseval.yaml +++ b/deploy/helm/kars/templates/crd-karseval.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsmemory.yaml b/deploy/helm/kars/templates/crd-karsmemory.yaml index b03ae8bf9..64244551d 100644 --- a/deploy/helm/kars/templates/crd-karsmemory.yaml +++ b/deploy/helm/kars/templates/crd-karsmemory.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsprofile.yaml b/deploy/helm/kars/templates/crd-karsprofile.yaml index d74264257..c6889236b 100644 --- a/deploy/helm/kars/templates/crd-karsprofile.yaml +++ b/deploy/helm/kars/templates/crd-karsprofile.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml index 97be21a94..87e7af600 100644 --- a/deploy/helm/kars/templates/crd-karsreceipt.yaml +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsskill.yaml b/deploy/helm/kars/templates/crd-karsskill.yaml index b0125117c..b4ffafcb9 100644 --- a/deploy/helm/kars/templates/crd-karsskill.yaml +++ b/deploy/helm/kars/templates/crd-karsskill.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karssreaction.yaml b/deploy/helm/kars/templates/crd-karssreaction.yaml index 77dcbbf6d..65aa69057 100644 --- a/deploy/helm/kars/templates/crd-karssreaction.yaml +++ b/deploy/helm/kars/templates/crd-karssreaction.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karssreregistration.yaml b/deploy/helm/kars/templates/crd-karssreregistration.yaml index 1f4b1add8..3b8bf90a2 100644 --- a/deploy/helm/kars/templates/crd-karssreregistration.yaml +++ b/deploy/helm/kars/templates/crd-karssreregistration.yaml @@ -1,4 +1,5 @@ -apiVersion: apiextensions.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: karssreregistrations.kars.azure.com diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index f6c270ba4..6b0ec1f50 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsteam.yaml b/deploy/helm/kars/templates/crd-karsteam.yaml index a8d4ebe7a..a8155e8c2 100644 --- a/deploy/helm/kars/templates/crd-karsteam.yaml +++ b/deploy/helm/kars/templates/crd-karsteam.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-mcpserver.yaml b/deploy/helm/kars/templates/crd-mcpserver.yaml index a76eb16eb..a7d1a7021 100644 --- a/deploy/helm/kars/templates/crd-mcpserver.yaml +++ b/deploy/helm/kars/templates/crd-mcpserver.yaml @@ -1,4 +1,5 @@ -# kars McpServer CRD +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars McpServer CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/mcp_server.rs` plus the CEL rules in diff --git a/deploy/helm/kars/templates/crd-toolpolicy.yaml b/deploy/helm/kars/templates/crd-toolpolicy.yaml index 989651aa3..a8dc8acd7 100644 --- a/deploy/helm/kars/templates/crd-toolpolicy.yaml +++ b/deploy/helm/kars/templates/crd-toolpolicy.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-trustgraph.yaml b/deploy/helm/kars/templates/crd-trustgraph.yaml index 8011d03f5..90a4c4c17 100644 --- a/deploy/helm/kars/templates/crd-trustgraph.yaml +++ b/deploy/helm/kars/templates/crd-trustgraph.yaml @@ -1,4 +1,5 @@ -# kars TrustGraph CRD (Phase F1). +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars TrustGraph CRD (Phase F1). # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/trust_graph.rs` plus the CEL rules in diff --git a/deploy/helm/kars/templates/crd.yaml b/deploy/helm/kars/templates/crd.yaml index 9f1c4b044..0780b78b8 100644 --- a/deploy/helm/kars/templates/crd.yaml +++ b/deploy/helm/kars/templates/crd.yaml @@ -1,4 +1,5 @@ -# kars KarsSandbox CRD +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# kars KarsSandbox CRD # This CRD defines the custom resource for managing OpenClaw sandboxes apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition diff --git a/deploy/helm/kars/templates/credential-grant-admission.yaml b/deploy/helm/kars/templates/credential-grant-admission.yaml index 5d609d2b7..828de979f 100644 --- a/deploy/helm/kars/templates/credential-grant-admission.yaml +++ b/deploy/helm/kars/templates/credential-grant-admission.yaml @@ -1,4 +1,5 @@ -apiVersion: admissionregistration.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-credential-grant-authority diff --git a/deploy/helm/kars/templates/credential-grant-rbac.yaml b/deploy/helm/kars/templates/credential-grant-rbac.yaml index 5e971e6ae..fead1a169 100644 --- a/deploy/helm/kars/templates/credential-grant-rbac.yaml +++ b/deploy/helm/kars/templates/credential-grant-rbac.yaml @@ -1,4 +1,5 @@ -# Unbound: an operator explicitly delegates workspace credential administration. +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# Unbound: an operator explicitly delegates workspace credential administration. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/deploy/helm/kars/templates/credential-namespace-admission.yaml b/deploy/helm/kars/templates/credential-namespace-admission.yaml index efbd57dd9..29a1c696c 100644 --- a/deploy/helm/kars/templates/credential-namespace-admission.yaml +++ b/deploy/helm/kars/templates/credential-namespace-admission.yaml @@ -1,4 +1,5 @@ -apiVersion: admissionregistration.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-credential-namespace-boundary diff --git a/deploy/helm/kars/templates/credential-reader-admission.yaml b/deploy/helm/kars/templates/credential-reader-admission.yaml index 79cdc184d..78ae70712 100644 --- a/deploy/helm/kars/templates/credential-reader-admission.yaml +++ b/deploy/helm/kars/templates/credential-reader-admission.yaml @@ -1,4 +1,5 @@ -# These guards apply only to identities enrolled by the controller. DELETE +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# These guards apply only to identities enrolled by the controller. DELETE # remains allowed: the core revokes owned read Roles, proves their absence, then # removes the guard. Namespace /finalize cannot bypass a pending name hold. apiVersion: admissionregistration.k8s.io/v1 diff --git a/deploy/helm/kars/templates/credential-rebind-admission.yaml b/deploy/helm/kars/templates/credential-rebind-admission.yaml index 9dcccaa3b..404982359 100644 --- a/deploy/helm/kars/templates/credential-rebind-admission.yaml +++ b/deploy/helm/kars/templates/credential-rebind-admission.yaml @@ -1,4 +1,5 @@ -apiVersion: admissionregistration.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-credential-rebind-authority diff --git a/deploy/helm/kars/templates/credential-store-admission.yaml b/deploy/helm/kars/templates/credential-store-admission.yaml index 7aebe7380..f57bd1db9 100644 --- a/deploy/helm/kars/templates/credential-store-admission.yaml +++ b/deploy/helm/kars/templates/credential-store-admission.yaml @@ -1,4 +1,5 @@ -# Protect enrolled operator stores even from an accidental write by another +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# Protect enrolled operator stores even from an accidental write by another # controller. An empty integration store cannot turn into a privileged key store. apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy diff --git a/deploy/helm/kars/templates/inference-budget-admission.yaml b/deploy/helm/kars/templates/inference-budget-admission.yaml index 0c7a6146b..7e4bef33f 100644 --- a/deploy/helm/kars/templates/inference-budget-admission.yaml +++ b/deploy/helm/kars/templates/inference-budget-admission.yaml @@ -1,4 +1,5 @@ -{{- $budget := .Values.inferenceBudget | default dict -}} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $budget := .Values.inferenceBudget | default dict -}} {{- if ($budget.enabled | default false) -}} {{- $bundle := (.Files.Get "files/inference-budget-admission.json" | replace "__ACCOUNTING_NAMESPACE__" .Release.Namespace | fromJson) -}} {{- range $policy := $bundle.items }} diff --git a/deploy/helm/kars/templates/inference-budget.yaml b/deploy/helm/kars/templates/inference-budget.yaml index 20f97f9ef..223d54076 100644 --- a/deploy/helm/kars/templates/inference-budget.yaml +++ b/deploy/helm/kars/templates/inference-budget.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* Governed inference only. No prices, model limits, free providers, or TLS identities are guessed. Old/reused values without this block render no broker. */ -}} diff --git a/deploy/helm/kars/templates/inspektor-gadget.yaml b/deploy/helm/kars/templates/inspektor-gadget.yaml index f64e56455..6675b4a29 100644 --- a/deploy/helm/kars/templates/inspektor-gadget.yaml +++ b/deploy/helm/kars/templates/inspektor-gadget.yaml @@ -1,4 +1,5 @@ -{{- if .Values.monitoring.inspektorGadget.enabled }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.monitoring.inspektorGadget.enabled }} # Inspektor Gadget is deployed via 'kubectl gadget deploy' (official method). # This is a placeholder — the actual DaemonSet is managed by the kubectl-gadget plugin. # diff --git a/deploy/helm/kars/templates/namespace.yaml b/deploy/helm/kars/templates/namespace.yaml index b94692fda..86bb9178b 100644 --- a/deploy/helm/kars/templates/namespace.yaml +++ b/deploy/helm/kars/templates/namespace.yaml @@ -1,4 +1,5 @@ -apiVersion: v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: v1 kind: Namespace metadata: name: kars-system diff --git a/deploy/helm/kars/templates/observation-privacy.yaml b/deploy/helm/kars/templates/observation-privacy.yaml index 3749672d3..54b2b85aa 100644 --- a/deploy/helm/kars/templates/observation-privacy.yaml +++ b/deploy/helm/kars/templates/observation-privacy.yaml @@ -1,4 +1,5 @@ -{{- $rpc := .Values.observationPrivacyRpc | default dict }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $rpc := .Values.observationPrivacyRpc | default dict }} {{- if ($rpc.enabled | default false) }} apiVersion: v1 kind: Service diff --git a/deploy/helm/kars/templates/operator-default-deny-networkpolicy.yaml b/deploy/helm/kars/templates/operator-default-deny-networkpolicy.yaml index 4480574fc..35dc4e905 100644 --- a/deploy/helm/kars/templates/operator-default-deny-networkpolicy.yaml +++ b/deploy/helm/kars/templates/operator-default-deny-networkpolicy.yaml @@ -1,4 +1,5 @@ -# Default-deny NetworkPolicy for the operator namespace. +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}# Default-deny NetworkPolicy for the operator namespace. # # Pinned by the CNCF K8s AI conformance suite (criterion C8). The # kars controller reaches the K8s API server via the diff --git a/deploy/helm/kars/templates/private-consumption.yaml b/deploy/helm/kars/templates/private-consumption.yaml index f948bb0d4..dea53dc64 100644 --- a/deploy/helm/kars/templates/private-consumption.yaml +++ b/deploy/helm/kars/templates/private-consumption.yaml @@ -1,4 +1,5 @@ -{{- $bundle := .Files.Get "files/private-consumption.json" | fromJson }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- $bundle := .Files.Get "files/private-consumption.json" | fromJson }} {{- range $bundle.objects }} --- {{ toYaml . }} diff --git a/deploy/helm/kars/templates/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index 7c31e1385..6fc926d79 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -1,4 +1,5 @@ ---- +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}--- # Controller ServiceAccount apiVersion: v1 kind: ServiceAccount diff --git a/deploy/helm/kars/templates/seccomp-installer.yaml b/deploy/helm/kars/templates/seccomp-installer.yaml index d9d0bc239..6b803171d 100644 --- a/deploy/helm/kars/templates/seccomp-installer.yaml +++ b/deploy/helm/kars/templates/seccomp-installer.yaml @@ -1,4 +1,5 @@ -{{- if .Values.sandbox.seccompProfile }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if .Values.sandbox.seccompProfile }} # Seccomp Profile Installer DaemonSet # Deploys the kars-strict seccomp profile to every node so sandbox pods # can reference it as Localhost type under the restricted PodSecurity standard. diff --git a/deploy/helm/kars/templates/signer-policy-configmap.yaml b/deploy/helm/kars/templates/signer-policy-configmap.yaml index 16be56f41..6431e3cc2 100644 --- a/deploy/helm/kars/templates/signer-policy-configmap.yaml +++ b/deploy/helm/kars/templates/signer-policy-configmap.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* S12.d — SignerPolicy ConfigMap. Cluster-scoped trust roots for cosign-signed egress allowlist artifacts diff --git a/deploy/helm/kars/templates/sre-authority-admission.yaml b/deploy/helm/kars/templates/sre-authority-admission.yaml index 878fc7f1d..b4b2b81f3 100644 --- a/deploy/helm/kars/templates/sre-authority-admission.yaml +++ b/deploy/helm/kars/templates/sre-authority-admission.yaml @@ -1,4 +1,5 @@ -apiVersion: admissionregistration.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-sre-source-authority diff --git a/deploy/helm/kars/templates/sre-authority-consumers.yaml b/deploy/helm/kars/templates/sre-authority-consumers.yaml index 583f8168b..fb3914c3f 100644 --- a/deploy/helm/kars/templates/sre-authority-consumers.yaml +++ b/deploy/helm/kars/templates/sre-authority-consumers.yaml @@ -1,4 +1,5 @@ -apiVersion: admissionregistration.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-sre-consumer-authority diff --git a/deploy/helm/kars/templates/sre-authority-rbac.yaml b/deploy/helm/kars/templates/sre-authority-rbac.yaml index c3580fb6d..5efa344ff 100644 --- a/deploy/helm/kars/templates/sre-authority-rbac.yaml +++ b/deploy/helm/kars/templates/sre-authority-rbac.yaml @@ -1,4 +1,5 @@ -apiVersion: rbac.authorization.k8s.io/v1 +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: kars-sre-registrar diff --git a/deploy/helm/kars/templates/sre.yaml b/deploy/helm/kars/templates/sre.yaml index 5fef64b6d..43718f01d 100644 --- a/deploy/helm/kars/templates/sre.yaml +++ b/deploy/helm/kars/templates/sre.yaml @@ -1,4 +1,5 @@ -{{- /* +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- /* kars-sre — the built-in SRE agent (Slice 1 MVP). Gated on `.Values.sre.enabled` (default: false). `kars sre install` creates diff --git a/deploy/helm/kars/templates/toolpolicy-default.yaml b/deploy/helm/kars/templates/toolpolicy-default.yaml index af706e4cb..82bab9c1e 100644 --- a/deploy/helm/kars/templates/toolpolicy-default.yaml +++ b/deploy/helm/kars/templates/toolpolicy-default.yaml @@ -1,4 +1,5 @@ -{{- if (.Values.governance | default dict).enabled | default true }} +{{/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */}}{{- if (.Values.governance | default dict).enabled | default true }} # kars-default ToolPolicy — the system-default AGT profile that the # controller falls back to when a KarsSandbox sets # `spec.governance.enabled=true` (the new default) and omits diff --git a/deploy/helm/kars/values-existing-aks.yaml b/deploy/helm/kars/values-existing-aks.yaml index 1bf14ca92..4ddf66401 100644 --- a/deploy/helm/kars/values-existing-aks.yaml +++ b/deploy/helm/kars/values-existing-aks.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Existing AKS installation template. # # Copy this file, replace every REPLACE_ME value, and install with: diff --git a/deploy/helm/kars/values-generic.yaml b/deploy/helm/kars/values-generic.yaml index 5dc42b401..f1137a54a 100644 --- a/deploy/helm/kars/values-generic.yaml +++ b/deploy/helm/kars/values-generic.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Generic Kubernetes overlay for an existing non-AKS cluster. # # Azure/AKS defaults remain authoritative in values.yaml. This opt-in overlay diff --git a/deploy/helm/kars/values-local-dev.yaml b/deploy/helm/kars/values-local-dev.yaml index 34fb63597..242b2d51a 100644 --- a/deploy/helm/kars/values-local-dev.yaml +++ b/deploy/helm/kars/values-local-dev.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Local development overlay for kind-based dev mode. # # Usage (the CLI does this for you — see cli/src/commands/dev/local-k8s.ts): diff --git a/deploy/helm/kars/values.yaml b/deploy/helm/kars/values.yaml index ba6635ac2..fe6015edb 100644 --- a/deploy/helm/kars/values.yaml +++ b/deploy/helm/kars/values.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Helm Chart Values # NOTE: For production, replace "latest" tags with specific image digests # (e.g., sha256:abc123...) and set pullPolicy to IfNotPresent. diff --git a/deploy/monitoring/agentmesh-json-exporter.yaml b/deploy/monitoring/agentmesh-json-exporter.yaml index 058fa0533..877afee5c 100644 --- a/deploy/monitoring/agentmesh-json-exporter.yaml +++ b/deploy/monitoring/agentmesh-json-exporter.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: v1 kind: ConfigMap metadata: diff --git a/deploy/monitoring/dashboards.md b/deploy/monitoring/dashboards.md index db54c7755..c81da03c4 100644 --- a/deploy/monitoring/dashboards.md +++ b/deploy/monitoring/dashboards.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Azure Monitor Dashboards ## Token Usage per Sandbox (KQL) diff --git a/deploy/monitoring/grafana-dashboard-configmap.yaml b/deploy/monitoring/grafana-dashboard-configmap.yaml index 4ff838227..74c75d5f0 100644 --- a/deploy/monitoring/grafana-dashboard-configmap.yaml +++ b/deploy/monitoring/grafana-dashboard-configmap.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Auto-generated from grafana-dashboard-kars-*.json — do not edit by hand. # Regenerate via: python3 scripts/regen-grafana-configmap.py (or this inline snippet). # The grafana_dashboard=1 label triggers the kps-grafana sidecar diff --git a/deploy/monitoring/podmonitor-sandbox-router.yaml b/deploy/monitoring/podmonitor-sandbox-router.yaml index 488224836..fef1bd1cf 100644 --- a/deploy/monitoring/podmonitor-sandbox-router.yaml +++ b/deploy/monitoring/podmonitor-sandbox-router.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: diff --git a/deploy/security/notation-ratify.md b/deploy/security/notation-ratify.md index 7a4a70810..675fb9ca4 100644 --- a/deploy/security/notation-ratify.md +++ b/deploy/security/notation-ratify.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Image Supply Chain Security — Notation + Ratify ## Overview diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0a6d4425a..7e7695cee 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # ───────────────────────────────────────────────────────────────────────────── # docker-compose.dev.yml — local dev stack for inner-loop testing (plan T4) # diff --git a/docs/README.md b/docs/README.md index a2be5f997..9afdbeecc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + <div align="center"> <img src="assets/logo.png" alt="kars logo" width="128" /> diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 278f66605..7956597a3 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Summary [Introduction](README.md) diff --git a/docs/adr/0001-a2a-ingress-front-edge.md b/docs/adr/0001-a2a-ingress-front-edge.md index 8707c071a..28382a894 100644 --- a/docs/adr/0001-a2a-ingress-front-edge.md +++ b/docs/adr/0001-a2a-ingress-front-edge.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # ADR 0001: A2A 1.0 ingress — single gateway, router never publicly exposed **Status:** Accepted diff --git a/docs/adr/0002-inference-endpoint-sourcing.md b/docs/adr/0002-inference-endpoint-sourcing.md index db54aeb22..5ef323f84 100644 --- a/docs/adr/0002-inference-endpoint-sourcing.md +++ b/docs/adr/0002-inference-endpoint-sourcing.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # ADR 0002: Inference endpoint sourcing — cluster-wide via env vars; no per-sandbox CR override **Status:** Accepted diff --git a/docs/adr/README.md b/docs/adr/README.md index f7f54504d..a0a3a7262 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # ADR Index Architecture Decision Records for kars. Each ADR is immutable diff --git a/docs/agent-identity.md b/docs/agent-identity.md index 7429a5ca9..6506d1687 100644 --- a/docs/agent-identity.md +++ b/docs/agent-identity.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Per-sandbox identity (Entra Agent ID) Every kars sandbox runs under its own **Microsoft Entra Agent ID**. diff --git a/docs/api/conditions.md b/docs/api/conditions.md index c90cebf4b..c481845bc 100644 --- a/docs/api/conditions.md +++ b/docs/api/conditions.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Conditions Taxonomy — kars CRDs Every kars CRD exposes a `status.conditions[]` array following the diff --git a/docs/api/crd-reference.md b/docs/api/crd-reference.md index cd662152e..0c3d26b7a 100644 --- a/docs/api/crd-reference.md +++ b/docs/api/crd-reference.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # CRD reference kars exposes its API through **fifteen** CustomResourceDefinitions in the `kars.azure.com` group, all at version `v1alpha1`. **Thirteen are workload CRDs** you author per agent, task or policy (or, for `KarsSREAction`, that the SRE operator proposes on your behalf) — catalogued in [At a glance](#at-a-glance) below. **Two are infrastructure CRDs** you do not hand-write: [`KarsAuthConfig`](#karsauthconfig--cluster-trust-anchor) (a cluster-scoped singleton created by `kars mesh setup-trust`) and [`KarsPairing`](#infrastructure-crds) (a controller-internal binding record). This page is the canonical schema reference. For the prose explanation of how these fit together, see **[Architecture — CRDs as the API](../architecture.md#crds-as-the-api)**. diff --git a/docs/api/karseval.md b/docs/api/karseval.md index 71a4f9c08..c2dfc0395 100644 --- a/docs/api/karseval.md +++ b/docs/api/karseval.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # `KarsEval` — Policy Conformance Runner `KarsEval` is the **operator-facing surface** for replaying a signed diff --git a/docs/api/lifecycle.md b/docs/api/lifecycle.md index 8c4bfb148..45a99e5f1 100644 --- a/docs/api/lifecycle.md +++ b/docs/api/lifecycle.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Lifecycle — what happens when you apply a CRD This page is the end-to-end story for every kars CRD: which CLI command writes it, what the controller does when it lands, what cluster artifacts get produced, and which component consumes those artifacts at runtime. diff --git a/docs/api/policy-canonical-format.md b/docs/api/policy-canonical-format.md index e3c6e423f..04986481d 100644 --- a/docs/api/policy-canonical-format.md +++ b/docs/api/policy-canonical-format.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Policy canonical format — per-kind byte rules > Byte-exact canonicalization rules for kars signed Policy artifacts. diff --git a/docs/architecture-diagrams.md b/docs/architecture-diagrams.md index 56f134e61..d147392dd 100644 --- a/docs/architecture-diagrams.md +++ b/docs/architecture-diagrams.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Architecture diagrams Every diagram on this page is rendered from Mermaid in the source markdown. The rendered site (mdBook) shows them as SVG; on GitHub they render natively. If you are reading the source, paste any code block into [mermaid.live](https://mermaid.live) for a rendered preview. diff --git a/docs/architecture.md b/docs/architecture.md index 4bd2cb3fa..80d7b923f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Architecture This document explains *what kars is made of* and *why each part exists*. For diagrams, see **[Architecture diagrams](architecture-diagrams.md)**. For a faster on-ramp, see **[Getting started](getting-started.md)**. diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index ce07ff071..475180c63 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # A2A public-ingress gateway > **Status — partial (library-complete, edge-wiring in progress).** The inbound diff --git a/docs/architecture/agt-boundary.md b/docs/architecture/agt-boundary.md index 694f4850d..aa48514c2 100644 --- a/docs/architecture/agt-boundary.md +++ b/docs/architecture/agt-boundary.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # AGT Boundary — what kars consumes vs. what kars builds > Defines the operational seam between [Microsoft AGT](https://github.com/microsoft/agent-governance-toolkit) and kars: what kars imports, what it builds in-tree, and the four provider contracts that keep them aligned. diff --git a/docs/architecture/entra-agent-id/01-runtime-token-flow.md b/docs/architecture/entra-agent-id/01-runtime-token-flow.md index 3d2c52c53..a57b4d769 100644 --- a/docs/architecture/entra-agent-id/01-runtime-token-flow.md +++ b/docs/architecture/entra-agent-id/01-runtime-token-flow.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Entra Agent ID — Runtime Token Flow This document captures the architecture that was validated end-to-end on diff --git a/docs/architecture/entra-agent-id/05-security-alignment.md b/docs/architecture/entra-agent-id/05-security-alignment.md index d93286e55..3c84ed2a7 100644 --- a/docs/architecture/entra-agent-id/05-security-alignment.md +++ b/docs/architecture/entra-agent-id/05-security-alignment.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Conditional Access + custom security attributes (Phase 5) > **Audience**: operators rolling out kars in tenants where Entra diff --git a/docs/architecture/entra-agent-id/06-mesh-trust-design.md b/docs/architecture/entra-agent-id/06-mesh-trust-design.md index 310a75fe9..4308c29c0 100644 --- a/docs/architecture/entra-agent-id/06-mesh-trust-design.md +++ b/docs/architecture/entra-agent-id/06-mesh-trust-design.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Entra-signed AGT mesh trust (design + status) > **Status: shipped.** Verified end-to-end on AKS (`kars up --mesh-trust=entra`), diff --git a/docs/architecture/entra-agent-id/README.md b/docs/architecture/entra-agent-id/README.md index 1332b868d..4353d5cbf 100644 --- a/docs/architecture/entra-agent-id/README.md +++ b/docs/architecture/entra-agent-id/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Entra Agent ID — Architecture Index > kars per-sandbox Entra Agent ID with **shared auth-sidecar** architecture. diff --git a/docs/blueprints/00-index.md b/docs/blueprints/00-index.md index 0f7431533..e545621af 100644 --- a/docs/blueprints/00-index.md +++ b/docs/blueprints/00-index.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Deployment blueprints Six concrete shapes for running kars. Each blueprint pins down **who runs what**, **where the trust boundary sits**, and **the main flow** end to end. diff --git a/docs/blueprints/01-developer-inner-loop.md b/docs/blueprints/01-developer-inner-loop.md index 917239392..e2aeced4d 100644 --- a/docs/blueprints/01-developer-inner-loop.md +++ b/docs/blueprints/01-developer-inner-loop.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Blueprint 01 — Developer inner loop > *"I am on my laptop. I want to write an agent, change a tool policy, fix a router bug, and see the effect in seconds — without provisioning AKS, without paying for Azure, and without a different code path that 'will be replaced in production'."* diff --git a/docs/blueprints/02-local-k8s-dev-loop.md b/docs/blueprints/02-local-k8s-dev-loop.md index 3273bd41a..ca51b3f09 100644 --- a/docs/blueprints/02-local-k8s-dev-loop.md +++ b/docs/blueprints/02-local-k8s-dev-loop.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Blueprint 02 — Local Kubernetes dev loop > *"I'm on my laptop. I want production-shaped infrastructure — kind cluster, CRDs, controller, sidecar router, NetworkPolicies, Headlamp dashboard — without standing up AKS. When I'm done, one command tears it all down."* diff --git a/docs/blueprints/03-enterprise-self-hosted.md b/docs/blueprints/03-enterprise-self-hosted.md index e428e9498..e0129a9b1 100644 --- a/docs/blueprints/03-enterprise-self-hosted.md +++ b/docs/blueprints/03-enterprise-self-hosted.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Blueprint 03 — Enterprise self-hosted cluster > "I'm a platform team inside one organisation. I want to give my engineers and product teams a hardened, governed AI agent runtime on AKS that I own end-to-end — same Entra tenant, same network island, same audit destination, no third-party SaaS in the data path." diff --git a/docs/blueprints/04-managed-public-offload.md b/docs/blueprints/04-managed-public-offload.md index 928ae76b5..e05b4a150 100644 --- a/docs/blueprints/04-managed-public-offload.md +++ b/docs/blueprints/04-managed-public-offload.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Blueprint 04 — Managed public offload service > "I run a managed kars offering. Maybe I'm a hyperscale SaaS, maybe I'm a 3-person MSP, maybe I'm a community co-op renting capacity to hobbyists. My customers want to offload heavier or sensitive agent tasks — bigger models, longer runs, parallel fan-out — that don't fit on their laptops. I want to host them all on one cluster, in different Entra tenants, none with kubectl access, all onboarded by token, all isolated from each other and from me at every layer including the host kernel." diff --git a/docs/blueprints/05-cross-org-federation.md b/docs/blueprints/05-cross-org-federation.md index 2a21143f5..3e334679f 100644 --- a/docs/blueprints/05-cross-org-federation.md +++ b/docs/blueprints/05-cross-org-federation.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Blueprint 05 — Cross-org federation > "We're two organisations who want our agents to collaborate. Each side runs their own kars cluster. Neither side trusts the other's network, the other's Foundry quota, or the other's audit destination. We want E2E-encrypted, mutually-policy-evaluated agent-to-agent collaboration without merging trust domains." diff --git a/docs/blueprints/06-sovereign-airgapped.md b/docs/blueprints/06-sovereign-airgapped.md index 805f72b22..30717e81e 100644 --- a/docs/blueprints/06-sovereign-airgapped.md +++ b/docs/blueprints/06-sovereign-airgapped.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Blueprint 06 — Sovereign / air-gapped > "We run regulated, classified, sovereign-cloud, or fully air-gapped workloads. There is no public internet. There is no commercial Foundry endpoint. There is no Microsoft-hosted MCP catalogue. We still want kars's isolation + governance + audit guarantees, on locally-hosted models, with everything reproducible from a signed bundle." diff --git a/docs/channels-plugins.md b/docs/channels-plugins.md index 1dc747f77..3a6f8a3ea 100644 --- a/docs/channels-plugins.md +++ b/docs/channels-plugins.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Channels & external plugins Messaging channels (Telegram, Slack, Discord, WhatsApp) and **third-party** search/scrape API integrations (Brave, Tavily, Exa, Firecrawl, Perplexity, OpenAI) extend your kars agent with external communication and search capabilities. Configuration is via CLI flags — the sandbox entrypoint auto-configures everything from environment variables at startup. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b8e53a8dc..2e108c09a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars CLI Reference kars ships **dozens of top-level commands** organised by purpose: **Lifecycle**, diff --git a/docs/compliance.md b/docs/compliance.md index 5fed7a756..5a3a2f63b 100644 --- a/docs/compliance.md +++ b/docs/compliance.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Control mapping This page maps kars's **shipped, enforced** controls (the ✅ rows in diff --git a/docs/egress-proxy.md b/docs/egress-proxy.md index 69a384331..b64bb1904 100644 --- a/docs/egress-proxy.md +++ b/docs/egress-proxy.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Network Egress & Proxy ## Where the policy lives diff --git a/docs/examples.md b/docs/examples.md index 615d84b96..f2522173b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Examples catalogue Eight end-to-end examples live under [`examples/`](https://github.com/Azure/kars/tree/main/examples). Each one is a self-contained `kubectl apply -f` after `kars up`. All examples share the same control-plane install and isolation guarantees — only the agent runtime image changes. (For higher-level *deployment shapes* — who runs what, where the trust boundary sits — see [Blueprints](blueprints/00-index.md) instead.) diff --git a/docs/getting-started.md b/docs/getting-started.md index ff04d772b..9daf2b851 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Getting started One `npm i` and one `kars dev`, and you're talking to a secured AI agent on your laptop in about five minutes — no Azure account required. diff --git a/docs/github-services.md b/docs/github-services.md index 55023ea85..3d84ae880 100644 --- a/docs/github-services.md +++ b/docs/github-services.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Optional keyless GitHub engineering services The router can authenticate a bounded set of GitHub repository operations with diff --git a/docs/governed-inference-budgets.md b/docs/governed-inference-budgets.md index 8f3a4482a..66dc6c2d1 100644 --- a/docs/governed-inference-budgets.md +++ b/docs/governed-inference-budgets.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Governed inference budgets — v1 contract **Implementation candidate, not yet qualified for publication.** Only the new diff --git a/docs/governed-services.md b/docs/governed-services.md index f6cbbd329..428abdea6 100644 --- a/docs/governed-services.md +++ b/docs/governed-services.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Router governed services The separate [optional keyless GitHub service](github-services.md) supplies diff --git a/docs/hermes-plugin.md b/docs/hermes-plugin.md index 0b142e2eb..fd74654e3 100644 --- a/docs/hermes-plugin.md +++ b/docs/hermes-plugin.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Hermes plugin (`runtimes/hermes/`) The **kars Hermes plugin** is the agent-side runtime surface for kars on top of the [Hermes Agent](https://github.com/NousResearch/hermes-agent) (Nous Research, MIT) — a Python 3.11+ agent harness with **20+ messaging channels**, **18+ inference providers**, **70+ built-in tools**, and a native MCP client. When a Hermes sandbox boots, the Hermes gateway auto-discovers the kars plugin from `$HERMES_HOME/plugins/kars/` and loads it; from that point on the agent's tool surface is the governance-aware kars tools the plugin registers plus the 6 Hermes built-ins kars explicitly denies. diff --git a/docs/how-to/credential-sources.md b/docs/how-to/credential-sources.md index 4335caec6..a539b81b7 100644 --- a/docs/how-to/credential-sources.md +++ b/docs/how-to/credential-sources.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Workspace credential sources (v1) Credential sources are an **optional, explicit** alternative to the existing diff --git a/docs/how-to/governed-credential-grants.md b/docs/how-to/governed-credential-grants.md index 3254ae143..1e7d575ce 100644 --- a/docs/how-to/governed-credential-grants.md +++ b/docs/how-to/governed-credential-grants.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Governed credential sources and operator stores This additive contract does not require Bridge. Direct credentials and the diff --git a/docs/how-to/helm-installation.md b/docs/how-to/helm-installation.md index b49c9d5af..ec08a1d80 100644 --- a/docs/how-to/helm-installation.md +++ b/docs/how-to/helm-installation.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Install Kars with Helm Use the Helm chart when the Kubernetes cluster, image access, inference backend, diff --git a/docs/how-to/namespace-ownership.md b/docs/how-to/namespace-ownership.md index e8eb1d23c..9072ffe0c 100644 --- a/docs/how-to/namespace-ownership.md +++ b/docs/how-to/namespace-ownership.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Sandbox namespace ownership (claim v1) KarsSandbox CRs are namespaced, but their runtime namespace remains diff --git a/docs/how-to/sre-authority.md b/docs/how-to/sre-authority.md index 330c3910e..511f83c2a 100644 --- a/docs/how-to/sre-authority.md +++ b/docs/how-to/sre-authority.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Registered SRE authority and credential privacy SRE namespace occupancy is not authorization. The cluster-scoped diff --git a/docs/local-inference.md b/docs/local-inference.md index f8e10b547..b07753f25 100644 --- a/docs/local-inference.md +++ b/docs/local-inference.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Local inference and model failover Kars can route to operator-configured OpenAI-compatible endpoints alongside diff --git a/docs/maturity.md b/docs/maturity.md index c4fce6b85..39a4e2686 100644 --- a/docs/maturity.md +++ b/docs/maturity.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Feature maturity & enforcement status kars is `v0.1.18`. Most of the control plane is enforced at runtime today, but some diff --git a/docs/mcp.md b/docs/mcp.md index 4ae756e7b..869587a89 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # MCP servers in kars For controller-owned Playwright/Everything workloads, see diff --git a/docs/mesh-plugin.md b/docs/mesh-plugin.md index a0d8ce273..4e794a86c 100644 --- a/docs/mesh-plugin.md +++ b/docs/mesh-plugin.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # `@kars/mesh` — the local-OpenClaw companion plugin (`mesh-plugin/`) `@kars/mesh` is the **local-OpenClaw companion plugin** that turns any local OpenClaw install into a mesh-federated client of a kars cluster. It is **not yet published on npm** — today you build it from source (`mesh-plugin/`) and load it into your local OpenClaw (see [Building and testing locally](#building-and-testing-locally)). The `@kars` npm scope is reserved for a future release. You build it on your laptop, pair it once to a kars cluster with a token, and from then on your local agent can: diff --git a/docs/multi-tenant.md b/docs/multi-tenant.md index fcef0db64..7cc2b7577 100644 --- a/docs/multi-tenant.md +++ b/docs/multi-tenant.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Multi-Tenant Namespace Isolation Each sandbox runs in its own Kubernetes namespace with independent security boundaries. No shared state between tenants. diff --git a/docs/openclaw-plugin.md b/docs/openclaw-plugin.md index 24092882a..994a7bd93 100644 --- a/docs/openclaw-plugin.md +++ b/docs/openclaw-plugin.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars OpenClaw plugin (`runtimes/openclaw/`) The **kars OpenClaw plugin** is the agent-side runtime surface for kars. When a sandbox boots, the [OpenClaw](https://github.com/openclawai/openclaw) gateway auto-discovers and loads the plugin from `~/.openclaw-data/extensions/kars/`. From that point on, the agent's tool surface is the **24 governance-aware tools** the plugin registers — every privileged OpenClaw built-in is replaced with a kars equivalent that routes through the inference router and is subject to AGT governance. diff --git a/docs/operations/README.md b/docs/operations/README.md index 6dd64cd41..43aee0a73 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Operations How to operate kars in production. Each page is one operational concern, with the full runbook for that concern. diff --git a/docs/operations/a2a-gateway.md b/docs/operations/a2a-gateway.md index bc62ac0ba..f32cc2393 100644 --- a/docs/operations/a2a-gateway.md +++ b/docs/operations/a2a-gateway.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # A2A gateway operations > Companion to `docs/architecture/a2a-gateway.md`. Read that first. diff --git a/docs/operations/branch-protection.md b/docs/operations/branch-protection.md index c1c6c8f02..6156c47e9 100644 --- a/docs/operations/branch-protection.md +++ b/docs/operations/branch-protection.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Branch Protection — `dev`, `main` and Bridge integration This is the canonical list of CI jobs that must be set as **required diff --git a/docs/operations/byo-strict.md b/docs/operations/byo-strict.md index c6d29e08b..3bd14ab21 100644 --- a/docs/operations/byo-strict.md +++ b/docs/operations/byo-strict.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # BYO Strict-Mode Admission **Status:** shipped. Default `false`; recommended `true` in production. diff --git a/docs/operations/chaos-tier.md b/docs/operations/chaos-tier.md index 10829ab63..5d0e63591 100644 --- a/docs/operations/chaos-tier.md +++ b/docs/operations/chaos-tier.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Chaos tier — operations guide The chaos tier is a permanent CI surface that protects diff --git a/docs/operations/gitops.md b/docs/operations/gitops.md index 1d927a476..8733efc78 100644 --- a/docs/operations/gitops.md +++ b/docs/operations/gitops.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # GitOps mode for egress allowlists This walkthrough covers the **sign-by-default** + **`--emit-manifest`** diff --git a/docs/operations/helm-packaging.md b/docs/operations/helm-packaging.md index dab11e551..5ca16917e 100644 --- a/docs/operations/helm-packaging.md +++ b/docs/operations/helm-packaging.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Helm chart packaging The kars Helm chart lives under [`deploy/helm/kars/`](../../deploy/helm/kars). This page documents how the chart is **versioned** and how a maintainer **packages** it for a release. diff --git a/docs/operations/image-versioning.md b/docs/operations/image-versioning.md index ed9b7706c..ad7b1b919 100644 --- a/docs/operations/image-versioning.md +++ b/docs/operations/image-versioning.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Image versioning & release tagging kars produces eight container images: the controller, the diff --git a/docs/operations/secret-rotation.md b/docs/operations/secret-rotation.md index 3d0fd2228..817eb8e5b 100644 --- a/docs/operations/secret-rotation.md +++ b/docs/operations/secret-rotation.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Secret Rotation Runbook This runbook covers rotation of every secret kars materialises: per-sandbox credentials, TLS certs, AgentMesh identities, and Azure-side credentials. Rotation never requires recompiling the controller or router. diff --git a/docs/operations/supply-chain.md b/docs/operations/supply-chain.md index e39bed5b5..15b34e4b1 100644 --- a/docs/operations/supply-chain.md +++ b/docs/operations/supply-chain.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars — Supply-Chain Hardening This document describes the kars build, sign, and verify pipeline diff --git a/docs/operations/upgrades.md b/docs/operations/upgrades.md index a19ce359c..dced0608b 100644 --- a/docs/operations/upgrades.md +++ b/docs/operations/upgrades.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Upgrades & rollback This runbook covers moving a running kars cluster from one release to the next, diff --git a/docs/operator-tui.md b/docs/operator-tui.md index 61b3d0818..8b7357c43 100644 --- a/docs/operator-tui.md +++ b/docs/operator-tui.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Operator TUI — modular panels > Source: `cli/src/commands/operator/panels/`. diff --git a/docs/permissions.md b/docs/permissions.md index 0e5a01aad..9b37435a5 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Azure permissions required for `kars up` `kars up` provisions a complete secure-by-default AKS runtime: cluster, diff --git a/docs/quickstart.md b/docs/quickstart.md index 732d81771..42fa39809 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Quickstart Get a governed, sandboxed agent running on your laptop in **three commands** — no Azure account, no Rust, no clone. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9c522c1f9..01f232fe8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Roadmap > Living document. The project is at **`v0.1.18`** — see [`CHANGELOG.md`](../CHANGELOG.md) for what's shipped. This roadmap lists the themes we are evolving the platform towards. Versions and ordering may change as we learn from production deployments. diff --git a/docs/runbooks/hermes-troubleshooting.md b/docs/runbooks/hermes-troubleshooting.md index b8944ce41..97a8423c0 100644 --- a/docs/runbooks/hermes-troubleshooting.md +++ b/docs/runbooks/hermes-troubleshooting.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Hermes runtime — troubleshooting runbook A short, scoped runbook for the most common Hermes-specific issues. For the broader kars operator surface (sandboxes, mesh, governance) see the [Operations guide](../operations/README.md) and the [Operator TUI](../operator-tui.md) guide. diff --git a/docs/runtimes.md b/docs/runtimes.md index 0b39fbc0e..35d6e8b6c 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Runtime catalog kars is a host for *agent runtimes*. The runtime is the framework your agent code is written against (OpenClaw, OpenAI Agents SDK, LangGraph, …) plus the small adapter that wires it to the kars sandbox shape. diff --git a/docs/runtimes/CONTRACT.md b/docs/runtimes/CONTRACT.md index 2f6d47936..318dc5cd9 100644 --- a/docs/runtimes/CONTRACT.md +++ b/docs/runtimes/CONTRACT.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Runtime Contract — v1 **Status**: stable contract; runtimes adopting this spec are first-class peers of OpenClaw. diff --git a/docs/security-audits/2026-06-27-foundry-memory-mcp-accept-header.md b/docs/security-audits/2026-06-27-foundry-memory-mcp-accept-header.md index 529244916..9d02c6c27 100644 --- a/docs/security-audits/2026-06-27-foundry-memory-mcp-accept-header.md +++ b/docs/security-audits/2026-06-27-foundry-memory-mcp-accept-header.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Foundry memory MCP Accept header (fix runtime memory end-to-end) Date: 2026-06-27 diff --git a/docs/security-audits/2026-06-27-kars-upgrade-flow-fixes.md b/docs/security-audits/2026-06-27-kars-upgrade-flow-fixes.md index 71d8a894a..b6ecd3549 100644 --- a/docs/security-audits/2026-06-27-kars-upgrade-flow-fixes.md +++ b/docs/security-audits/2026-06-27-kars-upgrade-flow-fixes.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — `kars upgrade` flow fixes + security-audit gate relocation (v0.1.21) Date: 2026-06-27 diff --git a/docs/security-audits/2026-06-29-egress-learn-enforce-flow-repair.md b/docs/security-audits/2026-06-29-egress-learn-enforce-flow-repair.md index 609db1e5c..856d3ac0c 100644 --- a/docs/security-audits/2026-06-29-egress-learn-enforce-flow-repair.md +++ b/docs/security-audits/2026-06-29-egress-learn-enforce-flow-repair.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Egress learn/enforce flow repair (operator toggle + CLI approve/deny/enforce) Date: 2026-06-29 diff --git a/docs/security-audits/2026-06-29-upgrade-changelog-impact-confirm.md b/docs/security-audits/2026-06-29-upgrade-changelog-impact-confirm.md index 9e823d3a2..4b036dc71 100644 --- a/docs/security-audits/2026-06-29-upgrade-changelog-impact-confirm.md +++ b/docs/security-audits/2026-06-29-upgrade-changelog-impact-confirm.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — `kars upgrade` changelog + impact table + confirm (additive UX) Date: 2026-06-29 diff --git a/docs/security-audits/2026-06-30-mcp-out-of-the-box.md b/docs/security-audits/2026-06-30-mcp-out-of-the-box.md index 1c9721b93..3a8ae46b3 100644 --- a/docs/security-audits/2026-06-30-mcp-out-of-the-box.md +++ b/docs/security-audits/2026-06-30-mcp-out-of-the-box.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — MCP out-of-the-box: session keepalive, egress auto-derive, CLI update (v0.1.24) Date: 2026-06-30 diff --git a/docs/security-audits/2026-08-24-dependency-security-baseline.md b/docs/security-audits/2026-08-24-dependency-security-baseline.md index 19cba843a..9e70eaf2f 100644 --- a/docs/security-audits/2026-08-24-dependency-security-baseline.md +++ b/docs/security-audits/2026-08-24-dependency-security-baseline.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — dependency and CI security baseline recovery Date: 2026-08-24 diff --git a/docs/security-audits/2026-08-25-langgraph-runtime-alias.md b/docs/security-audits/2026-08-25-langgraph-runtime-alias.md index db4bd9b21..c83a13fe5 100644 --- a/docs/security-audits/2026-08-25-langgraph-runtime-alias.md +++ b/docs/security-audits/2026-08-25-langgraph-runtime-alias.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — canonical LangGraph runtime flag Date: 2026-08-25 diff --git a/docs/security-audits/2026-08-25-multi-provider-guardrails.md b/docs/security-audits/2026-08-25-multi-provider-guardrails.md index ebce1cd36..3ee674363 100644 --- a/docs/security-audits/2026-08-25-multi-provider-guardrails.md +++ b/docs/security-audits/2026-08-25-multi-provider-guardrails.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit, Multi-provider LLM upstreams + pluggable guardrail pipeline (PR #488) Date: 2026-08-25 diff --git a/docs/security-audits/2026-09-03-core-governance-apis.md b/docs/security-audits/2026-09-03-core-governance-apis.md index eba4a6100..64278e2fa 100644 --- a/docs/security-audits/2026-09-03-core-governance-apis.md +++ b/docs/security-audits/2026-09-03-core-governance-apis.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Core governance APIs Date: 2026-09-03 diff --git a/docs/security-audits/2026-09-03-standing-team-control-plane.md b/docs/security-audits/2026-09-03-standing-team-control-plane.md index e8cb0af78..df3feba95 100644 --- a/docs/security-audits/2026-09-03-standing-team-control-plane.md +++ b/docs/security-audits/2026-09-03-standing-team-control-plane.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Standing team control plane Date: 2026-09-03 diff --git a/docs/security-audits/2026-09-04-existing-aks-adoption.md b/docs/security-audits/2026-09-04-existing-aks-adoption.md index 714b0353c..6e0b61b2c 100644 --- a/docs/security-audits/2026-09-04-existing-aks-adoption.md +++ b/docs/security-audits/2026-09-04-existing-aks-adoption.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Existing AKS CLI adoption Date: 2026-09-04 diff --git a/docs/security-audits/2026-09-07-credential-sources.md b/docs/security-audits/2026-09-07-credential-sources.md index 0241faae3..20caa11fa 100644 --- a/docs/security-audits/2026-09-07-credential-sources.md +++ b/docs/security-audits/2026-09-07-credential-sources.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Agent credential-source capability review — 2026-09-07 **Status:** additive candidate with maintainer sign-off received; independent diff --git a/docs/security-audits/2026-09-07-inference-local-failover.md b/docs/security-audits/2026-09-07-inference-local-failover.md index 0f9a911c5..c2b7a3b60 100644 --- a/docs/security-audits/2026-09-07-inference-local-failover.md +++ b/docs/security-audits/2026-09-07-inference-local-failover.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Inference routing and local failover Date: 2026-09-07 diff --git a/docs/security-audits/2026-09-07-sandbox-namespace-ownership.md b/docs/security-audits/2026-09-07-sandbox-namespace-ownership.md index 6af028e15..73fcf912c 100644 --- a/docs/security-audits/2026-09-07-sandbox-namespace-ownership.md +++ b/docs/security-audits/2026-09-07-sandbox-namespace-ownership.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit - Sandbox namespace ownership Date: 2026-09-07 diff --git a/docs/security-audits/2026-09-08-github-services.md b/docs/security-audits/2026-09-08-github-services.md index fd76403bd..5dac5efd5 100644 --- a/docs/security-audits/2026-09-08-github-services.md +++ b/docs/security-audits/2026-09-08-github-services.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Capability audit — Bounded keyless GitHub services Date: 2026-09-08 diff --git a/docs/security-audits/2026-09-08-governed-credential-grants.md b/docs/security-audits/2026-09-08-governed-credential-grants.md index 0a9289aab..4dd417ecb 100644 --- a/docs/security-audits/2026-09-08-governed-credential-grants.md +++ b/docs/security-audits/2026-09-08-governed-credential-grants.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Governed credential grants — qualification record Status: implementation candidate; **not a sign-off**. No author or independent diff --git a/docs/security-audits/2026-09-08-governed-inference-budgets.md b/docs/security-audits/2026-09-08-governed-inference-budgets.md index a8398692b..83861d166 100644 --- a/docs/security-audits/2026-09-08-governed-inference-budgets.md +++ b/docs/security-audits/2026-09-08-governed-inference-budgets.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — Governed inference budgets (v1) Date: **2026-09-08 UTC** diff --git a/docs/security-audits/2026-09-08-governed-router-services.md b/docs/security-audits/2026-09-08-governed-router-services.md index 25e12bfa5..2c8428b0a 100644 --- a/docs/security-audits/2026-09-08-governed-router-services.md +++ b/docs/security-audits/2026-09-08-governed-router-services.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Capability audit — Scoped router governed services Date: 2026-09-08 diff --git a/docs/security-audits/2026-09-08-managed-mcp.md b/docs/security-audits/2026-09-08-managed-mcp.md index 69dc5f618..a86f99129 100644 --- a/docs/security-audits/2026-09-08-managed-mcp.md +++ b/docs/security-audits/2026-09-08-managed-mcp.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Managed MCP capability audit — 2026-09-08 Status: **Source audit approved under explicit maintainer delegation**. diff --git a/docs/security-audits/2026-09-08-sre-authority-prerequisite.md b/docs/security-audits/2026-09-08-sre-authority-prerequisite.md index 636a3342b..3154abc8b 100644 --- a/docs/security-audits/2026-09-08-sre-authority-prerequisite.md +++ b/docs/security-audits/2026-09-08-sre-authority-prerequisite.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security audit — registered SRE credential authority Status: **source audit approved under explicit maintainer delegation**. diff --git a/docs/security-audits/2026-09-10-evaluator-runner-contract.md b/docs/security-audits/2026-09-10-evaluator-runner-contract.md index e884acbcd..63f16045b 100644 --- a/docs/security-audits/2026-09-10-evaluator-runner-contract.md +++ b/docs/security-audits/2026-09-10-evaluator-runner-contract.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Capability audit - Evaluator runner compatibility Date: 2026-09-10 diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 3714b8ccc..a7fad01aa 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Kars Bridge application publication record Status: **draft assembly; no source sign-off or release approval claimed**. diff --git a/docs/security-audits/2026-09-11-evaluator-evidence-parity.md b/docs/security-audits/2026-09-11-evaluator-evidence-parity.md index 2b9930b4c..700bb3918 100644 --- a/docs/security-audits/2026-09-11-evaluator-evidence-parity.md +++ b/docs/security-audits/2026-09-11-evaluator-evidence-parity.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Evaluator evidence parity - bounded delegated source approval Date: 2026-09-11 diff --git a/docs/security-audits/2026-09-11-receipt-log-parity.md b/docs/security-audits/2026-09-11-receipt-log-parity.md index 76d8002a2..48c4144ca 100644 --- a/docs/security-audits/2026-09-11-receipt-log-parity.md +++ b/docs/security-audits/2026-09-11-receipt-log-parity.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Capability audit - Bounded receipt inclusion logs Date: 2026-09-11 diff --git a/docs/security-audits/README.md b/docs/security-audits/README.md index fb30cf03f..1bfaa4a5d 100644 --- a/docs/security-audits/README.md +++ b/docs/security-audits/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security audits Lightweight, per-change security review records. The `security-audit-required` diff --git a/docs/security-audits/_template.md b/docs/security-audits/_template.md index 5c209a50e..64196e78c 100644 --- a/docs/security-audits/_template.md +++ b/docs/security-audits/_template.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security Audit — <title> (<version>) Date: YYYY-MM-DD diff --git a/docs/security-mcp-top10.md b/docs/security-mcp-top10.md index 5623b2cd0..2539eccd0 100644 --- a/docs/security-mcp-top10.md +++ b/docs/security-mcp-top10.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # OWASP MCP Top 10 (2025) — kars controls matrix Internal mapping. Each row answers: what kars surface takes the hit, diff --git a/docs/security-validation.md b/docs/security-validation.md index e4b093e6e..3cba95789 100644 --- a/docs/security-validation.md +++ b/docs/security-validation.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Security Validation Report — 2026-03-23 snapshot > **What this is.** A frozen-in-time evidence dump from one specific validation diff --git a/docs/security.md b/docs/security.md index 5d7927b68..a1aa357f1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Security model kars is a layered control plane. Each layer enforces a specific property; together they bound the blast radius of a compromised agent. This page documents what each layer does, what it does not do, and where the relevant code lives. diff --git a/docs/security/crd-trust-model.md b/docs/security/crd-trust-model.md index 0fb5ababd..301168a9e 100644 --- a/docs/security/crd-trust-model.md +++ b/docs/security/crd-trust-model.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # CRD trust model This page is the threat model and proof for kars's signed-CRD surface. The schema and per-CRD details are in **[CRD reference → Signing and verification](../api/crd-reference.md#signing-and-verification)**. This page answers three questions an SRE or security reviewer will ask: diff --git a/docs/security/red-team.md b/docs/security/red-team.md index 3b1c6c774..7e53a521f 100644 --- a/docs/security/red-team.md +++ b/docs/security/red-team.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Internal Red-Team — Findings Log > Living log of internal red-team / adversarial-test exercises against kars. Each entry records what was tested, what was found, and how it was closed. Findings that are still open carry an `OPEN` tag and link to a tracking issue. diff --git a/docs/security/stride.md b/docs/security/stride.md index 7b76b4825..6ca8b3e07 100644 --- a/docs/security/stride.md +++ b/docs/security/stride.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # STRIDE Threat Model — kars > Companion to [`docs/security.md`](../security.md) (defense-in-depth layers). This document classifies the threats kars mitigates using STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege) across the four primary trust boundaries. diff --git a/docs/security/supply-chain-posture.md b/docs/security/supply-chain-posture.md index 99372ad10..6e9d9cbde 100644 --- a/docs/security/supply-chain-posture.md +++ b/docs/security/supply-chain-posture.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Supply-chain posture & OpenSSF Scorecard notes This document records kars's supply-chain decisions and how we address — or diff --git a/docs/site/README.md b/docs/site/README.md index 3f6fdc99f..5db728b2f 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars documentation site The `docs/site/` directory contains the **mdbook** configuration that turns the canonical markdown tree under `docs/` into a browsable HTML site. diff --git a/docs/site/book.toml b/docs/site/book.toml index a0a78132b..cc07f3122 100644 --- a/docs/site/book.toml +++ b/docs/site/book.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [book] title = "kars — Documentation" description = "kars: a secure runtime for AI agents on Azure Kubernetes Service. Per-agent sandbox isolation, declarative governance via CRDs, end-to-end encrypted inter-agent messaging, and a Rust router that enforces every external call." diff --git a/docs/site/theme/css/custom.css b/docs/site/theme/css/custom.css index f73e2c404..1ba0e5600 100644 --- a/docs/site/theme/css/custom.css +++ b/docs/site/theme/css/custom.css @@ -1,3 +1,6 @@ +/* Copyright (c) Microsoft Corporation. +Licensed under the MIT License. */ + /* ========================================================================== kars — documentation theme Layered on top of the stock mdBook themes (light / rust / coal / navy / ayu). diff --git a/docs/site/theme/index.hbs b/docs/site/theme/index.hbs index 81585ca3f..77c5ae93f 100644 --- a/docs/site/theme/index.hbs +++ b/docs/site/theme/index.hbs @@ -1,4 +1,5 @@ -<!DOCTYPE HTML> +{{!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --}}<!DOCTYPE HTML> <html lang="{{ language }}" class="{{ default_theme }} sidebar-visible" dir="{{ text_direction }}"> <head> <!-- Book generated using mdBook --> diff --git a/docs/tutorials/managed-mcp.md b/docs/tutorials/managed-mcp.md index 4c35ad0f7..6db7bfc10 100644 --- a/docs/tutorials/managed-mcp.md +++ b/docs/tutorials/managed-mcp.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Managed MCP workloads Kars can deploy the reviewed `playwright` and `everything` MCP presets. An diff --git a/docs/upstream-alignment.md b/docs/upstream-alignment.md index 4a795edf7..faeda16b8 100644 --- a/docs/upstream-alignment.md +++ b/docs/upstream-alignment.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # OpenClaw upstream alignment **TL;DR** — kars does **not** fork OpenClaw. It uses only first-class extension diff --git a/docs/use-cases.md b/docs/use-cases.md index 18efe9492..d94862ab9 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Use Cases Six fully-shipped use cases covering every deployment pattern from laptop inner-loop to cross-organisation A2A federation. All six are implemented end-to-end and exercised by the compat / conformance / e2e harness before any merge. diff --git a/docs/use-cases/exec-brief-walkthrough.md b/docs/use-cases/exec-brief-walkthrough.md index eb1068222..9e573f881 100644 --- a/docs/use-cases/exec-brief-walkthrough.md +++ b/docs/use-cases/exec-brief-walkthrough.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Exec-brief walkthrough — a four-agent showcase This page walks a real, reproducible end-to-end scenario: **one parent agent orchestrates three sub-agents to produce a two-page executive brief on the 2026 state of agentic AI runtimes.** It exists for one reason: when somebody asks "what does kars actually do, and what is it enforcing for me?", this is the answer you can point at, run, and observe. diff --git a/eval-corpus/Cargo.toml b/eval-corpus/Cargo.toml index a315a6390..94446733c 100644 --- a/eval-corpus/Cargo.toml +++ b/eval-corpus/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-eval-corpus" description = "KarsEval corpus types, strict parser, verdict function, and built-in conformance corpora — shared library consumed by the controller (for the EvalCorpusKind PolicyKind impl) and by the conformance-runner binary." diff --git a/examples/README.md b/examples/README.md index c46cb23fd..abe909deb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Examples End-to-end blueprints you can `kubectl apply -f` after running `kars up`. diff --git a/examples/basic-agent/README.md b/examples/basic-agent/README.md index e5d24e777..14850017a 100644 --- a/examples/basic-agent/README.md +++ b/examples/basic-agent/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Basic Agent — minimal kars example The smallest possible end-to-end kars deployment: one OpenClaw diff --git a/examples/basic-agent/clawsandbox.yaml b/examples/basic-agent/clawsandbox.yaml index fbd647f96..a1146c72e 100644 --- a/examples/basic-agent/clawsandbox.yaml +++ b/examples/basic-agent/clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Example: Basic OpenClaw Agent # This creates a sandboxed OpenClaw agent with default security settings. diff --git a/examples/byo-quickstart/README.md b/examples/byo-quickstart/README.md index 0dad7c690..eda500b3a 100644 --- a/examples/byo-quickstart/README.md +++ b/examples/byo-quickstart/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # BYO Runtime Quickstart A minimal **Bring-Your-Own** runtime for kars. Demonstrates the diff --git a/examples/byo-quickstart/app/requirements.txt b/examples/byo-quickstart/app/requirements.txt index a50821075..6a0634627 100644 --- a/examples/byo-quickstart/app/requirements.txt +++ b/examples/byo-quickstart/app/requirements.txt @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + fastapi==0.115.0 uvicorn[standard]==0.32.0 openai==1.54.3 diff --git a/examples/byo-quickstart/k8s/clawsandbox-strict-demo.yaml b/examples/byo-quickstart/k8s/clawsandbox-strict-demo.yaml index 03f97bc4a..c30a1f87a 100644 --- a/examples/byo-quickstart/k8s/clawsandbox-strict-demo.yaml +++ b/examples/byo-quickstart/k8s/clawsandbox-strict-demo.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Strict-mode demo: this CR is INTENTIONALLY invalid. # # When the controller is rolled out with `controller.byoStrict=true`, diff --git a/examples/byo-quickstart/k8s/clawsandbox.yaml b/examples/byo-quickstart/k8s/clawsandbox.yaml index 1f33c072c..83eea8eb4 100644 --- a/examples/byo-quickstart/k8s/clawsandbox.yaml +++ b/examples/byo-quickstart/k8s/clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # BYO quickstart sandbox. # # The image must meet the contract documented in diff --git a/examples/confidential-agent/README.md b/examples/confidential-agent/README.md index b0ed28289..d1ccc968c 100644 --- a/examples/confidential-agent/README.md +++ b/examples/confidential-agent/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Confidential Agent — Kata VM isolation `basic-agent`'s twin, but with **per-pod dedicated-kernel isolation** diff --git a/examples/confidential-agent/clawsandbox.yaml b/examples/confidential-agent/clawsandbox.yaml index 8c9a185be..6f1fb59ea 100644 --- a/examples/confidential-agent/clawsandbox.yaml +++ b/examples/confidential-agent/clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Example: Confidential Agent # Uses Kata VM isolation for per-pod dedicated kernel. # Container escape attacks are trapped inside the VM, not the host. diff --git a/examples/demo-clawshield/README.md b/examples/demo-clawshield/README.md index 7f18df291..5c47a4657 100644 --- a/examples/demo-clawshield/README.md +++ b/examples/demo-clawshield/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Operation Claw Shield — multi-tenant attack-simulation demo A 30-minute scripted demo showing **three tenants on one cluster** diff --git a/examples/demo-clawshield/contoso-bank-agent.yaml b/examples/demo-clawshield/contoso-bank-agent.yaml index c108de2fb..78350caf3 100644 --- a/examples/demo-clawshield/contoso-bank-agent.yaml +++ b/examples/demo-clawshield/contoso-bank-agent.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Contoso Bank — Financial Compliance Agent # Isolation: enhanced (seccomp + runc on standard node pool) # Role: Analyzes transaction records, generates compliance reports diff --git a/examples/demo-clawshield/fabrikam-legal-agent.yaml b/examples/demo-clawshield/fabrikam-legal-agent.yaml index b3bdcd035..57d402ba3 100644 --- a/examples/demo-clawshield/fabrikam-legal-agent.yaml +++ b/examples/demo-clawshield/fabrikam-legal-agent.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Fabrikam Legal — Legal Compliance Agent (THE TARGET) # Isolation: confidential (Kata VM on kata node pool) # Role: Reviews legal documents for regulatory compliance diff --git a/examples/demo-clawshield/northwind-trade-agent.yaml b/examples/demo-clawshield/northwind-trade-agent.yaml index fad4e1807..b8d811fcc 100644 --- a/examples/demo-clawshield/northwind-trade-agent.yaml +++ b/examples/demo-clawshield/northwind-trade-agent.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Northwind Traders — Trade Audit Agent # Isolation: enhanced (seccomp + runc on standard node pool) # Role: Validates trade records against compliance frameworks diff --git a/examples/demo-clawshield/poisoned-document.md b/examples/demo-clawshield/poisoned-document.md index 70701abc1..bbdf7b95c 100644 --- a/examples/demo-clawshield/poisoned-document.md +++ b/examples/demo-clawshield/poisoned-document.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Poisoned Document — For Demo Purposes Only # # This file simulates a poisoned legal document containing an indirect diff --git a/examples/full-stack-demo/README.md b/examples/full-stack-demo/README.md index de509653a..26dc766ff 100644 --- a/examples/full-stack-demo/README.md +++ b/examples/full-stack-demo/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Full-Stack Demo One `kubectl apply` provisions everything kars can wire to a single agent: diff --git a/examples/full-stack-demo/demo.yaml b/examples/full-stack-demo/demo.yaml index 54a4c1753..d6403d34e 100644 --- a/examples/full-stack-demo/demo.yaml +++ b/examples/full-stack-demo/demo.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars full-stack demo — one kubectl apply, every CRD wired up. # # Provisions a single agent named `demo-agent` with: diff --git a/examples/hermes-quickstart/README.md b/examples/hermes-quickstart/README.md index bdac4e1e0..a6c7fb7b7 100644 --- a/examples/hermes-quickstart/README.md +++ b/examples/hermes-quickstart/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Hermes Quickstart — minimal kars Hermes-runtime example The smallest possible Hermes deployment: one [Hermes Agent](https://github.com/NousResearch/hermes-agent) (Nous Research, MIT) in a `KarsSandbox` with the default isolation posture, the kars plugin auto-loaded, AGT governance on, and the agent joined to the mesh. diff --git a/examples/hermes-quickstart/karssandbox.yaml b/examples/hermes-quickstart/karssandbox.yaml index ceed72dc0..9bd70e769 100644 --- a/examples/hermes-quickstart/karssandbox.yaml +++ b/examples/hermes-quickstart/karssandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Example: Basic Hermes Agent # Minimal Hermes-runtime sandbox — the kars plugin auto-loads, the agent # joins the AGT mesh (verified-tier when foundryRbac is set in the diff --git a/examples/lethal-trifecta-demo/README.md b/examples/lethal-trifecta-demo/README.md index 23d392b74..f83d3cc4a 100644 --- a/examples/lethal-trifecta-demo/README.md +++ b/examples/lethal-trifecta-demo/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Demo: The Lethal Trifecta, Defused > *"Any time you grant an LLM-based system access to private data, exposure to diff --git a/examples/lethal-trifecta-demo/WALKTHROUGH.md b/examples/lethal-trifecta-demo/WALKTHROUGH.md index 030b09c92..53c938963 100644 --- a/examples/lethal-trifecta-demo/WALKTHROUGH.md +++ b/examples/lethal-trifecta-demo/WALKTHROUGH.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Walkthrough: The Lethal Trifecta, Defused A timed, ~7-minute live or recorded demo. Two AKS namespaces, one diff --git a/examples/lethal-trifecta-demo/bait/poisoned-skill.md b/examples/lethal-trifecta-demo/bait/poisoned-skill.md index 3cc6c98f2..cbfdac06e 100644 --- a/examples/lethal-trifecta-demo/bait/poisoned-skill.md +++ b/examples/lethal-trifecta-demo/bait/poisoned-skill.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Real Estate Appraisals — Q1 2026 Welcome to the **Acme Appraisals** quarterly skill update. This skill diff --git a/examples/lethal-trifecta-demo/scenarios/00-namespaces.yaml b/examples/lethal-trifecta-demo/scenarios/00-namespaces.yaml index 4241a9de7..008bb1078 100644 --- a/examples/lethal-trifecta-demo/scenarios/00-namespaces.yaml +++ b/examples/lethal-trifecta-demo/scenarios/00-namespaces.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + --- apiVersion: v1 kind: Namespace diff --git a/examples/lethal-trifecta-demo/scenarios/01-naked-claw.yaml b/examples/lethal-trifecta-demo/scenarios/01-naked-claw.yaml index a751aff28..0d67b7f5c 100644 --- a/examples/lethal-trifecta-demo/scenarios/01-naked-claw.yaml +++ b/examples/lethal-trifecta-demo/scenarios/01-naked-claw.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # ── Naked claw — vanilla OpenClaw with ONLY a domain-only egress # allowlist. No kars control plane. This is the strawman that # falls to the lethal trifecta. We deploy it as a plain Pod diff --git a/examples/lethal-trifecta-demo/scenarios/02-kars-sandbox.yaml b/examples/lethal-trifecta-demo/scenarios/02-kars-sandbox.yaml index c3c0ec2b2..dcec110e3 100644 --- a/examples/lethal-trifecta-demo/scenarios/02-kars-sandbox.yaml +++ b/examples/lethal-trifecta-demo/scenarios/02-kars-sandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # ── kars-managed agent: full nine-layer stack. # The controller reconciles the KarsSandbox below into: # - dedicated namespace (already created) diff --git a/examples/lethal-trifecta-demo/scenarios/03-bait-server.yaml b/examples/lethal-trifecta-demo/scenarios/03-bait-server.yaml index c215e2d86..bfb137b6c 100644 --- a/examples/lethal-trifecta-demo/scenarios/03-bait-server.yaml +++ b/examples/lethal-trifecta-demo/scenarios/03-bait-server.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Cluster-internal HTTP server that hosts the poisoned skill. # Same Deployment lives in both namespaces so each agent fetches # its skill from "next door" and the demo doesn't depend on diff --git a/examples/maf-quickstart/README.md b/examples/maf-quickstart/README.md index e51cd85ab..cc9b4267d 100644 --- a/examples/maf-quickstart/README.md +++ b/examples/maf-quickstart/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Microsoft Agent Framework (MAF) — Quickstart This blueprint hosts a [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) diff --git a/examples/maf-quickstart/clawsandbox.yaml b/examples/maf-quickstart/clawsandbox.yaml index 66cc30ec4..6250ecdd5 100644 --- a/examples/maf-quickstart/clawsandbox.yaml +++ b/examples/maf-quickstart/clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Example: Microsoft Agent Framework (MAF) runtime # # Hosts a Microsoft Agent Framework (Python) agent inside an kars diff --git a/examples/openai-agents-quickstart/README.md b/examples/openai-agents-quickstart/README.md index d300f1a81..96f6dd632 100644 --- a/examples/openai-agents-quickstart/README.md +++ b/examples/openai-agents-quickstart/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # OpenAI Agents Python — Quickstart This blueprint hosts an [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) (Python) diff --git a/examples/openai-agents-quickstart/clawsandbox.yaml b/examples/openai-agents-quickstart/clawsandbox.yaml index 8e1acb12d..3c39828b3 100644 --- a/examples/openai-agents-quickstart/clawsandbox.yaml +++ b/examples/openai-agents-quickstart/clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Example: OpenAI Agents Python runtime # # Hosts an OpenAI Agents SDK (Python) agent inside an kars sandbox. diff --git a/examples/playwright-mcp/00-playwright-mcp.yaml b/examples/playwright-mcp/00-playwright-mcp.yaml index b55df984b..ef9f42383 100644 --- a/examples/playwright-mcp/00-playwright-mcp.yaml +++ b/examples/playwright-mcp/00-playwright-mcp.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 00-playwright-mcp.yaml — the Playwright MCP server, in-cluster. # # This is an ordinary Deployment + Service running Microsoft's official diff --git a/examples/playwright-mcp/01-mcpserver.yaml b/examples/playwright-mcp/01-mcpserver.yaml index dc4123553..f0f311622 100644 --- a/examples/playwright-mcp/01-mcpserver.yaml +++ b/examples/playwright-mcp/01-mcpserver.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 01-mcpserver.yaml — register the Playwright MCP server with kars. # # The McpServer CR is the declarative "this MCP exists and these tools are diff --git a/examples/playwright-mcp/02-karssandbox.yaml b/examples/playwright-mcp/02-karssandbox.yaml index ce9539126..0fcfc9562 100644 --- a/examples/playwright-mcp/02-karssandbox.yaml +++ b/examples/playwright-mcp/02-karssandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 02-karssandbox.yaml — a browser-automation agent that consumes the # Playwright MCP. OpenClaw runtime, default kars hardening, governance on. # diff --git a/examples/playwright-mcp/README.md b/examples/playwright-mcp/README.md index 20b624f88..5ced1a6d7 100644 --- a/examples/playwright-mcp/README.md +++ b/examples/playwright-mcp/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Example: Browser-automation agent on a Playwright MCP A sandboxed OpenClaw agent that drives a **real headless Chromium** through the diff --git a/examples/telegram-agent/README.md b/examples/telegram-agent/README.md index aa404abde..76d0227be 100644 --- a/examples/telegram-agent/README.md +++ b/examples/telegram-agent/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Telegram Agent Example Deploy an AI agent connected to Telegram with optional Bing web search. diff --git a/examples/telegram-agent/clawsandbox.yaml b/examples/telegram-agent/clawsandbox.yaml index 410b729d2..e87e15ecd 100644 --- a/examples/telegram-agent/clawsandbox.yaml +++ b/examples/telegram-agent/clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Example: Telegram-Connected Agent # # Deploys an OpenClaw agent with Telegram channel integration. diff --git a/inference-router/Cargo.toml b/inference-router/Cargo.toml index 4bb1ee148..61eef3bad 100644 --- a/inference-router/Cargo.toml +++ b/inference-router/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-inference-router" description = "High-performance inference router for kars — routes LLM calls to Azure OpenAI / AI Foundry with Managed Identity auth, content safety, and token budgets" diff --git a/inference-router/Dockerfile b/inference-router/Dockerfile index 9fb61b3ab..199566b71 100644 --- a/inference-router/Dockerfile +++ b/inference-router/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Inference Router — distroless (build-once pattern) # # Built from a pre-compiled binary produced by the `build-rust` CI job diff --git a/inference-router/Dockerfile.dev b/inference-router/Dockerfile.dev index b142a7cd4..7c20049d1 100644 --- a/inference-router/Dockerfile.dev +++ b/inference-router/Dockerfile.dev @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Dev-mode inference-router image. # # Differs from the canonical distroless Dockerfile by using diff --git a/inference-router/Dockerfile.multistage b/inference-router/Dockerfile.multistage index a9c4ba32a..467dc699e 100644 --- a/inference-router/Dockerfile.multistage +++ b/inference-router/Dockerfile.multistage @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Inference Router — multi-stage Rust build (Azure Linux) # Produces a minimal distroless binary (~15MB) diff --git a/inference-router/fuzz/.gitignore b/inference-router/fuzz/.gitignore index a0925114d..33d3b9675 100644 --- a/inference-router/fuzz/.gitignore +++ b/inference-router/fuzz/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + target corpus artifacts diff --git a/inference-router/fuzz/Cargo.toml b/inference-router/fuzz/Cargo.toml index 163ea47cb..f48a58f39 100644 --- a/inference-router/fuzz/Cargo.toml +++ b/inference-router/fuzz/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-inference-router-fuzz" version = "0.0.0" diff --git a/inference-router/fuzz/README.md b/inference-router/fuzz/README.md index 1dd4efa1f..4860c5f20 100644 --- a/inference-router/fuzz/README.md +++ b/inference-router/fuzz/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Inference-router fuzz targets (s4) Fuzz targets for attacker-controlled parsers in the router. Targets are diff --git a/inference-router/tests/fixtures/foundry/README.md b/inference-router/tests/fixtures/foundry/README.md index 775258f6f..a6be78a3c 100644 --- a/inference-router/tests/fixtures/foundry/README.md +++ b/inference-router/tests/fixtures/foundry/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Sanitized Azure / Foundry fixtures These JSON files are **sanitized copies** of real Azure AI Foundry / Azure OpenAI diff --git a/kars-a2a-core/Cargo.toml b/kars-a2a-core/Cargo.toml index aaf84e301..f04d6b650 100644 --- a/kars-a2a-core/Cargo.toml +++ b/kars-a2a-core/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-a2a-core" description = "Shared A2A 1.0.0 primitives — JWS verification, AgentCard parsing, signing-key helpers. Lifted from kars-inference-router so the public-edge a2a-gateway can reuse the same verifier." diff --git a/mesh-plugin/.gitignore b/mesh-plugin/.gitignore index f4e2c6d6b..4e93b848b 100644 --- a/mesh-plugin/.gitignore +++ b/mesh-plugin/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + node_modules/ dist/ *.tsbuildinfo diff --git a/mesh-plugin/README.md b/mesh-plugin/README.md index 60b15a339..1b0396cba 100644 --- a/mesh-plugin/README.md +++ b/mesh-plugin/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # @kars/mesh — OpenClaw Federation Plugin > **Status — build from source (not yet published).** This plugin is **not yet diff --git a/mesh-plugin/nemoclaw/policies/presets/kars-mesh.yaml b/mesh-plugin/nemoclaw/policies/presets/kars-mesh.yaml index 73a935485..497b957ce 100644 --- a/mesh-plugin/nemoclaw/policies/presets/kars-mesh.yaml +++ b/mesh-plugin/nemoclaw/policies/presets/kars-mesh.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars mesh federation — relay (WebSocket) and registry (REST) # # This preset enables NemoClaw/OpenShell sandbox agents to connect to an diff --git a/mesh-plugin/skills/mesh-federation/SKILL.md b/mesh-plugin/skills/mesh-federation/SKILL.md index 81f2cca52..a00ea657f 100644 --- a/mesh-plugin/skills/mesh-federation/SKILL.md +++ b/mesh-plugin/skills/mesh-federation/SKILL.md @@ -3,6 +3,9 @@ name: mesh-federation description: Pair with a kars cluster and offload heavy tasks to governed cloud sandboxes with GPU / foundation-model inference / Azure AI services, or communicate with other agents over end-to-end encrypted AgentMesh. Triggers on natural-language intents like "offload to the cloud", "run this on Azure", "ask my cluster to…", "send a message to agent X", "who is on the mesh", "check my inbox", "is my offload done". metadata: {"openclaw": {"always": true}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Mesh Federation — Cloud Offload & Inter-Agent Messaging diff --git a/osv-scanner.toml b/osv-scanner.toml index 7f5e73d30..6c2477360 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # osv-scanner ignore config — accepted, triaged advisories. # # Each entry is a vulnerability with NO upstream fix (unmaintained or no-patch diff --git a/runtimes/.gitignore b/runtimes/.gitignore index 5240aaece..097cc1788 100644 --- a/runtimes/.gitignore +++ b/runtimes/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Built AGT-Python wheels (regenerated from upstream by build-agt-wheels.sh) wheels/*.whl wheels/*.tar.gz diff --git a/runtimes/agt-mesh-python/README.md b/runtimes/agt-mesh-python/README.md index 02bcfdfbb..b162284d4 100644 --- a/runtimes/agt-mesh-python/README.md +++ b/runtimes/agt-mesh-python/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars-agt-mesh — Python AGT MeshClient for any Python agent framework **Status:** Act 2.1 — core MeshClient + Hermes adapter. diff --git a/runtimes/anthropic/README.md b/runtimes/anthropic/README.md index e688c915d..242be2926 100644 --- a/runtimes/anthropic/README.md +++ b/runtimes/anthropic/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars runtime adapter — Anthropic Claude Agent SDK `kars_runtime_anthropic` is the in-pod adapter that wires the diff --git a/runtimes/hermes/README.md b/runtimes/hermes/README.md index e3205a85c..47f35d908 100644 --- a/runtimes/hermes/README.md +++ b/runtimes/hermes/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # `kars-runtime-hermes` — kars in-pod adapter for Hermes Agent Implements the kars **v1 runtime contract** for [Hermes Agent](https://hermes-agent.nousresearch.com/) (Nous Research). When this package is installed inside a kars sandbox pod, it registers itself as a Hermes plugin and wires Hermes into kars' governance, mesh, and orchestration plane. diff --git a/runtimes/hermes/pyproject.toml b/runtimes/hermes/pyproject.toml index a847d8b59..0f6409140 100644 --- a/runtimes/hermes/pyproject.toml +++ b/runtimes/hermes/pyproject.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [build-system] requires = ["hatchling>=1.18"] build-backend = "hatchling.build" diff --git a/runtimes/hermes/src/kars_runtime_hermes/__init__.py b/runtimes/hermes/src/kars_runtime_hermes/__init__.py index c065e744f..04afb07a6 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/__init__.py +++ b/runtimes/hermes/src/kars_runtime_hermes/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars-runtime-hermes — in-pod adapter that wires Hermes into kars governance. Public API: just import the package; Hermes' plugin discovery finds diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py index 7f4fe6a0d..24a30fd92 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars — Hermes plugin entry point. Hermes discovers this plugin by scanning ``$HERMES_HOME/plugins/<name>/`` diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/discover.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/discover.py index 5fd7fed31..f2e7c2be0 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/discover.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/discover.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars_discover — Phase A1.6. Look up peer agents in the AGT registry via the router's diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/foundry.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/foundry.py index 47661fa85..6d0a4d4a2 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/foundry.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/foundry.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Foundry tool wrappers — Phase A1.7. **Design**: Hermes ships with a strong native MCP client; the kars diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/governance.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/governance.py index f23a3bf9c..94022b149 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/governance.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/governance.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """AGT policy gate — Phase A1.4. Every tool call goes through ``ctx.register_hook("pre_tool_call", ...)`` diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/handoff.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/handoff.py index e2f4b73c5..3fb14c325 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/handoff.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/handoff.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars_handoff_* — agent migration / escalation tools. Thin Python port of `runtimes/openclaw/src/core/agt-tools/agt.ts` diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/http_fetch.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/http_fetch.py index 35deb9098..eb3bf0f1c 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/http_fetch.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/http_fetch.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """http_fetch tool — Phase A1.4 (always-on). HTTP fetch routed through the inference router's ``/egress/fetch`` diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/mesh.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/mesh.py index 8dba077ab..75a929bd1 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/mesh.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/mesh.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars_mesh_* tool implementations — Act 2 (Python AGT MeshClient). Replaces the Act 1 stubs at ``mesh_stubs.py`` with real implementations diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/plugin.yaml b/runtimes/hermes/src/kars_runtime_hermes/plugin/plugin.yaml index d2560432a..ee13b732a 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/plugin.yaml +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/plugin.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + name: kars version: "0.1.0" description: "kars in-pod adapter — wires Hermes into AGT governance, sub-agent spawn, Foundry tools, MCP, channels" diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/router_client.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/router_client.py index 454bcdaf6..2fabe132d 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/router_client.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/router_client.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """HTTP client to the inference-router sidecar at ``http://127.0.0.1:8443``. Single source of truth for: base URL, admin-token discovery, default diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/spawn.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/spawn.py index 0457b39d5..7b7be7d60 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/spawn.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/spawn.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars_spawn family — Phase A1.5. Spawn / status / destroy / list sub-agents via the inference-router's diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/telemetry.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/telemetry.py index 27e33ea0f..d157fd0c1 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/telemetry.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/telemetry.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Trust + signing-counter telemetry pushes — Phase A1.10. After successful peer interactions: push trust update to the router's diff --git a/runtimes/hermes/tests/test_file_transfer_unconditional.py b/runtimes/hermes/tests/test_file_transfer_unconditional.py index e07ce5465..5e52a1ac5 100644 --- a/runtimes/hermes/tests/test_file_transfer_unconditional.py +++ b/runtimes/hermes/tests/test_file_transfer_unconditional.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Regression guard: file_transfer auto-save runs even with KARS_MESH_AUTO_RESPONDER off. diff --git a/runtimes/hermes/tests/test_foundry_http_fetch.py b/runtimes/hermes/tests/test_foundry_http_fetch.py index 7f11767b5..291839b89 100644 --- a/runtimes/hermes/tests/test_foundry_http_fetch.py +++ b/runtimes/hermes/tests/test_foundry_http_fetch.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for foundry_memory + http_fetch.""" from __future__ import annotations diff --git a/runtimes/hermes/tests/test_foundry_native.py b/runtimes/hermes/tests/test_foundry_native.py index 758a4e6b8..687fc7cd6 100644 --- a/runtimes/hermes/tests/test_foundry_native.py +++ b/runtimes/hermes/tests/test_foundry_native.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for the 4 new native Foundry tools in Hermes plugin (web_search, code_execute, image_generation, file_search) plus the shared `_extract_response_text` helper. diff --git a/runtimes/hermes/tests/test_governance.py b/runtimes/hermes/tests/test_governance.py index 98ced1a93..4da884415 100644 --- a/runtimes/hermes/tests/test_governance.py +++ b/runtimes/hermes/tests/test_governance.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for the AGT governance pre_tool_call hook.""" from __future__ import annotations diff --git a/runtimes/hermes/tests/test_handoff.py b/runtimes/hermes/tests/test_handoff.py index 2b780f95b..ba4d11526 100644 --- a/runtimes/hermes/tests/test_handoff.py +++ b/runtimes/hermes/tests/test_handoff.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for kars_handoff_* family — status, request, confirm. The Hermes plugin's handoff module is a thin wrapper over the diff --git a/runtimes/hermes/tests/test_mesh_transfer_file.py b/runtimes/hermes/tests/test_mesh_transfer_file.py index 5d86ec29d..0ae164663 100644 --- a/runtimes/hermes/tests/test_mesh_transfer_file.py +++ b/runtimes/hermes/tests/test_mesh_transfer_file.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for kars_mesh_transfer_file (sender side) and mesh_worker._maybe_save_file_transfer (receiver side). diff --git a/runtimes/hermes/tests/test_mesh_worker.py b/runtimes/hermes/tests/test_mesh_worker.py index 84dce2338..0190028a1 100644 --- a/runtimes/hermes/tests/test_mesh_worker.py +++ b/runtimes/hermes/tests/test_mesh_worker.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for mesh_worker hooks — specifically the trust-publish hook that surfaces inbound peers in the operator's per-sandbox AGT panel. diff --git a/runtimes/hermes/tests/test_package_shape.py b/runtimes/hermes/tests/test_package_shape.py index ea725f60f..49c99a3ce 100644 --- a/runtimes/hermes/tests/test_package_shape.py +++ b/runtimes/hermes/tests/test_package_shape.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Basic import-shape sanity tests — runs in CI without Hermes installed.""" from __future__ import annotations diff --git a/runtimes/hermes/tests/test_peer_roster.py b/runtimes/hermes/tests/test_peer_roster.py index f1a5daa7b..bb7636607 100644 --- a/runtimes/hermes/tests/test_peer_roster.py +++ b/runtimes/hermes/tests/test_peer_roster.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for peer-roster auto-prepend on kars_mesh_send. Mirrors OpenClaw's `spawnedRoster` logic at diff --git a/runtimes/hermes/tests/test_router_client.py b/runtimes/hermes/tests/test_router_client.py index fb304b6a6..838a244e7 100644 --- a/runtimes/hermes/tests/test_router_client.py +++ b/runtimes/hermes/tests/test_router_client.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for router_client.call header forwarding.""" from __future__ import annotations diff --git a/runtimes/hermes/tests/test_spawn_discover.py b/runtimes/hermes/tests/test_spawn_discover.py index 8fe678fac..20c265713 100644 --- a/runtimes/hermes/tests/test_spawn_discover.py +++ b/runtimes/hermes/tests/test_spawn_discover.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for kars_spawn family + kars_discover.""" from __future__ import annotations diff --git a/runtimes/hermes/tests/test_telemetry.py b/runtimes/hermes/tests/test_telemetry.py index 8e6c7a014..3a3b7734d 100644 --- a/runtimes/hermes/tests/test_telemetry.py +++ b/runtimes/hermes/tests/test_telemetry.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Unit tests for telemetry trust + signing-counter pushes.""" from __future__ import annotations diff --git a/runtimes/langgraph-ts/README.md b/runtimes/langgraph-ts/README.md index 4ce2de136..c2358a553 100644 --- a/runtimes/langgraph-ts/README.md +++ b/runtimes/langgraph-ts/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # `@kars/runtime-langgraph-ts` In-pod adapter for **LangGraph (TypeScript / Node.js 22)** running on diff --git a/runtimes/langgraph/README.md b/runtimes/langgraph/README.md index dbe957a9d..fbf669810 100644 --- a/runtimes/langgraph/README.md +++ b/runtimes/langgraph/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars-runtime-langgraph In-pod adapter for the [LangGraph](https://github.com/langchain-ai/langgraph) diff --git a/runtimes/maf-python/README.md b/runtimes/maf-python/README.md index 093c66336..a0f1cfb97 100644 --- a/runtimes/maf-python/README.md +++ b/runtimes/maf-python/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars runtime adapter — Microsoft Agent Framework (Python) `kars_runtime_maf_python` is the in-pod adapter that wires the diff --git a/runtimes/openai-agents/README.md b/runtimes/openai-agents/README.md index fb1729b6e..df57d9154 100644 --- a/runtimes/openai-agents/README.md +++ b/runtimes/openai-agents/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars runtime adapter — OpenAI Agents (Python) `kars_runtime_openai_agents` is the in-pod adapter that wires the diff --git a/runtimes/openclaw/.gitignore b/runtimes/openclaw/.gitignore index dd6e803c7..9cc4f573f 100644 --- a/runtimes/openclaw/.gitignore +++ b/runtimes/openclaw/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + node_modules/ dist/ *.log diff --git a/runtimes/openclaw/skills/agt-governance/SKILL.md b/runtimes/openclaw/skills/agt-governance/SKILL.md index aa241908e..ebbaa810a 100644 --- a/runtimes/openclaw/skills/agt-governance/SKILL.md +++ b/runtimes/openclaw/skills/agt-governance/SKILL.md @@ -3,6 +3,9 @@ name: agt-governance description: Behavioral governance for OpenClaw agents via AGT — tool-level policy, inter-agent trust, audit logging. metadata: {"openclaw": {"requires": {"env": ["AGT_GOVERNANCE_ENABLED"]}, "primaryEnv": "AGT_GOVERNANCE_ENABLED"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # AGT Governance — Tool Policy, Trust, and Audit diff --git a/runtimes/openclaw/skills/foundry-agents/SKILL.md b/runtimes/openclaw/skills/foundry-agents/SKILL.md index 2ea8466f3..2992b8ba9 100644 --- a/runtimes/openclaw/skills/foundry-agents/SKILL.md +++ b/runtimes/openclaw/skills/foundry-agents/SKILL.md @@ -3,6 +3,9 @@ name: foundry-agents description: Query and inspect Foundry prompt agents and invoke Foundry tools via the Responses API. OpenClaw is the orchestrator — Foundry provides managed AI services. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Services — Agent Tools via Responses API diff --git a/runtimes/openclaw/skills/foundry-code/SKILL.md b/runtimes/openclaw/skills/foundry-code/SKILL.md index 9b941adc2..91922eed4 100644 --- a/runtimes/openclaw/skills/foundry-code/SKILL.md +++ b/runtimes/openclaw/skills/foundry-code/SKILL.md @@ -3,6 +3,9 @@ name: foundry-code description: Python code execution via Azure AI Foundry Responses API with code_interpreter tool. Data analysis, charts, and math in a managed sandbox. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Code — Code Interpreter (Responses API) diff --git a/runtimes/openclaw/skills/foundry-conversations/SKILL.md b/runtimes/openclaw/skills/foundry-conversations/SKILL.md index 0d4d284cf..29c47f8e3 100644 --- a/runtimes/openclaw/skills/foundry-conversations/SKILL.md +++ b/runtimes/openclaw/skills/foundry-conversations/SKILL.md @@ -3,6 +3,9 @@ name: foundry-conversations description: Manage persistent conversations via Foundry Conversations API. Create conversations, add messages, and maintain history across sessions. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Conversations — Persistent Conversation Management diff --git a/runtimes/openclaw/skills/foundry-deployments/SKILL.md b/runtimes/openclaw/skills/foundry-deployments/SKILL.md index 3bbeb7a1c..7dc1f7ac6 100644 --- a/runtimes/openclaw/skills/foundry-deployments/SKILL.md +++ b/runtimes/openclaw/skills/foundry-deployments/SKILL.md @@ -3,6 +3,9 @@ name: foundry-deployments description: Query model deployments, connections, and indexes in the Foundry project. Discover available models and infrastructure. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Infrastructure — Deployments, Connections & Indexes diff --git a/runtimes/openclaw/skills/foundry-evaluations/SKILL.md b/runtimes/openclaw/skills/foundry-evaluations/SKILL.md index 764082993..78df60427 100644 --- a/runtimes/openclaw/skills/foundry-evaluations/SKILL.md +++ b/runtimes/openclaw/skills/foundry-evaluations/SKILL.md @@ -3,6 +3,9 @@ name: foundry-evaluations description: Evaluate agent quality using Foundry OpenAI Evals API. Create evaluations, run them against models, and analyze results. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Evaluations — OpenAI Evals API diff --git a/runtimes/openclaw/skills/foundry-knowledge/SKILL.md b/runtimes/openclaw/skills/foundry-knowledge/SKILL.md index 3e9548b23..82a16faa4 100644 --- a/runtimes/openclaw/skills/foundry-knowledge/SKILL.md +++ b/runtimes/openclaw/skills/foundry-knowledge/SKILL.md @@ -3,6 +3,9 @@ name: foundry-knowledge description: Knowledge retrieval (RAG) via Foundry file_search and azure_ai_search tools. Agentic retrieval with citations — uses Responses API. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Knowledge — File Search & Azure AI Search (Responses API) diff --git a/runtimes/openclaw/skills/foundry-memory/SKILL.md b/runtimes/openclaw/skills/foundry-memory/SKILL.md index 2f71ed24b..7c415a44d 100644 --- a/runtimes/openclaw/skills/foundry-memory/SKILL.md +++ b/runtimes/openclaw/skills/foundry-memory/SKILL.md @@ -3,6 +3,9 @@ name: foundry-memory description: Persistent long-term memory via Foundry Memory Store APIs. User preferences and chat summaries survive pod restarts — no Foundry hosted agent needed. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Memory — Memory Store APIs diff --git a/runtimes/openclaw/skills/foundry-web-search/SKILL.md b/runtimes/openclaw/skills/foundry-web-search/SKILL.md index 00ab15bb7..2d9ddd435 100644 --- a/runtimes/openclaw/skills/foundry-web-search/SKILL.md +++ b/runtimes/openclaw/skills/foundry-web-search/SKILL.md @@ -3,6 +3,9 @@ name: foundry-web-search description: Real-time web search via Azure AI Foundry Responses API with bing_grounding tool. Get current information with citations — no egress policy needed. metadata: {"openclaw": {"requires": {"env": ["FOUNDRY_PROJECT_ENDPOINT"]}, "primaryEnv": "FOUNDRY_PROJECT_ENDPOINT"}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Foundry Web Search — Bing Grounding (Responses API) diff --git a/runtimes/openclaw/skills/kars-spawn/SKILL.md b/runtimes/openclaw/skills/kars-spawn/SKILL.md index 7332c530f..e3c9663b7 100644 --- a/runtimes/openclaw/skills/kars-spawn/SKILL.md +++ b/runtimes/openclaw/skills/kars-spawn/SKILL.md @@ -3,6 +3,9 @@ name: kars-spawn description: Spawn secure isolated sub-agent sandboxes, delegate tasks via AGT mesh, receive results, and destroy sub-agents. Uses the kars_spawn, kars_mesh_send, kars_mesh_inbox, and kars_spawn_destroy tools. metadata: {"openclaw": {"always": true}} --- +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Sub-Agent Spawn diff --git a/runtimes/pydantic-ai/README.md b/runtimes/pydantic-ai/README.md index ca82a0fcb..6c00e770d 100644 --- a/runtimes/pydantic-ai/README.md +++ b/runtimes/pydantic-ai/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars-runtime-pydantic-ai In-pod adapter for the [Pydantic-AI](https://ai.pydantic.dev/) agent diff --git a/sandbox-images/anthropic/Dockerfile b/sandbox-images/anthropic/Dockerfile index b8d2354a9..43eb0218c 100644 --- a/sandbox-images/anthropic/Dockerfile +++ b/sandbox-images/anthropic/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: Anthropic Claude Agent SDK (Python). # diff --git a/sandbox-images/anthropic/default-agent/main.py b/sandbox-images/anthropic/default-agent/main.py index eac78b141..640139190 100644 --- a/sandbox-images/anthropic/default-agent/main.py +++ b/sandbox-images/anthropic/default-agent/main.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """ kars default agent for the Anthropic Claude runtime. diff --git a/sandbox-images/conformance-runner/Dockerfile b/sandbox-images/conformance-runner/Dockerfile index c91cd330e..7b077f8bb 100644 --- a/sandbox-images/conformance-runner/Dockerfile +++ b/sandbox-images/conformance-runner/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars Conformance Runner — distroless (build-once pattern) # # A `KarsEval` run spawns this as an ephemeral K8s Job; the runner diff --git a/sandbox-images/hermes/Dockerfile b/sandbox-images/hermes/Dockerfile index d07c5f758..4f92ecb6d 100644 --- a/sandbox-images/hermes/Dockerfile +++ b/sandbox-images/hermes/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: Hermes Agent (Nous Research) # diff --git a/sandbox-images/hermes/default-agent/main.py b/sandbox-images/hermes/default-agent/main.py index 13f803f2e..039200385 100644 --- a/sandbox-images/hermes/default-agent/main.py +++ b/sandbox-images/hermes/default-agent/main.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """kars default agent for the Hermes runtime — smoke test. This file is staged at /opt/kars-default-agent/main.py in the sandbox diff --git a/sandbox-images/langgraph-ts/Dockerfile b/sandbox-images/langgraph-ts/Dockerfile index 8e3803c88..e0abc8e75 100644 --- a/sandbox-images/langgraph-ts/Dockerfile +++ b/sandbox-images/langgraph-ts/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: LangGraph (LangChain.js) for TypeScript / # Node.js 22. diff --git a/sandbox-images/langgraph/Dockerfile b/sandbox-images/langgraph/Dockerfile index b66b28a05..40bb86cc7 100644 --- a/sandbox-images/langgraph/Dockerfile +++ b/sandbox-images/langgraph/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: LangGraph (LangChain) for Python. # diff --git a/sandbox-images/langgraph/default-agent/main.py b/sandbox-images/langgraph/default-agent/main.py index 6f1b7d1c3..308584d1a 100644 --- a/sandbox-images/langgraph/default-agent/main.py +++ b/sandbox-images/langgraph/default-agent/main.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """ kars default agent for the LangGraph (Python) runtime. diff --git a/sandbox-images/maf-python/Dockerfile b/sandbox-images/maf-python/Dockerfile index b1cf9b8bb..64d7e2719 100644 --- a/sandbox-images/maf-python/Dockerfile +++ b/sandbox-images/maf-python/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: Microsoft Agent Framework Python. # diff --git a/sandbox-images/maf-python/default-agent/main.py b/sandbox-images/maf-python/default-agent/main.py index 6933f4532..e70357217 100644 --- a/sandbox-images/maf-python/default-agent/main.py +++ b/sandbox-images/maf-python/default-agent/main.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """ kars default agent for the Microsoft Agent Framework Python runtime. diff --git a/sandbox-images/nemoclaw/Dockerfile b/sandbox-images/nemoclaw/Dockerfile index 1740439db..5d49b6f67 100644 --- a/sandbox-images/nemoclaw/Dockerfile +++ b/sandbox-images/nemoclaw/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # NemoClaw sandbox image — OpenClaw + NemoClaw plugin inside OpenShell # # Layers PR-specific code (plugin, blueprint, config, startup script) on top diff --git a/sandbox-images/openai-agents/Dockerfile b/sandbox-images/openai-agents/Dockerfile index 0146e47f6..500d57f9b 100644 --- a/sandbox-images/openai-agents/Dockerfile +++ b/sandbox-images/openai-agents/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: OpenAI Agents Python. # diff --git a/sandbox-images/openai-agents/default-agent/main.py b/sandbox-images/openai-agents/default-agent/main.py index 9814d64e2..eaaca4755 100644 --- a/sandbox-images/openai-agents/default-agent/main.py +++ b/sandbox-images/openai-agents/default-agent/main.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """ kars default agent for the OpenAI Agents Python SDK runtime. diff --git a/sandbox-images/openclaw/Dockerfile b/sandbox-images/openclaw/Dockerfile index 9a78b039f..e6b100a9f 100644 --- a/sandbox-images/openclaw/Dockerfile +++ b/sandbox-images/openclaw/Dockerfile @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars OpenClaw Sandbox Image # Slim overlay on top of kars-sandbox-base — adds only the router binary, # kars plugin, vendored SDK, and entrypoint. diff --git a/sandbox-images/openclaw/Dockerfile.base b/sandbox-images/openclaw/Dockerfile.base index 304176e23..92c008571 100644 --- a/sandbox-images/openclaw/Dockerfile.base +++ b/sandbox-images/openclaw/Dockerfile.base @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # kars OpenClaw Sandbox — Base Image # Contains all heavy, rarely-changing dependencies: OS packages, Node.js, # Python, Go CLI tools, OpenClaw framework, extension symlinks, and user setup. diff --git a/sandbox-images/pydantic-ai/Dockerfile b/sandbox-images/pydantic-ai/Dockerfile index 7c55ad63f..c9a099cae 100644 --- a/sandbox-images/pydantic-ai/Dockerfile +++ b/sandbox-images/pydantic-ai/Dockerfile @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # # kars runtime image: Pydantic-AI for Python. # diff --git a/sandbox-images/pydantic-ai/default-agent/main.py b/sandbox-images/pydantic-ai/default-agent/main.py index 4f0bfadb9..f6ad4e484 100644 --- a/sandbox-images/pydantic-ai/default-agent/main.py +++ b/sandbox-images/pydantic-ai/default-agent/main.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """ kars default agent for the Pydantic-AI runtime. diff --git a/scripts/apply-copyright-headers.sh b/scripts/apply-copyright-headers.sh index 9846722e7..32f73990e 100755 --- a/scripts/apply-copyright-headers.sh +++ b/scripts/apply-copyright-headers.sh @@ -1,58 +1,7 @@ #!/usr/bin/env bash # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# scripts/apply-copyright-headers.sh — one-shot idempotent header applier. -# Run from repo root. Idempotent: running twice is a no-op. +# Insertion-only, idempotent applier; shares all format/coverage rules with CI. set -euo pipefail - -SLASH_HEADER="// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License." - -HASH_HEADER="# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License." - -applied=0 -skipped=0 - -while IFS= read -r file; do - # Skip if already has the header - if head -5 "$file" | grep -qE '^(//|#) *Copyright \(c\) Microsoft Corporation'; then - ((skipped++)) || true - continue - fi - - # Determine comment style by extension - ext="${file##*.}" - case "$ext" in - rs|ts|tsx|js) header="$SLASH_HEADER" ;; - sh) header="$HASH_HEADER" ;; - *) continue ;; - esac - - # Read file content - content=$(<"$file") - - # Handle shebang - first_line=$(head -1 "$file") - if [[ "$first_line" == '#!'* ]]; then - rest=$(tail -n +2 "$file") - printf '%s\n%s\n\n%s\n' "$first_line" "$header" "$rest" > "$file" - else - printf '%s\n\n%s\n' "$header" "$content" > "$file" - fi - - ((applied++)) || true -done < <( - git ls-files \ - | grep -E '\.(rs|ts|tsx|js|sh)$' \ - | grep -v '^vendor/' \ - | grep -v 'node_modules/' \ - | grep -v '/dist/' \ - | grep -v '^target/' \ - | grep -v '/build/' \ - | grep -v '\.d\.ts$' \ - | grep -v '\.turbo/' \ - | grep -v '/coverage/' -) - -echo "✅ Applied headers to $applied file(s). Skipped $skipped (already had header)." +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "$ROOT/ci/copyright_headers.py" apply "$@" diff --git a/scripts/showcase/README.md b/scripts/showcase/README.md index 9a52021b2..148964db0 100644 --- a/scripts/showcase/README.md +++ b/scripts/showcase/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Showcase asset builders ## Pitch deck (15 slides) diff --git a/tests/chaos/Cargo.toml b/tests/chaos/Cargo.toml index 75219e94c..a7c9df654 100644 --- a/tests/chaos/Cargo.toml +++ b/tests/chaos/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-chaos-tests" description = "Phase 2 S16 — fault-injection chaos tier (K8s API flakes, Foundry 429 storms, Entra rotation, AGT relay timeouts)" diff --git a/tests/chaos/README.md b/tests/chaos/README.md index 7ac82454d..d151cca36 100644 --- a/tests/chaos/README.md +++ b/tests/chaos/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Chaos tier (Phase 2 S16) Self-contained fault-injection test suite under `tests/chaos/`. diff --git a/tests/cncf-conformance/CONFORMANCE-REPORT.md b/tests/cncf-conformance/CONFORMANCE-REPORT.md index bf595ea48..57c9613b9 100644 --- a/tests/cncf-conformance/CONFORMANCE-REPORT.md +++ b/tests/cncf-conformance/CONFORMANCE-REPORT.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars — CNCF K8s AI Conformance Report > **Self-assessment, not an official certification.** This report is the output of a self-hosted harness that asserts the kars repository against the criteria listed below. It is not an official CNCF conformance certification. diff --git a/tests/cncf-conformance/Cargo.toml b/tests/cncf-conformance/Cargo.toml index 26be0a1d5..031b5bb76 100644 --- a/tests/cncf-conformance/Cargo.toml +++ b/tests/cncf-conformance/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "kars-cncf-conformance" description = "K8s AI Conformance v1.35+ test suite for kars CRDs and operator manifests (S17)" diff --git a/tests/compat/README.md b/tests/compat/README.md index eb6ecac5d..fddf13856 100644 --- a/tests/compat/README.md +++ b/tests/compat/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Compatibility Suite (`tests/compat/`) **Status:** Phase 0 skeleton. Grows with every Phase-0→Phase-4 decomposition. diff --git a/tests/compat/fixtures/null-provider-devonly-ok.yaml b/tests/compat/fixtures/null-provider-devonly-ok.yaml index 6ef274e2f..ec2c27f08 100644 --- a/tests/compat/fixtures/null-provider-devonly-ok.yaml +++ b/tests/compat/fixtures/null-provider-devonly-ok.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Null-provider admission fixtures — positive case. # Dev-labelled KarsSandbox explicitly opts into a null provider. The # ValidatingAdmissionPolicy in deploy/helm/kars/templates/ diff --git a/tests/compat/fixtures/null-provider-prod-denied.yaml b/tests/compat/fixtures/null-provider-prod-denied.yaml index ea9552e9a..e52fdb2a9 100644 --- a/tests/compat/fixtures/null-provider-prod-denied.yaml +++ b/tests/compat/fixtures/null-provider-prod-denied.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Null-provider admission fixtures — negative case. # Production-style KarsSandbox (no dev-only label) declaring noop/null/ # disabled providers. The ValidatingAdmissionPolicy in diff --git a/tests/conformance/README.md b/tests/conformance/README.md index 76ebdb771..471341e3d 100644 --- a/tests/conformance/README.md +++ b/tests/conformance/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Behavioral Conformance Corpus Protocol invariants beyond happy-path — the net that catches diff --git a/tests/conformance/fixtures/README.md b/tests/conformance/fixtures/README.md index 03fa4aa9d..f17f46971 100644 --- a/tests/conformance/fixtures/README.md +++ b/tests/conformance/fixtures/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Conformance corpus fixtures Vendored test vectors and fixtures for protocol-invariant tests. diff --git a/tests/e2e-manual/README.md b/tests/e2e-manual/README.md index 92bd832f7..f656464be 100644 --- a/tests/e2e-manual/README.md +++ b/tests/e2e-manual/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Manual E2E suite This directory holds the **manually-runnable** end-to-end test matrix diff --git a/tests/e2e/Dockerfile.sandbox-stub b/tests/e2e/Dockerfile.sandbox-stub index 4fa6f78ad..625c48eef 100644 --- a/tests/e2e/Dockerfile.sandbox-stub +++ b/tests/e2e/Dockerfile.sandbox-stub @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Minimal sandbox stand-in for the e2e distroless gate (test_sandbox_pod_starts). # # The real sandbox image (sandbox-images/openclaw) is ~3.8GB — far too heavy to diff --git a/tests/e2e/interop/manifests/aks-hermes-bidi-2.yaml b/tests/e2e/interop/manifests/aks-hermes-bidi-2.yaml index b4d7c2c6a..ccf8cba8d 100644 --- a/tests/e2e/interop/manifests/aks-hermes-bidi-2.yaml +++ b/tests/e2e/interop/manifests/aks-hermes-bidi-2.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: kars.azure.com/v1alpha1 kind: InferencePolicy metadata: diff --git a/tests/k6/README.md b/tests/k6/README.md index 19567ff1f..baf6e1987 100644 --- a/tests/k6/README.md +++ b/tests/k6/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # k6 perf smoke (Phase 2 S16) The k6 smoke test exercises the inference router at modest concurrency diff --git a/tools/README.md b/tools/README.md index 852ff78e9..38f1a5241 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # tools/ Repo-local tooling that is **not** shipped in any deployable artifact. diff --git a/tools/demo/README.md b/tools/demo/README.md index d395bac78..ebc6fdf96 100644 --- a/tools/demo/README.md +++ b/tools/demo/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # `tools/demo/` — scripted end-to-end walkthrough A single shell script that exercises the full kars stack across the diff --git a/tools/demo/act2/agent-a-research.yaml b/tools/demo/act2/agent-a-research.yaml index 9dfe3fa0a..0843b6795 100644 --- a/tools/demo/act2/agent-a-research.yaml +++ b/tools/demo/act2/agent-a-research.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Agent A — the kars sandbox the showcase demo (Acts I + II) runs. # # Act I uses this sandbox to demonstrate the architecture in motion: diff --git a/tools/demo/act2/demo-1-minimal-summarizer.yaml b/tools/demo/act2/demo-1-minimal-summarizer.yaml index b3f3f9809..3073e0cf1 100644 --- a/tools/demo/act2/demo-1-minimal-summarizer.yaml +++ b/tools/demo/act2/demo-1-minimal-summarizer.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # ════════════════════════════════════════════════════════════════════ # DEMO SANDBOX #1 — minimal Hermes "summarizer" # ════════════════════════════════════════════════════════════════════ diff --git a/tools/demo/act2/demo-2-governed-translator.yaml b/tools/demo/act2/demo-2-governed-translator.yaml index 2c9f52fbd..4aaddd353 100644 --- a/tools/demo/act2/demo-2-governed-translator.yaml +++ b/tools/demo/act2/demo-2-governed-translator.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # ════════════════════════════════════════════════════════════════════ # DEMO SANDBOX #2 — OpenClaw "translator" with FULL GOVERNANCE # ════════════════════════════════════════════════════════════════════ diff --git a/tools/demo/act2/demo-3-mesh-analyst.yaml b/tools/demo/act2/demo-3-mesh-analyst.yaml index 0a38ddd38..086400bdf 100644 --- a/tools/demo/act2/demo-3-mesh-analyst.yaml +++ b/tools/demo/act2/demo-3-mesh-analyst.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # ════════════════════════════════════════════════════════════════════ # DEMO SANDBOX #3 — Hermes "analyst" with MESH + MEMORY # ════════════════════════════════════════════════════════════════════ diff --git a/tools/demo/act2/platform-hardening-quota.yaml b/tools/demo/act2/platform-hardening-quota.yaml index 65959b5d9..5b98d8cbc 100644 --- a/tools/demo/act2/platform-hardening-quota.yaml +++ b/tools/demo/act2/platform-hardening-quota.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Act II — the infrastructure break. # # Scenario: "the platform team's GitOps refactor lands a hardening diff --git a/tools/demo/act2/runbook.md b/tools/demo/act2/runbook.md index 03d99532a..8e47d874e 100644 --- a/tools/demo/act2/runbook.md +++ b/tools/demo/act2/runbook.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # Act II — presenter runbook Use this when the kars-sre agent isn't built yet (S1-S5 in progress) diff --git a/tools/demo/scenarios/01-sandbox.yaml b/tools/demo/scenarios/01-sandbox.yaml index 1a906f55a..0fd8405dc 100644 --- a/tools/demo/scenarios/01-sandbox.yaml +++ b/tools/demo/scenarios/01-sandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Demo scenario step 1 — minimal governed sandbox. # # Applies an InferencePolicy + KarsSandbox, expects router echo Ready. diff --git a/tools/demo/scenarios/02-toolpolicy.yaml b/tools/demo/scenarios/02-toolpolicy.yaml index 448e96afe..f4cc9c7df 100644 --- a/tools/demo/scenarios/02-toolpolicy.yaml +++ b/tools/demo/scenarios/02-toolpolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Demo scenario step 2 — ToolPolicy gates a sensitive tool. # # Demonstrates approval-required path: an outbound HTTP tool needs diff --git a/tools/demo/scenarios/03-egress-approval.yaml b/tools/demo/scenarios/03-egress-approval.yaml index d15d06f64..bf5137ce2 100644 --- a/tools/demo/scenarios/03-egress-approval.yaml +++ b/tools/demo/scenarios/03-egress-approval.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Demo scenario step 3 — Time-boxed egress approval. # # Demonstrates the EgressApproval overlay: a temporary grant to reach diff --git a/tools/demo/scenarios/04-claweval.yaml b/tools/demo/scenarios/04-claweval.yaml index 975d4e37e..ed6548599 100644 --- a/tools/demo/scenarios/04-claweval.yaml +++ b/tools/demo/scenarios/04-claweval.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Demo scenario step 4 — Run-now KarsEval against the demo sandbox. # # The reconciler immediately creates a one-shot Job (run-now annotation diff --git a/tools/drift/README.md b/tools/drift/README.md index bb2784d98..42c003b4b 100644 --- a/tools/drift/README.md +++ b/tools/drift/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # drift — behavioral-equivalence proof for mechanical refactors `drift.py` compares two item manifests (produced by diff --git a/tools/drift/allowlist-q1.txt b/tools/drift/allowlist-q1.txt index 312c98d1d..f5b7c2755 100644 --- a/tools/drift/allowlist-q1.txt +++ b/tools/drift/allowlist-q1.txt @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Allowlisted fn body mutations for the q1 routes split (waves 1-5). # # Format: one fn leaf-name per line; '#' starts a comment. Each entry MUST diff --git a/tools/drift/drift.py b/tools/drift/drift.py index 5045125ca..8cb36110e 100644 --- a/tools/drift/drift.py +++ b/tools/drift/drift.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Q1 refactor drift checker. Compares a baseline item manifest against a post-refactor manifest and diff --git a/tools/e2e-harness/README.md b/tools/e2e-harness/README.md index 0fc040735..8601c0036 100644 --- a/tools/e2e-harness/README.md +++ b/tools/e2e-harness/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars e2e-harness A scenario- and platform-pluggable end-to-end test harness for kars. diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/README.md b/tools/e2e-harness/scenarios/exec-brief-hermes-single/README.md index 39d60dcd6..0cef9cf4e 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/README.md +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # exec-brief-hermes-single — single-agent variant on Hermes The canonical [`exec-brief`](../exec-brief/) scenario is a four-agent diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/00-namespace.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/00-namespace.yaml index 04d9ecb3b..665a4f0e0 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/00-namespace.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/00-namespace.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 00-namespace.yaml — dedicated namespace for the single-agent Hermes # variant of exec-brief. # diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/01-inferencepolicy.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/01-inferencepolicy.yaml index a16fc4604..096c51dd3 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/01-inferencepolicy.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/01-inferencepolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 01-inferencepolicy.yaml — Foundry / Azure-OpenAI provider for the # single-agent Hermes exec-brief. Same provider requirement as the # canonical exec-brief: Foundry data-plane (web search, image gen, diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/02-toolpolicy.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/02-toolpolicy.yaml index 0e3e02072..ff324b0c5 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/02-toolpolicy.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/02-toolpolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 02-toolpolicy.yaml — ToolPolicy CR for the single-agent Hermes # exec-brief. Inlines the kars-default AGT profile (same source of # truth as cli/profiles/agt/kars-default.yaml) so the in-pod diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/03-clawmemory.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/03-clawmemory.yaml index 5ac5339b4..7bb2c6866 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/03-clawmemory.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/03-clawmemory.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 03-karsmemory.yaml — Foundry Memory Store binding for the # single-agent Hermes exec-brief. The agent persists the analyst JSON # under key='analyst.json' via foundry_memory upsert so a follow-up diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/04-mcpserver.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/04-mcpserver.yaml index 2f1d30480..1729466d6 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/04-mcpserver.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/04-mcpserver.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 04-mcpserver.yaml — DeepWiki MCP for the Hermes scenario. Same # public unauthenticated endpoint as the canonical exec-brief. # Hermes' native MCP client picks it up from diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/05-clawsandbox.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/05-clawsandbox.yaml index 3970dd382..ed3e12eff 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/05-clawsandbox.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/05-clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 05-karssandbox.yaml — single-agent Hermes exec-brief sandbox. # # Runtime = Hermes (Nous Research). Same plugin contract as OpenClaw — diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes/README.md b/tools/e2e-harness/scenarios/exec-brief-hermes/README.md index aeb6a0233..220480a6b 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes/README.md +++ b/tools/e2e-harness/scenarios/exec-brief-hermes/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # exec-brief-hermes — multi-agent Hermes mesh e2e Parent Hermes sandbox uses `kars_spawn` to launch 3 Hermes sub-agents diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/00-namespace.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/00-namespace.yaml index 1eda998ca..32790eef0 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/00-namespace.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/00-namespace.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 00-namespace.yaml — dedicated namespace for the multi-agent # exec-brief-hermes e2e sandbox. --- diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/01-inferencepolicy.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/01-inferencepolicy.yaml index 219a53674..5c085540b 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/01-inferencepolicy.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/01-inferencepolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 01-inferencepolicy.yaml — Foundry/AOAI provider for the multi-agent # exec-brief-hermes scenario. The parent spawns 3 sub-agents (analyst, # viz, writer) at runtime via kars_spawn; sub-agents are spawned as diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/02-toolpolicy.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/02-toolpolicy.yaml index 69e548d19..92de646ba 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/02-toolpolicy.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/02-toolpolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 02-toolpolicy.yaml — single AGT profile attached to the parent # sandbox. The kars_spawn helper points every child's CRD at # `<parent>-toolpolicy` so all sub-agents share this profile (same diff --git a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/05-clawsandbox.yaml b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/05-clawsandbox.yaml index 7e17265f8..f09653c57 100644 --- a/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/05-clawsandbox.yaml +++ b/tools/e2e-harness/scenarios/exec-brief-hermes/manifests/05-clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 05-clawsandbox.yaml — parent Hermes sandbox for multi-agent # exec-brief. Three sub-agents (analyst, viz, writer) are spawned at # runtime by the parent via kars_spawn; the router's spawn endpoint diff --git a/tools/e2e-harness/scenarios/exec-brief/manifests/00-namespace.yaml b/tools/e2e-harness/scenarios/exec-brief/manifests/00-namespace.yaml index 86edbe8f3..4d790a210 100644 --- a/tools/e2e-harness/scenarios/exec-brief/manifests/00-namespace.yaml +++ b/tools/e2e-harness/scenarios/exec-brief/manifests/00-namespace.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 00-namespace.yaml — dedicated namespace for the exec-brief e2e sandbox. # # The controller installs KarsSandbox / InferencePolicy / ToolPolicy / diff --git a/tools/e2e-harness/scenarios/exec-brief/manifests/01-inferencepolicy.yaml b/tools/e2e-harness/scenarios/exec-brief/manifests/01-inferencepolicy.yaml index 0568dfac5..59deed007 100644 --- a/tools/e2e-harness/scenarios/exec-brief/manifests/01-inferencepolicy.yaml +++ b/tools/e2e-harness/scenarios/exec-brief/manifests/01-inferencepolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 01-inferencepolicy.yaml — Foundry / Azure-OpenAI provider for the # executive-brief sandbox. Must use a provider that unlocks the Foundry # data-plane (web search, image generation via gpt-image-1, code diff --git a/tools/e2e-harness/scenarios/exec-brief/manifests/02-toolpolicy.yaml b/tools/e2e-harness/scenarios/exec-brief/manifests/02-toolpolicy.yaml index 96ce06166..1502ed365 100644 --- a/tools/e2e-harness/scenarios/exec-brief/manifests/02-toolpolicy.yaml +++ b/tools/e2e-harness/scenarios/exec-brief/manifests/02-toolpolicy.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 02-toolpolicy.yaml — single ToolPolicy CR for the exec-brief sandbox. # # `agtProfile.inline` is the **verbatim** contents of the canonical diff --git a/tools/e2e-harness/scenarios/exec-brief/manifests/03-clawmemory.yaml b/tools/e2e-harness/scenarios/exec-brief/manifests/03-clawmemory.yaml index 9850d754f..6a7b0ab2d 100644 --- a/tools/e2e-harness/scenarios/exec-brief/manifests/03-clawmemory.yaml +++ b/tools/e2e-harness/scenarios/exec-brief/manifests/03-clawmemory.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 03-karsmemory.yaml — Foundry Memory Store binding for the exec-brief # sandbox. Lets the analyst persist its JSON artifact so later # sub-agent invocations (or a retry) can pick it up without re-running diff --git a/tools/e2e-harness/scenarios/exec-brief/manifests/04-mcpserver.yaml b/tools/e2e-harness/scenarios/exec-brief/manifests/04-mcpserver.yaml index 6a34886b7..7829070aa 100644 --- a/tools/e2e-harness/scenarios/exec-brief/manifests/04-mcpserver.yaml +++ b/tools/e2e-harness/scenarios/exec-brief/manifests/04-mcpserver.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 04-mcpserver.yaml — register an external MCP server the analyst can # call alongside foundry_web_search. We use DeepWiki's hosted MCP # (https://mcp.deepwiki.com/mcp), which serves diff --git a/tools/e2e-harness/scenarios/exec-brief/manifests/05-clawsandbox.yaml b/tools/e2e-harness/scenarios/exec-brief/manifests/05-clawsandbox.yaml index 9d69b794c..086ccd95c 100644 --- a/tools/e2e-harness/scenarios/exec-brief/manifests/05-clawsandbox.yaml +++ b/tools/e2e-harness/scenarios/exec-brief/manifests/05-clawsandbox.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 05-karssandbox.yaml — the actual sandbox that runs the executive-brief # pipeline. Three sub-agents (analyst, viz, writer) are spawned at # runtime by the parent agent based on the prompt's coordination diff --git a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/README.md b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/README.md index cbce55200..330989e00 100644 --- a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/README.md +++ b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # mesh-roundtrip-hermes — Hermes Act 2 mesh end-to-end validation Smallest possible scenario that exercises the **Python AGT MeshClient** diff --git a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/00-namespaces.yaml b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/00-namespaces.yaml index 56c527d5d..ebca0ae90 100644 --- a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/00-namespaces.yaml +++ b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/00-namespaces.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Two namespaces — one per sandbox. The controller creates the # per-sandbox namespaces as `kars-<sandbox-name>` so we pre-create # them here for any credentials Secrets the driver wants to land diff --git a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/01-inferencepolicies.yaml b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/01-inferencepolicies.yaml index d8fbbd980..6e431888f 100644 --- a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/01-inferencepolicies.yaml +++ b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/01-inferencepolicies.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 01-inferencepolicies.yaml — Foundry/AOAI provider for both sandboxes. --- apiVersion: kars.azure.com/v1alpha1 diff --git a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/02-toolpolicies.yaml b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/02-toolpolicies.yaml index 96fab6185..8ede895e6 100644 --- a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/02-toolpolicies.yaml +++ b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/02-toolpolicies.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # 02-toolpolicies.yaml — minimal AGT profiles for both mesh sandboxes. # # Inlines the same Act 2 Hermes built-in deny list as diff --git a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/05-sandboxes.yaml b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/05-sandboxes.yaml index 75abf5772..c3476c44e 100644 --- a/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/05-sandboxes.yaml +++ b/tools/e2e-harness/scenarios/mesh-roundtrip-hermes/manifests/05-sandboxes.yaml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Two Hermes sandboxes. Both use the same image (kars-runtime-hermes:latest) # the controller picks via RuntimeKind::Hermes; the only difference is # which one is LLM-driven (mesh-ping-hermes) vs which one runs the diff --git a/tools/headlamp-plugin/.gitignore b/tools/headlamp-plugin/.gitignore index 4036d8a97..270805fdd 100644 --- a/tools/headlamp-plugin/.gitignore +++ b/tools/headlamp-plugin/.gitignore @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + node_modules/ *.log .cache/ diff --git a/tools/headlamp-plugin/README.md b/tools/headlamp-plugin/README.md index fd122f881..541583ed7 100644 --- a/tools/headlamp-plugin/README.md +++ b/tools/headlamp-plugin/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # kars Headlamp Plugin Adds an **kars** sidebar to the [Headlamp](https://headlamp.dev/) Kubernetes diff --git a/tools/item-manifest/.gitignore b/tools/item-manifest/.gitignore index 4fffb2f89..e7cf2fd5a 100644 --- a/tools/item-manifest/.gitignore +++ b/tools/item-manifest/.gitignore @@ -1,2 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + /target /Cargo.lock diff --git a/tools/item-manifest/Cargo.toml b/tools/item-manifest/Cargo.toml index 0820aedfe..e3f0f2eee 100644 --- a/tools/item-manifest/Cargo.toml +++ b/tools/item-manifest/Cargo.toml @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + [package] name = "item-manifest" version = "0.1.0" diff --git a/tools/item-manifest/README.md b/tools/item-manifest/README.md index 86a96f905..fdb1da0ab 100644 --- a/tools/item-manifest/README.md +++ b/tools/item-manifest/README.md @@ -1,3 +1,6 @@ +<!-- Copyright (c) Microsoft Corporation. +Licensed under the MIT License. --> + # item-manifest `syn`-based extractor for behavioral-equivalence proofs on large mechanical From 4b301e0568a89c951b834d79525e9d955e336dcc Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 19:31:37 +0200 Subject: [PATCH 098/111] fix(deps): require patched Rustls for RUSTSEC-2026-0285 Raise the existing TLS dependency floor to 0.23.45 in core and the separate BFF without changing provider/features. Update only Rustls and its required aws-lc/webpki dependency family, retaining unrelated locked package choices; Cargo workspace-locked resolution validates both minimized graphs. Do not suppress the newly published advisory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- Cargo.lock | 28 ++++++++++++++++++---------- Cargo.toml | 3 ++- bridge/bff/Cargo.lock | 23 +++++++++++++++-------- bridge/bff/Cargo.toml | 3 ++- controller/Cargo.toml | 2 +- 5 files changed, 38 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cf09aba7..c0b9b1084 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -318,23 +318,24 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-fips-sys" -version = "0.13.14" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d619165468401dec3caa3366ebffbcb83f2f31883e5b3932f8e2dec2ddc568" +checksum = "03367707e92796b190a4207d4d39b0a4271d574503d2969c2b0cfbf5c87658ee" dependencies = [ "bindgen", "cc", "cmake", "dunce", "fs_extra", + "pkg-config", "regex", ] [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-fips-sys", "aws-lc-sys", @@ -344,15 +345,16 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "bindgen", "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -3615,6 +3617,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "poly1305" version = "0.8.0" @@ -4298,9 +4306,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -4372,9 +4380,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", diff --git a/Cargo.toml b/Cargo.toml index 00edc4b49..f8f1e5306 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,8 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" # explicitly — otherwise kube-client's first TLS handshake panics # with "Could not automatically determine the process-level # CryptoProvider" when multiple feature flags resolve. -rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } +# This minimum fixes RUSTSEC-2026-0285. +rustls = { version = "0.23.45", default-features = false, features = ["aws-lc-rs"] } rcgen = { version = "0.13.2", default-features = false, features = ["aws_lc_rs", "pem"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs"] } rustls-pemfile = "2" diff --git a/bridge/bff/Cargo.lock b/bridge/bff/Cargo.lock index f6737d1ec..945e4f531 100644 --- a/bridge/bff/Cargo.lock +++ b/bridge/bff/Cargo.lock @@ -104,9 +104,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -115,14 +115,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1570,6 +1571,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1824,9 +1831,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -1862,9 +1869,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", diff --git a/bridge/bff/Cargo.toml b/bridge/bff/Cargo.toml index a1fa2fd50..b80dbf918 100644 --- a/bridge/bff/Cargo.toml +++ b/bridge/bff/Cargo.toml @@ -32,7 +32,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" kube = { version = "0.99", default-features = false, features = ["client", "runtime", "derive", "rustls-tls", "jsonpatch"] } k8s-openapi = { version = "0.24", features = ["latest"] } schemars = "0.8" -rustls = { version = "0.23", features = ["aws-lc-rs"] } +# This minimum fixes RUSTSEC-2026-0285. +rustls = { version = "0.23.45", features = ["aws-lc-rs"] } base64 = "0.22" sha2 = "0.10" subtle = "2.6" diff --git a/controller/Cargo.toml b/controller/Cargo.toml index 4ed4cee5a..06fba6e92 100644 --- a/controller/Cargo.toml +++ b/controller/Cargo.toml @@ -75,7 +75,7 @@ oci-client = { version = "=0.16.1", default-features = false, features = ["rustl # aws-lc-rs) enable both providers, which makes rustls 0.23.40+ # refuse to auto-detect and panic on first TLS handshake. Pin to # `aws-lc-rs` to align with the rest of the workspace. -rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } +rustls = { version = "0.23.45", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } rcgen.workspace = true time.workspace = true regex = "1.12.3" From b516713319a651ffb4dc86fcffb40e5a2c716c47 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 20:19:01 +0200 Subject: [PATCH 099/111] test(bridge): retain rotation failure state before controller restart Capture bounded controller lifecycle, grant generation and metadata-only observer resource facts at the original failure hook, before later teardown changes the evidence. Preserve original failure classification, request defaults and production behavior; keep unknown reconciliation progress explicitly unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/docs/governed-credentials.md | 19 ++ bridge/tests/native-credentials/native_api.py | 7 +- .../rotation_diagnostics.py | 262 ++++++++++++++++++ bridge/tests/native-credentials/run.py | 8 +- .../test_rotation_diagnostics.py | 243 ++++++++++++++++ 5 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 bridge/tests/native-credentials/rotation_diagnostics.py create mode 100644 bridge/tests/native-credentials/test_rotation_diagnostics.py diff --git a/bridge/docs/governed-credentials.md b/bridge/docs/governed-credentials.md index e7a882089..55bdc48ce 100644 --- a/bridge/docs/governed-credentials.md +++ b/bridge/docs/governed-credentials.md @@ -473,6 +473,25 @@ deadline and core pin are unchanged. Run the dependency-free provenance, fencing and cleanup tests with: `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests/native-credentials -p 'test_*.py'`. +### Failure-time rotation snapshot + +The rotation case's exact current-grant/writer timeout adds +`rotationFailureSnapshot` synchronously, saving the failed result and snapshot +before the later writer-uninstall case can restart the controller. It records +up to two lineage-bound controller Pod identities, restart counts and fixed +termination reasons/exit codes/signals; the canonical grant's current/observed +generations; and namespace/grant-bound observer Secret and CNP metadata. +Secret/CNP requests require Kubernetes `PartialObjectMetadata` representations +and reject full-object fallback: no Secret data or policy bodies are retained. +Existing controller warnings from the first bound Pod are limited to 128 lines, +64 KiB and 16 projected records; arbitrary errors, termination messages and log +fields are discarded. Missing reconciliation stages remain `unavailable`. +Reads have a 40-second scheduling budget, at most 48 GETs and per-operation +timeouts up to 10 seconds; the existing CLI-origin check has its own 10-second +bound. Metadata inventories are capped at 32 CNPs and rechecked for drift. +This is diagnostic-only: it adds no retries, changes no readiness deadline and +cannot turn the original failure into a pass. + ### Actor-scoped native API outcomes Only the two failing native cases collect `actorApiOutcomes`: credential diff --git a/bridge/tests/native-credentials/native_api.py b/bridge/tests/native-credentials/native_api.py index 0a4efc42b..a20395372 100644 --- a/bridge/tests/native-credentials/native_api.py +++ b/bridge/tests/native-credentials/native_api.py @@ -145,14 +145,15 @@ def __init__(self, server, context, token=None): self.token = token self.server = server - def request(self, method, path, body=None, expected=(200,), patch_type=None): - headers = {"Accept": "application/json"} + def request(self, method, path, body=None, expected=(200,), patch_type=None, *, + accept="application/json", timeout=15): + headers = {"Accept": accept} if self.token: headers["Authorization"] = f"Bearer {self.token}" if body is not None: headers["Content-Type"] = patch_type or "application/json" connection = http.client.HTTPSConnection( - self.host, self.port, context=self.context, timeout=15, + self.host, self.port, context=self.context, timeout=timeout, ) try: connection.request(method, path, body=None if body is None else json.dumps(body), diff --git a/bridge/tests/native-credentials/rotation_diagnostics.py b/bridge/tests/native-credentials/rotation_diagnostics.py new file mode 100644 index 000000000..d2d2eb100 --- /dev/null +++ b/bridge/tests/native-credentials/rotation_diagnostics.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Failure-time rotation facts; never retries, mutates, or reads Secret data.""" + +from datetime import datetime, timezone +import json +import re +import subprocess +import time +from types import SimpleNamespace +from urllib.parse import urlencode + +from api_outcome_diagnostics import timestamp +from native_api import CORE, ROOT, require, core, resource +from observation_diagnostics import READ_ERRORS, identity, recheck_actor, resolve_actor +from observer_network_diagnostics import complete_inventory, metadata, selected_origin + +FAILURE = f"Deadline: current native grant and writer in {CORE}" +UNAVAILABLE = "Rotation snapshot unavailable" +ERRORS = READ_ERRORS + (AttributeError, IndexError) +LABEL = "kars.azure.com/observer-metadata-grant" +OWNER = "kars.azure.com/credential-grant-owner" +TERMINATION_REASONS = frozenset(("Completed", "Error", "OOMKilled", "ContainerCannotRun", + "StartError", "DeadlineExceeded", "Evicted")) +ERROR_STAGES = { + "Discover observer Cilium policy API": "cilium-discovery", + "Read namespaced observer API policies for retirement": "cilium-retirement-read", + "Retire owned observer API policy": "cilium-retirement-delete", + "Replace owned observer network policy": "policy-replace", + "Publish credential authority": "grant-publication", + "Read credential grants": "grant-list", +} + + +def integer(value, minimum=0): + require(type(value) is int and minimum <= value < 2**31, UNAVAILABLE) + return value + + +class _Reader: + def __init__(self, admin): + self.admin, self.deadline, self.calls = admin, time.monotonic() + 40, 0 + + def remaining(self): + remaining = self.deadline - time.monotonic() + require(remaining > 0 and self.calls < 48, UNAVAILABLE) + return min(10, remaining) + + def request(self, path, **kwargs): + timeout = self.remaining() + self.calls += 1 + return self.admin.request("GET", path, timeout=timeout, **kwargs) + + def get(self, path): + return self.request(path)[1] + + +def lifecycle_metadata(value): + fields = value["metadata"] + deletion = fields.get("deletionTimestamp") + require(deletion is None or timestamp(deletion) is not None, UNAVAILABLE) + projected = metadata({"metadata": {key: item for key, item in fields.items() + if key != "deletionTimestamp"}}) + finalizers = fields.get("finalizers", []) + require(isinstance(finalizers, list) and len(finalizers) <= 32 + and all(isinstance(item, str) for item in finalizers), UNAVAILABLE) + return {**projected, "deleting": deletion is not None, "finalizerCount": len(finalizers)} + + +def metadata_read(reader, path, listed=False): + kind = "PartialObjectMetadataList" if listed else "PartialObjectMetadata" + code, value = reader.request(path, expected=(200, 404), + accept=f"application/json;as={kind};g=meta.k8s.io;v=v1") + if code == 404: + require(not listed, UNAVAILABLE) + return None + require(isinstance(value, dict) and value.get("kind") == kind + and value.get("apiVersion") == "meta.k8s.io/v1" + and set(value) <= {"kind", "apiVersion", "metadata", "items"}, UNAVAILABLE) + if listed: + values = complete_inventory(value, 32) + require(all(isinstance(item, dict) and set(item) <= {"kind", "apiVersion", "metadata"} + and isinstance(item.get("metadata"), dict) for item in values), UNAVAILABLE) + return values + require("items" not in value, UNAVAILABLE) + return value + + +def controller_pod(pod): + statuses = pod["status"]["containerStatuses"] + require(isinstance(statuses, list) and len(statuses) <= 4, UNAVAILABLE) + matches = [item for item in statuses if item.get("name") == "controller"] + require(len(matches) == 1 and type(matches[0].get("ready")) is bool, UNAVAILABLE) + status = matches[0] + result = {"identity": metadata(pod), "ready": status["ready"], + "restartCount": integer(status["restartCount"]), "lastTermination": {"status": "unrecorded"}} + last = status.get("lastState", {}) + require(isinstance(last, dict), UNAVAILABLE) + if "terminated" in last: + terminal = last["terminated"] + reason = terminal.get("reason") + result["lastTermination"] = { + "status": "recorded", "reason": reason if reason in TERMINATION_REASONS else "other", + "exitCode": integer(terminal["exitCode"], -(2**31)), + "signal": integer(terminal["signal"]) if "signal" in terminal else None, + } + return result + + +def project_warnings(raw, since, until): + require(isinstance(raw, bytes) and len(raw) <= 65536, UNAVAILABLE) + records = [] + for line in raw.splitlines()[-128:]: + if len(line) > 8192: + continue + try: + event = json.loads(line) + at = timestamp(event.get("timestamp")) + fields = event["fields"] + if at is None or not since <= at <= until: + continue + if (event.get("target") == "kars_controller" + and fields.get("message") == "Credential grant controller stopped"): + category, status = "controller-task-stopped", None + elif (event.get("target") == "kars_controller::credential_grants" + and fields.get("message") in ("Credential grant is not ready", "Credential authority unavailable")): + category, status = "unclassified", None + error = fields.get("error") + if isinstance(error, str) and len(error) <= 4096: + for prefix, name in ERROR_STAGES.items(): + matched = re.fullmatch(re.escape(prefix) + r": Kubernetes status ([1-5][0-9]{2})", error) + if matched: + category, status = name, int(matched[1]) + if error == "Observer API policy retirement is pending": + category = "cilium-retirement-pending" + else: + continue + records.append({"category": category, "httpStatus": status, + "workspaceMatches": (fields["namespace"] in (CORE, f'Some("{CORE}")') + if isinstance(fields.get("namespace"), str) else None), + "afterCaseStartMs": round((at - since).total_seconds() * 1000)}) + except ERRORS: + continue + return records[-16:] + + +def warnings(setup, reader, pod, since): + selected_origin(setup) + require(isinstance(since, datetime) and since.tzinfo is not None + and 0 <= (datetime.now(timezone.utc) - since).total_seconds() <= 600, UNAVAILABLE) + result = subprocess.run( + ["kubectl", "--context", "kind-bridge-native", "--request-timeout=10s", "logs", + "-n", CORE, pod["name"], "-c", "controller", "--tail=128", "--limit-bytes=65536", + "--since-time=" + since.isoformat()], + cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=reader.remaining(), check=False) + require(result.returncode == 0, UNAVAILABLE) + records = project_warnings(result.stdout, since, datetime.now(timezone.utc)) + return {"status": "observed" if records else "unobserved", "records": records, + "podUid": pod["uid"], "coverage": "first-bound-controller-pod", "reconcileStage": "unavailable"} + + +def bound_metadata(value, namespace): + result = lifecycle_metadata(value) + owners = value["metadata"].get("ownerReferences", []) + require(result.get("namespace") == namespace["metadata"]["name"] + and isinstance(owners, list) and len(owners) == 1 + and owners[0].get("apiVersion") == "v1" and owners[0].get("kind") == "Namespace" + and owners[0].get("name") == namespace["metadata"]["name"] + and owners[0].get("uid") == identity(namespace)[0], UNAVAILABLE) + return result + + +def observer_metadata(reader, target, grant): + require(isinstance(target.get("sandbox"), str) + and re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", target["sandbox"]), UNAVAILABLE) + source_path = resource(CORE, "karssandboxes", target["sandbox"]) + source = reader.get(source_path) + require(metadata(source)["uid"] == target["uid"] and source["metadata"].get("namespace") == CORE + and source["metadata"].get("name") == target["sandbox"], UNAVAILABLE) + name = f"kars-{target['sandbox']}" + namespace = reader.get(f"/api/v1/namespaces/{name}") + annotations = namespace["metadata"].get("annotations", {}) + observed = source.get("status", {}).get("serviceObservation") or {} + expected_uids = [value for value in ( + source["metadata"].get("annotations", {}).get("kars.azure.com/namespace-uid"), + observed.get("namespaceUid")) if value is not None] + require(metadata(namespace)["name"] == name + and annotations.get("kars.azure.com/sandbox-uid") == target["uid"] + and annotations.get("kars.azure.com/sandbox-namespace") == CORE + and annotations.get("kars.azure.com/sandbox-name") == target["sandbox"] + and expected_uids and all(value == identity(namespace)[0] for value in expected_uids), UNAVAILABLE) + secret_path = core(name, "secrets", "router-services-observer") + policy_path = resource(name, "ciliumnetworkpolicies", group="/apis/cilium.io/v2") + "?" + urlencode({ + "labelSelector": f"{LABEL}={identity(grant)[0]}", "limit": 33}) + secret = metadata_read(reader, secret_path) + policies = metadata_read(reader, policy_path, listed=True) + secret_fact = {"status": "absent"} + if secret is not None: + secret_fact = {"status": "present", **bound_metadata(secret, namespace)} + require(secret_fact["name"] == "router-services-observer", UNAVAILABLE) + secret_fact["matchesPublishedVersion"] = ( + f"{secret_fact['uid']}:{secret_fact['resourceVersion']}" == observed["version"] + if isinstance(observed.get("version"), str) else None) + facts = [] + for policy in policies: + fact = bound_metadata(policy, namespace) + labels, annotations = policy["metadata"].get("labels", {}), policy["metadata"].get("annotations", {}) + require(labels.get(LABEL) == identity(grant)[0] and annotations.get(OWNER) == identity(grant)[0] + and annotations.get("kars.azure.com/observer-namespace-uid") == identity(namespace)[0] + and annotations.get("kars.azure.com/sandbox-uid") == target["uid"], UNAVAILABLE) + generation = annotations.get("kars.azure.com/observer-grant-generation") + require(isinstance(generation, str) and re.fullmatch(r"[1-9][0-9]{0,9}", generation), UNAVAILABLE) + facts.append({**fact, "grantGeneration": integer(int(generation), 1)}) + require(len({item["uid"] for item in facts}) == len(facts), UNAVAILABLE) + require(secret == metadata_read(reader, secret_path) + and policies == metadata_read(reader, policy_path, listed=True), UNAVAILABLE) + recheck_actor(SimpleNamespace(admin=reader), {"anchors": { + source_path: source, f"/api/v1/namespaces/{name}": namespace, + resource(CORE, "karscredentialgrants", "workspace"): grant}}) + return {"available": True, "namespace": metadata(namespace), "secret": secret_fact, "policies": facts} + + +def collect(setup, target, failed, since): + result = {"diagnosticOnly": True, "originalResult": "failed", "category": "not-eligible", + "controller": {"available": False}, "grant": {"available": False}, + "observerMetadata": {"available": False}, "reconcileStage": "unavailable"} + if not isinstance(failed, dict) or failed.get("result") != "failed" or failed.get("failure") != FAILURE: + return result + reader = _Reader(setup.admin) + scoped = SimpleNamespace(admin=reader) + result["category"] = "failure-time-snapshot" + try: + actor = resolve_actor(scoped, "controller", target) + require(len(actor["pods"]) <= 2, UNAVAILABLE) + pods = [controller_pod(actor["anchors"][core(CORE, "pods", pod["name"])]) for pod in actor["pods"]] + recheck_actor(scoped, actor) + result["controller"] = {"available": True, "pods": pods, "warnings": { + "status": "unavailable", "records": [], "reconcileStage": "unavailable"}} + except ERRORS: + pass + try: + grant = reader.get(resource(CORE, "karscredentialgrants", "workspace")) + require(metadata(grant)["name"] == "workspace" and grant["metadata"].get("namespace") == CORE, UNAVAILABLE) + current, observed = integer(grant["metadata"]["generation"], 1), integer(grant["status"]["observedGeneration"]) + result["grant"] = {"available": True, "identity": metadata(grant), "currentGeneration": current, + "observedGeneration": observed, "generationCurrent": current == observed} + require(isinstance(target, dict) and target.get("workspace") == CORE, UNAVAILABLE) + result["observerMetadata"] = observer_metadata(reader, target, grant) + except ERRORS: + pass + if result["controller"]["available"]: + try: + reader.remaining() + records = warnings(setup, reader, actor["pods"][0], since) + recheck_actor(scoped, actor) + result["controller"]["warnings"] = records + except ERRORS: + pass + result["readCalls"] = reader.calls + return result diff --git a/bridge/tests/native-credentials/run.py b/bridge/tests/native-credentials/run.py index 40f13783d..6f71f7fc3 100644 --- a/bridge/tests/native-credentials/run.py +++ b/bridge/tests/native-credentials/run.py @@ -19,6 +19,7 @@ from observation_cases import ObservationCases from observation_diagnostics import collect as observation_diagnostics from observer_network_diagnostics import collect as observer_network_diagnostics +from rotation_diagnostics import collect as rotation_diagnostics from template_diagnostics import collect as template_diagnostics @@ -119,6 +120,11 @@ def case(name, operation, allowed=True): "failure": str(error) if isinstance(error, Failure) else type(error).__name__, } if setup: + if name == "observer-rotation-current-bearer-and-revocation": + save() + report["cases"][name]["rotationFailureSnapshot"] = rotation_diagnostics( + setup, observations.observer_target, report["cases"][name], started_at) + save() if name == "private-bff-observer-and-fresh-privacy-rpc": report["cases"][name]["enrollmentTemplateDrift"] = template_diagnostics( setup, observations.enrollment_templates, report["cases"][name]["failure"]) @@ -142,7 +148,7 @@ def case(name, operation, allowed=True): print(json.dumps({"nativeCase": name, **{key: value for key, value in report["cases"][name].items() if key not in ("metadataAtFailure", "observationReadiness", "actorApiOutcomes", "observerApiReachability", - "enrollmentTemplateDrift")}}), flush=True) + "enrollmentTemplateDrift", "rotationFailureSnapshot")}}), flush=True) def passed(name): return report["cases"].get(name, {}).get("result") == "passed" diff --git a/bridge/tests/native-credentials/test_rotation_diagnostics.py b/bridge/tests/native-credentials/test_rotation_diagnostics.py new file mode 100644 index 000000000..881d7d2fa --- /dev/null +++ b/bridge/tests/native-credentials/test_rotation_diagnostics.py @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import contextlib +import copy +from datetime import datetime, timedelta, timezone +import io +import json +import os +from pathlib import Path +import ssl +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch +from urllib.parse import urlsplit + +import native_api +import rotation_diagnostics as rotation +import test_observer_network_diagnostics as fixtures +from native_api import CORE, Failure, core, resource + +CONTROLLER = core(CORE, "pods", "kars-controller-pod") +GRANT = resource(CORE, "karscredentialgrants", "workspace") +SECRET = core(fixtures.RUNTIME, "secrets", "router-services-observer") +CNP = resource(fixtures.RUNTIME, "ciliumnetworkpolicies", "owned-api", "/apis/cilium.io/v2") +FAILED = {"result": "failed", "failure": rotation.FAILURE} + + +class Fixture(fixtures.NetworkFixture): + def __init__(self): + super().__init__() + self.calls, self.full_metadata_response, self.change_metadata = [], False, False + self.partial_inventory = False + self.meta_reads = {} + self.objects[CONTROLLER]["status"] = {"containerStatuses": [{ + "name": "controller", "ready": True, "restartCount": 2, + "containerID": "private-container-canary", + "lastState": {"terminated": {"reason": "OOMKilled", "exitCode": 137, "signal": 9, + "message": "private-termination-canary"}}}]} + self.objects[GRANT] = { + "metadata": {"name": "workspace", "namespace": CORE, "uid": "grant-uid", + "resourceVersion": "2", "generation": 4}, + "spec": {"private": "private-grant-canary"}, "status": {"observedGeneration": 3}} + self.objects[f"/api/v1/namespaces/{fixtures.RUNTIME}"]["metadata"]["annotations"].update({ + "kars.azure.com/sandbox-namespace": CORE, "kars.azure.com/sandbox-name": "agent"}) + owners = [{"apiVersion": "v1", "kind": "Namespace", "name": fixtures.RUNTIME, + "uid": fixtures.RUNTIME + "-uid", "controller": True, "blockOwnerDeletion": False}] + self.objects[SECRET] = {"kind": "Secret", "metadata": { + "name": "router-services-observer", "namespace": fixtures.RUNTIME, "uid": "observer-uid", + "resourceVersion": "7", "ownerReferences": copy.deepcopy(owners), + "annotations": {"private": "private-secret-annotation-canary"}}, + "data": {"observation-token": "private-token-canary"}} + self.objects[fixtures.SANDBOX]["status"]["serviceObservation"]["version"] = "observer-uid:6" + self.objects[CNP] = {"kind": "CiliumNetworkPolicy", "metadata": { + "name": "owned-api", "namespace": fixtures.RUNTIME, "uid": "policy-uid", + "resourceVersion": "9", "ownerReferences": copy.deepcopy(owners), + "labels": {rotation.LABEL: "grant-uid"}, + "annotations": {"kars.azure.com/credential-grant-owner": "grant-uid", + "kars.azure.com/observer-grant-generation": "3", + "kars.azure.com/observer-namespace-uid": fixtures.RUNTIME + "-uid", + "kars.azure.com/sandbox-uid": "sandbox-uid", + "private": "private-policy-annotation-canary"}}, + "spec": {"private": "private-policy-body-canary"}} + + def request(self, method, path, expected=(200,), *, accept="application/json", timeout=15): + assert method == "GET" + self.calls.append((path, accept, timeout)) + path = urlsplit(path).path + if path == SECRET or path.endswith("/ciliumnetworkpolicies"): + listed = path != SECRET + kind = "PartialObjectMetadataList" if listed else "PartialObjectMetadata" + assert accept == f"application/json;as={kind};g=meta.k8s.io;v=v1" + if path == SECRET and path not in self.objects: + return 404, {"message": "private-404-canary"} + if self.full_metadata_response: + return 200, copy.deepcopy(self.objects[SECRET]) + self.meta_reads[path] = self.meta_reads.get(path, 0) + 1 + def partial(value): + result = {"kind": "PartialObjectMetadata", "apiVersion": "meta.k8s.io/v1", + "metadata": copy.deepcopy(value["metadata"])} + if self.change_metadata and self.meta_reads[path] > 1: + result["metadata"]["resourceVersion"] = "changed" + return result + if listed: + return 200, {"kind": kind, "apiVersion": "meta.k8s.io/v1", + "metadata": {"continue": "private-continuation-canary"} if self.partial_inventory else {}, + "items": [partial(self.objects[CNP])]} + return 200, partial(self.objects[path]) + return 200, self.get(path) + + +class RotationDiagnosticsTests(unittest.TestCase): + def setUp(self): + self.api = Fixture() + self.setup = SimpleNamespace(admin=self.api, cluster={"server": self.api.server}) + self.since = datetime.now(timezone.utc) - timedelta(seconds=180) + self.event = {"timestamp": (self.since + timedelta(seconds=100)).isoformat(), + "target": "kars_controller::credential_grants", "fields": { + "message": "Credential grant is not ready", "namespace": f'Some("{CORE}")', + "error": "Retire owned observer API policy: Kubernetes status 409", + "private": "private-log-canary"}} + + def collect(self, failed=FAILED, output=None, log_effect=None): + output = json.dumps(self.event).encode() if output is None else output + with patch.object(rotation, "selected_origin"), patch.object(rotation.subprocess, "run", + side_effect=log_effect, return_value=SimpleNamespace(returncode=0, stdout=output)): + return rotation.collect(self.setup, fixtures.TARGET, failed, self.since) + + def test_scoped_snapshot_retains_lag_restart_and_metadata_without_bodies(self): + failed = copy.deepcopy(FAILED) + result = self.collect(failed) + self.assertEqual(failed, FAILED) + self.assertTrue(result["controller"]["available"]) + pod = result["controller"]["pods"][0] + self.assertEqual(pod["identity"]["uid"], "kars-controller-pod-uid") + self.assertEqual(pod["restartCount"], 2) + self.assertEqual(pod["lastTermination"], { + "status": "recorded", "reason": "OOMKilled", "exitCode": 137, "signal": 9}) + self.assertEqual(result["grant"]["currentGeneration"], 4) + self.assertEqual(result["grant"]["observedGeneration"], 3) + self.assertFalse(result["grant"]["generationCurrent"]) + self.assertEqual(result["observerMetadata"]["policies"][0]["grantGeneration"], 3) + self.assertFalse(result["observerMetadata"]["secret"]["matchesPublishedVersion"]) + self.assertEqual(result["controller"]["warnings"]["records"][0]["category"], "cilium-retirement-delete") + self.assertEqual(result["reconcileStage"], "unavailable") + self.assertNotIn("canary", json.dumps(result)) + self.assertTrue(all(0 < timeout <= 10 for _, _, timeout in self.api.calls)) + + def test_metadata_transport_does_not_fall_back_to_secret_or_policy_bodies(self): + self.api.full_metadata_response = True + result = self.collect() + self.assertFalse(result["observerMetadata"]["available"]) + self.assertTrue(result["grant"]["available"]) + self.assertNotIn("canary", json.dumps(result)) + response = MagicMock(status=200) + response.read.return_value = b'{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{}}' + connection = MagicMock() + connection.getresponse.return_value = response + with patch.object(native_api.http.client, "HTTPSConnection", return_value=connection) as transport: + api = native_api.Api("https://127.0.0.1:36443", ssl.create_default_context()) + api.request("GET", SECRET, accept="application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1", timeout=4) + self.assertEqual(transport.call_args.kwargs["timeout"], 4) + self.assertEqual(connection.request.call_args.kwargs["headers"]["Accept"], + "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1") + + def test_malformed_restart_termination_and_grant_fields_are_unavailable(self): + for field in ("restartCount", "ready"): + self.setUp() + self.api.objects[CONTROLLER]["status"]["containerStatuses"][0][field] = "private-canary" + result = self.collect() + self.assertFalse(result["controller"]["available"]) + self.assertNotIn("canary", json.dumps(result)) + self.setUp() + self.api.objects[CONTROLLER]["status"]["containerStatuses"][0]["lastState"]["terminated"]["exitCode"] = True + self.assertFalse(self.collect()["controller"]["available"]) + self.setUp() + self.api.objects[GRANT]["status"]["observedGeneration"] = True + self.assertFalse(self.collect()["grant"]["available"]) + + def test_unrecorded_absent_and_unavailable_remain_distinct(self): + self.api.objects[CONTROLLER]["status"]["containerStatuses"][0]["lastState"] = {} + del self.api.objects[SECRET] + result = self.collect(output=b"") + self.assertEqual(result["controller"]["pods"][0]["lastTermination"]["status"], "unrecorded") + self.assertEqual(result["controller"]["warnings"]["status"], "unobserved") + self.assertEqual(result["observerMetadata"]["secret"]["status"], "absent") + result = self.collect(log_effect=OSError("private-canary")) + self.assertEqual(result["controller"]["warnings"]["status"], "unavailable") + self.assertNotIn("canary", json.dumps(result)) + + def test_metadata_drift_or_foreign_namespace_owner_invalidates_only_that_section(self): + self.api.change_metadata = True + result = self.collect() + self.assertFalse(result["observerMetadata"]["available"]) + self.assertTrue(result["controller"]["available"]) + self.setUp() + self.api.objects[CNP]["metadata"]["ownerReferences"][0]["uid"] = "foreign" + self.assertFalse(self.collect()["observerMetadata"]["available"]) + self.setUp() + self.api.partial_inventory = True + result = self.collect() + self.assertFalse(result["observerMetadata"]["available"]) + self.assertNotIn("canary", json.dumps(result)) + + def test_warning_redaction_staleness_and_size_bounds(self): + self.event["fields"]["error"] = "private-error-canary" + self.assertEqual(self.collect()["controller"]["warnings"]["records"][0]["category"], "unclassified") + self.event["timestamp"] = (self.since - timedelta(seconds=1)).isoformat() + self.assertEqual(self.collect()["controller"]["warnings"]["status"], "unobserved") + self.assertEqual(self.collect(output=b"x" * 65537)["controller"]["warnings"]["status"], "unavailable") + self.assertEqual(self.collect(output=b'{"fields":null}\nprivate-canary')["controller"]["warnings"]["status"], "unobserved") + reader = rotation._Reader(self.api) + reader.deadline = 0 + with self.assertRaises(Failure): + reader.get(GRANT) + reader = rotation._Reader(self.api) + reader.calls = 48 + with self.assertRaises(Failure): + reader.get(GRANT) + + def test_ineligible_cases_do_not_read_any_resources(self): + for failed in (None, {"result": "passed"}, {"result": "blocked"}, + {"result": "failed", "failure": "other"}): + self.assertEqual(self.collect(failed)["category"], "not-eligible") + self.assertEqual(self.api.calls, []) + + def test_failure_snapshot_is_saved_before_subsequent_controller_restart(self): + import run as native_run + observations, lifecycle = MagicMock(), MagicMock() + observations.rotation.side_effect = Failure(rotation.FAILURE) + observations.observer_target = fixtures.TARGET + order = [] + with tempfile.TemporaryDirectory(dir=Path(__file__).resolve().parent) as directory: + state = Path(directory) + def snapshot(_setup, _target, failed, _since): + saved = json.loads((state / "evidence/native.json").read_text()) + self.assertEqual(saved["cases"]["observer-rotation-current-bearer-and-revocation"], FAILED) + self.assertEqual(failed, FAILED) + order.append("snapshot") + return self.collect(failed) + def restart(): + self.api.objects[CONTROLLER]["metadata"]["uid"] = "replacement-controller" + saved = json.loads((state / "evidence/native.json").read_text()) + snapshot = saved["cases"]["observer-rotation-current-bearer-and-revocation"]["rotationFailureSnapshot"] + self.assertEqual(snapshot["controller"]["pods"][0]["identity"]["uid"], "kars-controller-pod-uid") + order.append("restart") + lifecycle.writer_uninstall.side_effect = restart + with patch.dict(os.environ, fixtures.ENV), patch.object(native_run, "STATE", state), \ + patch.object(native_run, "command", return_value=native_run.CORE_REVISION), \ + patch.object(native_run, "Setup"), patch.object(native_run, "install_core"), \ + patch.object(native_run, "install_bridge"), patch.object(native_run, "bridge_connection"), \ + patch.object(native_run, "CredentialCases"), \ + patch.object(native_run, "LifecycleCases", return_value=lifecycle), \ + patch.object(native_run, "ObservationCases", return_value=observations), \ + patch.object(native_run, "diagnostics", return_value={}), \ + patch.object(native_run, "rotation_diagnostics", side_effect=snapshot), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(native_run.main(), 1) + result = json.loads((state / "evidence/native.json").read_text()) + self.assertEqual(order, ["snapshot", "restart"]) + self.assertEqual(result["cases"]["observer-rotation-current-bearer-and-revocation"]["failure"], rotation.FAILURE) + self.assertFalse(result["runtimeQualified"]) From 1d0fc5c96810d21f083196e8c6f654b4204ad2c1 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 20:43:41 +0200 Subject: [PATCH 100/111] fix(ci): preserve raw YAML and Helm document boundaries in headers Use YAML comments for dual-use plain templates and whitespace-neutral Go comments only for leading Helm actions. Place hash headers after the original initial document separator. Preserve original body bytes, modes, schemas and document counts across all twenty CRD inputs; do not relax CLI/Rust readers or assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- CONTRIBUTING.md | 20 ++- .../helm/kars-bridge/templates/bff.yaml | 6 +- .../kars-bridge/templates/teams-gateway.yaml | 6 +- .../helm/kars-bridge/templates/web.yaml | 6 +- .../templates/namespace.yaml | 6 +- ci/copyright_headers.py | 89 ++++++++-- ci/tests/copyright_headers_test.py | 161 +++++++++++++++++- deploy/helm/kars/templates/crd-a2aagent.yaml | 6 +- .../kars/templates/crd-egressapproval.yaml | 6 +- .../kars/templates/crd-inferencepolicy.yaml | 6 +- .../helm/kars/templates/crd-karsapproval.yaml | 6 +- .../kars/templates/crd-karsauthconfig.yaml | 6 +- .../kars/templates/crd-karsbudgetaccount.yaml | 6 +- .../templates/crd-karscredentialgrant.yaml | 6 +- deploy/helm/kars/templates/crd-karseval.yaml | 6 +- .../helm/kars/templates/crd-karsmemory.yaml | 6 +- .../helm/kars/templates/crd-karsprofile.yaml | 6 +- .../helm/kars/templates/crd-karsreceipt.yaml | 6 +- deploy/helm/kars/templates/crd-karsskill.yaml | 6 +- .../kars/templates/crd-karssreaction.yaml | 6 +- .../templates/crd-karssreregistration.yaml | 6 +- deploy/helm/kars/templates/crd-karstask.yaml | 6 +- deploy/helm/kars/templates/crd-karsteam.yaml | 6 +- deploy/helm/kars/templates/crd-mcpserver.yaml | 6 +- .../helm/kars/templates/crd-toolpolicy.yaml | 6 +- .../helm/kars/templates/crd-trustgraph.yaml | 6 +- deploy/helm/kars/templates/crd.yaml | 6 +- .../templates/credential-grant-admission.yaml | 6 +- .../kars/templates/credential-grant-rbac.yaml | 6 +- .../credential-namespace-admission.yaml | 6 +- .../credential-reader-admission.yaml | 6 +- .../credential-rebind-admission.yaml | 6 +- .../templates/credential-store-admission.yaml | 6 +- deploy/helm/kars/templates/namespace.yaml | 6 +- deploy/helm/kars/templates/rbac.yaml | 6 +- .../templates/sre-authority-admission.yaml | 6 +- .../templates/sre-authority-consumers.yaml | 6 +- .../kars/templates/sre-authority-rbac.yaml | 6 +- scripts/apply-copyright-headers.sh | 2 +- 39 files changed, 392 insertions(+), 90 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90854690e..e1a1c7da7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -224,14 +224,22 @@ comments **must** carry the two-line notice in its format's comment syntax: Use `//` for Rust, TypeScript/JavaScript (including TSX and MJS), and Bicep; `#` for shell/Python, YAML/TOML, Dockerfiles, Makefiles, ignore files, CODEOWNERS and environment examples; HTML comments for Markdown; CSS block comments for CSS; -and Handlebars comments for `.hbs`. Helm templates (including template YAML and -`NOTES.txt`) use Go-template comments with **no surrounding output whitespace**, -not YAML comments that can interact with `{{- ... -}}` trimming. +and Handlebars comments for `.hbs`. Plain YAML under a chart's `templates/` +directory uses `#` too: CLI staging also reads some CRDs as raw YAML. YAML that +begins with a Helm action (after any blank lines/YAML comments), plus `.tpl` and +template `NOTES.txt`, uses Go-template comments with **no surrounding output +whitespace**. A hash header before leading `{{- ... -}}` can be joined to +`apiVersion` by trimming. The directory name alone does not determine YAML syntax. Use `scripts/apply-copyright-headers.sh` rather than rewriting files manually. It preserves original body bytes, line endings, file modes, author notices, shebangs, Python encoding cookies, Docker parser directives, Markdown frontmatter, CSS charset directives and frontend directive prologues. It is idempotent. +For YAML with an incorrect comment style, it replaces only the exact existing +Microsoft/MIT license prefix; original body bytes and author notices remain intact. +For chart YAML with an initial `---`, the license is placed inside that first +document, after the marker, rather than creating a separate comment-only Helm +document. Existing preamble comments and document delimiters are not rewritten. Both existing commands share `ci/copyright_headers.py` (Python standard library). `ci/check-copyright-headers.sh` checks **every tracked file**, and fails on unknown formats, missing notices or unsafe inputs. Run the format regression tests with @@ -266,8 +274,10 @@ payload is explicitly covered without a literal header. The checker prints coverage totals, including non-header categories. Pass `--verbose` to list every non-header path and reason, or `--report copyright-report.json` for a complete machine-readable inventory. -The applier accepts the same options; its report includes insertion offsets, -lengths and before/after SHA-256 hashes. Reports are local artifacts, not source +The applier accepts the same options; its report includes edit offsets, inserted +and removed license-prefix lengths, and before/after SHA-256 hashes. +`removed_offset` addresses the original file; `offset` addresses the body after +removing the old license block. Reports are local artifacts, not source files to commit. Optional repository-relative paths limit a local check/apply; CI invokes the checker without paths, so there are no silent extension omissions. diff --git a/bridge/deploy/helm/kars-bridge/templates/bff.yaml b/bridge/deploy/helm/kars-bridge/templates/bff.yaml index 85beecd00..f65202906 100644 --- a/bridge/deploy/helm/kars-bridge/templates/bff.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/bff.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: apps/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: apps/v1 kind: Deployment metadata: name: kars-bridge-bff diff --git a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml index 6886d9da9..b6e6054ab 100644 --- a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml @@ -1,9 +1,11 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars Bridge — Teams Gateway: dedicated ServiceAccount, RBAC, Deployment, +# kars Bridge — Teams Gateway: dedicated ServiceAccount, RBAC, Deployment, # Service, NetworkPolicy, and optional Ingress for Teams webhook callbacks. # Credentials live in a DEDICATED Secret (kars-bridge-teams), NEVER in the # workspace-channels Secret that gets propagated to sandbox pods. --- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: v1 kind: ConfigMap metadata: diff --git a/bridge/deploy/helm/kars-bridge/templates/web.yaml b/bridge/deploy/helm/kars-bridge/templates/web.yaml index 38fd62ee3..9335625d7 100644 --- a/bridge/deploy/helm/kars-bridge/templates/web.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/web.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: apps/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: apps/v1 kind: Deployment metadata: name: kars-bridge-web diff --git a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml index 6aec11491..a49d843f2 100644 --- a/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml +++ b/bridge/teams-gateway/tests/fixtures/legacy-namespace-chart/templates/namespace.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: v1 kind: Namespace metadata: name: {{ .Release.Namespace }} diff --git a/ci/copyright_headers.py b/ci/copyright_headers.py index bb7b59548..a330758ea 100644 --- a/ci/copyright_headers.py +++ b/ci/copyright_headers.py @@ -83,7 +83,7 @@ def load_policy(root): return policy -def classification(path, policy): +def classification(path, policy, data=b""): p = PurePosixPath(path) if path in policy["files"]: return dict(policy["files"][path]) @@ -92,7 +92,9 @@ def classification(path, policy): "category": "third-party", "notice": "NOTICE", "reason": "Vendored inputs retain their upstream/package licenses and checksums.", } - if "templates" in p.parts and p.suffix in (".yaml", ".yml", ".tpl", ".txt"): + if "templates" in p.parts and p.suffix in (".yaml", ".yml"): + return {"category": "header", "style": template_yaml_style(data)} + if "templates" in p.parts and p.suffix in (".tpl", ".txt"): return {"category": "header", "style": "helm"} if p.name.startswith("Dockerfile") and (p.name == "Dockerfile" or p.name[10:11] == "."): return {"category": "header", "style": "hash"} @@ -163,6 +165,45 @@ def header_for(style, data): return STYLES[style].replace("\n", newline).encode("ascii") +def yaml_document_start(data, start): + cursor = start + for line in data[start:].splitlines(keepends=True): + token = line.strip() + if re.fullmatch(rb"---(?:[ \t]+#.*)?", token): + if not line.endswith(b"\n"): + raise CoverageError("leading YAML document marker needs a terminating newline") + return cursor + len(line) + if token.startswith((b"--- ", b"---\t")): + raise CoverageError("inline YAML document content needs reviewed header placement") + if token and not token.startswith((b"#", b"%")): + break + cursor += len(line) + return start + + +def yaml_license_prefix(data): + start = len(codecs.BOM_UTF8) if data.startswith(codecs.BOM_UTF8) else 0 + for offset in (start, yaml_document_start(data, start)): + for style in ("hash", "helm"): + prefix = header_for(style, data[offset:]) + if data[offset:].startswith(prefix): + return offset, prefix + return start, b"" + + +def template_yaml_style(data): + offset, prefix = yaml_license_prefix(data) + body = data[:offset] + data[offset + len(prefix):] + start = len(codecs.BOM_UTF8) if body.startswith(codecs.BOM_UTF8) else 0 + for line in body[yaml_document_start(body, start):].splitlines(): + token = line.strip() + if token and not token.startswith(b"#"): + # Only a leading Helm action can chomp a preceding license comment + # into the first YAML token. Plain/dual-use YAML must remain raw-parseable. + return "helm" if token.startswith(b"{{") else "hash" + return "hash" + + def has_header(data, offset, style): prefix = data[offset:].replace(b"\r\n", b"\n") expected = STYLES[style].encode("ascii").rstrip(b"\n") @@ -193,6 +234,10 @@ def has_header(data, offset, style): def insertion(path, data, style): offset = anchor(path, data) + p = PurePosixPath(path) + if style == "hash" and "templates" in p.parts and p.suffix in (".yaml", ".yml"): + # A license-only chunk before "---" becomes an extra Helm document. + offset = yaml_document_start(data, offset) if has_header(data, offset, style): return offset, b"" if PurePosixPath(path).suffix == ".rs": @@ -204,6 +249,19 @@ def insertion(path, data, style): return offset, header_for(style, data) +def header_edit(path, data, style): + offset, header = insertion(path, data, style) + if header and PurePosixPath(path).suffix in (".yaml", ".yml"): + start, previous = yaml_license_prefix(data) + if previous: + body = data[:start] + data[start + len(previous):] + destination, header = insertion(path, body, style) + # Relocate only our license block; preamble comments, delimiters and + # every other body byte retain their original order and content. + return start, len(previous), destination, header + return offset, 0, offset, header + + def tracked_files(root): result = subprocess.check_output(["git", "ls-files", "-z"], cwd=root) return sorted(set(result.decode("utf-8").split("\0")) - {""}) @@ -230,17 +288,23 @@ def process(root, paths, policy, apply=False): record = {"path": name} try: data, mode = file_bytes(root, name) - record.update(classification(name, policy)) + record.update(classification(name, policy, data)) if record["category"] == "header": - offset, header = insertion(name, data, record["style"]) - record["status"] = "missing" if header else "present" - if header: + remove_offset, removed, offset, header = header_edit(name, data, record["style"]) + record["status"] = "missing" if header or removed else "present" + if header or removed: + body = data[:remove_offset] + data[remove_offset + removed:] + updated = body[:offset] + header + body[offset:] record.update({ "offset": offset, "inserted_bytes": len(header), + "removed_offset": remove_offset, + "removed_bytes": removed, "before_sha256": hashlib.sha256(data).hexdigest(), - "after_sha256": hashlib.sha256(data[:offset] + header + data[offset:]).hexdigest(), + "after_sha256": hashlib.sha256(updated).hexdigest(), }) - changes.append((name, data, mode, offset, header, record)) + if removed: + record["diagnostic"] = "existing Microsoft + MIT header has incorrect syntax or placement" + changes.append((name, data, mode, updated, record)) else: record["status"] = "covered-without-header" except (CoverageError, OSError, UnicodeError) as exc: @@ -248,13 +312,13 @@ def process(root, paths, policy, apply=False): records.append(record) # Fail closed, before writing any file, if coverage is incomplete/unsafe. if apply and not any(r["status"] == "error" for r in records): - for name, data, mode, offset, header, record in changes: + for name, data, mode, updated, record in changes: current, current_mode = file_bytes(root, name) if current != data or current_mode != mode: raise CoverageError(f"{name}: changed during inspection; nothing should overwrite another editor") - for name, data, mode, offset, header, record in changes: + for name, data, mode, updated, record in changes: target = root / name - target.write_bytes(data[:offset] + header + data[offset:]) + target.write_bytes(updated) if target.stat().st_mode != mode: raise CoverageError(f"{name}: file mode changed") record["status"] = "applied" @@ -296,7 +360,8 @@ def main(argv=None): output.write("\n") for record in records: if record["status"] in ("missing", "error"): - print(f"{record['path']}: {record.get('error', 'missing Microsoft + MIT header')}", file=sys.stderr) + detail = record.get("error", record.get("diagnostic", "missing Microsoft + MIT header")) + print(f"{record['path']}: {detail}", file=sys.stderr) elif args.verbose and record["status"] == "covered-without-header": print(f"{record['path']}: {record['category']} via {record['notice']}: {record['reason']}") print( diff --git a/ci/tests/copyright_headers_test.py b/ci/tests/copyright_headers_test.py index 6420cc256..8e09b4d26 100644 --- a/ci/tests/copyright_headers_test.py +++ b/ci/tests/copyright_headers_test.py @@ -38,7 +38,7 @@ def write(self, name, data): return path def apply(self, name, data, expected_offset=None): - rule = headers.classification(name, self.policy) + rule = headers.classification(name, self.policy, data) offset, block = headers.insertion(name, data, rule["style"]) if expected_offset is not None: self.assertEqual(offset, expected_offset) @@ -147,7 +147,7 @@ def test_css_charset_and_import(self): self.apply("a.css", prefix + b'\n@import "theme.css";\n', len(prefix)) def test_template_headers_never_emit_or_trim_whitespace(self): - for path in ("chart/templates/config.yaml", "chart/templates/NOTES.txt", "a.tpl", "a.hbs"): + for path in ("chart/templates/NOTES.txt", "a.tpl", "a.hbs"): for body in ( b'{{- if .Values.enabled -}}\nkey: value\n{{- end -}}\n', b' leading whitespace\n{{- /* existing comment */ -}}\n', @@ -159,6 +159,163 @@ def test_template_headers_never_emit_or_trim_whitespace(self): marker = b"--}}" if path.endswith(".hbs") else b"*/}}" self.assertEqual(after.split(marker, 1)[1], body) + def test_plain_template_yaml_normalizes_only_its_license_prefix(self): + for suffix in (".yaml", ".yml"): + for bom, newline in ((b"", b"\n"), (b"", b"\r\n"), (codecs.BOM_UTF8, b"\r\n")): + for document in (b"", b"---\n"): + body = ( + b"# Original author notice\n" + document + + b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: plain" + ).replace(b"\n", newline) + name = "chart/templates/plain" + suffix + previous = headers.header_for("helm", body) + bad = bom + previous + body + path = self.write(name, bad) + path.chmod(0o640) + result, = headers.process(self.root, [name], self.policy) + self.assertEqual(result["style"], "hash") + self.assertEqual(result["status"], "missing") + self.assertEqual(path.read_bytes(), bad) + result, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(result["removed_bytes"], len(previous)) + self.assertEqual(result["removed_offset"], len(bom)) + destination = body.index(b"---") + 3 + len(newline) if document else 0 + self.assertEqual(result["offset"], len(bom) + destination) + expected = bom + body[:destination] + headers.header_for("hash", body) + body[destination:] + self.assertEqual(path.read_bytes(), expected) + self.assertEqual(path.stat().st_mode & 0o777, 0o640) + self.assertEqual(result["after_sha256"], hashlib.sha256(expected).hexdigest()) + again, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(again["status"], "present") + self.assertEqual(path.read_bytes(), expected) + invalid = headers.STYLES["helm"].encode() + b"\x00" + path = self.write("chart/templates/unsafe.yaml", invalid) + result, = headers.process(self.root, ["chart/templates/unsafe.yaml"], self.policy, apply=True) + self.assertEqual(result["status"], "error") + self.assertEqual(path.read_bytes(), invalid) + + def test_chart_yaml_keeps_its_initial_document_marker_before_the_license(self): + name = "chart/templates/plain.yaml" + for preamble in (b"---\n", b"\n# Original note\n--- # document\n", b"%YAML 1.2\n---\n"): + body = preamble + b"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: example\n" + expected = preamble + headers.STYLES["hash"].encode() + body[len(preamble):] + self.assertEqual(self.apply(name, body, len(preamble)), expected) + path = self.write(name, headers.STYLES["hash"].encode() + body) + result, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(result["removed_offset"], 0) + self.assertEqual(result["offset"], len(preamble)) + self.assertEqual(path.read_bytes(), expected) + for body in (b"---", b"--- {kind: ConfigMap}\n"): + with self.assertRaises(headers.CoverageError): + headers.classification(name, self.policy, body) + + def test_leading_helm_controls_keep_output_neutral_license_comments(self): + for prefix in (b"", b"\n ", b"# Original comment\n\n"): + body = prefix + ( + b"{{- if .Values.enabled -}}\napiVersion: v1\nkind: ConfigMap\n" + b"metadata:\n name: controlled\n{{- end -}}\n" + ) + name = "chart/templates/controlled.yaml" + self.assertEqual(headers.classification(name, self.policy, body)["style"], "helm") + self.assertEqual(self.apply(name, body), headers.STYLES["helm"].encode() + body) + path = self.write(name, headers.STYLES["hash"].encode() + body) + result, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(result["removed_bytes"], len(headers.STYLES["hash"].encode())) + self.assertEqual(path.read_bytes(), headers.STYLES["helm"].encode() + body) + again, = headers.process(self.root, [name], self.policy, apply=True) + self.assertEqual(again["status"], "present") + + @unittest.skipUnless(shutil.which("helm"), "Helm not installed") + def test_dual_use_yaml_raw_parse_and_helm_resources_are_preserved(self): + try: + import yaml + except ImportError: + self.skipTest("PyYAML is not installed") + fixtures = { + "chart/Chart.yaml": b"apiVersion: v2\nname: fixture\nversion: 0.1.0\n", + "chart/values.yaml": b"enabled: true\n", + "chart/templates/plain.yaml": ( + b"---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n" + b" name: plain\n namespace: '{{ .Release.Namespace }}'\n" + b"data:\n literal: |\n preserve these exact bytes\n" + ), + "chart/templates/controlled.yaml": ( + b"{{- if .Values.enabled -}}\napiVersion: v1\nkind: ConfigMap\n" + b"metadata:\n name: controlled\n{{- end -}}\n" + ), + } + for name, body in fixtures.items(): + self.write(name, body) + command = ["helm", "template", "fixture", str(self.root / "chart")] + def resources(): + return list(yaml.safe_load_all(subprocess.check_output(command))) + before = resources() + self.assertEqual(len(before), 2) + self.assertTrue(all(isinstance(doc, dict) for doc in before)) + name = "chart/templates/plain.yaml" + plain = self.root / name + raw_before = list(yaml.safe_load_all(plain.read_bytes())) + plain.write_bytes(headers.STYLES["helm"].encode() + fixtures[name]) + with self.assertRaises(yaml.YAMLError): + list(yaml.safe_load_all(plain.read_bytes())) + records = headers.process(self.root, list(fixtures), self.policy, apply=True) + self.assertTrue(all(r["status"] == "applied" for r in records)) + self.assertEqual(list(yaml.safe_load_all(plain.read_bytes())), raw_before) + self.assertEqual(resources(), before) + self.assertEqual(plain.read_bytes(), b"---\n" + headers.STYLES["hash"].encode() + fixtures[name][4:]) + self.assertTrue(all(r["status"] == "present" for r in headers.process( + self.root, list(fixtures), self.policy, apply=True, + ))) + + @unittest.skipUnless(shutil.which("helm"), "Helm not installed") + def test_all_crd_consumers_preserve_document_counts_and_schema_json(self): + try: + import yaml + except ImportError: + self.skipTest("PyYAML is not installed") + chart = ROOT / "deploy/helm/kars" + crds = sorted((chart / "templates").glob("crd*.yaml")) + self.assertEqual(len(crds), 20) + before_chart = self.root / "license-free-chart" + shutil.copytree(chart, before_chart) + bare = {} + for path in crds: + data = path.read_bytes() + offset, license_block = headers.yaml_license_prefix(data) + self.assertTrue(license_block, path.name) + bare[path.name] = data[:offset] + data[offset + len(license_block):] + (before_chart / "templates" / path.name).write_bytes(bare[path.name]) + + def render(directory, name): + return subprocess.check_output([ + "helm", "template", "kars", str(directory), "--namespace", "kars-system", + "--show-only", "templates/" + name, + ]) + + raw_only = {"crd-karsbudgetaccount.yaml", "crd-karssreaction.yaml", "crd-karssreregistration.yaml"} + for path in crds: + with self.subTest(crd=path.name): + source = path.read_bytes() + rendered = render(chart, path.name) + rendered_before = render(before_chart, path.name) + # Do not discard empty documents: single-document consumers reject them. + self.assertEqual( + list(yaml.safe_load_all(rendered)), + list(yaml.safe_load_all(rendered_before)), + ) + actual = rendered if b"{{" in source and path.name not in raw_only else source + original = rendered_before if b"{{" in bare[path.name] and path.name not in raw_only else bare[path.name] + actual_docs = list(yaml.safe_load_all(actual)) + expected_count = 2 if path.name == "crd.yaml" else 1 + self.assertEqual(len(actual_docs), expected_count) + self.assertTrue(all(isinstance(doc, dict) for doc in actual_docs)) + self.assertEqual(actual_docs, list(yaml.safe_load_all(original))) + if expected_count == 1: + self.assertEqual(yaml.safe_load(actual), yaml.safe_load(original)) + for doc in actual_docs: + self.assertEqual(doc["kind"], "CustomResourceDefinition") + self.assertIn("openAPIV3Schema", doc["spec"]["versions"][0]["schema"]) + def test_original_attribution_and_legacy_annotation_preserved(self): body = b"// Copyright (c) 2026 Original Author\n// SPDX-License-Identifier: MIT\nfn main() {}\n" after = self.apply("a.rs", body) diff --git a/deploy/helm/kars/templates/crd-a2aagent.yaml b/deploy/helm/kars/templates/crd-a2aagent.yaml index c9a94b516..edbf01cc8 100644 --- a/deploy/helm/kars/templates/crd-a2aagent.yaml +++ b/deploy/helm/kars/templates/crd-a2aagent.yaml @@ -1,5 +1,4 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars A2AAgent CRD +# kars A2AAgent CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/a2a_agent.rs` plus the CEL rules in @@ -14,6 +13,9 @@ Licensed under the MIT License. */}}# kars A2AAgent CRD # # and replace the body below with the captured YAML. --- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-egressapproval.yaml b/deploy/helm/kars/templates/crd-egressapproval.yaml index db12b644b..4a5e51236 100644 --- a/deploy/helm/kars/templates/crd-egressapproval.yaml +++ b/deploy/helm/kars/templates/crd-egressapproval.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-inferencepolicy.yaml b/deploy/helm/kars/templates/crd-inferencepolicy.yaml index 659c4233a..ec925745a 100644 --- a/deploy/helm/kars/templates/crd-inferencepolicy.yaml +++ b/deploy/helm/kars/templates/crd-inferencepolicy.yaml @@ -1,5 +1,4 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars InferencePolicy CRD +# kars InferencePolicy CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/inference_policy.rs` plus the CEL rules in @@ -14,6 +13,9 @@ Licensed under the MIT License. */}}# kars InferencePolicy CRD # # and replace the body below with the captured YAML. --- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsapproval.yaml b/deploy/helm/kars/templates/crd-karsapproval.yaml index 134844f49..1c0f77239 100644 --- a/deploy/helm/kars/templates/crd-karsapproval.yaml +++ b/deploy/helm/kars/templates/crd-karsapproval.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsauthconfig.yaml b/deploy/helm/kars/templates/crd-karsauthconfig.yaml index 71aea2018..f2145c2e8 100644 --- a/deploy/helm/kars/templates/crd-karsauthconfig.yaml +++ b/deploy/helm/kars/templates/crd-karsauthconfig.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars KarsAuthConfig CRD +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# kars KarsAuthConfig CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/auth_config.rs`. diff --git a/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml b/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml index 02287db10..455ed68fe 100644 --- a/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml +++ b/deploy/helm/kars/templates/crd-karsbudgetaccount.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# Copyright (c) Microsoft Corporation. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition diff --git a/deploy/helm/kars/templates/crd-karscredentialgrant.yaml b/deploy/helm/kars/templates/crd-karscredentialgrant.yaml index 1f743fec0..14bbd5720 100644 --- a/deploy/helm/kars/templates/crd-karscredentialgrant.yaml +++ b/deploy/helm/kars/templates/crd-karscredentialgrant.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: apiextensions.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: karscredentialgrants.kars.azure.com diff --git a/deploy/helm/kars/templates/crd-karseval.yaml b/deploy/helm/kars/templates/crd-karseval.yaml index 0a2421f2d..47ad71b65 100644 --- a/deploy/helm/kars/templates/crd-karseval.yaml +++ b/deploy/helm/kars/templates/crd-karseval.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsmemory.yaml b/deploy/helm/kars/templates/crd-karsmemory.yaml index 64244551d..282a3666e 100644 --- a/deploy/helm/kars/templates/crd-karsmemory.yaml +++ b/deploy/helm/kars/templates/crd-karsmemory.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsprofile.yaml b/deploy/helm/kars/templates/crd-karsprofile.yaml index c6889236b..95366c78a 100644 --- a/deploy/helm/kars/templates/crd-karsprofile.yaml +++ b/deploy/helm/kars/templates/crd-karsprofile.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsreceipt.yaml b/deploy/helm/kars/templates/crd-karsreceipt.yaml index 87e7af600..2a1d77ba9 100644 --- a/deploy/helm/kars/templates/crd-karsreceipt.yaml +++ b/deploy/helm/kars/templates/crd-karsreceipt.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsskill.yaml b/deploy/helm/kars/templates/crd-karsskill.yaml index b4ffafcb9..111a1c173 100644 --- a/deploy/helm/kars/templates/crd-karsskill.yaml +++ b/deploy/helm/kars/templates/crd-karsskill.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karssreaction.yaml b/deploy/helm/kars/templates/crd-karssreaction.yaml index 65aa69057..ef9eb4cbd 100644 --- a/deploy/helm/kars/templates/crd-karssreaction.yaml +++ b/deploy/helm/kars/templates/crd-karssreaction.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karssreregistration.yaml b/deploy/helm/kars/templates/crd-karssreregistration.yaml index 3b8bf90a2..85c35f945 100644 --- a/deploy/helm/kars/templates/crd-karssreregistration.yaml +++ b/deploy/helm/kars/templates/crd-karssreregistration.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: apiextensions.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: karssreregistrations.kars.azure.com diff --git a/deploy/helm/kars/templates/crd-karstask.yaml b/deploy/helm/kars/templates/crd-karstask.yaml index 6b0ec1f50..0843fbcd2 100644 --- a/deploy/helm/kars/templates/crd-karstask.yaml +++ b/deploy/helm/kars/templates/crd-karstask.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-karsteam.yaml b/deploy/helm/kars/templates/crd-karsteam.yaml index a8155e8c2..36237c3dd 100644 --- a/deploy/helm/kars/templates/crd-karsteam.yaml +++ b/deploy/helm/kars/templates/crd-karsteam.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-mcpserver.yaml b/deploy/helm/kars/templates/crd-mcpserver.yaml index a7d1a7021..9ead41e0f 100644 --- a/deploy/helm/kars/templates/crd-mcpserver.yaml +++ b/deploy/helm/kars/templates/crd-mcpserver.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars McpServer CRD +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# kars McpServer CRD # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/mcp_server.rs` plus the CEL rules in diff --git a/deploy/helm/kars/templates/crd-toolpolicy.yaml b/deploy/helm/kars/templates/crd-toolpolicy.yaml index a8dc8acd7..1168ca3e2 100644 --- a/deploy/helm/kars/templates/crd-toolpolicy.yaml +++ b/deploy/helm/kars/templates/crd-toolpolicy.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd-trustgraph.yaml b/deploy/helm/kars/templates/crd-trustgraph.yaml index 90a4c4c17..ef1120ee3 100644 --- a/deploy/helm/kars/templates/crd-trustgraph.yaml +++ b/deploy/helm/kars/templates/crd-trustgraph.yaml @@ -1,5 +1,4 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars TrustGraph CRD (Phase F1). +# kars TrustGraph CRD (Phase F1). # # DO NOT EDIT BY HAND. This file is the helm-side mirror of the Rust # schema in `controller/src/trust_graph.rs` plus the CEL rules in @@ -14,6 +13,9 @@ Licensed under the MIT License. */}}# kars TrustGraph CRD (Phase F1). # # and replace the body below with the captured YAML. --- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: diff --git a/deploy/helm/kars/templates/crd.yaml b/deploy/helm/kars/templates/crd.yaml index 0780b78b8..4b6ecaa24 100644 --- a/deploy/helm/kars/templates/crd.yaml +++ b/deploy/helm/kars/templates/crd.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# kars KarsSandbox CRD +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# kars KarsSandbox CRD # This CRD defines the custom resource for managing OpenClaw sandboxes apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition diff --git a/deploy/helm/kars/templates/credential-grant-admission.yaml b/deploy/helm/kars/templates/credential-grant-admission.yaml index 828de979f..e4631cf9b 100644 --- a/deploy/helm/kars/templates/credential-grant-admission.yaml +++ b/deploy/helm/kars/templates/credential-grant-admission.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-credential-grant-authority diff --git a/deploy/helm/kars/templates/credential-grant-rbac.yaml b/deploy/helm/kars/templates/credential-grant-rbac.yaml index fead1a169..c4dbef0ef 100644 --- a/deploy/helm/kars/templates/credential-grant-rbac.yaml +++ b/deploy/helm/kars/templates/credential-grant-rbac.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# Unbound: an operator explicitly delegates workspace credential administration. +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Unbound: an operator explicitly delegates workspace credential administration. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: diff --git a/deploy/helm/kars/templates/credential-namespace-admission.yaml b/deploy/helm/kars/templates/credential-namespace-admission.yaml index 29a1c696c..c3dfd83dc 100644 --- a/deploy/helm/kars/templates/credential-namespace-admission.yaml +++ b/deploy/helm/kars/templates/credential-namespace-admission.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-credential-namespace-boundary diff --git a/deploy/helm/kars/templates/credential-reader-admission.yaml b/deploy/helm/kars/templates/credential-reader-admission.yaml index 78ae70712..8cd5b4aab 100644 --- a/deploy/helm/kars/templates/credential-reader-admission.yaml +++ b/deploy/helm/kars/templates/credential-reader-admission.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# These guards apply only to identities enrolled by the controller. DELETE +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# These guards apply only to identities enrolled by the controller. DELETE # remains allowed: the core revokes owned read Roles, proves their absence, then # removes the guard. Namespace /finalize cannot bypass a pending name hold. apiVersion: admissionregistration.k8s.io/v1 diff --git a/deploy/helm/kars/templates/credential-rebind-admission.yaml b/deploy/helm/kars/templates/credential-rebind-admission.yaml index 404982359..26cb5ea75 100644 --- a/deploy/helm/kars/templates/credential-rebind-admission.yaml +++ b/deploy/helm/kars/templates/credential-rebind-admission.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-credential-rebind-authority diff --git a/deploy/helm/kars/templates/credential-store-admission.yaml b/deploy/helm/kars/templates/credential-store-admission.yaml index f57bd1db9..0e439514f 100644 --- a/deploy/helm/kars/templates/credential-store-admission.yaml +++ b/deploy/helm/kars/templates/credential-store-admission.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}# Protect enrolled operator stores even from an accidental write by another +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Protect enrolled operator stores even from an accidental write by another # controller. An empty integration store cannot turn into a privileged key store. apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy diff --git a/deploy/helm/kars/templates/namespace.yaml b/deploy/helm/kars/templates/namespace.yaml index 86bb9178b..d53cfa3d5 100644 --- a/deploy/helm/kars/templates/namespace.yaml +++ b/deploy/helm/kars/templates/namespace.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: v1 kind: Namespace metadata: name: kars-system diff --git a/deploy/helm/kars/templates/rbac.yaml b/deploy/helm/kars/templates/rbac.yaml index 6fc926d79..f6eb12e68 100644 --- a/deploy/helm/kars/templates/rbac.yaml +++ b/deploy/helm/kars/templates/rbac.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}--- +--- +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + # Controller ServiceAccount apiVersion: v1 kind: ServiceAccount diff --git a/deploy/helm/kars/templates/sre-authority-admission.yaml b/deploy/helm/kars/templates/sre-authority-admission.yaml index b4b2b81f3..730106e66 100644 --- a/deploy/helm/kars/templates/sre-authority-admission.yaml +++ b/deploy/helm/kars/templates/sre-authority-admission.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-sre-source-authority diff --git a/deploy/helm/kars/templates/sre-authority-consumers.yaml b/deploy/helm/kars/templates/sre-authority-consumers.yaml index fb3914c3f..ea07a3720 100644 --- a/deploy/helm/kars/templates/sre-authority-consumers.yaml +++ b/deploy/helm/kars/templates/sre-authority-consumers.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: admissionregistration.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingAdmissionPolicy metadata: name: kars-sre-consumer-authority diff --git a/deploy/helm/kars/templates/sre-authority-rbac.yaml b/deploy/helm/kars/templates/sre-authority-rbac.yaml index 5efa344ff..02b94b370 100644 --- a/deploy/helm/kars/templates/sre-authority-rbac.yaml +++ b/deploy/helm/kars/templates/sre-authority-rbac.yaml @@ -1,5 +1,7 @@ -{{/* Copyright (c) Microsoft Corporation. -Licensed under the MIT License. */}}apiVersion: rbac.authorization.k8s.io/v1 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: kars-sre-registrar diff --git a/scripts/apply-copyright-headers.sh b/scripts/apply-copyright-headers.sh index 32f73990e..3d45a6de2 100755 --- a/scripts/apply-copyright-headers.sh +++ b/scripts/apply-copyright-headers.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# Insertion-only, idempotent applier; shares all format/coverage rules with CI. +# Format-safe, idempotent applier; shares all format/coverage rules with CI. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" exec python3 "$ROOT/ci/copyright_headers.py" apply "$@" From 5e9a1fe53bc4ff069aafa78d9cde2cd016279250 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 21:40:12 +0200 Subject: [PATCH 101/111] test: make Cilium API-group equality explicit Preserve all rendered controller-only CNP authority assertions. Compare API-group array elements by exact equality rather than an Array.includes expression classified as a URL substring check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/testing/credential-grant-contract.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/testing/credential-grant-contract.test.ts b/cli/src/testing/credential-grant-contract.test.ts index a04812485..bf93db4ca 100644 --- a/cli/src/testing/credential-grant-contract.test.ts +++ b/cli/src/testing/credential-grant-contract.test.ts @@ -208,9 +208,9 @@ describe("governed credential public contract",()=>{ it("limits optional observer Cilium permissions to the controller and namespaced policies",()=>{ const owners=manifests.filter(item=>["Role","ClusterRole"].includes(item.kind) - &&item.rules?.some((rule:{apiGroups?:string[]})=>rule.apiGroups?.includes("cilium.io"))); + &&item.rules?.some((rule:{apiGroups?:string[]})=>rule.apiGroups?.some(group=>group==="cilium.io"))); expect(owners.map(item=>item.metadata.name)).toEqual(["kars-credential-grant-controller"]); - expect(owners[0].rules.filter((rule:{apiGroups:string[]})=>rule.apiGroups.includes("cilium.io"))) + expect(owners[0].rules.filter((rule:{apiGroups:string[]})=>rule.apiGroups.some(group=>group==="cilium.io"))) .toEqual([{apiGroups:["cilium.io"],resources:["ciliumnetworkpolicies"], verbs:["get","list","create","update","delete"]}]); expect(resource("ClusterRoleBinding","kars-credential-grant-controller").subjects) From 4b4b5778a3bef76a51ded5781781027142f12eae Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 21:50:39 +0200 Subject: [PATCH 102/111] docs: record governed GitHub privacy source closure Carry the same bounded current issuer/cache review evidence as the core prerequisite. Close stale source-wiring wording while retaining live GitHub/operator acceptance and final audit requirements; no signature or approval is added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../2026-09-08-github-services.md | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/docs/security-audits/2026-09-08-github-services.md b/docs/security-audits/2026-09-08-github-services.md index 5dac5efd5..1490a10c3 100644 --- a/docs/security-audits/2026-09-08-github-services.md +++ b/docs/security-audits/2026-09-08-github-services.md @@ -4,8 +4,9 @@ Licensed under the MIT License. --> # Capability audit — Bounded keyless GitHub services Date: 2026-09-08 -Status: Bounded automated review/repair closure complete; human sign-offs and -cross-layer privacy qualification remain pending. +Status: Bounded automated review/repair closure complete. The missing privacy +issuance/reuse wiring is closed in current source; final audit attestations and +live GitHub/operator acceptance remain pending. ## Scope and provenance @@ -21,9 +22,9 @@ not adopted. New production authentication uses existing `jsonwebtoken` RS256, `reqwest`, and standard runtime libraries; no crypto implementation or dependency manifest/lock change is introduced. -## Blocking deployment dependency +## Historical deployment dependency -This baseline predates the separately reviewed SRE-authority repair. The +The original extraction baseline predates the separately reviewed SRE-authority repair. The historical agent-held SRE Kubernetes credential can read cluster-wide Secrets. Until that grant is removed through the qualified operator-authority migration, router-private GitHub App custody is **not established against that principal**. @@ -48,6 +49,51 @@ and privacy-gated issuance have been wired. No cloud deployment, image/release publication, main promotion, public API mutation, or live GitHub App installation was performed as qualification. +### Current source-wiring closure (2026-09-14) + +A bounded independent-context AI review traced actual issuance and reuse at +core `03174dcaaa13cef956f4660074ce1f3dcc635c42`, also present in application +`1d0fc5c96810d21f083196e8c6f654b4204ad2c1`. The relevant production files are +unchanged at the subsequent test-only `9ee285be` and `5e9a1fe5` heads. +The historical statement that the gate is absent or unwired no longer describes +these candidates: + +- `controller/src/credential_grants/github.rs:331-388` revalidates the Sandbox, + grant, connection/App-store UID/resourceVersion and managed namespace before + calling `credentials::ensure_bound` with the GitHub purpose. +- `controller/src/reconciler/governed_services/credentials.rs:317-342,518-546` + calls actual privacy readiness and `sre_authority::privacy_epoch` before the + unchanged-Secret fast path as well as issuance. Missing/stale privacy does not + authorize reuse; invalid proof quarantines material and pending migration + remains non-issuance. +- `controller/src/sre_authority/live.rs:156-205` and + `shared/sre_privacy.rs:11-46` require live shared GET/LIST/WATCH denials and + current registration/epoch evidence. Absent registration does not skip the + denial checks. +- Private-activation stamp changes require old-consumer retirement and a + genuinely different RSA key before requalification. Source revisions prevent + unchanged projection reuse. `inference-router/src/github_services.rs:56-100` + invalidates obsolete credential caches, and + `inference-router/src/routes/github_proxy.rs:180-199` rechecks the credential + incarnation after token acquisition. + +The review found no unguarded governed issuance/reuse path within this scope. +Core run [34882574974](https://github.com/Azure/kars/actions/runs/34882574974) +and application core run +[34882574933](https://github.com/Azure/kars/actions/runs/34882574933) passed all +21 jobs at the preceding revisions, including 184/184 Kind cases and actual +historical SRE migration. That execution evidence accompanies, but does not +replace, the source trace. + +This closes the missing source-integration finding, not complete deployment +acceptance. Router token-cache hits rely on controller-gated projection and +retirement, not a fresh SRE authorization review on every GitHub request. +Projection delay and already-dispatched work are not instantaneous revocation; +external GitHub key/token revocation remains an operator responsibility. +The separate native credential 18/18 result does not exercise a live GitHub +App installation or establish that complete privacy-loss/rotation chain. +No signature, whole-PR approval or live-service qualification is supplied here. + ## Security contract - Optional operator-owned Secret in the exactly owned Sandbox namespace, mounted @@ -100,8 +146,9 @@ The parent subsequently ran all 33 selected Rust cases successfully (27 authored GitHub cases and six existing provider cases), plus strict paired all-target Clippy and formatting. Only two new test layouts required formatting. The same independent automated reviewer found no significant issues in the bounded repair -delta. This does not constitute a human sign-off. The privacy-epoch -issuance/reuse integration remains independently deployment-blocking. +delta. This does not constitute a human sign-off. At that review, privacy-epoch +issuance/reuse integration remained deployment-blocking; its later source +closure is recorded above with the remaining execution and approval limits. Ready selector under the parent's prescribed combined-crate lease: @@ -158,7 +205,7 @@ Completed before the repairs above: passed without changing the gate or adding waivers. New Rust headers/module caps were also checked directly. -Pending: +Pending at the original review: - Independent reviewer assessment, supply-chain sign-off, forward-merged SRE boundary qualification, and real installation/operator acceptance are pending. From 4804eec2cc4d9918a43dd3830ffef4b21f5514fd Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 22:09:53 +0200 Subject: [PATCH 103/111] test(ci): preserve historical audit scope during header updates Require a newly added fully signed capability record even when historical audit notices change, without demanding retrospective signatures on already completed scopes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- ci/tests/security_audit_gate_test.py | 17 +++++++++++++++++ docs/security-audits/README.md | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/ci/tests/security_audit_gate_test.py b/ci/tests/security_audit_gate_test.py index 3e707b60c..ee508271e 100644 --- a/ci/tests/security_audit_gate_test.py +++ b/ci/tests/security_audit_gate_test.py @@ -72,6 +72,23 @@ def test_documentation_only_changes_do_not_require_capability_approval(self): self.commit() self.assertEqual(self.gate().returncode, 0) + def test_historical_header_changes_do_not_reopen_a_completed_scope(self): + historical = "# Previously accepted scope\nSigned-off-by: Author <author@example.invalid>\n" + self.write(OLD, historical) + self.commit() + self.base = self.git("rev-parse", "HEAD").strip() + self.capability() + self.write(OLD, "<!-- Copyright (c) Microsoft Corporation.\n" + "Licensed under the MIT License. -->\n\n" + historical) + self.write(NEW, SIGNED.replace("Approved old scope", "Current scope")) + self.commit() + self.assertEqual(self.gate().returncode, 0) + self.write(NEW, "# Current source review pending\n") + self.commit() + result = self.gate() + self.assertEqual(result.returncode, 1) + self.assertIn(NEW, result.stderr) + def test_missing_review_base_cannot_fall_back_to_an_empty_worktree_diff(self): self.capability() self.commit() diff --git a/docs/security-audits/README.md b/docs/security-audits/README.md index 1bfaa4a5d..1161f0ee6 100644 --- a/docs/security-audits/README.md +++ b/docs/security-audits/README.md @@ -37,3 +37,9 @@ technical gates were waived. No delegation is inferred by default. These docs are intentionally **tracked** (committed with the PR), unlike the private `docs/internal/` planning folder. + +The record must be newly added relative to the reviewed base. Modifying or +renaming an old signed record does not approve a new capability. Formatting or +copyright changes to historical records neither extend their sign-off scope nor +reopen their completed approval decisions. A new capability still requires its +own newly added, signed record, and an unavailable review base fails the gate. From 89fef916f6910e253fb639ecefd73929e477e20d Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 22:09:53 +0200 Subject: [PATCH 104/111] docs: attest scoped credential and GitHub integration review Record exact reviewed source, independent-context coverage and executed public evidence under the existing explicit maintainer delegation. Preserve historical failures and limits on live GitHub, active-SRE combined, Bridge and H100 acceptance; no human review, check waiver or deployment approval is implied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../2026-09-08-github-services.md | 46 ++++++++-- .../2026-09-08-governed-credential-grants.md | 84 +++++++++++++++++-- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/docs/security-audits/2026-09-08-github-services.md b/docs/security-audits/2026-09-08-github-services.md index 1490a10c3..967867b9c 100644 --- a/docs/security-audits/2026-09-08-github-services.md +++ b/docs/security-audits/2026-09-08-github-services.md @@ -4,9 +4,9 @@ Licensed under the MIT License. --> # Capability audit — Bounded keyless GitHub services Date: 2026-09-08 -Status: Bounded automated review/repair closure complete. The missing privacy -issuance/reuse wiring is closed in current source; final audit attestations and -live GitHub/operator acceptance remain pending. +Status: **Bounded source-approved under explicit maintainer delegation.** +The missing privacy issuance/reuse wiring is closed in current source. +Current-head technical gates and live GitHub/operator acceptance remain separate. ## Scope and provenance @@ -92,7 +92,8 @@ Projection delay and already-dispatched work are not instantaneous revocation; external GitHub key/token revocation remains an operator responsibility. The separate native credential 18/18 result does not exercise a live GitHub App installation or establish that complete privacy-loss/rotation chain. -No signature, whole-PR approval or live-service qualification is supplied here. +This source trace does not supply whole-PR approval or live-service qualification. +The bounded delegated source attestation is recorded below. ## Security contract @@ -271,13 +272,40 @@ remain trust dependencies. Branch protections must deny App bypass before write is enabled. This candidate provides no durable budget broker, workflow engine, user approval ledger, or automatic worker enrollment. -## Sign-offs +## Bounded delegated source attestation (2026-09-14) + +Reviewed current source: `9ee285be5068c7bde7c694e7dccbab2ae8a51c32`, relative to +integration base `b5ad6791f9085e908cbf3d16b5de9021eb4b43a7`. The subsequent +`fbc24af2` is audit-text-only. This attestation combines the original bounded +service/repair review recorded above with the independent current issuer, +privacy-gate, projection and cache integration trace. It does not approve the +complete Bridge application or every unrelated change in the prerequisite PR. + +The author attestation is exercised by Copilot under the maintainer's explicit +[delegation](https://github.com/Azure/kars/pull/551#issuecomment-5615522306), +not a claim that the maintainer personally reviewed this source. The final +independent trace was performed by the separate read-only AI context +`frozen-bridge-review` (`7f37ca6e-6256-4dd1-8064-d8fe6ac2fc92`), not a second +human. The original service-repair reviewer did not approve its own fixes. + +Actual public Rust job `104105426223` at `03174dca` passed the enrolled issuer +schema/canonicalization/adoption cases, private-purpose issuance/retirement/ +source-revision cases, and router incarnation/cache/token/proxy regressions. +The reviewed production source is unchanged at `9ee285be`; only two unrelated +CLI test expressions changed. Passing local fake-upstream and Kubernetes +fixtures are not live GitHub App installation or instantaneous external +revocation evidence. | Role | Name | Date | Decision | | --- | --- | --- | --- | -| Independent security reviewer | Pending | Pending | Pending | -| Runtime/controller maintainer | Pending | Pending | Pending | -| Supply-chain reviewer | Pending | Pending | Pending | +| Author source attestation | Copilot under explicit pallakatos delegation | 2026-09-14 | Approved for the exact bounded source scope | +| Independent source review | Separate read-only Copilot context, not a human | 2026-09-14 | No blocker in the reviewed issuance/reuse integration | | Operator acceptance | Pending | Pending | Pending | -No reviewer identity or signature is asserted by this document. +All residual operational constraints above remain. Current-head protected +checks and PR review are still required. No audit/check waiver, merge bypass, +main promotion, release/image publication or customer/H100 deployment is +authorized by this attestation. + +Signed-off-by: pallakatos (author source attestation through explicit maintainer-delegated AI review, not a claim of personal code review) <191481949+pallakatos@users.noreply.github.com> +Signed-off-by: GitHub Copilot (independent-context delegated AI source review, not a second human) <223556219+Copilot@users.noreply.github.com> diff --git a/docs/security-audits/2026-09-08-governed-credential-grants.md b/docs/security-audits/2026-09-08-governed-credential-grants.md index 4dd417ecb..7ef8e2475 100644 --- a/docs/security-audits/2026-09-08-governed-credential-grants.md +++ b/docs/security-audits/2026-09-08-governed-credential-grants.md @@ -3,17 +3,83 @@ Licensed under the MIT License. --> # Governed credential grants — qualification record -Status: implementation candidate; **not a sign-off**. No author or independent -reviewer signatures are supplied. Existing audit gates remain required. +Status: **Bounded source-approved under explicit maintainer delegation.** +Current-head technical gates, required PR review and operational acceptance +remain separate. Historical candidate failures below are not relabeled passing. ## Scope Metadata-only operator grants, native Secret source authoring, UID-bound Sandbox/Task/Team delivery, explicit workspace/Team/target precedence, legacy preflight/import, purpose-bound operator stores, private read-only egress -observations, and a real App-store-to-router GitHub issuer. Private Bridge -adapts to the public core contract; it is not copied into -this repository. +observations, and a real App-store-to-router GitHub issuer. This core prerequisite +does not require Bridge. The complete optional application is separately reviewed +in Azure/kars#563 and is not approved by this credential-source attestation. + +## Current delegated source attestation (2026-09-14) + +Reviewed source: `9ee285be5068c7bde7c694e7dccbab2ae8a51c32`. +Integration base: `b5ad6791f9085e908cbf3d16b5de9021eb4b43a7`. +The subsequent `fbc24af2` changes only the GitHub audit narrative, not production +source. The attestation binds the reviewed capability, not every file in the PR. + +The maintainer explicitly authorized publication sign-offs after focused-agent +review rounds in +[comment 5615522306](https://github.com/Azure/kars/pull/551#issuecomment-5615522306). +The author attestation is exercised by Copilot under that delegation, not a claim +of personal code review by the maintainer. Independent closure was performed by +the separate read-only AI context `frozen-bridge-review` +(`7f37ca6e-6256-4dd1-8064-d8fe6ac2fc92`), not a second human. +Neither a passing audit script nor the signatures below authenticate a human +review that did not occur. + +The final integrated review found no high-confidence blocker in these seams: + +| Reviewed seam | Source evidence and retained boundary | +| --- | --- | +| Reviewed enrollment and publication | `cli/src/commands/credential-grants.ts:128-207` and `cli/src/lib/private-activation.ts:335-430`: reviewed identity/root/profile/consumer fences, writer retirement acknowledgement and current UID/RV publication | +| Late enrollment, recovery and rotation | `cli/src/lib/private-activation-late-scope.ts:425-560` and `controller/src/private_activation/late_scope.rs:201-376`: captured intent, old-Pod retirement, changed authentication material and controller-verified restoration | +| Shared-root continuity | `cli/src/lib/private-activation-continuity.ts:186-385` and `cli/src/lib/private-activation-writer-settle.ts:264-433`: sealed root history, retained scope epochs, current Task/source authority and actual pause/refill/restore transitions | +| Source delivery and bundle recovery | `controller/src/credential_grants/sources.rs:480-640` and `sources/bundle.rs:176-307`: current target/grant/source versions before writes; recovery limited to this invocation's acknowledged empty CREATE, never stale-value replay | +| Task/Team pause and resume | `controller/src/credential_grants/readiness.rs:17-84`, `controller/src/kars_team_reconciler/credential_bindings.rs:31-145` and `controller/src/kars_task_rebind.rs:39-247`: withdrawn readiness, acknowledged quiescence and fresh owned authority before hold release | +| Private observation and RPC | `controller/src/credential_grants/operator.rs:20-150`, `inference-router/src/service_observation.rs:88-340` and `controller/src/privacy_rpc/authority.rs:77-280`: current purpose/recipient/target/credential/privacy checks, not a general Secret, proxy or mutation service | +| Authority and network retirement | `controller/src/credential_grants/writers/permissions.rs:180-208`, `writers/guards.rs:130-221`, matching admission definitions and `observer_metadata/api_egress.rs:223-350`: deny indeterminate dangerous authority, require read-role absence, preserve namespace/ownership/deletion fences | +| Schema lifecycle | `cli/src/lib/schema-stage.ts:95-218`, `core-helm-schemas.ts:86-166`, `sre-schema-migration.ts:50-153` and actual install/upgrade/rollback/removal callers: schema-before-admission ordering, no foreign adoption or lossy rollback, explicit canonical migration | +| Core independence | `controller/src/private_activation/runtime.rs:16-59`: absent, disabled and unrelated unselected activation stays unchanged; private RPC remains opt-in | + +The separate GitHub record supplies the reviewed actual issuance/reuse/cache +closure. This record does not extend it to live GitHub App acceptance. + +### Executed evidence and remaining limits + +Production source is unchanged from `03174dcaaa13cef956f4660074ce1f3dcc635c42`; +`9ee285be` only clarifies two CLI test expressions. At `03174dca`, +[public CI 34882574974](https://github.com/Azure/kars/actions/runs/34882574974) +passed all 21 jobs. Rust job `104105426223` actually executed **3,066 tests, +zero skipped**, including bundle recovery, Task rebind, late scope, private RPC +and GitHub/private-purpose/cache cases. Kind passed **184/184**, including actual +historical schema/SRE migration, authority denials and lifecycle cleanup. +The 31 corrected Helm templates retain their non-header body bytes and the +actual raw-YAML/document boundaries. + +The paired `1d0fc5c9` application passed all 21 core jobs, all 11 component jobs +and all 18 native cases plus three cold API installs. Its native lane is +`controlled-no-LLM-agent` / `no-active-sre-native`. It does not establish active-SRE +combined, live GitHub, H100/model-serving or complete standing-Team acceptance. +The separate application source review remains independently required. + +This is not an exhaustive approval of unrelated controller/router capabilities, +all historical/custom schema variants, external SDK/provider behavior or every +changed file. Existing `/sandbox` storage remains ephemeral `emptyDir`; Pod +retirement can discard Pod-local files. No persistence guarantee or new storage +requirement is introduced. + +Current and future PR heads still require their own protected checks and review. +This source attestation does not waive failures, authorize a merge bypass, approve +main/release/image promotion or permit a customer/H100 deployment. + +Signed-off-by: pallakatos (author source attestation through explicit maintainer-delegated AI review, not a claim of personal code review) <191481949+pallakatos@users.noreply.github.com> +Signed-off-by: GitHub Copilot (independent-context delegated AI source review, not a second human) <223556219+Copilot@users.noreply.github.com> ## Enforced boundaries @@ -38,7 +104,7 @@ this repository. - No raw credential values in the grant schema, metadata status, preview files or diagnostic messages. -## Current validation +## Historical implementation and validation ### Shared-root workspace continuity candidate @@ -747,7 +813,7 @@ API tests alone cannot qualify those claims. Any author waiver on earlier publication PRs does not apply to this change. -## Explicit open blockers +## Historical blockers before subsequent qualification - The first direct Cargo lease was released unused because the newly required privacy closure had not yet been forwarded. The exact @@ -787,7 +853,9 @@ Any author waiver on earlier publication PRs does not apply to this change. enrollment/preflight is implemented with explicit private chart opt-in and remains subject to real CNI/API qualification. -These are not waived and the candidate is not ready for publication or rollout. +At that checkpoint these blockers were not waived and that candidate was not +ready for publication or rollout. Current source closure and the still-separate +operational acceptance limits are recorded in the dated attestation above. ## Guarded Rust command record and pending private plan From 8f35353581e3e1ff74afa40f5f2f84f2011251a0 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 22:24:46 +0200 Subject: [PATCH 105/111] fix(bridge): isolate artifact content and enforce signed admin authority Keep active and unknown artifact bytes downloadable without inheriting the authenticated Bridge origin. Enforce existing budget and retention admin boundaries in BFF middleware and handlers, and use verified session roles in the web gate. Require the nine Rust regressions to be registered and run all web contract files in hosted qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 17 +- bridge/bff/src/auth.rs | 27 ++ bridge/bff/src/auth_admin_tests.rs | 296 ++++++++++++++++++ bridge/bff/src/routes/budgets.rs | 9 +- bridge/bff/src/routes/retention.rs | 11 +- bridge/bff/src/routes/tasks/artifact_tests.rs | 218 +++++++++++++ bridge/bff/src/routes/tasks/artifacts.rs | 38 ++- bridge/docs/inference-budgets.md | 10 +- bridge/docs/missions-and-teams.md | 18 ++ bridge/docs/rbac.md | 16 + bridge/web/src/lib/request-roles.ts | 18 ++ bridge/web/src/lib/session-token.ts | 17 +- bridge/web/src/lib/session.ts | 28 +- bridge/web/src/proxy.ts | 26 +- bridge/web/tests/authorization.test.mjs | 173 ++++++++++ bridge/web/tests/proxy-routes.test.mjs | 37 +++ 16 files changed, 892 insertions(+), 67 deletions(-) create mode 100644 bridge/bff/src/auth_admin_tests.rs create mode 100644 bridge/bff/src/routes/tasks/artifact_tests.rs create mode 100644 bridge/web/src/lib/request-roles.ts create mode 100644 bridge/web/tests/authorization.test.mjs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index bb516c05e..148b265de 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -47,7 +47,7 @@ jobs: workspaces: bridge/bff - run: cargo fmt --all -- --check - run: cargo clippy --locked --all-targets -- -D warnings - - name: Require receipt trust regression registration + - name: Require trust and authorization regression registration run: | cargo test --locked -- --list > /tmp/kars-bridge-bff-tests.txt for name in \ @@ -62,7 +62,16 @@ jobs: routes::github::tests::connection_names_keep_the_original_raw_subject_and_eight_byte_digest \ providers::receipt::tests::rfc8032_known_answer_and_malformed_signatures_keep_exact_verification_semantics \ providers::credential_review::tests::legacy_v1_key_preserves_domain_null_byte_and_raw_secret_encoding \ - providers::credential_review::tests::tag_comparison_requires_equal_length_and_every_byte_without_normalization + providers::credential_review::tests::tag_comparison_requires_equal_length_and_every_byte_without_normalization \ + auth::admin_tests::admin_route_and_role_matrix_does_not_promote_operators_or_gate_reads \ + auth::admin_tests::direct_bff_admin_mutations_deny_missing_forged_expired_and_non_admin_principals \ + auth::admin_tests::budget_and_retention_handlers_require_admin_even_without_route_middleware \ + auth::admin_tests::signed_admins_can_set_and_clear_each_budget_scope_and_retention \ + auth::admin_tests::operator_reads_stay_available_while_user_and_auditor_console_reads_stay_denied \ + routes::tasks::artifacts::tests::active_and_unknown_artifacts_download_unchanged_from_live_or_retained_tasks \ + routes::tasks::artifacts::tests::passive_artifact_previews_keep_inline_viewing_without_mime_sniffing_or_byte_changes \ + routes::tasks::artifacts::tests::artifact_head_has_the_same_protection_and_filename_is_header_safe \ + routes::tasks::artifacts::tests::artifact_response_hardening_preserves_ownership_and_missing_file_denials do grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt done @@ -86,8 +95,8 @@ jobs: - run: npm ci - run: npm run lint - run: npx --no-install tsc --noEmit - - name: Check credential forms, DTOs, proxies and evidence links - run: node --experimental-strip-types --test tests/credential-review.test.mjs tests/type-contract.test.mjs tests/proxy-routes.test.mjs tests/team-run-links.test.mjs + - name: Check all web authorization, form, proxy and evidence contracts + run: node --experimental-strip-types --test tests/*.test.mjs - name: Build the production web image without publishing run: docker build --tag kars-bridge-web-qualification:latest . - name: Start web with an immutable root filesystem diff --git a/bridge/bff/src/auth.rs b/bridge/bff/src/auth.rs index a890100b4..57137f3ac 100644 --- a/bridge/bff/src/auth.rs +++ b/bridge/bff/src/auth.rs @@ -17,10 +17,15 @@ use axum::response::Response; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use serde::{Deserialize, Serialize}; +use crate::error::{AppError, AppResult}; use crate::state::AppState; pub const PRINCIPAL_HEADER: &str = "x-kars-principal-token"; +#[cfg(test)] +#[path = "auth_admin_tests.rs"] +mod admin_tests; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Principal { pub sub: String, @@ -43,6 +48,7 @@ enum RequiredPersona { User, Operator, Auditor, + Admin, } fn is_mutating(method: &Method) -> bool { @@ -53,6 +59,16 @@ fn is_mutating(method: &Method) -> bool { } fn required_persona(path: &str, method: &Method) -> RequiredPersona { + if is_mutating(method) + && [ + "/api/operator/inference-budgets", + "/api/operator/retention-policy", + ] + .iter() + .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/"))) + { + return RequiredPersona::Admin; + } if path == "/api/operator/audit" && *method == Method::GET { return RequiredPersona::Auditor; } @@ -80,6 +96,17 @@ fn has_role(principal: &Principal, required: RequiredPersona) -> bool { RequiredPersona::User => has("user") || has("operator"), RequiredPersona::Operator => has("operator"), RequiredPersona::Auditor => has("auditor"), + RequiredPersona::Admin => false, + } +} + +pub(crate) fn require_admin(principal: &Principal) -> AppResult<()> { + if has_role(principal, RequiredPersona::Admin) { + Ok(()) + } else { + Err(AppError::Forbidden( + "only a cluster or org admin can change this setting".into(), + )) } } diff --git a/bridge/bff/src/auth_admin_tests.rs b/bridge/bff/src/auth_admin_tests.rs new file mode 100644 index 000000000..2c8319845 --- /dev/null +++ b/bridge/bff/src/auth_admin_tests.rs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use axum::{Extension, Router, body::to_bytes, middleware}; +use jsonwebtoken::{EncodingKey, Header, encode}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tower::{ServiceExt, service_fn}; + +const SECRET: &str = "test-only-bridge-principal-signing-key"; +const BUDGETS: &str = "/api/v1/namespaces/kars-system/configmaps/kars-inference-budgets"; +const RETENTION: &str = "/api/v1/namespaces/kars-system/configmaps/kars-retention-policy"; +const MUTATIONS: &[&str] = &[ + "/api/operator/inference-budgets/cluster", + "/api/operator/inference-budgets/workspaces/work", + "/api/operator/inference-budgets/users/alice", + "/api/operator/retention-policy", +]; + +#[derive(Default)] +struct TestApi { + calls: Vec<(Method, String)>, + objects: std::collections::BTreeMap<String, Value>, +} + +fn fixture() -> (AppState, Arc<Mutex<TestApi>>) { + let api = Arc::new(Mutex::new(TestApi::default())); + let store = api.clone(); + let service = service_fn(move |request: Request<_>| { + let store = store.clone(); + async move { + let (parts, body) = request.into_parts(); + let bytes = to_bytes(Body::new(body), 65536).await.unwrap(); + let path = parts.uri.path(); + let mut api = store.lock().unwrap(); + api.calls.push((parts.method.clone(), path.into())); + let (status, value) = if [BUDGETS, RETENTION].contains(&path) { + if parts.method == Method::PATCH { + let value: Value = serde_json::from_slice(&bytes).unwrap(); + api.objects.insert(path.into(), value.clone()); + (200, value) + } else { + assert_eq!(parts.method, Method::GET); + match api.objects.get(path) { + Some(value) => (200, value.clone()), + None => ( + 404, + json!({ + "apiVersion":"v1","kind":"Status","status":"Failure", + "reason":"NotFound","code":404,"message":"not found" + }), + ), + } + } + } else { + assert_eq!(parts.method, Method::GET); + assert!( + path == "/apis/kars.azure.com/v1alpha1/karstasks" + || path == "/api/v1/namespaces/kars-system/configmaps", + "unexpected Kubernetes request: {path}" + ); + ( + 200, + json!({"apiVersion":"v1","kind":"List","metadata":{},"items":[]}), + ) + }; + Ok::<_, std::io::Error>( + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(value.to_string())) + .unwrap(), + ) + } + }); + ( + AppState::for_test_client(kube::Client::new(service, "work"), "work") + .with_principal_secret(Some(SECRET.into())), + api, + ) +} + +fn signed(roles: &[&str], secret: &str, expires: i64) -> String { + encode( + &Header::new(Algorithm::HS256), + &json!({"sub":"alice","name":"Alice","roles":roles,"exp":expires}), + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap() +} + +fn token(roles: &[&str]) -> String { + signed(roles, SECRET, chrono::Utc::now().timestamp() + 3600) +} + +fn app(state: AppState) -> Router { + crate::routes::router(state.clone()).layer(middleware::from_fn_with_state(state, require_token)) +} + +fn request(path: &str, method: Method, token: Option<&str>, body: Value) -> Request<Body> { + let mut request = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("cookie", "bridge-role=admin") + .header("x-bridge-role", "admin"); + if let Some(token) = token { + request = request.header(PRINCIPAL_HEADER, token); + } + request.body(Body::from(body.to_string())).unwrap() +} + +fn input(path: &str, clear: bool) -> Value { + if path.ends_with("retention-policy") { + json!({"default_ttl_seconds":if clear { 0 } else { 3600 }}) + } else { + json!({"daily_tokens":100,"mode":"strict","clear":clear}) + } +} + +#[test] +fn admin_route_and_role_matrix_does_not_promote_operators_or_gate_reads() { + for path in MUTATIONS { + for method in [Method::PUT, Method::POST, Method::PATCH, Method::DELETE] { + assert_eq!(required_persona(path, &method), RequiredPersona::Admin); + } + assert_eq!( + required_persona(path, &Method::GET), + RequiredPersona::Operator + ); + } + for role in ["user", "auditor", "operator", "viewer"] { + let principal = Principal { + sub: "alice".into(), + name: "Alice".into(), + roles: vec![role.into()], + }; + assert!(!has_role(&principal, RequiredPersona::Admin)); + } + assert_eq!( + required_persona("/api/operator/inferencepolicies/example", &Method::PATCH), + RequiredPersona::Operator + ); + assert_eq!( + required_persona("/api/operator/retention-policy-extra", &Method::PUT), + RequiredPersona::Operator + ); +} + +#[tokio::test] +async fn direct_bff_admin_mutations_deny_missing_forged_expired_and_non_admin_principals() { + let invalid = [ + None, + Some("forged".into()), + Some(signed( + &["admin"], + "wrong-key", + chrono::Utc::now().timestamp() + 3600, + )), + Some(signed( + &["admin"], + SECRET, + chrono::Utc::now().timestamp() - 3600, + )), + ]; + for token in invalid { + let (state, api) = fixture(); + for path in MUTATIONS { + let response = app(state.clone()) + .oneshot(request( + path, + Method::PUT, + token.as_deref(), + input(path, false), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); + } + assert!(api.lock().unwrap().calls.is_empty()); + } + for role in ["operator", "user", "auditor", "viewer"] { + let (state, api) = fixture(); + let token = token(&[role]); + for path in MUTATIONS { + for clear in [false, true] { + let response = app(state.clone()) + .oneshot(request(path, Method::PUT, Some(&token), input(path, clear))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{role}: {path}"); + } + } + assert!(api.lock().unwrap().calls.is_empty()); + } +} + +#[tokio::test] +async fn budget_and_retention_handlers_require_admin_even_without_route_middleware() { + let (state, api) = fixture(); + let principal = Principal { + sub: "alice".into(), + name: "Alice".into(), + roles: vec!["operator".into()], + }; + for path in MUTATIONS { + let response = crate::routes::router(state.clone()) + .layer(Extension(principal.clone())) + .oneshot(request(path, Method::PUT, None, input(path, false))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } + assert!(api.lock().unwrap().calls.is_empty()); +} + +#[tokio::test] +async fn signed_admins_can_set_and_clear_each_budget_scope_and_retention() { + for (index, path) in MUTATIONS.iter().enumerate() { + let (state, api) = fixture(); + let token = token(&["admin"]); + for clear in [false, true] { + let response = app(state.clone()) + .oneshot(request(path, Method::PUT, Some(&token), input(path, clear))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + let body: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()) + .unwrap(); + let api = api.lock().unwrap(); + if index == 3 { + assert_eq!(body["default_ttl_seconds"], if clear { 0 } else { 3600 }); + assert_eq!( + api.objects[RETENTION]["data"]["defaultTtlSeconds"], + if clear { "0" } else { "3600" } + ); + } else { + let hierarchy: Value = serde_json::from_str( + api.objects[BUDGETS]["data"]["budgets.json"] + .as_str() + .unwrap(), + ) + .unwrap(); + let rule = match index { + 0 => &hierarchy["cluster"], + 1 => &hierarchy["workspaces"]["work"], + _ => &hierarchy["users"]["alice"], + }; + if clear { + assert!(rule.is_null()); + } else { + assert_eq!(rule["daily_tokens"], 100); + assert_eq!(rule["mode"], "strict"); + } + } + } + assert_eq!( + api.lock() + .unwrap() + .calls + .iter() + .filter(|(method, _)| *method == Method::PATCH) + .count(), + 2 + ); + } +} + +#[tokio::test] +async fn operator_reads_stay_available_while_user_and_auditor_console_reads_stay_denied() { + for role in ["operator", "admin", "user", "auditor", "viewer"] { + let (state, api) = fixture(); + let token = token(&[role]); + let allowed = matches!(role, "operator" | "admin"); + for path in [ + "/api/operator/inference-budgets", + "/api/operator/retention-policy", + ] { + let response = app(state.clone()) + .oneshot(request(path, Method::GET, Some(&token), Value::Null)) + .await + .unwrap(); + assert_eq!( + response.status(), + if allowed { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + } + ); + } + assert_eq!(api.lock().unwrap().calls.is_empty(), !allowed); + } +} diff --git a/bridge/bff/src/routes/budgets.rs b/bridge/bff/src/routes/budgets.rs index cb7381de2..55145f292 100644 --- a/bridge/bff/src/routes/budgets.rs +++ b/bridge/bff/src/routes/budgets.rs @@ -25,10 +25,11 @@ // stored in the `kars-inference-budgets` ConfigMap (cluster-native, editable). use axum::Json; -use axum::extract::{Path, State}; +use axum::extract::{Extension, Path, State}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use crate::auth::{Principal, require_admin}; use crate::error::{AppError, AppResult}; use crate::kars::cluster::Cluster; use crate::state::AppState; @@ -348,8 +349,10 @@ pub struct SetRuleRequest { /// `PUT /api/operator/inference-budgets/cluster` — set/clear the cluster cap. pub async fn set_cluster_budget( State(state): State<AppState>, + Extension(principal): Extension<Principal>, Json(req): Json<SetRuleRequest>, ) -> AppResult<Json<BudgetsDto>> { + require_admin(&principal)?; let cluster = require_cluster(&state)?; let mut h = BudgetHierarchy::load(cluster).await; if req.clear { @@ -371,9 +374,11 @@ pub async fn set_cluster_budget( /// `PUT /api/operator/inference-budgets/workspaces/{ns}` — set/clear a workspace. pub async fn set_workspace_budget( State(state): State<AppState>, + Extension(principal): Extension<Principal>, Path(ns): Path<String>, Json(req): Json<SetRuleRequest>, ) -> AppResult<Json<BudgetsDto>> { + require_admin(&principal)?; if ns.trim().is_empty() { return Err(AppError::BadRequest( "workspace namespace is required".into(), @@ -401,9 +406,11 @@ pub async fn set_workspace_budget( /// `PUT /api/operator/inference-budgets/users/{user}` — set/clear a per-user cap. pub async fn set_user_budget( State(state): State<AppState>, + Extension(principal): Extension<Principal>, Path(user): Path<String>, Json(req): Json<SetRuleRequest>, ) -> AppResult<Json<BudgetsDto>> { + require_admin(&principal)?; if user.trim().is_empty() { return Err(AppError::BadRequest("user identity is required".into())); } diff --git a/bridge/bff/src/routes/retention.rs b/bridge/bff/src/routes/retention.rs index 1663f1bf1..e4cec633a 100644 --- a/bridge/bff/src/routes/retention.rs +++ b/bridge/bff/src/routes/retention.rs @@ -19,9 +19,13 @@ // (the controller pins them to `0` unconditionally) — only individual // missions and team RUN records are eligible. -use axum::{Json, extract::State}; +use axum::{ + Json, + extract::{Extension, State}, +}; use serde::{Deserialize, Serialize}; +use crate::auth::{Principal, require_admin}; use crate::error::{AppError, AppResult}; use crate::kars::cluster::Cluster; use crate::state::AppState; @@ -74,12 +78,13 @@ pub struct SetRetentionPolicyRequest { pub default_ttl_seconds: i64, } -/// `PUT /api/operator/retention-policy` — admin-only (gated at the web-proxy -/// layer, same pattern as inference budgets). +/// `PUT /api/operator/retention-policy` — admin-only in the BFF and web proxy. pub async fn set_retention_policy( State(state): State<AppState>, + Extension(principal): Extension<Principal>, Json(body): Json<SetRetentionPolicyRequest>, ) -> AppResult<Json<RetentionPolicyDto>> { + require_admin(&principal)?; let cluster = require_cluster(&state)?; if body.default_ttl_seconds < 0 { return Err(AppError::BadRequest( diff --git a/bridge/bff/src/routes/tasks/artifact_tests.rs b/bridge/bff/src/routes/tasks/artifact_tests.rs new file mode 100644 index 000000000..2cac07c58 --- /dev/null +++ b/bridge/bff/src/routes/tasks/artifact_tests.rs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use axum::{ + body::{Body, to_bytes}, + http::{Method, Request, StatusCode}, + response::Response, +}; +use serde_json::json; +use std::sync::{Arc, Mutex}; +use tower::{ServiceExt, service_fn}; + +fn fixture( + name: &str, + bytes: &[u8], + binary: bool, + retained: bool, +) -> (AppState, Arc<Mutex<Vec<String>>>) { + let key = artifact_key(name); + let artifact = if binary { + json!({"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"kars-mission-artifacts-task"}, + "binaryData":{key:k8s_openapi::ByteString(bytes.to_vec())}}) + } else { + json!({"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"kars-mission-artifacts-task"}, + "data":{key:std::str::from_utf8(bytes).unwrap()}}) + }; + let calls = Arc::new(Mutex::new(Vec::new())); + let requests = calls.clone(); + let service = service_fn(move |request: Request<_>| { + let artifact = artifact.clone(); + let requests = requests.clone(); + async move { + assert_eq!(request.method(), Method::GET); + let path = request.uri().path(); + requests.lock().unwrap().push(path.to_string()); + let (status, body) = match path { + "/apis/kars.azure.com/v1alpha1/namespaces/work/karstasks/task" if retained => ( + 404, + json!({ + "apiVersion":"v1","kind":"Status","status":"Failure","reason":"NotFound","code":404,"message":"not found" + }), + ), + "/apis/kars.azure.com/v1alpha1/namespaces/work/karstasks/task" => ( + 200, + json!({ + "apiVersion":"kars.azure.com/v1alpha1","kind":"KarsTask", + "metadata":{"name":"task","namespace":"work","annotations":{"kars.azure.com/owner-sub":"owner"}}, + "spec":{"objective":"test","envelope":{"tier":1,"authorityCeiling":1}} + }), + ), + "/api/v1/namespaces/kars-system/configmaps/kars-mission-output-task" => ( + 200, + json!({ + "apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"kars-mission-output-task"}, + "data":{"ownerSub":"owner"} + }), + ), + "/api/v1/namespaces/kars-system/configmaps/kars-mission-artifacts-task" => { + (200, artifact) + } + _ => panic!("unexpected artifact API call: {path}"), + }; + Ok::<_, std::io::Error>( + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + } + }); + ( + AppState::for_test_client(kube::Client::new(service, "work"), "work"), + calls, + ) +} + +async fn fetch(state: AppState, file: &str, owner: &str, method: Method) -> Response { + crate::routes::router(state) + .layer(Extension(Principal { + sub: owner.into(), + name: owner.into(), + roles: vec!["user".into()], + })) + .oneshot( + Request::builder() + .method(method) + .uri(format!("/api/namespaces/work/tasks/task/artifact/{file}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn active_and_unknown_artifacts_download_unchanged_from_live_or_retained_tasks() { + let payload = b"<script>fetch('/api/operator/retention-policy')</script>"; + for (name, mime) in [ + ("report.html", "text/html; charset=utf-8"), + ("report.HTM", "text/html; charset=utf-8"), + ("diagram.svg", "image/svg+xml"), + ("diagram.SVG", "image/svg+xml"), + ("document.pdf", "application/pdf"), + ("document.xhtml", "application/octet-stream"), + ("data.xml", "application/octet-stream"), + ("script.js", "application/octet-stream"), + ("opaque", "application/octet-stream"), + ] { + for binary in [false, true] { + for retained in [false, true] { + let (state, _) = fixture(name, payload, binary, retained); + let response = fetch(state, name, "owner", Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], mime); + assert_eq!( + response.headers()["content-disposition"], + format!("attachment; filename=\"{name}\"") + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert_eq!( + response.headers()["content-security-policy"], + "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + ); + assert_eq!(response.headers()["cache-control"], "private, no-store"); + assert_eq!( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .as_ref(), + payload + ); + } + } + } +} + +#[tokio::test] +async fn passive_artifact_previews_keep_inline_viewing_without_mime_sniffing_or_byte_changes() { + for name in [ + "notes.md", + "notes.txt", + "events.log", + "data.json", + "data.csv", + "config.yaml", + "image.png", + "image.jpg", + ] { + for binary in [false, true] { + let bytes: &[u8] = if binary { + b"\x89PNG\r\n\x1a\n\x00\xff" + } else { + b"<html><script>never execute</script></html>" + }; + let (state, _) = fixture(name, bytes, binary, false); + let response = fetch(state, name, "owner", Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["content-disposition"], + format!("inline; filename=\"{name}\"") + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert!( + response.headers()["content-security-policy"] + .to_str() + .unwrap() + .starts_with("sandbox;") + ); + assert_eq!( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .as_ref(), + bytes + ); + } + } +} + +#[tokio::test] +async fn artifact_head_has_the_same_protection_and_filename_is_header_safe() { + let name = "report\"\r\n.svg"; + let (state, _) = fixture(name, b"<svg/>", false, false); + let response = fetch(state, "report%22%0D%0A.svg", "owner", Method::HEAD).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["content-disposition"], + "attachment; filename=\"report___.svg\"" + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert!(response.headers().contains_key("content-security-policy")); + assert!( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn artifact_response_hardening_preserves_ownership_and_missing_file_denials() { + for retained in [false, true] { + let (state, calls) = fixture("report.html", b"<html/>", false, retained); + let response = fetch(state.clone(), "report.html", "other-user", Method::GET).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!( + !calls + .lock() + .unwrap() + .iter() + .any(|path| path.ends_with("kars-mission-artifacts-task")) + ); + let response = fetch(state, "missing.html", "owner", Method::GET).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} diff --git a/bridge/bff/src/routes/tasks/artifacts.rs b/bridge/bff/src/routes/tasks/artifacts.rs index fc5c6a409..846790552 100644 --- a/bridge/bff/src/routes/tasks/artifacts.rs +++ b/bridge/bff/src/routes/tasks/artifacts.rs @@ -11,6 +11,10 @@ use crate::state::AppState; use super::evidence::{ARTIFACT_PREVIEW_TOTAL_BYTES, artifact_preview}; use super::{MissionArtifactDto, require_cluster}; +#[cfg(test)] +#[path = "artifact_tests.rs"] +mod tests; + /// Sanitize a filename to the ConfigMap key form the controller uses (alnum, /// '-', '_', '.') so the manifest name can look up its stored content. fn artifact_key(name: &str) -> String { @@ -27,8 +31,7 @@ fn artifact_key(name: &str) -> String { if k.is_empty() { "artifact".into() } else { k } } -/// Best-effort content type from a filename extension, so a downloaded artifact -/// opens sensibly in the browser instead of forcing a save dialog for text. +/// Best-effort content type; only passive text and raster images may be inline. fn artifact_content_type(name: &str) -> &'static str { match name .rsplit('.') @@ -49,10 +52,11 @@ fn artifact_content_type(name: &str) -> &'static str { } } -/// `GET /api/tasks/:ns/:name/artifact/:file` — Bridge-native artifact fetch. +/// `GET /api/namespaces/:ns/tasks/:name/artifact/:file` — Bridge-native artifact fetch. /// Streams one artifact file's bytes (text from `data`, binary from /// `binaryData`) so operators download deliverables in-product, never via -/// `kubectl`. Inline for previewable types; attachment otherwise. +/// `kubectl`. Agent-produced active content is always a download, never an +/// authenticated same-origin document (including via the web API proxy). pub async fn download_artifact( State(state): State<AppState>, Extension(principal): Extension<Principal>, @@ -62,21 +66,35 @@ pub async fn download_artifact( let cluster = require_cluster(&state)?; require_owned_task_or_output(cluster, &ns, &name, &principal).await?; let key = artifact_key(&file); - let (bytes, is_binary) = cluster + let (bytes, _) = cluster .read_mission_artifact_bytes(&name, &key) .await .ok_or(AppError::NotFound)?; let ctype = artifact_content_type(&file); - // Inline-render text/known media; force a download for opaque binaries. - let disposition = if is_binary && ctype == "application/octet-stream" { - format!("attachment; filename=\"{key}\"") - } else { + let disposition = if matches!( + ctype, + "text/markdown; charset=utf-8" + | "application/json; charset=utf-8" + | "text/csv; charset=utf-8" + | "application/yaml; charset=utf-8" + | "image/png" + | "image/jpeg" + ) { format!("inline; filename=\"{key}\"") + } else { + format!("attachment; filename=\"{key}\"") }; axum::response::Response::builder() .header(header::CONTENT_TYPE, ctype) .header(header::CONTENT_DISPOSITION, disposition) - .header(header::CACHE_CONTROL, "private, max-age=60") + // Defense in depth for MIME confusion and clients that render downloads. + // No allow-scripts or allow-same-origin: a rendered document is opaque. + .header( + header::CONTENT_SECURITY_POLICY, + "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + ) + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header(header::CACHE_CONTROL, "private, no-store") .body(axum::body::Body::from(bytes)) .map_err(|e| AppError::Upstream(e.to_string())) } diff --git a/bridge/docs/inference-budgets.md b/bridge/docs/inference-budgets.md index 9ee531c48..fb22aef76 100644 --- a/bridge/docs/inference-budgets.md +++ b/bridge/docs/inference-budgets.md @@ -15,10 +15,11 @@ flowchart LR ``` Configure it in **Operator Console → Policies → Inference budgets**. -The UI reserves cluster/workspace increases for admins. The current BFF groups -many mutation routes under the operator persona, so deployments that require a -hard admin boundary must verify server-side enforcement for their release. See -[RBAC](rbac.md). +All hierarchy changes (cluster, workspace, and user caps, including decreases +and clearing a rule) require **admin**. Under SSO, both the web request proxy and +the BFF enforce this from verified signed session roles. Operators can still +read budgets and manage the separate per-sandbox inference policies. The dev +role cookie and `BRIDGE_ROLES` cannot grant SSO admin access. See [RBAC](rbac.md). ## Enforcement modes @@ -67,5 +68,6 @@ controller-generated from each mission's budget. On the same page an operator ca | GET | `/api/operator/inference-budgets` — hierarchy + live measured usage | | PUT | `/api/operator/inference-budgets/cluster` — set/clear the cluster cap | | PUT | `/api/operator/inference-budgets/workspaces/{ns}` — set/clear a workspace cap | +| PUT | `/api/operator/inference-budgets/users/{user}` — set/clear a user cap | Utilization also surfaces on **Insights → Budget utilization** (live meters). diff --git a/bridge/docs/missions-and-teams.md b/bridge/docs/missions-and-teams.md index 42565743b..97116041f 100644 --- a/bridge/docs/missions-and-teams.md +++ b/bridge/docs/missions-and-teams.md @@ -57,6 +57,24 @@ recommendation / action / note, with an "in brief" summary). A **pull request** agent opened is a first-class delivery type, shown as an artifact chip (repo + number + link) — see [Connections → GitHub](connections.md). +### Artifact viewing and downloads + +Mission and team-run artifact links share +`GET /api/namespaces/{ns}/tasks/{name}/artifact/{file}`, including retained +artifacts after the task CR is deleted. Ownership checks still apply. +Artifact bytes are untrusted agent output: HTML, SVG, PDF, and unknown formats +are served as **attachments**, even when using **Open** rather than **Download**. +Passive text/structured-data formats and PNG/JPEG retain inline viewing. +Downloads preserve the original bytes; file names are sanitized for headers. + +Every raw artifact response carries `X-Content-Type-Options: nosniff`, private +no-store caching, and a restrictive CSP with `sandbox` (no script or same-origin +permissions), blocked external resources, forms, base URLs, and framing. The +same-origin web API proxy preserves these headers and streams bytes unchanged. +This response boundary—not `noopener` or a link's `download` attribute—prevents +agent-produced executable content from inheriting the authenticated Bridge +origin. Downloaded files remain untrusted; inspect them before opening locally. + For the detailed relationship between engineering intake, backlog milestones, runs, activity, role artifacts, principal deliverables, review gates, checkpoints, and team memory, see diff --git a/bridge/docs/rbac.md b/bridge/docs/rbac.md index c59bbaecd..54017fc83 100644 --- a/bridge/docs/rbac.md +++ b/bridge/docs/rbac.md @@ -22,6 +22,7 @@ Neither layer replaces the other. | Use Operator Console | No | No | Yes | Yes | | Manage policies, MCP, skills, and approvals | No | No | Yes | Yes | | Administrative configuration | No | No | Limited | Yes | +| Change cluster/workspace/user inference budgets or cluster retention | No | No | No | Yes | Role implication: @@ -64,6 +65,21 @@ hiding as a hard admin boundary unless the BFF route itself requires admin. Before public release, every admin-only operation must have explicit server-side enforcement and direct API tests. +Inference-budget hierarchy and cluster retention mutations have explicit +admin checks in both BFF middleware and handlers. Their current write routes +are `PUT /api/operator/inference-budgets/cluster`, +`PUT /api/operator/inference-budgets/workspaces/{ns}`, +`PUT /api/operator/inference-budgets/users/{user}`, and +`PUT /api/operator/retention-policy`. This includes setting, lowering, disabling, +and clearing settings, not only increases. Operators retain GET access. +Direct BFF requests without a valid signed principal receive 401 under SSO; +valid non-admin principals receive 403 for these writes. + +The web request proxy uses the same signed-session role resolver as server +components. Under SSO, an absent/invalid session yields no roles, and neither +`bridge-role=admin` nor an admin `BRIDGE_ROLES` floor grants access. Local +development fallbacks remain limited to the non-SSO, single-developer mode. + ## Kubernetes RBAC The `kars-bridge` ServiceAccount defines Bridge’s maximum cluster permissions. diff --git a/bridge/web/src/lib/request-roles.ts b/bridge/web/src/lib/request-roles.ts new file mode 100644 index 000000000..4cb1afc42 --- /dev/null +++ b/bridge/web/src/lib/request-roles.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { type Role, envRoles, expandRoles, parseRoles } from "./config"; +import { ssoConfigured } from "./oidc-config"; +import { SESSION_COOKIE, verifySession } from "./session-token"; + +/** Shared by server components and the request proxy; dev roles never grant SSO access. */ +export async function requestRoles( + cookie: (name: string) => string | undefined, +): Promise<Role[]> { + if (ssoConfigured()) { + const token = cookie(SESSION_COOKIE); + const session = token ? await verifySession(token) : null; + return session ? expandRoles(session.roles) : []; + } + return parseRoles(cookie("bridge-role")) ?? envRoles(); +} diff --git a/bridge/web/src/lib/session-token.ts b/bridge/web/src/lib/session-token.ts index 3a9b37edb..661585116 100644 --- a/bridge/web/src/lib/session-token.ts +++ b/bridge/web/src/lib/session-token.ts @@ -11,7 +11,7 @@ // change at the IdP takes effect on next login, not instantly). import { SignJWT, jwtVerify } from "jose"; -import type { Role } from "./config"; +import { ALL_ROLES, type Role } from "./config"; import { oidcConfig } from "./oidc-config"; export interface BridgeSession { @@ -39,18 +39,19 @@ export async function signSession(session: BridgeSession): Promise<string> { .sign(secretKey(cfg.sessionSecret)); } -/** Verify + decode a session cookie. Returns `null` on any failure (expired, - * tampered, wrong key, or SSO no longer configured) — callers must fall back - * to the existing dev-role-switch/env-floor path, never treat a failure as - * "signed in with no roles". */ +/** Verify + decode a session cookie. Under SSO, any failure denies access; + * it must never fall back to the dev role switch or env floor. */ export async function verifySession(token: string): Promise<BridgeSession | null> { const cfg = oidcConfig(); if (!cfg) return null; try { const { payload } = await jwtVerify(token, secretKey(cfg.sessionSecret), { algorithms: [ALG] }); - if (!payload.sub) return null; - const roles = Array.isArray(payload.roles) ? (payload.roles as Role[]) : []; - return { sub: payload.sub, name: (payload.name as string | undefined) ?? payload.sub, roles }; + if (!payload.sub || typeof payload.name !== "string") return null; + const roles = payload.roles; + const isRole = (role: unknown): role is Role => + typeof role === "string" && ALL_ROLES.some((known) => known === role); + if (!Array.isArray(roles) || roles.length === 0 || !roles.every(isRole)) return null; + return { sub: payload.sub, name: payload.name, roles }; } catch { return null; } diff --git a/bridge/web/src/lib/session.ts b/bridge/web/src/lib/session.ts index 1c8e17c5e..77063881b 100644 --- a/bridge/web/src/lib/session.ts +++ b/bridge/web/src/lib/session.ts @@ -13,10 +13,8 @@ // 3. the `BRIDGE_ROLES` env floor (`lib/config.ts::envRoles`); // 4. default: all roles (single-developer dev convenience). // -// Whether or not SSO is configured, the REAL authorization boundary remains -// the Bridge's Kubernetes ServiceAccount RBAC (deploy/rbac.yaml) — the roles -// resolved here only drive which UI affordances render, never what the BFF -// is actually allowed to do against the cluster. +// Under SSO there is no dev fallback. The web and BFF independently verify +// signed roles; the Kubernetes ServiceAccount is a separate aggregate boundary. import { cookies } from "next/headers"; import { @@ -29,6 +27,7 @@ import { } from "./config"; import { ssoConfigured } from "./oidc-config"; import { verifySession, SESSION_COOKIE } from "./session-token"; +import { requestRoles } from "./request-roles"; const COOKIE = "bridge-role"; @@ -42,22 +41,8 @@ async function oidcSession() { /** The active roles for this request (real SSO session → dev cookie → env floor). */ export async function sessionRoles(): Promise<Role[]> { - const session = await oidcSession(); - if (session) return expandRoles(session.roles); - - // When SSO is configured, there is NO dev-cookie / env-floor fallback. A - // missing or invalid session means "not signed in" → ZERO roles, and the - // layout guards (+ the /workspace guard) redirect to /auth/login. This closes - // the hole where an unauthenticated request would otherwise inherit the - // `bridge-role` dev cookie or the `BRIDGE_ROLES` floor (which defaults to ALL - // roles) — i.e. full access with no login. Dev fallbacks apply ONLY when no - // IdP is configured (single-developer local mode). - if (ssoConfigured()) return []; - const jar = await cookies(); - const fromCookie = parseRoles(jar.get(COOKIE)?.value); - if (fromCookie) return fromCookie; - return envRoles(); + return requestRoles((name) => jar.get(name)?.value); } export async function hasRole(role: Role): Promise<boolean> { @@ -108,8 +93,9 @@ export async function currentPrincipal(): Promise<{ ssoSignedIn: false, }; } - const simulated = !!parseRoles(cookieVal); - const roles = simulated ? expandRoles(parseRoles(cookieVal)!) : envRoles(); + const parsedRoles = parseRoles(cookieVal); + const simulated = parsedRoles !== null; + const roles = parsedRoles ?? envRoles(); const primary = primaryRole(roles); const name = (simulated ? `${ROLE_META[primary].label.toLowerCase().replace(/\s+/g, "-")}@local` : undefined) ?? diff --git a/bridge/web/src/proxy.ts b/bridge/web/src/proxy.ts index 24c7d6566..066223936 100644 --- a/bridge/web/src/proxy.ts +++ b/bridge/web/src/proxy.ts @@ -6,9 +6,9 @@ // The Operator Console UI disables admin-only controls, but that is cosmetic: a // browser can call the BFF directly through the same-origin /api proxy. This // middleware runs on the WEB SERVER before the request reaches the proxy route -// handler, so it enforces admin-only mutations for real — the role comes from the -// httpOnly `bridge-role` cookie the browser cannot forge, falling back to the -// BRIDGE_ROLES env floor, exactly like lib/session.ts. +// handler. With SSO, authority comes only from verified signed session roles. +// Dev-cookie/env roles are available only without SSO. The BFF independently +// enforces the same admin requirement, including for direct API clients. // // The actual /api/* -> BFF proxying is done at RUNTIME by the catch-all route // handler app/api/[...path]/route.ts (it reads BRIDGE_BFF_URL per request, so the @@ -16,33 +16,27 @@ // destination at build time. import { NextResponse, type NextRequest } from "next/server"; -import { parseRoles, envRoles } from "@/lib/config"; +import { requestRoles } from "@/lib/request-roles"; // (pathPrefix, methods) tuples that require the `admin` role. const ADMIN_ONLY: { prefix: string; methods: string[] }[] = [ - { prefix: "/api/operator/inference-budgets", methods: ["PUT", "POST", "DELETE"] }, - { prefix: "/api/operator/retention-policy", methods: ["PUT", "POST", "DELETE"] }, + { prefix: "/api/operator/inference-budgets", methods: ["PUT", "POST", "PATCH", "DELETE"] }, + { prefix: "/api/operator/retention-policy", methods: ["PUT", "POST", "PATCH", "DELETE"] }, ]; -function isAdmin(req: NextRequest): boolean { - const cookie = req.cookies.get("bridge-role")?.value; - const roles = parseRoles(cookie) ?? envRoles(); - return roles.includes("admin"); -} - -export function proxy(req: NextRequest) { +export async function proxy(req: NextRequest) { const { pathname } = req.nextUrl; const method = req.method.toUpperCase(); const gated = ADMIN_ONLY.find( - (g) => pathname.startsWith(g.prefix) && g.methods.includes(method), + (g) => (pathname === g.prefix || pathname.startsWith(`${g.prefix}/`)) && g.methods.includes(method), ); - if (gated && !isAdmin(req)) { + if (gated && !(await requestRoles((name) => req.cookies.get(name)?.value)).includes("admin")) { return NextResponse.json( { error: { code: "forbidden", message: - "Only a cluster or org admin can change this setting. Switch to the Admin role (or ask an admin).", + "Only a cluster or org admin can change this setting.", }, }, { status: 403 }, diff --git a/bridge/web/tests/authorization.test.mjs b/bridge/web/tests/authorization.test.mjs new file mode 100644 index 000000000..87e1d41e4 --- /dev/null +++ b/bridge/web/tests/authorization.test.mjs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const { NextRequest } = require("next/server"); +const { SignJWT } = require("jose"); +const root = fileURLToPath(new URL("../src/", import.meta.url)); +const secret = "test-only-bridge-session-signing-key"; +const adminPaths = [ + "/api/operator/inference-budgets/cluster", + "/api/operator/inference-budgets/workspaces/work", + "/api/operator/inference-budgets/users/alice", + "/api/operator/retention-policy", +]; + +function app({ sso = true, floor, roleCookie, token } = {}) { + const env = sso ? { + BRIDGE_OIDC_ISSUER: "https://idp.example", + BRIDGE_OIDC_CLIENT_ID: "bridge", + BRIDGE_OIDC_CLIENT_SECRET: "test-only-client-secret", + BRIDGE_SESSION_SECRET: secret, + } : {}; + if (floor !== undefined) env.BRIDGE_ROLES = floor; + const cookies = new Map([ + ["bridge-session", token], ["bridge-role", roleCookie], + ].filter(([, value]) => value !== undefined)); + const cache = new Map(); + const calls = []; + function load(path) { + if (cache.has(path)) return cache.get(path); + const exports = {}; + cache.set(path, exports); + const source = readFileSync(path, "utf8"); + runInNewContext(ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, { + exports, Headers, Response, TextEncoder, process: { env }, console, + fetch: async (...args) => { calls.push(args); return new Response(null, { status: 204 }); }, + require: (name) => { + if (name === "next/headers") return { + cookies: async () => ({ get: (key) => cookies.has(key) ? { value: cookies.get(key) } : undefined }), + }; + if (name.startsWith("@/")) return load(resolve(root, `${name.slice(2)}.ts`)); + if (name.startsWith(".")) return load(resolve(dirname(path), `${name}.ts`)); + return require(name); + }, + }); + return exports; + } + function request(path, method = "PUT") { + const headers = { cookie: [...cookies].map(([key, value]) => `${key}=${value}`).join(";") }; + return new NextRequest(`https://bridge.example${path}`, { method, headers }); + } + return { + edge: load(resolve(root, "proxy.ts")), + sessions: load(resolve(root, "lib/session.ts")), + routes: load(resolve(root, "app/api/[...path]/route.ts")), + request, calls, + }; +} + +async function token(roles, signingSecret = secret, expires = "1h", claims = {}) { + return new SignJWT({ name: "Alice", roles, ...claims }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject("alice") + .setExpirationTime(expires) + .sign(new TextEncoder().encode(signingSecret)); +} + +test("SSO admin mutations reject signed operators regardless of forged dev cookie or env floor", async () => { + const signed = await token(["operator"]); + for (const roleCookie of [undefined, "admin"]) { + const service = app({ token: signed, roleCookie }); + assert.deepEqual(Array.from(await service.sessions.sessionRoles()), ["user", "operator"]); + assert.equal(await service.sessions.canAdminister(), false); + for (const path of adminPaths) { + const response = await service.edge.proxy(service.request(path)); + assert.equal(response.status, 403, path); + } + } +}); + +test("SSO admin mutations never fall back to dev roles for absent, forged, expired, or malformed sessions", async () => { + for (const signed of [ + undefined, "forged", await token(["admin"], "wrong-key"), + await token(["admin"], secret, "0s"), + await token([]), await token(["administrator"]), + await token(["admin", 1]), await token(["admin"], secret, "1h", { name: {} }), + ]) { + const service = app({ token: signed, roleCookie: "admin", floor: "admin" }); + assert.deepEqual(Array.from(await service.sessions.sessionRoles()), []); + const principal = await service.sessions.currentPrincipal(); + assert.equal(principal.ssoSignedIn, false); + assert.deepEqual(Array.from(principal.roles), []); + for (const path of adminPaths) { + assert.equal((await service.edge.proxy(service.request(path))).status, 403, path); + assert.equal((await service.routes.PUT(service.request(path))).status, 401, path); + } + assert.equal(service.calls.length, 0); + } +}); + +test("genuine signed admins reach the authenticated API proxy despite a restrictive dev cookie and floor", async () => { + const signed = await token(["admin"]); + const service = app({ token: signed, roleCookie: "user", floor: "user" }); + assert.equal(await service.sessions.canAdminister(), true); + assert.equal((await service.sessions.currentPrincipal()).simulated, false); + for (const path of adminPaths) { + const request = service.request(`${path}?source=console`); + const edge = await service.edge.proxy(request); + assert.equal(edge.headers.get("x-middleware-next"), "1"); + const response = await service.routes.PUT(request); + assert.equal(response.status, 204); + assert.equal(service.calls.at(-1)[1].headers.get("x-kars-principal-token"), signed); + assert.equal(service.calls.at(-1)[1].headers.get("cookie"), null); + } +}); + +test("operator reads and operational mutations remain available; user and auditor never become admins", async () => { + for (const role of ["operator", "user", "auditor"]) { + const service = app({ token: await token([role]) }); + for (const path of ["/api/operator/inference-budgets", "/api/operator/retention-policy"]) { + assert.equal((await service.edge.proxy(service.request(path, "GET"))).headers.get("x-middleware-next"), "1"); + } + for (const path of adminPaths) { + for (const method of ["PUT", "POST", "PATCH", "DELETE"]) { + assert.equal((await service.edge.proxy(service.request(path, method))).status, 403); + } + } + assert.equal((await service.edge.proxy(service.request("/api/operator/inferencepolicies/example", "PATCH"))).headers.get("x-middleware-next"), "1"); + } +}); + +test("local development role switching is unchanged and return-to remains server-derived", async () => { + for (const [roleCookie, floor, allowed] of [ + [undefined, undefined, true], ["operator", undefined, false], + ["admin", "user", true], [undefined, "user", false], + ]) { + const service = app({ sso: false, roleCookie, floor }); + const response = await service.edge.proxy(service.request(adminPaths[0])); + assert.equal(response.status, allowed ? 200 : 403); + } + const service = app({ token: await token(["user"]) }); + const request = service.request("/workspace/missions?tab=recent", "GET"); + request.headers.set("x-bridge-return-to", "https://untrusted.example"); + const response = await service.edge.proxy(request); + assert.equal(response.headers.get("x-middleware-request-x-bridge-return-to"), "/workspace/missions?tab=recent"); +}); + +test("non-admin browser mutations stop before the catch-all forwards a principal", async () => { + for (const options of [{ roleCookie: "admin" }, { token: await token(["operator"]), roleCookie: "admin" }]) { + const service = app(options); + for (const path of adminPaths) { + const request = service.request(path); + const response = await service.edge.proxy(request); + if (response.headers.get("x-middleware-next") === "1") { + await service.routes.PUT(request); + assert.fail("admin mutation passed the web role boundary"); + } + assert.equal(response.status, 403); + } + assert.equal(service.calls.length, 0); + } +}); diff --git a/bridge/web/tests/proxy-routes.test.mjs b/bridge/web/tests/proxy-routes.test.mjs index 7ba54e77d..cd1fea53c 100644 --- a/bridge/web/tests/proxy-routes.test.mjs +++ b/bridge/web/tests/proxy-routes.test.mjs @@ -136,6 +136,43 @@ test("api: streams SSE unchanged while removing hop-by-hop response headers", as assert.equal(await response.text(), "data: event\n\n"); }); +test("api: preserves the artifact download, sandbox, and nosniff boundary for same-origin browser navigation", async () => { + const policy = "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + const payload = new TextEncoder().encode("<script>fetch('/api/operator/retention-policy')</script>\0"); + for (const [file, mime, disposition] of [ + ["report.html", "text/html; charset=utf-8", "attachment"], + ["diagram.svg", "image/svg+xml", "attachment"], + ["report.pdf", "application/pdf", "attachment"], + ["notes.txt", "text/markdown; charset=utf-8", "inline"], + ["image.png", "image/png", "inline"], + ]) { + for (const method of ["GET", "HEAD"]) { + const upstream = new Response(method === "HEAD" ? null : payload, { headers: { + "content-type": mime, + "content-disposition": `${disposition}; filename="${file}"`, + "content-security-policy": policy, + "x-content-type-options": "nosniff", + "cache-control": "private, no-store", + connection: "keep-alive", + } }); + const service = proxy("api", { sso: true, fetch: async () => upstream }); + const response = await service.routes[method](request("api", { + method, token: "signed-session", + suffix: `/namespaces/work/tasks/task/artifact/${file}`, + })); + assert.equal(response.status, 200); + assert.equal(response.body, upstream.body, "artifact bytes must be streamed, never rewritten"); + assert.equal(response.headers.get("content-disposition"), `${disposition}; filename="${file}"`); + assert.equal(response.headers.get("content-type"), mime); + assert.equal(response.headers.get("content-security-policy"), policy); + assert.equal(response.headers.get("x-content-type-options"), "nosniff"); + assert.equal(response.headers.get("cache-control"), "private, no-store"); + assert.equal(response.headers.get("connection"), null); + assert.deepEqual(new Uint8Array(await response.arrayBuffer()), method === "HEAD" ? new Uint8Array() : payload); + } + } +}); + test("dex: forwards login cookies, redirects, and separate Set-Cookie headers", async () => { const headers = new Headers({ location: "/auth/callback?code=opaque", connection: "keep-alive" }); headers.append("set-cookie", "csrf=one; Path=/dex; HttpOnly"); From f276558497a134eff839dd2b05d5cc4ff2685eaf Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 22:42:00 +0200 Subject: [PATCH 106/111] test(ci): retain bounded public CRD readiness errors Expose the existing public-schema diagnostic for CRD establishment waits, without changing any acceptance assertion, context, request/child deadline or retry behavior. Extract the unchanged command body for real-child redaction and argument regressions, and preserve the workload-proof context binding. The prior hosted failure cause remains unclassified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 2 + tests/e2e/budget-api-kubectl.mjs | 23 ++++++++ tests/e2e/budget-api-kubectl.test.mjs | 80 +++++++++++++++++++++++++++ tests/e2e/inference-budget-api.mjs | 20 +------ 4 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/budget-api-kubectl.mjs create mode 100644 tests/e2e/budget-api-kubectl.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02785e835..17079e1c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -547,6 +547,8 @@ jobs: version: v0.24.0 - name: Restore existing YAML and test dependencies run: npm ci --prefix cli + - name: Verify public schema diagnostic and credential redaction boundaries + run: node --test tests/e2e/budget-api-kubectl.test.mjs - name: Create disposable supported apiserver before any Rust image build run: kind create cluster --name kars-budget-api --image kindest/node:v1.31.0 --config tests/e2e/kind-config.yaml - name: Validate real CRD/CEL and Pod audience identity diff --git a/tests/e2e/budget-api-kubectl.mjs b/tests/e2e/budget-api-kubectl.mjs new file mode 100644 index 000000000..5efdb9e57 --- /dev/null +++ b/tests/e2e/budget-api-kubectl.mjs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); +export const context = "kind-kars-budget-api"; + +export function kubectl(args, input, publicSchema = false) { + try { + return execFileSync("kubectl", ["--context", context, "--request-timeout=20s", ...args], { + cwd: root, encoding: "utf8", input: input === undefined ? undefined : JSON.stringify(input), + stdio: ["pipe", "pipe", "pipe"], timeout: 30_000, + }); + } catch (error) { + if (publicSchema) { + // Only public CRD/VAP creation and CRD readiness opt in, never Secret/token commands. + console.error(String(error.stderr ?? "").slice(0, 12_000)); + } + throw new Error("Disposable budget API assertion command failed", { cause: undefined }); + } +} diff --git a/tests/e2e/budget-api-kubectl.test.mjs b/tests/e2e/budget-api-kubectl.test.mjs new file mode 100644 index 000000000..e8b762f2b --- /dev/null +++ b/tests/e2e/budget-api-kubectl.test.mjs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { context, kubectl } from "./budget-api-kubectl.mjs"; + +function fixture(t, { stderr = "", stdout = "", status = 0 } = {}) { + const directory = mkdtempSync(join(tmpdir(), "kars-budget-command-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const record = join(directory, "request.json"); + writeFileSync(join(directory, "kubectl"), `#!/usr/bin/env node +const fs = require("node:fs"); +fs.writeFileSync(${JSON.stringify(record)}, JSON.stringify({ + args: process.argv.slice(2), input: fs.readFileSync(0, "utf8"), cwd: process.cwd() +})); +fs.writeSync(1, ${JSON.stringify(stdout)}); +fs.writeSync(2, ${JSON.stringify(stderr)}); +process.exit(${status}); +`, { mode: 0o700 }); + const original = process.env.PATH; + process.env.PATH = `${directory}:${original ?? ""}`; + t.after(() => { + if (original === undefined) delete process.env.PATH; + else process.env.PATH = original; + }); + const errors = []; + t.mock.method(console, "error", (message) => errors.push(message)); + return { errors, request: () => JSON.parse(readFileSync(record, "utf8")) }; +} + +function commandFailed(error) { + assert.equal(error.message, "Disposable budget API assertion command failed"); + assert.equal(error.cause, undefined); + return true; +} + +test("public CRD readiness preserves bounded diagnostics without changing context or wait arguments", (t) => { + const diagnostic = `error: no matching resources found\n${"x".repeat(13_000)}`; + const state = fixture(t, { stderr: diagnostic, status: 1 }); + const args = ["wait", "--for=condition=Established", "crd/karssandboxes.kars.azure.com", "--timeout=60s"]; + assert.throws(() => kubectl(args, undefined, true), commandFailed); + assert.deepEqual(state.errors, [diagnostic.slice(0, 12_000)]); + assert.deepEqual(state.request().args, ["--context", "kind-kars-budget-api", "--request-timeout=20s", ...args]); + assert.equal(state.request().input, ""); +}); + +test("Secret and TokenRequest failures never expose input, stderr or an underlying cause", (t) => { + const secret = "fixture-private-credential"; + const state = fixture(t, { stderr: `upstream included ${secret}`, status: 1 }); + for (const args of [ + ["get", "secret", "fixture", "-o", "json"], + ["create", "--raw", "/api/v1/namespaces/budget-api-fixture/serviceaccounts/untrusted/token", "-f", "-"], + ]) { + assert.throws(() => kubectl(args, { value: secret }), commandFailed); + assert.equal(state.request().input, JSON.stringify({ value: secret })); + } + assert.deepEqual(state.errors, []); +}); + +test("successful public and private commands preserve their output without diagnostic logging", (t) => { + const output = '{"metadata":{"uid":"fixture-uid"}}\n'; + const state = fixture(t, { stdout: output }); + for (const publicSchema of [false, true]) { + assert.equal(kubectl(["create", "-f", "-", "-o", "json"], { kind: "Fixture" }, publicSchema), output); + assert.equal(state.request().input, '{"kind":"Fixture"}'); + } + assert.deepEqual(state.errors, []); +}); + +test("the actual preflight opts only its public CRD wait into schema diagnostics", () => { + const source = readFileSync(new URL("./inference-budget-api.mjs", import.meta.url), "utf8"); + assert.equal(context, "kind-kars-budget-api"); + assert.ok(source.includes('import { context, kubectl } from "./budget-api-kubectl.mjs";')); + assert.ok(source.includes("root, context, kubectl, until, namespace, controller, principal,")); + assert.ok(source.includes('kubectl(["wait", "--for=condition=Established", `crd/${definition.metadata.name}`, "--timeout=60s"], undefined, true);')); +}); diff --git a/tests/e2e/inference-budget-api.mjs b/tests/e2e/inference-budget-api.mjs index 55a60cc97..31b36469e 100644 --- a/tests/e2e/inference-budget-api.mjs +++ b/tests/e2e/inference-budget-api.mjs @@ -9,11 +9,11 @@ import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { runWorkloadProof } from "./budget-workload-cases.mjs"; +import { context, kubectl } from "./budget-api-kubectl.mjs"; const require = createRequire(new URL("../../cli/package.json", import.meta.url)); const { parseAllDocuments } = require("yaml"); const root = fileURLToPath(new URL("../../", import.meta.url)); -const context = "kind-kars-budget-api"; const namespace = "budget-api-fixture"; const controller = `system:serviceaccount:${namespace}:kars-controller`; const principal = `system:serviceaccount:${namespace}:untrusted`; @@ -21,22 +21,6 @@ const audience = "kars.azure.com/governed-inference-budget"; const shared = JSON.parse(readFileSync(new URL("../../deploy/helm/kars/files/inference-budget-admission.json", import.meta.url), "utf8") .replaceAll("__ACCOUNTING_NAMESPACE__", namespace)); -function kubectl(args, input, publicSchema = false) { - try { - return execFileSync("kubectl", ["--context", context, "--request-timeout=20s", ...args], { - cwd: root, encoding: "utf8", input: input === undefined ? undefined : JSON.stringify(input), - stdio: ["pipe", "pipe", "pipe"], timeout: 30_000, - }); - } catch (error) { - if (publicSchema) { - // This opt-in is used ONLY for the four public CRDs and eight public VAPs - // below. Do not enable it for Secret/token/agent-response commands. - console.error(String(error.stderr ?? "").slice(0, 12_000)); - } - throw new Error("Disposable budget API assertion command failed", { cause: undefined }); - } -} - function create(value, as, publicSchema = false) { return JSON.parse(kubectl(["create", "-f", "-", "-o", "json", ...(as ? ["--as", as] : [])], value, publicSchema)); } @@ -75,7 +59,7 @@ const definitions = parseAllDocuments(rendered).map((document) => { assert.equal(definitions.length, crdNames.length); for (const definition of definitions) { create(definition, undefined, true); - kubectl(["wait", "--for=condition=Established", `crd/${definition.metadata.name}`, "--timeout=60s"]); + kubectl(["wait", "--for=condition=Established", `crd/${definition.metadata.name}`, "--timeout=60s"], undefined, true); } for (const policy of shared.items) { create({ apiVersion: "admissionregistration.k8s.io/v1", kind: "ValidatingAdmissionPolicy", From 366f02c853790e7846241b25efb0ee355174b0d1 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 23:01:01 +0200 Subject: [PATCH 107/111] fix(bridge): align gateway SDK calls and workspace authority Use the locked Kubernetes SDK request-object contracts without compatibility casts. Watch and bind teams in the configured core workspace while retaining conversation storage and ServiceAccount authority in the add-on namespace. Cover real SDK HTTP calls, both chart namespace layouts and owned uninstall preservation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../kars-bridge/templates/teams-gateway.yaml | 39 +- bridge/deploy/helm/kars-bridge/values.yaml | 5 +- bridge/docs/deployment.md | 24 + .../teams-gateway/src/conversation-store.ts | 59 +-- bridge/teams-gateway/src/watcher-types.ts | 30 +- bridge/teams-gateway/src/watcher.ts | 31 +- .../teams-gateway/tests/chart-upgrade.test.ts | 5 + bridge/teams-gateway/tests/chart.test.ts | 69 ++- .../tests/gateway-uninstall.test.ts | 203 ++++++++ bridge/teams-gateway/tests/gateway.test.ts | 68 +-- bridge/teams-gateway/tests/kubernetes.test.ts | 459 ++++++++++++++++++ 11 files changed, 863 insertions(+), 129 deletions(-) create mode 100644 bridge/teams-gateway/tests/gateway-uninstall.test.ts create mode 100644 bridge/teams-gateway/tests/kubernetes.test.ts diff --git a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml index b6e6054ab..c6ae21907 100644 --- a/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml +++ b/bridge/deploy/helm/kars-bridge/templates/teams-gateway.yaml @@ -28,8 +28,7 @@ metadata: {{- include "kars-bridge.labels" . | nindent 4 }} app.kubernetes.io/component: teams-gateway --- -# Least-privilege Role: watch KarsApprovals, read/write the conversation ConfigMap. -# No cluster-wide write privileges. +# Keep the existing storage Role/Binding in the add-on namespace on upgrades. apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -38,9 +37,6 @@ metadata: labels: {{- include "kars-bridge.labels" . | nindent 4 }} rules: - - apiGroups: ["kars.azure.com"] - resources: ["karsapprovals", "karstasks", "karsteams"] - verbs: ["get", "list", "watch"] - apiGroups: [""] resources: ["configmaps"] resourceNames: ["{{ .Values.teamsGateway.conversationConfigMapName }}"] @@ -62,6 +58,37 @@ subjects: name: kars-bridge-teams-gateway namespace: {{ include "kars-bridge.namespace" . }} --- +# Only read core resources, binding the add-on's ServiceAccount across namespaces. +# Include the add-on namespace in the name to avoid collisions between releases +# with the same name in different namespaces sharing one core workspace. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }}-{{ include "kars-bridge.namespace" . }}-teams-gateway-core-read + namespace: {{ include "kars-bridge.coreNamespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +rules: + - apiGroups: ["kars.azure.com"] + resources: ["karsapprovals", "karstasks", "karsteams"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }}-{{ include "kars-bridge.namespace" . }}-teams-gateway-core-read + namespace: {{ include "kars-bridge.coreNamespace" . }} + labels: + {{- include "kars-bridge.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Release.Name }}-{{ include "kars-bridge.namespace" . }}-teams-gateway-core-read +subjects: + - kind: ServiceAccount + name: kars-bridge-teams-gateway + namespace: {{ include "kars-bridge.namespace" . }} +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -108,7 +135,7 @@ spec: - name: TEAMS_CONFIGMAP_NAME value: {{ .Values.teamsGateway.conversationConfigMapName | quote }} - name: TEAMS_WATCH_NAMESPACE - value: {{ include "kars-bridge.namespace" . | quote }} + value: {{ include "kars-bridge.coreNamespace" . | quote }} # All secrets from the dedicated kars-bridge-teams Secret - name: TEAMS_CLIENT_ID valueFrom: diff --git a/bridge/deploy/helm/kars-bridge/values.yaml b/bridge/deploy/helm/kars-bridge/values.yaml index 57cb4c8f3..c17a9c27f 100644 --- a/bridge/deploy/helm/kars-bridge/values.yaml +++ b/bridge/deploy/helm/kars-bridge/values.yaml @@ -4,7 +4,7 @@ # kars Bridge Helm values. # # The Bridge is ADDITIVE: it deploys on top of an existing kars install (kars -# CRDs + controller in `namespace`). It never creates or replaces kars components. +# CRDs + controller in `core.namespace`). It never creates or replaces kars components. # Kubernetes-native templates. Each deployment requires environment-specific # identity, registry, ingress, CNI, inference, and compatibility validation. # Historical preview image defaults are retained for existing installations. @@ -209,6 +209,9 @@ affinity: {} # 3. The BFF must also mount bff-internal-secret via BRIDGE_TEAMS_INTERNAL_SECRET # # The dedicated Secret is NEVER propagated to sandbox pods. +# Commands and Kars resource watches use core.namespace. Gateway workloads, +# credentials and conversation ConfigMap stay in namespace. The release owns +# a namespaced core-read Role/Binding, not any core workspace resources. teamsGateway: enabled: false image: diff --git a/bridge/docs/deployment.md b/bridge/docs/deployment.md index dd595a411..4b9540af6 100644 --- a/bridge/docs/deployment.md +++ b/bridge/docs/deployment.md @@ -105,6 +105,24 @@ tenant/bot credentials do not block the web surface. Enable the gateway only after its dedicated credentials and role mapping are configured; web OIDC authentication is a separate requirement. +For Teams in a dedicated add-on namespace, set `namespace: bridge-private` and +`core.namespace: kars-system` (or the existing core workspace name). +`TEAMS_WATCH_NAMESPACE` follows `core.namespace` for `/bind`, subsequent team +commands and Kars approval/task/team watches. `TEAMS_CONFIGMAP_NAMESPACE`, the +gateway ServiceAccount, credentials and conversation ConfigMap remain in the +add-on namespace. Do not override only the watch environment variable: RBAC +must target the same core workspace. + +The existing `<release>-teams-gateway` Role/Binding retain their names and +ConfigMap-only permissions in the add-on namespace. A separate +`<release>-<addon-namespace>-teams-gateway-core-read` Role/Binding grants only +`get/list/watch` on Kars approvals, tasks and teams in `core.namespace` to the +add-on ServiceAccount. The Helm caller needs permission to manage this +release-owned RBAC in the **existing** core namespace; the chart neither creates +nor adopts that namespace or its Kars resources. Same-namespace installations +use the same two bounded Roles. These resources remain installed at zero +gateway replicas so later credential configuration can enable the gateway. + ## Receipt trust anchors For independently pinned receipt verification, configure the BFF with @@ -216,6 +234,12 @@ dedicated namespace with `createNamespace: true`, the namespace is annotated wit cascade-deleting namespaced Kars resources. Remove an empty retained namespace explicitly only after inspecting its contents. +Teams removal also deletes the release-owned core-read Role/Binding from the +configured core namespace and the gateway's conversation ConfigMap from the +add-on namespace. Back up the ConfigMap first if conversation bindings, +approval message IDs and watch checkpoints must survive a reinstall. Neither +the core namespace nor its workloads or user data are part of this deletion. + The retention annotation must be present in the **installed release manifest** before uninstalling. Upgrades inspect the configured namespace's Helm ownership and retain it if this release already owns it, even when `createNamespace` diff --git a/bridge/teams-gateway/src/conversation-store.ts b/bridge/teams-gateway/src/conversation-store.ts index 52cd4898e..a05f4a941 100644 --- a/bridge/teams-gateway/src/conversation-store.ts +++ b/bridge/teams-gateway/src/conversation-store.ts @@ -45,21 +45,10 @@ export interface ConversationStore { setLastResourceVersion(stream: string, value: string): Promise<void>; } -interface CoreV1ApiLike { - readNamespacedConfigMap( - name: string, - namespace: string - ): Promise<unknown>; - createNamespacedConfigMap( - namespace: string, - body: V1ConfigMap - ): Promise<unknown>; - replaceNamespacedConfigMap( - name: string, - namespace: string, - body: V1ConfigMap - ): Promise<unknown>; -} +export type CoreV1ApiLike = Pick< + CoreV1Api, + "readNamespacedConfigMap" | "createNamespacedConfigMap" | "replaceNamespacedConfigMap" +>; export interface KubernetesConversationStoreOptions { readonly namespace: string; @@ -86,17 +75,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function unwrapResponse<T>(response: unknown): T { - if ( - typeof response === "object" && - response !== null && - "body" in response - ) { - return (response as { body: T }).body; - } - return response as T; -} - function statusCodeOf(error: unknown): number | undefined { if (typeof error !== "object" || error === null) { return undefined; @@ -286,7 +264,7 @@ export class KubernetesConversationStore implements ConversationStore { if (!options.kubeConfig) { kubeConfig.loadFromCluster(); } - this.api = kubeConfig.makeApiClient(CoreV1Api) as unknown as CoreV1ApiLike; + this.api = kubeConfig.makeApiClient(CoreV1Api); } } @@ -375,12 +353,10 @@ export class KubernetesConversationStore implements ConversationStore { private async readConfigMap(): Promise<V1ConfigMap | undefined> { try { - return unwrapResponse<V1ConfigMap>( - await this.api.readNamespacedConfigMap( - this.configMapName, - this.namespace - ) - ); + return await this.api.readNamespacedConfigMap({ + name: this.configMapName, + namespace: this.namespace, + }); } catch (error) { if (statusCodeOf(error) === 404) { return undefined; @@ -400,9 +376,10 @@ export class KubernetesConversationStore implements ConversationStore { data: this.serialize(), }; try { - return unwrapResponse<V1ConfigMap>( - await this.api.createNamespacedConfigMap(this.namespace, configMap) - ); + return await this.api.createNamespacedConfigMap({ + namespace: this.namespace, + body: configMap, + }); } catch (error) { if (statusCodeOf(error) === 409) { const existing = await this.readConfigMap(); @@ -434,11 +411,11 @@ export class KubernetesConversationStore implements ConversationStore { }, data: this.serialize(), }; - await this.api.replaceNamespacedConfigMap( - this.configMapName, - this.namespace, - body - ); + await this.api.replaceNamespacedConfigMap({ + name: this.configMapName, + namespace: this.namespace, + body, + }); } private serialize(): Record<string, string> { diff --git a/bridge/teams-gateway/src/watcher-types.ts b/bridge/teams-gateway/src/watcher-types.ts index cb385edd2..df97a9900 100644 --- a/bridge/teams-gateway/src/watcher-types.ts +++ b/bridge/teams-gateway/src/watcher-types.ts @@ -3,6 +3,8 @@ // GatewayWatcher wire and collaborator types; no runtime initialization. +import type { CustomObjectsApi, Watch } from "@kubernetes/client-node"; + export interface Metadata { readonly name?: string | undefined; readonly namespace?: string | undefined; @@ -81,33 +83,9 @@ export interface KubernetesList<T> { } | undefined; } -export interface CustomObjectsApiLike { - listNamespacedCustomObject( - group: string, - version: string, - namespace: string, - plural: string, - pretty?: string, - allowWatchBookmarks?: boolean, - _continue?: string, - fieldSelector?: string, - labelSelector?: string, - limit?: number, - resourceVersion?: string, - resourceVersionMatch?: string, - timeoutSeconds?: number, - watch?: boolean - ): Promise<unknown>; -} +export type CustomObjectsApiLike = Pick<CustomObjectsApi, "listNamespacedCustomObject">; -export interface WatchLike { - watch( - path: string, - queryParams: Record<string, string | number | boolean | undefined>, - callback: (phase: string, apiObj: unknown, watchObj?: unknown) => void, - done: (err: unknown) => void - ): Promise<AbortController>; -} +export type WatchLike = Pick<Watch, "watch">; export interface TeamsMessenger { readonly api?: { diff --git a/bridge/teams-gateway/src/watcher.ts b/bridge/teams-gateway/src/watcher.ts index 68f7e46dc..7629b92ba 100644 --- a/bridge/teams-gateway/src/watcher.ts +++ b/bridge/teams-gateway/src/watcher.ts @@ -54,17 +54,6 @@ function normalizeString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function unwrapResponse<T>(response: unknown): T { - if ( - typeof response === "object" && - response !== null && - "body" in response - ) { - return (response as { body: T }).body; - } - return response as T; -} - function statusCodeOf(error: unknown): number | undefined { if (typeof error !== "object" || error === null) { return undefined; @@ -251,10 +240,7 @@ export class GatewayWatcher { const kubeConfig = new KubeConfig(); kubeConfig.loadFromCluster(); this.customObjectsApi = - (options?.customObjectsApi ?? - (kubeConfig.makeApiClient( - CustomObjectsApi - ) as unknown as CustomObjectsApiLike)); + options?.customObjectsApi ?? kubeConfig.makeApiClient(CustomObjectsApi); this.watch = options?.watch ?? new Watch(kubeConfig); } } @@ -431,14 +417,13 @@ export class GatewayWatcher { readonly items: readonly T[]; readonly resourceVersion: string; }> { - const response = unwrapResponse<KubernetesList<T>>( - await this.customObjectsApi.listNamespacedCustomObject( - GROUP, - VERSION, - this.config.watchNamespace, - plural - ) - ); + const response: KubernetesList<T> = + await this.customObjectsApi.listNamespacedCustomObject({ + group: GROUP, + version: VERSION, + namespace: this.config.watchNamespace, + plural, + }); const resourceVersion = normalizeString( response.metadata?.resourceVersion ); diff --git a/bridge/teams-gateway/tests/chart-upgrade.test.ts b/bridge/teams-gateway/tests/chart-upgrade.test.ts index ee5362145..529ad4a40 100644 --- a/bridge/teams-gateway/tests/chart-upgrade.test.ts +++ b/bridge/teams-gateway/tests/chart-upgrade.test.ts @@ -89,6 +89,11 @@ describe("BASE105 private release-value compatibility",()=>{ expect(env.find((item:any)=>item.name==="BRIDGE_DEFAULT_NAMESPACE").value).toBe("kars-system"); if(name.endsWith("bff"))expect(env.find((item:any)=>item.name==="BRIDGE_CORE_NAMESPACE").value).toBe("kars-system"); } + const gateway=objects.find(item=>item.kind==="Deployment"&&item.metadata.name==="kars-bridge-teams-gateway"); + expect(gateway.spec.template.spec.containers[0].env.find((item:any)=>item.name==="TEAMS_WATCH_NAMESPACE").value).toBe("kars-system"); + const binding=objects.find(item=>item.kind==="RoleBinding"&&item.metadata.name==="kars-bridge-kars-system-teams-gateway-core-read"); + expect(binding.metadata.namespace).toBe("kars-system"); + expect(binding.roleRef.name).toBe("kars-bridge-kars-system-teams-gateway-core-read"); }); it("executes Helm lookup and preserves a formerly-owned namespace with default flags",async()=>{ const {objects,calls}=await legacyRender([],true); diff --git a/bridge/teams-gateway/tests/chart.test.ts b/bridge/teams-gateway/tests/chart.test.ts index fbb2fb225..b7079b682 100644 --- a/bridge/teams-gateway/tests/chart.test.ts +++ b/bridge/teams-gateway/tests/chart.test.ts @@ -12,6 +12,7 @@ import { type V1Namespace, type V1NetworkPolicy, type V1Role, + type V1RoleBinding, } from "@kubernetes/client-node"; import { describe, expect, it } from "vitest"; @@ -149,7 +150,9 @@ describe("Bridge optional add-on boundary (offline Helm manifests)", () => { expect(namespace.metadata?.name).toBe("bridge-workspace"); expect(namespace.metadata?.annotations?.["helm.sh/resource-policy"]).toBe("keep"); for (const item of resources.filter((item) => item.metadata?.namespace)) { - expect(item.metadata?.namespace).toBe("bridge-workspace"); + const coreRead = ["Role", "RoleBinding"].includes(item.kind!) + && item.metadata?.name === "kars-bridge-bridge-workspace-teams-gateway-core-read"; + expect(item.metadata?.namespace).toBe(coreRead ? "kars-system" : "bridge-workspace"); } } }); @@ -226,6 +229,70 @@ describe("Bridge optional add-on boundary (offline Helm manifests)", () => { .toBe(2); }); + it.each([ + { namespace: "bridge-private", core: "kars-system" }, + { namespace: "bridge-private", core: "core-workspace" }, + { namespace: "kars-system", core: "kars-system" }, + { namespace: "shared-workspace", core: "shared-workspace" }, + ].flatMap((scenario) => [false, true].map((upgrade) => ({ ...scenario, upgrade }))))( + "keeps gateway commands/watches in $core and storage/identity in $namespace (upgrade=$upgrade)", ({ namespace, core, upgrade }) => { + const resources = render("--set", + `namespace=${namespace},core.namespace=${core},teamsGateway.enabled=true,createNamespace=false,teamsGateway.conversationConfigMapName=custom-store`, + ...(upgrade ? ["--is-upgrade"] : [])); + const gateway = resource<V1Deployment>(resources, "Deployment", "kars-bridge-teams-gateway"); + const pod = gateway.spec!.template.spec!; + const env = pod.containers[0]!.env!; + expect(gateway.metadata?.namespace).toBe(namespace); + expect(pod.serviceAccountName).toBe("kars-bridge-teams-gateway"); + expect(env.find((item) => item.name === "TEAMS_CONFIGMAP_NAMESPACE")?.value).toBe(namespace); + expect(env.find((item) => item.name === "TEAMS_CONFIGMAP_NAME")?.value).toBe("custom-store"); + expect(env.find((item) => item.name === "TEAMS_WATCH_NAMESPACE")?.value).toBe(core); + expect(env.find((item) => item.name === "TEAMS_BFF_BASE_URL")?.value) + .toBe(`http://kars-bridge-bff.${namespace}.svc.cluster.local:8081`); + expect(resource(resources, "ConfigMap", "custom-store").metadata?.namespace).toBe(namespace); + expect(resource(resources, "ServiceAccount", pod.serviceAccountName!).metadata?.namespace).toBe(namespace); + expect(resource(resources, "Service", "kars-bridge-teams-gateway").metadata?.namespace).toBe(namespace); + const coreName = `kars-bridge-${namespace}-teams-gateway-core-read`; + const storageRole = resource<V1Role>(resources, "Role", "kars-bridge-teams-gateway"); + const coreRole = resource<V1Role>(resources, "Role", coreName); + expect(storageRole.metadata?.namespace).toBe(namespace); + expect(storageRole.rules).toEqual([{ + apiGroups: [""], resources: ["configmaps"], resourceNames: ["custom-store"], verbs: ["get", "patch", "update"], + }]); + expect(coreRole.metadata?.namespace).toBe(core); + expect(coreRole.rules).toEqual([{ + apiGroups: ["kars.azure.com"], resources: ["karsapprovals", "karstasks", "karsteams"], verbs: ["get", "list", "watch"], + }]); + const gatewayBindings = resources.filter((item) => + ["RoleBinding", "ClusterRoleBinding"].includes(item.kind!) + && (item as V1RoleBinding).subjects?.some((subject) => subject.name === pod.serviceAccountName)); + expect(gatewayBindings).toHaveLength(2); + for (const [name, targetNamespace] of [["kars-bridge-teams-gateway", namespace], [coreName, core]]) { + const binding = resource<V1RoleBinding>(gatewayBindings, "RoleBinding", name!); + expect(binding.metadata?.namespace).toBe(targetNamespace); + expect(binding.roleRef).toEqual({ apiGroup: "rbac.authorization.k8s.io", kind: "Role", name }); + expect(binding.subjects).toEqual([{ kind: "ServiceAccount", name: pod.serviceAccountName, namespace }]); + expect(binding.metadata?.labels?.["app.kubernetes.io/instance"]).toBe("kars-bridge"); + expect(binding.metadata?.annotations?.["helm.sh/resource-policy"]).toBeUndefined(); + } + expect(resources.filter((item) => item.kind === "Namespace")).toEqual([]); + if (namespace !== core) { + expect(resources.filter((item) => item.metadata?.namespace === core) + .map((item) => `${item.kind}/${item.metadata?.name}`).sort()) + .toEqual([`Role/${coreName}`, `RoleBinding/${coreName}`]); + } + }); + + it("gives identically named releases in different add-on namespaces distinct core-read RBAC", () => { + const roleNames = ["bridge-one", "bridge-two"].map((namespace) => + render("--set", `namespace=${namespace},core.namespace=kars-system`) + .filter((item) => item.metadata?.namespace === "kars-system") + .map((item) => `${item.kind}/${item.metadata?.name}`)); + expect(roleNames[0]).toHaveLength(2); + expect(roleNames[1]).toHaveLength(2); + expect(roleNames[0]!.some((name) => roleNames[1]!.includes(name))).toBe(false); + }); + it("leaves all credential and Deployment mutation authority to core grants in both manifests", () => { const standalone = readFileSync(new URL("../../deploy/rbac.yaml", import.meta.url), "utf8") .split(/^---\s*$/m).map((doc) => loadYaml(doc) as KubernetesObject).filter(Boolean); diff --git a/bridge/teams-gateway/tests/gateway-uninstall.test.ts b/bridge/teams-gateway/tests/gateway-uninstall.test.ts new file mode 100644 index 000000000..7c770e7d9 --- /dev/null +++ b/bridge/teams-gateway/tests/gateway-uninstall.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { gzipSync } from "node:zlib"; +import { loadYaml, type KubernetesObject, type V1Secret } from "@kubernetes/client-node"; +import { describe, expect, it } from "vitest"; + +const exec = promisify(execFile); +const root = fileURLToPath(new URL("../../", import.meta.url)); +const chart = join(root, "deploy/helm/kars-bridge"); +const releaseName = "kars-bridge"; +const coreNamespace = "kars-system"; +const apis: Record<string, Array<[string, string, boolean]>> = { + v1: [ + ["namespaces", "Namespace", false], ["services", "Service", true], + ["serviceaccounts", "ServiceAccount", true], ["configmaps", "ConfigMap", true], ["secrets", "Secret", true], + ], + "apps/v1": [["deployments", "Deployment", true]], + "rbac.authorization.k8s.io/v1": [ + ["roles", "Role", true], ["rolebindings", "RoleBinding", true], + ["clusterroles", "ClusterRole", false], ["clusterrolebindings", "ClusterRoleBinding", false], + ], + "networking.k8s.io/v1": [["networkpolicies", "NetworkPolicy", true], ["ingresses", "Ingress", true]], + "apiextensions.k8s.io/v1": [["customresourcedefinitions", "CustomResourceDefinition", false]], + "kars.azure.com/v1alpha1": [ + ["karsteams", "KarsTeam", true], ["karstasks", "KarsTask", true], ["karsapprovals", "KarsApproval", true], + ], +}; +const apiPath = (version: string) => version === "v1" ? "/api/v1" : `/apis/${version}`; + +function objectPath(object: KubernetesObject): string { + const entry = apis[object.apiVersion!]?.find(([, kind]) => kind === object.kind); + if (!entry) throw new Error(`unexpected rendered kind ${object.apiVersion}/${object.kind}`); + return `${apiPath(object.apiVersion!)}${entry[2] ? `/namespaces/${object.metadata!.namespace}` : ""}/${entry[0]}/${object.metadata!.name}`; +} + +describe("real Helm gateway removal against a loopback API fixture", () => { + it.each(["kars-system", "bridge-private"])( + "removes only release-owned resources, preserving core with the gateway in %s", async (namespace) => { + const directory = join(root, `.gateway-uninstall-${randomUUID()}`); + mkdirSync(directory); + const objects = new Map<string, KubernetesObject>(); + const deletes: string[] = []; + const unexpected: string[] = []; + const releaseSecretPath = `/api/v1/namespaces/${namespace}/secrets/sh.helm.release.v1.${releaseName}.v1`; + const api = createServer((request, response) => { + const path = new URL(request.url!, "http://localhost").pathname; + const reply = (code: number, body: unknown) => { + response.writeHead(code, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); + }; + if (request.method === "GET") { + if (path === "/version") { + reply(200, { major: "1", minor: "32", gitVersion: "v1.32.0" }); + return; + } + if (path === "/api") { + reply(200, { apiVersion: "v1", kind: "APIVersions", versions: ["v1"], serverAddressByClientCIDRs: [] }); + return; + } + if (path === "/apis") { + reply(200, { apiVersion: "v1", kind: "APIGroupList", groups: Object.keys(apis) + .filter((version) => version !== "v1").map((groupVersion) => ({ + name: groupVersion.split("/")[0], + versions: [{ groupVersion, version: groupVersion.split("/")[1] }], + preferredVersion: { groupVersion, version: groupVersion.split("/")[1] }, + })) }); + return; + } + const version = Object.keys(apis).find((candidate) => apiPath(candidate) === path); + if (version) { + reply(200, { apiVersion: "v1", kind: "APIResourceList", groupVersion: version, + resources: apis[version]!.map(([name, kind, namespaced]) => ({ + name, kind, namespaced, singularName: "", verbs: ["get", "list", "delete", "update"], + })) }); + return; + } + if (path === `/api/v1/namespaces/${namespace}/secrets`) { + reply(200, { apiVersion: "v1", kind: "SecretList", metadata: { resourceVersion: "1" }, + items: objects.has(releaseSecretPath) ? [objects.get(releaseSecretPath)] : [] }); + return; + } + if (objects.has(path)) { + reply(200, objects.get(path)); + return; + } + } + if (request.method === "PUT" && path === releaseSecretPath) { + // Helm may encode this status-only storage update as protobuf. The + // uninstall's resource selection/deletion uses the original manifest. + request.resume(); + reply(200, objects.get(path)); + return; + } + if (request.method === "DELETE" && objects.has(path)) { + deletes.push(path); + objects.delete(path); + reply(200, { apiVersion: "v1", kind: "Status", status: "Success" }); + return; + } + unexpected.push(`${request.method} ${path}`); + reply(404, { apiVersion: "v1", kind: "Status", code: 404, reason: "NotFound" }); + }); + const commandOptions = { + timeout: 20_000, maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, HOME: directory, HELM_DRIVER: "secret", + HELM_CACHE_HOME: join(directory, "cache"), HELM_CONFIG_HOME: join(directory, "config"), + HELM_DATA_HOME: join(directory, "data") }, + }; + try { + const { stdout: manifest } = await exec("helm", [ + "template", releaseName, chart, "--namespace", namespace, + "--set", `namespace=${namespace},core.namespace=${coreNamespace},createNamespace=false,teamsGateway.enabled=true`, + ], commandOptions); + const rendered: KubernetesObject[] = manifest.split(/^---\s*$/m) + .filter((document) => document.split("\n").some((line) => line.trim() && !line.trimStart().startsWith("#"))) + .map((document) => loadYaml(document)); + expect(rendered.some((object) => object.kind === "Namespace")).toBe(false); + const coreData = { + apiVersion: "v1", kind: "ConfigMap", + metadata: { name: "existing-core-data", namespace: coreNamespace, uid: "existing-data" }, + data: { evidence: "preserve-existing-core-data" }, + }; + const existing: KubernetesObject[] = [ + ...[...new Set([namespace, coreNamespace])].map((name) => ({ + apiVersion: "v1", kind: "Namespace", metadata: { name, uid: `existing-${name}` }, + })), + { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "kars-controller", namespace: coreNamespace, uid: "existing-controller" } }, + coreData, + { apiVersion: "apiextensions.k8s.io/v1", kind: "CustomResourceDefinition", + metadata: { name: "karsteams.kars.azure.com", uid: "existing-crd" } }, + ...["KarsTeam", "KarsTask", "KarsApproval"].map((kind) => ({ + apiVersion: "kars.azure.com/v1alpha1", kind, + metadata: { name: "existing-user-resource", namespace: coreNamespace, uid: `existing-${kind}` }, + spec: { evidence: "preserve-existing-custom-resource" }, + })), + ]; + for (const object of existing) objects.set(objectPath(object), structuredClone(object)); + for (const object of rendered) { + expect(objects.has(objectPath(object)), "must not adopt an existing core resource").toBe(false); + object.metadata!.annotations = { + ...object.metadata!.annotations, + "meta.helm.sh/release-name": releaseName, + "meta.helm.sh/release-namespace": namespace, + }; + objects.set(objectPath(object), object); + } + // Seed Helm's persisted release record from the actual render. Uninstall + // executes Helm's real manifest decoding, REST mapping and deletion; this + // fixture does not claim API-server admission or live-cluster qualification. + const release = { + name: releaseName, namespace, version: 1, manifest, config: {}, + chart: { metadata: { name: "kars-bridge", version: "0.1.0", apiVersion: "v2" } }, + info: { status: "deployed", description: "loopback fixture", + first_deployed: "2026-01-01T00:00:00Z", last_deployed: "2026-01-01T00:00:00Z" }, + }; + const secret: V1Secret = { + apiVersion: "v1", kind: "Secret", type: "helm.sh/release.v1", + metadata: { name: `sh.helm.release.v1.${releaseName}.v1`, namespace, resourceVersion: "1", + labels: { owner: "helm", name: releaseName, status: "deployed", version: "1" } }, + data: { release: Buffer.from(gzipSync(JSON.stringify(release)).toString("base64")).toString("base64") }, + }; + objects.set(releaseSecretPath, secret); + await new Promise<void>((resolve) => api.listen(0, "127.0.0.1", resolve)); + const address = api.address(); + if (!address || typeof address === "string") throw new Error("fixture did not bind"); + const kubeconfig = join(directory, "kubeconfig"); + writeFileSync(kubeconfig, JSON.stringify({ + apiVersion: "v1", kind: "Config", + clusters: [{ name: "fixture", cluster: { server: `http://127.0.0.1:${address.port}` } }], + users: [{ name: "fixture", user: {} }], + contexts: [{ name: "fixture", context: { cluster: "fixture", user: "fixture" } }], + "current-context": "fixture", + }), { mode: 0o600 }); + const { stdout } = await exec("helm", [ + "uninstall", releaseName, "--namespace", namespace, "--kubeconfig", kubeconfig, "--no-hooks", + ], commandOptions); + expect(stdout).toContain(`release "${releaseName}" uninstalled`); + expect(unexpected).toEqual([]); + expect(deletes.sort()).toEqual([...rendered.map(objectPath), releaseSecretPath].sort()); + for (const object of existing) expect(objects.get(objectPath(object))).toEqual(object); + expect(objects.size).toBe(existing.length); + const coreRoleName = `${releaseName}-${namespace}-teams-gateway-core-read`; + for (const plural of ["roles", "rolebindings"]) { + expect(deletes).toContain(`/apis/rbac.authorization.k8s.io/v1/namespaces/${coreNamespace}/${plural}/${coreRoleName}`); + } + expect(deletes).toContain(`/api/v1/namespaces/${namespace}/configmaps/kars-teams-conversations`); + } finally { + if (api.listening) { + api.closeAllConnections(); + await new Promise<void>((resolve, reject) => api.close((error) => error ? reject(error) : resolve())); + } + rmSync(directory, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/bridge/teams-gateway/tests/gateway.test.ts b/bridge/teams-gateway/tests/gateway.test.ts index 0f3392ce4..2c7b25185 100644 --- a/bridge/teams-gateway/tests/gateway.test.ts +++ b/bridge/teams-gateway/tests/gateway.test.ts @@ -2,6 +2,12 @@ // Licensed under the MIT License. import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + CoreV1ApiCreateNamespacedConfigMapRequest, + CoreV1ApiReadNamespacedConfigMapRequest, + CoreV1ApiReplaceNamespacedConfigMapRequest, + V1ConfigMap, +} from "@kubernetes/client-node"; import { BffClient, type DecisionRequest, @@ -17,6 +23,7 @@ import { import { loadConfig, parseRoleMappings, type TeamsGatewayConfig } from "../src/config.js"; import { approvalMessageKey, + type CoreV1ApiLike, InMemoryConversationStore, KubernetesConversationStore, } from "../src/conversation-store.js"; @@ -306,17 +313,14 @@ describe("conversation store", () => { }); it("persists bindings, message ids, and resource versions via ConfigMap API", async () => { - class FakeCoreV1Api { - public configMap: - | { - apiVersion: string; - kind: string; - metadata: { name: string; namespace: string; resourceVersion?: string | undefined }; - data: Record<string, string>; - } - | undefined; - - public async readNamespacedConfigMap(): Promise<unknown> { + class FakeCoreV1Api implements CoreV1ApiLike { + public configMap: V1ConfigMap | undefined; + + public async readNamespacedConfigMap( + { name, namespace }: CoreV1ApiReadNamespacedConfigMapRequest + ): Promise<V1ConfigMap> { + expect(name).toBe("teams-store"); + expect(namespace).toBe("kars-system"); if (!this.configMap) { const error = Object.assign(new Error("Not Found"), { code: 404, @@ -328,14 +332,8 @@ describe("conversation store", () => { } public async createNamespacedConfigMap( - namespace: string, - body: { - apiVersion?: string; - kind?: string; - metadata?: { name?: string; namespace?: string }; - data?: Record<string, string>; - } - ): Promise<unknown> { + { namespace, body }: CoreV1ApiCreateNamespacedConfigMapRequest + ): Promise<V1ConfigMap> { this.configMap = { apiVersion: body.apiVersion ?? "v1", kind: body.kind ?? "ConfigMap", @@ -350,15 +348,9 @@ describe("conversation store", () => { } public async replaceNamespacedConfigMap( - _name: string, - namespace: string, - body: { - apiVersion?: string; - kind?: string; - metadata?: { name?: string; namespace?: string; resourceVersion?: string | undefined }; - data?: Record<string, string>; - } - ): Promise<unknown> { + { name, namespace, body }: CoreV1ApiReplaceNamespacedConfigMapRequest + ): Promise<V1ConfigMap> { + expect(name).toBe("teams-store"); this.configMap = { apiVersion: body.apiVersion ?? "v1", kind: body.kind ?? "ConfigMap", @@ -366,7 +358,7 @@ describe("conversation store", () => { name: body.metadata?.name ?? "teams-store", namespace, resourceVersion: String( - Number(this.configMap?.metadata.resourceVersion ?? "0") + 1 + Number(this.configMap?.metadata?.resourceVersion ?? "0") + 1 ), }, data: body.data ?? {}, @@ -540,7 +532,8 @@ describe("main handler wiring", () => { ]); }); - it("binds an unbound conversation via /bind before requiring an existing binding", async () => { + it.each(["kars-system", "bridge-private"])( + "binds and sends commands to core when gateway storage is in %s", async (storageNamespace) => { class FakeApp { public readonly handlers = new Map<string, (context: unknown) => unknown>(); public readonly api = { @@ -564,7 +557,7 @@ describe("main handler wiring", () => { const sendTeamCommand = vi.fn(async () => ({ success: true, message: "ok" })); const reconcileTeamApprovals = vi.fn(async () => undefined); registerAppHandlers(app, { - config: mockConfig(), + config: mockConfig({ conversationConfigMapNamespace: storageNamespace }), store, bff: { submitDecision: vi.fn(async () => ({ success: true, phase: "Denied" })), @@ -594,10 +587,23 @@ describe("main handler wiring", () => { expect.objectContaining({ teamName: "engineering", command: "bind", + namespace: "kars-system", }) ); expect(reconcileTeamApprovals).toHaveBeenCalledWith("engineering"); expect(sent[0]?.text).toContain("Bound this conversation"); + expect((await store.getByConversation("conv-3"))?.namespace).toBe("kars-system"); + await messageHandler?.({ + activity: { + text: "/status", + from: { aadObjectId: "oid-operator", name: "Alice" }, + conversation: { id: "conv-3", tenantId: "tenant-id" }, + }, + send: async () => undefined, + }); + expect(sendTeamCommand).toHaveBeenLastCalledWith( + expect.objectContaining({ teamName: "engineering", command: "status", namespace: "kars-system" }) + ); }); it("reads requestChangesReason from action.data in the routed handler", async () => { diff --git a/bridge/teams-gateway/tests/kubernetes.test.ts b/bridge/teams-gateway/tests/kubernetes.test.ts new file mode 100644 index 000000000..280c85371 --- /dev/null +++ b/bridge/teams-gateway/tests/kubernetes.test.ts @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { createServer, type ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { + CoreV1Api, CustomObjectsApi, KubeConfig, Watch, loadYaml, + type KubernetesObject, type V1ConfigMap, type V1Deployment, +} from "@kubernetes/client-node"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadConfig, type TeamsGatewayConfig } from "../src/config.js"; +import { KubernetesConversationStore } from "../src/conversation-store.js"; +import { registerAppHandlers } from "../src/main.js"; +import { GatewayWatcher } from "../src/watcher.js"; + +const storeName = "kars-teams-conversations"; +const plurals = ["karsapprovals", "karstasks", "karsteams"]; +const coreNamespace = "kars-system"; +const resourcePath = (plural: string) => + `/apis/kars.azure.com/v1alpha1/namespaces/${coreNamespace}/${plural}`; + +function apiStatus(code: number) { + const reasons: Record<number, string> = { + 403: "Forbidden", 404: "NotFound", 409: "Conflict", 410: "Gone", 500: "InternalError", 503: "ServiceUnavailable", + }; + return { + apiVersion: "v1", kind: "Status", status: "Failure", code, + reason: reasons[code], message: `fixture API error ${code}`, + }; +} + +function sdkFailure(code: number) { + return { code, body: JSON.stringify(apiStatus(code)) }; +} + +interface ApiRequest { + method: string; + path: string; + query: URLSearchParams; + body: V1ConfigMap | undefined; +} + +// A loopback API fixture, not a replacement SDK interface. Every operation goes +// through the installed client, including JSON serialization and watch streams. +class KubernetesApiFixture { + readonly kubeConfig = new KubeConfig(); + readonly requests: ApiRequest[] = []; + readonly lists = new Map<string, unknown[]>(plurals.map((plural) => [plural, []])); + readonly watches = new Map<string, ServerResponse[]>(); + readonly failures: Array<{ method: string; path: string; code: number; watch: boolean }> = []; + configMap: V1ConfigMap | undefined; + listResourceVersion: string | undefined = "10"; + createRace = false; + readonly server = createServer(async (request, response) => { + const url = new URL(request.url!, "http://localhost"); + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const rawBody = Buffer.concat(chunks).toString("utf8"); + const body: V1ConfigMap | undefined = rawBody ? JSON.parse(rawBody) : undefined; + const method = request.method!; + this.requests.push({ method, path: url.pathname, query: url.searchParams, body }); + const reply = (code: number, value: unknown) => { + response.writeHead(code, { "content-type": "application/json" }); + response.end(JSON.stringify(value)); + }; + const fail = (code: number) => reply(code, apiStatus(code)); + const failureIndex = this.failures.findIndex((failure) => + failure.method === method && failure.path === url.pathname + && failure.watch === (url.searchParams.get("watch") === "true")); + if (failureIndex >= 0) { + fail(this.failures.splice(failureIndex, 1)[0]!.code); + return; + } + if (url.pathname === this.configMapPath && method === "GET") { + if (this.configMap) reply(200, this.configMap); + else fail(404); + return; + } + if (url.pathname === this.configMapPath && method === "PUT" && body) { + if (body.metadata?.resourceVersion !== this.configMap?.metadata?.resourceVersion) { + fail(409); + return; + } + this.configMap = { + ...body, metadata: { ...body.metadata, resourceVersion: String(Number(body.metadata?.resourceVersion) + 1) }, + }; + reply(200, this.configMap); + return; + } + if (url.pathname === this.configMapCollectionPath && method === "POST" && body) { + this.configMap = { ...body, metadata: { ...body.metadata, resourceVersion: "1" } }; + if (this.createRace) fail(409); + else reply(201, this.configMap); + return; + } + const plural = plurals.find((candidate) => resourcePath(candidate) === url.pathname); + if (method === "GET" && plural) { + if (url.searchParams.get("watch") === "true") { + response.writeHead(200, { "content-type": "application/json" }); + response.flushHeaders(); + this.watches.set(plural, [...(this.watches.get(plural) ?? []), response]); + } else { + reply(200, { apiVersion: "kars.azure.com/v1alpha1", kind: "List", + metadata: { resourceVersion: this.listResourceVersion }, items: this.lists.get(plural) }); + } + return; + } + fail(404); + }); + + constructor(readonly storageNamespace = "bridge-private") { + this.configMap = { + apiVersion: "v1", kind: "ConfigMap", + metadata: { name: storeName, namespace: storageNamespace, resourceVersion: "1" }, + data: { "bindings.json": "[]", "approval-messages.json": "[]", "resource-versions.json": "{}" }, + }; + } + + get configMapCollectionPath(): string { + return `/api/v1/namespaces/${this.storageNamespace}/configmaps`; + } + + get configMapPath(): string { + return `${this.configMapCollectionPath}/${storeName}`; + } + + async start(): Promise<void> { + await new Promise<void>((resolve) => this.server.listen(0, "127.0.0.1", resolve)); + const address = this.server.address(); + if (!address || typeof address === "string") throw new Error("fixture did not bind"); + this.kubeConfig.loadFromOptions({ + clusters: [{ name: "fixture", server: `http://127.0.0.1:${address.port}`, skipTLSVerify: true }], + users: [{ name: "fixture" }], + contexts: [{ name: "fixture", cluster: "fixture", user: "fixture" }], + currentContext: "fixture", + }); + } + + async close(): Promise<void> { + this.server.closeAllConnections(); + await new Promise<void>((resolve, reject) => this.server.close((error) => error ? reject(error) : resolve())); + } + + store(): KubernetesConversationStore { + return new KubernetesConversationStore({ + namespace: this.storageNamespace, configMapName: storeName, kubeConfig: this.kubeConfig, + }); + } + + activeWatches(plural: string): ServerResponse[] { + return (this.watches.get(plural) ?? []).filter((response) => !response.destroyed && !response.writableEnded); + } + + emit(plural: string, type: string, object: unknown): void { + const responses = this.activeWatches(plural); + expect(responses).toHaveLength(1); + responses[0]!.write(`${JSON.stringify({ type, object })}\n`); + } + + listCount(plural: string): number { + return this.requests.filter((request) => + request.path === resourcePath(plural) && request.query.get("watch") !== "true").length; + } +} + +const binding = { + conversationId: "conversation-1", serviceUrl: "https://teams.example.test", + tenantId: "tenant-1", teamName: "engineering", namespace: coreNamespace, boundAt: "2026-01-01T00:00:00Z", +}; +const approval = { + metadata: { name: "approval-1", namespace: coreNamespace, resourceVersion: "10", + labels: { "kars.azure.com/team": "engineering" } }, + spec: { taskRef: { name: "task-1" }, action: { kind: "checkpoint", summary: "Review result" } }, + status: { phase: "Pending", boundEnvelopeDigest: "sha256:bound" }, +}; +const fixtures: KubernetesApiFixture[] = []; +const watchers: GatewayWatcher[] = []; + +async function fixture(namespace?: string): Promise<KubernetesApiFixture> { + const api = new KubernetesApiFixture(namespace); + fixtures.push(api); + await api.start(); + return api; +} + +function watcher(api: KubernetesApiFixture, store = api.store()) { + const config: TeamsGatewayConfig = { + clientId: "client-1", clientSecret: "fixture", tenantId: "tenant-1", entraRoleMappings: [], + bffBaseUrl: "http://bff.example.test", bffInternalSecret: "fixture", port: 3978, internalPort: 3979, + conversationConfigMapName: storeName, conversationConfigMapNamespace: api.storageNamespace, + watchNamespace: coreNamespace, + }; + const messenger = { + send: vi.fn(async () => ({ id: "message-1" })), + api: { conversations: { updateActivity: vi.fn(async () => undefined) } }, + }; + const gateway = new GatewayWatcher(config, store, messenger, { + customObjectsApi: api.kubeConfig.makeApiClient(CustomObjectsApi), + watch: new Watch(api.kubeConfig), + reconnectDelayMs: 5, + }); + watchers.push(gateway); + return { gateway, messenger }; +} + +async function allWatching(api: KubernetesApiFixture): Promise<void> { + await vi.waitFor(() => { + for (const plural of plurals) expect(api.activeWatches(plural)).toHaveLength(1); + }); +} + +afterEach(async () => { + for (const gateway of watchers.splice(0)) gateway.stop(); + for (const api of fixtures.splice(0)) await api.close(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("locked Kubernetes SDK request-object integration", () => { + it("uses the SDK version from the unchanged gateway lock", () => { + const require = createRequire(import.meta.url); + const installed = JSON.parse(readFileSync(require.resolve("@kubernetes/client-node/package.json"), "utf8")); + const locked = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8")); + expect(installed.version).toBe(locked.packages["node_modules/@kubernetes/client-node"].version); + }); + + it.each(["kars-system", "bridge-private"])( + "uses actual rendered %s configuration for /bind, command routing, SDK storage and watches", async (namespace) => { + const chart = fileURLToPath(new URL("../../deploy/helm/kars-bridge", import.meta.url)); + const manifest = execFileSync("helm", [ + "template", "kars-bridge", chart, "--namespace", namespace, + "--set", `namespace=${namespace},core.namespace=${coreNamespace},teamsGateway.enabled=true`, + "--show-only", "templates/teams-gateway.yaml", + ], { encoding: "utf8", timeout: 10_000 }); + const resources = manifest.split(/^---\s*$/m) + .filter((document) => document.split("\n").some((line) => line.trim() && !line.trimStart().startsWith("#"))) + .map((document) => loadYaml<KubernetesObject>(document)); + const deployment = resources.find((object) => object.kind === "Deployment") as V1Deployment; + for (const env of deployment.spec!.template.spec!.containers[0]!.env!) { + if (env.value !== undefined) vi.stubEnv(env.name, env.value); + } + vi.stubEnv("TEAMS_CLIENT_ID", "fixture"); + vi.stubEnv("TEAMS_CLIENT_SECRET", "fixture"); + vi.stubEnv("TEAMS_TENANT_ID", binding.tenantId); + vi.stubEnv("TEAMS_BFF_INTERNAL_SECRET", "fixture"); + vi.stubEnv("TEAMS_ENTRA_ROLE_MAP", JSON.stringify([{ + entra_subject: "operator", bridge_subject: "operator", roles: ["operator"], name: "Fixture Operator", + }])); + const config = loadConfig(); + const api = await fixture(namespace); + const store = new KubernetesConversationStore({ + namespace: config.conversationConfigMapNamespace, + configMapName: config.conversationConfigMapName, kubeConfig: api.kubeConfig, + }); + await store.initialize(); + const handlers = new Map<string, (context: unknown) => unknown>(); + const app = { + on: (route: string, handler: (context: unknown) => unknown) => { handlers.set(route, handler); }, + start: async () => undefined, + send: vi.fn(async () => ({ id: "message-1" })), + }; + const gateway = new GatewayWatcher(config, store, app, { + customObjectsApi: api.kubeConfig.makeApiClient(CustomObjectsApi), watch: new Watch(api.kubeConfig), + }); + watchers.push(gateway); + const sendTeamCommand = vi.fn(async () => ({ success: true, message: "ok" })); + registerAppHandlers(app, { + config, store, bff: { submitDecision: vi.fn(), sendTeamCommand }, + reconcileTeamApprovals: (team) => gateway.reconcileTeamApprovals(team), + }); + for (const text of ["/bind engineering", "/status"]) { + await handlers.get("message")!({ + activity: { text, from: { aadObjectId: "operator" }, + conversation: { id: binding.conversationId, tenantId: binding.tenantId }, serviceUrl: binding.serviceUrl }, + send: async () => undefined, + }); + } + expect(sendTeamCommand).toHaveBeenNthCalledWith(1, + expect.objectContaining({ command: "bind", namespace: coreNamespace })); + expect(sendTeamCommand).toHaveBeenNthCalledWith(2, + expect.objectContaining({ command: "status", namespace: coreNamespace })); + expect(await store.getByConversation(binding.conversationId)) + .toMatchObject({ teamName: "engineering", namespace: coreNamespace }); + await gateway.start(); + await allWatching(api); + expect(api.requests.some((request) => request.method === "PUT" && request.path === api.configMapPath)).toBe(true); + expect(api.requests.every((request) => + request.path === api.configMapPath || plurals.some((plural) => request.path === resourcePath(plural)))).toBe(true); + }); + + it.each(["kars-system", "bridge-private"])( + "initializes and persists/reloads core bindings using only the %s ConfigMap API", async (namespace) => { + const api = await fixture(namespace); + const store = api.store(); + await store.initialize(); + await store.bind(binding); + const record = { + approvalName: "approval-1", approvalNamespace: coreNamespace, conversationId: binding.conversationId, + teamName: binding.teamName, messageId: "message-1", resourceVersion: "10", + boundEnvelopeDigest: "sha256:bound", sentAt: binding.boundAt, + }; + await Promise.all([ + store.recordApprovalMessage(record), + store.setLastResourceVersion("watch.karsapprovals", "12"), + ]); + const reloaded = api.store(); + await reloaded.initialize(); + expect(await reloaded.getByConversation(binding.conversationId)).toEqual(binding); + expect(await reloaded.getApprovalMessage(coreNamespace, "approval-1")).toEqual(record); + expect(await reloaded.getLastResourceVersion("watch.karsapprovals")).toBe("12"); + expect(api.requests.every((request) => request.path === api.configMapPath)).toBe(true); + const writes = api.requests.filter((request) => request.method === "PUT"); + expect(writes).toHaveLength(3); + expect(writes.map((request) => request.body?.metadata?.resourceVersion)).toEqual(["1", "2", "3"]); + for (const request of writes) { + expect(request.body).toMatchObject({ + apiVersion: "v1", kind: "ConfigMap", metadata: { name: storeName, namespace }, + data: { "bindings.json": JSON.stringify([binding]) }, + }); + } + await reloaded.bind({ ...binding, teamName: "operations" }); + expect(await reloaded.getByTeam("engineering")).toBeUndefined(); + expect((await reloaded.getByTeam("operations"))?.conversationId).toBe(binding.conversationId); + }); + + it.each([false, true])("retains 404 creation and 409 concurrent-creation recovery (race=%s)", async (race) => { + const api = await fixture(); + api.configMap = undefined; + api.createRace = race; + await api.store().initialize(); + expect(api.requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET ${api.configMapPath}`, `POST ${api.configMapCollectionPath}`, + ...(race ? [`GET ${api.configMapPath}`] : []), + ]); + expect(api.requests[1]?.body).toEqual({ + apiVersion: "v1", kind: "ConfigMap", metadata: { name: storeName, namespace: api.storageNamespace }, + data: { "bindings.json": "[]", "approval-messages.json": "[]", "resource-versions.json": "{}" }, + }); + }); + + it.each([403, 500])("propagates exact SDK read errors rather than creating on %s", async (code) => { + const api = await fixture(); + api.failures.push({ method: "GET", path: api.configMapPath, code, watch: false }); + await expect(api.store().initialize()).rejects.toMatchObject(sdkFailure(code)); + expect(api.requests.map((request) => request.method)).toEqual(["GET"]); + }); + + it("propagates create/replace errors and keeps the persistence queue usable", async () => { + const api = await fixture(); + api.configMap = undefined; + api.failures.push({ method: "POST", path: api.configMapCollectionPath, code: 403, watch: false }); + await expect(api.store().initialize()).rejects.toMatchObject(sdkFailure(403)); + const store = api.store(); + await store.initialize(); + api.failures.push({ method: "PUT", path: api.configMapPath, code: 409, watch: false }); + await expect(store.bind(binding)).rejects.toMatchObject(sdkFailure(409)); + await store.bind(binding); + expect(JSON.parse(api.configMap!.data!["bindings.json"]!)).toEqual([binding]); + }); + + it("preserves list errors and the missing resourceVersion error", async () => { + const api = await fixture(); + const { gateway } = watcher(api); + api.failures.push({ method: "GET", path: resourcePath("karsapprovals"), code: 403, watch: false }); + await expect(gateway.reconcileTeamApprovals("engineering")).rejects.toMatchObject(sdkFailure(403)); + api.listResourceVersion = undefined; + await expect(gateway.reconcileTeamApprovals("engineering")).rejects.toThrow( + "list karsapprovals did not return a resourceVersion"); + expect(api.requests.every((request) => request.path === resourcePath("karsapprovals"))).toBe(true); + }); + + it("lists/watches all core resources, persists bookmarks, updates cards and deduplicates after restart", async () => { + const api = await fixture(); + api.lists.set("karsapprovals", [approval]); + const task = { metadata: { ...approval.metadata, name: "task-1" }, status: { phase: "Pending" } }; + const team = { metadata: { ...approval.metadata, name: "engineering" }, status: { phase: "Forming" } }; + api.lists.set("karstasks", [task]); + api.lists.set("karsteams", [team]); + const store = api.store(); + await store.initialize(); + await store.bind(binding); + const { gateway, messenger } = watcher(api, store); + await gateway.start(); + await allWatching(api); + expect(messenger.send).toHaveBeenCalledTimes(1); + for (const plural of plurals) { + expect(api.listCount(plural)).toBe(1); + const request = api.requests.find((item) => item.path === resourcePath(plural) && item.query.get("watch") === "true")!; + expect(Object.fromEntries(request.query)).toEqual({ + allowWatchBookmarks: "true", resourceVersion: "10", timeoutSeconds: "300", watch: "true", + }); + } + const updated = { ...approval, metadata: { ...approval.metadata, resourceVersion: "11" } }; + api.lists.set("karsapprovals", [updated]); + api.emit("karsapprovals", "MODIFIED", updated); + api.emit("karstasks", "MODIFIED", { ...task, status: { phase: "Ready" } }); + api.emit("karsteams", "MODIFIED", { ...team, status: { phase: "Active" } }); + for (const plural of plurals) api.emit(plural, "BOOKMARK", { metadata: { resourceVersion: "12" } }); + await vi.waitFor(async () => { + for (const plural of plurals) expect(await store.getLastResourceVersion(`watch.${plural}`)).toBe("12"); + expect(messenger.send).toHaveBeenCalledTimes(3); + expect(messenger.api.conversations.updateActivity).toHaveBeenCalledTimes(1); + }); + expect(messenger.api.conversations.updateActivity).toHaveBeenCalledWith( + binding.conversationId, "message-1", expect.objectContaining({ type: "message" })); + gateway.stop(); + await vi.waitFor(() => { + for (const plural of plurals) expect(api.activeWatches(plural)).toHaveLength(0); + }); + const reloaded = api.store(); + await reloaded.initialize(); + expect(await reloaded.getLastResourceVersion("watch.karsapprovals")).toBe("12"); + expect((await reloaded.getApprovalMessage(coreNamespace, "approval-1"))?.resourceVersion).toBe("11"); + const restarted = watcher(api, reloaded); + await restarted.gateway.start(); + await allWatching(api); + expect(restarted.messenger.send).not.toHaveBeenCalled(); + expect(restarted.messenger.api.conversations.updateActivity).not.toHaveBeenCalled(); + expect(api.requests.every((request) => + request.path === api.configMapPath || plurals.some((plural) => request.path === resourcePath(plural)))).toBe(true); + }); + + it("retries list failures, resumes a closed watch from bookmarks and relists after HTTP 410/500", async () => { + const api = await fixture(); + const store = api.store(); + await store.initialize(); + api.failures.push({ method: "GET", path: resourcePath("karsapprovals"), code: 503, watch: false }); + const { gateway } = watcher(api, store); + await gateway.start(); + await allWatching(api); + expect(api.listCount("karsapprovals")).toBe(2); + api.emit("karsapprovals", "BOOKMARK", { metadata: { resourceVersion: "12" } }); + await vi.waitFor(async () => expect(await store.getLastResourceVersion("watch.karsapprovals")).toBe("12")); + api.activeWatches("karsapprovals")[0]!.end(); + await allWatching(api); + expect(api.listCount("karsapprovals")).toBe(2); + expect(api.requests.filter((request) => request.path === resourcePath("karsapprovals")).at(-1)?.query.get("resourceVersion")).toBe("12"); + for (const code of [410, 500]) { + const count = api.listCount("karsapprovals"); + api.failures.push({ method: "GET", path: resourcePath("karsapprovals"), code, watch: true }); + api.activeWatches("karsapprovals")[0]!.end(); + await vi.waitFor(() => expect(api.listCount("karsapprovals")).toBe(count + 1)); + await allWatching(api); + } + }); + + it("accepts real SDK clients at the typed injection boundary without compatibility casts", async () => { + const api = await fixture(); + const store = new KubernetesConversationStore({ + namespace: api.storageNamespace, configMapName: storeName, + api: api.kubeConfig.makeApiClient(CoreV1Api), + }); + await store.initialize(); + expect(api.requests[0]?.path).toBe(api.configMapPath); + }); +}); From aa4056358561a2e02928fe5346f54b733ab8eb1a Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 23:05:34 +0200 Subject: [PATCH 108/111] fix(bridge): renew maintenance work and preserve honest artifact attribution Aggregate current Dependabot evidence and create bounded follow-up work without overwriting completed or in-flight identities, authority, inputs or PR history. Prefer explicit artifact producers over filename inference and retain per-file labels. Keep test and renderer modules bounded, and require all sixteen renewal regressions in hosted Rust qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 18 +- bridge/bff/src/routes/engineering/queue.rs | 119 +++-- .../bff/src/routes/engineering/remediation.rs | 403 +++++++++++++++- .../engineering/remediation_renewal_tests.rs | 437 ++++++++++++++++++ .../routes/engineering/remediation_tests.rs | 23 +- .../src/routes/engineering/synchronization.rs | 32 +- bridge/bff/src/routes/engineering/tests.rs | 5 +- bridge/docs/team-workflows.md | 21 + .../[name]/runs/[run]/artifact-summary.tsx | 30 ++ .../teams/[name]/runs/[run]/page.tsx | 14 +- bridge/web/src/lib/team-run-evidence.ts | 21 +- bridge/web/tests/team-run-evidence.test.mjs | 179 +++++++ 12 files changed, 1212 insertions(+), 90 deletions(-) create mode 100644 bridge/bff/src/routes/engineering/remediation_renewal_tests.rs create mode 100644 bridge/web/src/app/workspace/teams/[name]/runs/[run]/artifact-summary.tsx create mode 100644 bridge/web/tests/team-run-evidence.test.mjs diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index 148b265de..d6b8079ad 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -71,7 +71,23 @@ jobs: routes::tasks::artifacts::tests::active_and_unknown_artifacts_download_unchanged_from_live_or_retained_tasks \ routes::tasks::artifacts::tests::passive_artifact_previews_keep_inline_viewing_without_mime_sniffing_or_byte_changes \ routes::tasks::artifacts::tests::artifact_head_has_the_same_protection_and_filename_is_header_safe \ - routes::tasks::artifacts::tests::artifact_response_hardening_preserves_ownership_and_missing_file_denials + routes::tasks::artifacts::tests::artifact_response_hardening_preserves_ownership_and_missing_file_denials \ + routes::engineering::remediation::renewal_tests::current_producer_new_advisory_after_done_gets_followup_not_lost_or_replayed \ + routes::engineering::remediation::renewal_tests::completed_source_ignores_poll_time_titles_and_pr_candidates_but_not_changed_fix_facts \ + routes::engineering::remediation::renewal_tests::current_producer_pending_source_refreshes_without_spending_capacity_or_changing_identity \ + routes::engineering::remediation::renewal_tests::active_approved_and_nonce_bound_pending_inputs_are_frozen_with_dependent_followups \ + routes::engineering::remediation::renewal_tests::current_producer_aggregation_is_order_independent_and_removes_only_closed_pending_findings \ + routes::engineering::remediation::renewal_tests::full_empty_snapshot_retires_unassigned_source_without_claiming_delivery_or_erasing_runs \ + routes::engineering::remediation::renewal_tests::refreshed_followup_preserves_original_history_and_withdrawn_findings_are_not_reintroduced \ + routes::engineering::remediation::renewal_tests::proven_legacy_history_remains_immutable_when_current_producer_finds_a_new_alert \ + routes::engineering::remediation::renewal_tests::final_merge_rechecks_assignment_and_completion_after_admission \ + routes::engineering::remediation::renewal_tests::direct_final_merge_aggregates_current_producer_and_preserves_completed_pr_evidence \ + routes::engineering::remediation::renewal_tests::remediation_queue_cap_retries_new_followups_but_allows_unassigned_refreshes \ + routes::engineering::remediation::renewal_tests::missing_v2_original_metadata_cannot_authorize_overwriting_a_completed_row \ + routes::engineering::remediation::renewal_tests::legacy_retirement_cannot_rewrite_a_concurrently_assigned_pending_task \ + routes::engineering::remediation::renewal_tests::duplicated_alert_pages_keep_latest_facts_and_all_pr_candidate_links_in_either_order \ + routes::engineering::remediation::renewal_tests::partial_poll_refreshes_seen_alerts_without_discarding_unseen_pending_findings \ + routes::engineering::remediation::renewal_tests::source_withdrawal_final_merge_does_not_change_a_newly_assigned_v2_task do grep -Fx "$name: test" /tmp/kars-bridge-bff-tests.txt done diff --git a/bridge/bff/src/routes/engineering/queue.rs b/bridge/bff/src/routes/engineering/queue.rs index 1c93b7ae8..2bf18acc3 100644 --- a/bridge/bff/src/routes/engineering/queue.rs +++ b/bridge/bff/src/routes/engineering/queue.rs @@ -10,7 +10,9 @@ use chrono::Utc; use crate::kars::cluster::Cluster; use crate::routes::teams::read_task_list; -use super::remediation::match_remediation_task; +use super::remediation::{ + RemediationUpdate, aggregate_remediation_tasks, match_remediation_task, plan_remediation_update, +}; use super::{EngineeringSourceConfig, MAX_ITEMS_PER_SYNC, TeamTaskDto}; pub(super) fn merge_discovered_tasks( @@ -28,7 +30,22 @@ pub(super) fn merge_discovered_tasks( .map(|(index, task)| (task.id.clone(), index)) .collect::<BTreeMap<_, _>>(); let mut added = 0; - for mut task in discovered { + for mut task in aggregate_remediation_tasks(discovered) { + match plan_remediation_update(&mut task, &existing) { + RemediationUpdate::Unchanged => continue, + RemediationUpdate::Write(updated) => { + let updated = *updated; + if let Some(index) = positions.get(&updated.id).copied() { + existing[index] = updated; + } else { + positions.insert(updated.id.clone(), existing.len()); + existing.push(updated); + added += 1; + } + continue; + } + RemediationUpdate::NotRemediation => {} + } let (matching_id, _) = match_remediation_task(&mut task, |id| { positions .get(id) @@ -43,13 +60,17 @@ pub(super) fn merge_discovered_tasks( || task.id.starts_with("github-pr-feedback-"); let renewable_pr_control = task.id.starts_with("github-pr-fix-") || task.id.starts_with("github-pr-dedupe-"); - if renewable_alert && current.status == "pending" && task.status == "done" { - current.title = task.title; - current.description = task.description; - current.status = "done".into(); - current.run = None; - current.done_at = task.done_at; - current.stuck_since = None; + if renewable_alert && task.status == "done" { + if current.status == "pending" + && current.run.is_none() + && current.assignment_nonce.is_none() + { + current.title = task.title; + current.description = task.description; + current.status = "done".into(); + current.done_at = task.done_at; + current.stuck_since = None; + } } else if current.status == "done" && (renewable_human_decision || ((renewable_alert || renewable_pr_control) @@ -92,55 +113,77 @@ fn engineering_task_requires_review(task_id: &str) -> bool { pub(super) fn append_bounded_tasks( target: &mut Vec<TeamTaskDto>, - known_tasks: &mut BTreeMap<String, (String, String)>, + known_tasks: &mut BTreeMap<String, TeamTaskDto>, incoming: Vec<TeamTaskDto>, queued_slots_used: &mut usize, attempt_cap: usize, ) -> bool { - let mut queue_candidates = Vec::new(); - for mut task in incoming { + let remaining = MAX_ITEMS_PER_SYNC + .saturating_sub(*queued_slots_used) + .min(attempt_cap); + let mut accepted = 0; + let mut truncated = false; + for mut task in aggregate_remediation_tasks(incoming) { + let existing = known_tasks.values().cloned().collect::<Vec<_>>(); + match plan_remediation_update(&mut task, &existing) { + RemediationUpdate::Unchanged => continue, + RemediationUpdate::Write(updated) => { + let updated = *updated; + let needs_slot = !known_tasks.contains_key(&updated.id); + if needs_slot && accepted >= remaining { + truncated = true; + continue; + } + accepted += usize::from(needs_slot); + known_tasks.insert(updated.id.clone(), updated); + // Carry the source observation, not the plan: the final CAS must recheck + // assignment ownership and completed history against its fresh backlog. + target.push(task); + continue; + } + RemediationUpdate::NotRemediation => {} + } let (matching_id, _) = match_remediation_task(&mut task, |id| { - known_tasks - .get(id) - .map(|(_, description)| description.as_str()) + known_tasks.get(id).map(|task| task.description.as_str()) }); let renewable_alert = task.id.starts_with("dependabot-alert-") || task.id.starts_with("code-scanning-alert-") || task.id.starts_with("secret-scanning-alert-"); match known_tasks.get(&matching_id) { None => { - known_tasks.insert( - task.id.clone(), - (task.status.clone(), task.description.clone()), - ); - queue_candidates.push(task); + if accepted >= remaining { + truncated = true; + continue; + } + known_tasks.insert(task.id.clone(), task.clone()); + accepted += 1; + target.push(task); } - Some((status, description)) => { - let reopen = - renewable_alert && status == "done" && description != &task.description; + Some(current) => { + let reopen = renewable_alert + && current.status == "done" + && current.description != task.description; if reopen { - known_tasks.insert( - task.id.clone(), - ("pending".into(), task.description.clone()), - ); - queue_candidates.push(task); + if accepted >= remaining { + truncated = true; + continue; + } + known_tasks.insert(task.id.clone(), task.clone()); + accepted += 1; + target.push(task); } else if renewable_alert - && matches!(status.as_str(), "pending" | "active") - && description != &task.description + && matches!(current.status.as_str(), "pending" | "active") + && current.description != task.description { - known_tasks.insert(task.id.clone(), (status.clone(), task.description.clone())); + let mut updated = current.clone(); + updated.description = task.description.clone(); + known_tasks.insert(matching_id, updated); target.push(task); } } } } - let remaining = MAX_ITEMS_PER_SYNC - .saturating_sub(*queued_slots_used) - .min(attempt_cap); - let truncated = queue_candidates.len() > remaining; - queue_candidates.truncate(remaining); - *queued_slots_used += queue_candidates.len(); - target.extend(queue_candidates); + *queued_slots_used += accepted; truncated } diff --git a/bridge/bff/src/routes/engineering/remediation.rs b/bridge/bff/src/routes/engineering/remediation.rs index daaaf693e..b6b03012d 100644 --- a/bridge/bff/src/routes/engineering/remediation.rs +++ b/bridge/bff/src/routes/engineering/remediation.rs @@ -4,6 +4,7 @@ // kars Bridge BFF — remediation identity and compatibility with persisted intake. use crate::providers::signing::sha256; +use std::collections::{BTreeMap, BTreeSet}; use super::{GithubPull, TeamTaskDto}; @@ -55,16 +56,36 @@ pub(super) fn remediation_work_id( RemediationIdentity::new(repo, manifest_path, package).work_id() } -fn stored_identity(description: &str) -> Option<(String, RemediationIdentity)> { +fn source_json(description: &str) -> Option<serde_json::Value> { let (_, source) = description.split_once(SOURCE_MARKER)?; if source.contains(SOURCE_MARKER) { return None; } // Coverage notes may follow the original JSON. Read that object, not prose or substrings. - let source = serde_json::Deserializer::from_str(source) + serde_json::Deserializer::from_str(source) .into_iter::<serde_json::Value>() .next()? - .ok()?; + .ok() +} + +fn replace_source(task: &mut TeamTaskDto, source: serde_json::Value) { + let (prefix, suffix) = task + .description + .split_once(SOURCE_MARKER) + .expect("validated source"); + let mut stream = serde_json::Deserializer::from_str(suffix).into_iter::<serde_json::Value>(); + stream + .next() + .expect("source object") + .expect("valid source object"); + task.description = format!( + "{prefix}{SOURCE_MARKER}{source}{}", + &suffix[stream.byte_offset()..] + ); +} + +fn stored_identity(description: &str) -> Option<(String, RemediationIdentity)> { + let source = source_json(description)?; if source.get("signal")?.as_str()? != "dependabot_alert" { return None; } @@ -86,6 +107,378 @@ fn stored_identity(description: &str) -> Option<(String, RemediationIdentity)> { )) } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct AlertObservation { + alert_number: u64, + details: AlertDetails, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct AlertDetails { + #[serde(flatten)] + facts: DependencyFacts, + url: Option<String>, + updated_at: Option<String>, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct DependencyFacts { + package: String, + manifest_path: Option<String>, + ecosystem: Option<String>, + scope: Option<String>, + ghsa_id: Option<String>, + cve_id: Option<String>, + summary: Option<String>, + severity: Option<String>, + vulnerable_version_range: String, + first_patched_version: Option<String>, +} + +impl AlertObservation { + fn evidence_key(&self) -> String { + // Poll metadata and display URLs are not a new advisory or remediation input. + serde_json::json!([self.alert_number, self.details.facts]).to_string() + } +} + +fn observations(description: &str) -> Option<Vec<AlertObservation>> { + let source = source_json(description)?; + let alerts: Vec<AlertObservation> = match source.get("alerts") { + Some(alerts) => serde_json::from_value(alerts.clone()).ok(), + None => serde_json::from_value(source).ok().map(|alert| vec![alert]), + }?; + let (_, identity) = stored_identity(description)?; + alerts + .iter() + .all(|alert| { + alert.details.facts.package == identity.package + && alert.details.facts.manifest_path == identity.manifest_path + }) + .then_some(alerts) +} + +fn source_suffix(description: &str) -> Option<&str> { + let (_, suffix) = description.split_once(SOURCE_MARKER)?; + let mut stream = serde_json::Deserializer::from_str(suffix).into_iter::<serde_json::Value>(); + stream.next()?.ok()?; + Some(&suffix[stream.byte_offset()..]) +} + +fn canonical_observations(alerts: Vec<AlertObservation>) -> Vec<AlertObservation> { + let mut by_number = BTreeMap::<u64, AlertObservation>::new(); + for alert in alerts { + by_number + .entry(alert.alert_number) + .and_modify(|current| { + let ordering = alert + .details + .updated_at + .cmp(¤t.details.updated_at) + .then_with(|| { + serde_json::json!(alert) + .to_string() + .cmp(&serde_json::json!(current).to_string()) + }); + if ordering.is_gt() { + *current = alert.clone(); + } + }) + .or_insert(alert); + } + by_number.into_values().collect() +} + +fn with_observations(template: &TeamTaskDto, alerts: &[AlertObservation], id: &str) -> TeamTaskDto { + let (_, identity) = + stored_identity(&template.description).expect("validated remediation source"); + let first = alerts.first(); + let mut task = super::intake::alert_backlog_task( + super::EngineeringSignal::DependabotAlert, + super::GithubAlertRef { + repo: &identity.repo, + number: first.map_or(0, |alert| alert.alert_number), + }, + format!( + "[Dependabot alerts] {}: {} ({} open findings)", + identity.repo, + identity.package, + alerts.len() + ), + "GitHub Dependabot open findings are grouped by exact manifest and package. Assess every structured alert below, recheck that each is still open, and reuse matching prior PR work only after inspecting its current diff and checks.", + first.map_or_else( + || { + serde_json::json!({ + "package": identity.package, + "manifest_path": identity.manifest_path, + }) + }, + |alert| serde_json::json!(alert.details), + ), + Some(id.to_string()), + template.created_at.as_deref().unwrap_or(""), + ); + let (prefix, _) = task + .description + .split_once(SOURCE_MARKER) + .expect("producer source"); + let mut source = source_json(&task.description).expect("producer source JSON"); + source["alerts"] = serde_json::json!(alerts); + if let Some(complete) = source_json(&template.description) + .and_then(|source| source.get("snapshot_complete").cloned()) + { + source["snapshot_complete"] = complete; + } + task.description = format!("{prefix}{SOURCE_MARKER}{source}"); + // Candidate links and identity warnings are context, never source-comparison evidence. + if let Some(suffix) = source_suffix(&template.description) { + task.description.push_str(suffix); + } + task +} + +fn canonical_source(task: &TeamTaskDto) -> Option<RemediationIdentity> { + let (work_id, identity) = stored_identity(&task.description)?; + (work_id == task.id && identity.work_id() == task.id).then_some(identity) +} + +// Both admission and the CAS merge see a complete, order-independent observation per tuple. +pub(super) fn aggregate_remediation_tasks(tasks: Vec<TeamTaskDto>) -> Vec<TeamTaskDto> { + let mut grouped = + BTreeMap::<String, (TeamTaskDto, Vec<AlertObservation>, BTreeSet<String>)>::new(); + let mut other = Vec::new(); + for task in tasks { + if canonical_source(&task).is_some() + && let Some(alerts) = observations(&task.description) + { + let suffix = source_suffix(&task.description).unwrap_or("").to_string(); + grouped + .entry(task.id.clone()) + .and_modify(|(template, all, context)| { + all.extend(alerts.clone()); + context.insert(suffix.clone()); + if task.description < template.description { + *template = task.clone(); + } + }) + .or_insert((task, alerts, BTreeSet::from([suffix]))); + } else { + other.push(task); + } + } + other.extend(grouped.into_values().map(|(mut task, alerts, context)| { + let suffix_len = source_suffix(&task.description).map_or(0, str::len); + task.description + .truncate(task.description.len() - suffix_len); + for suffix in context { + task.description.push_str(&suffix); + } + with_observations(&task, &canonical_observations(alerts), &task.id) + })); + other +} + +// An absent tuple is a withdrawal only after a successful, untruncated open-alert scan. +pub(super) fn remediation_snapshot( + mut tasks: Vec<TeamTaskDto>, + existing: &BTreeMap<String, TeamTaskDto>, + repo: &str, + complete: bool, + now: &str, +) -> Vec<TeamTaskDto> { + if complete { + let mut seen = tasks + .iter() + .map(|task| task.id.clone()) + .collect::<BTreeSet<_>>(); + for task in existing.values() { + let Some((work_id, identity)) = stored_identity(&task.description) else { + continue; + }; + if work_id != task.id + || !task.id.starts_with("dependency-remediation-") + || !identity.repo.eq_ignore_ascii_case(repo) + || !seen.insert(identity.work_id()) + { + continue; + } + let mut withdrawn = with_observations(task, &[], &identity.work_id()); + withdrawn.created_at = Some(now.to_string()); + tasks.push(withdrawn); + } + } + let mut tasks = aggregate_remediation_tasks(tasks); + for task in &mut tasks { + if canonical_source(task).is_some() { + let mut source = source_json(&task.description).expect("validated source"); + source["snapshot_complete"] = serde_json::json!(complete); + replace_source(task, source); + } + } + tasks +} + +pub(super) enum RemediationUpdate { + NotRemediation, + Unchanged, + Write(Box<TeamTaskDto>), +} + +// Descriptions authorized by an assignment and completed run/PR links are immutable. +// New source inputs live in ordinary dependent backlog rows, not in that assignment. +pub(super) fn plan_remediation_update( + incoming: &mut TeamTaskDto, + existing: &[TeamTaskDto], +) -> RemediationUpdate { + let Some(identity) = canonical_source(incoming) else { + return RemediationUpdate::NotRemediation; + }; + let Some(mut alerts) = observations(&incoming.description) else { + return RemediationUpdate::NotRemediation; + }; + let (matching_id, _) = match_remediation_task(incoming, |id| { + existing + .iter() + .find(|task| task.id == id) + .map(|task| task.description.as_str()) + }); + let revision_prefix = format!("{}-r-", identity.work_id()); + let family = existing + .iter() + .filter(|task| { + task.id == matching_id + || (task.id.starts_with(&revision_prefix) + && stored_identity(&task.description) + .is_some_and(|(id, stored)| id == task.id && stored == identity)) + }) + .collect::<Vec<_>>(); + let editable = |task: &&TeamTaskDto| { + task.status == "pending" && task.run.is_none() && task.assignment_nonce.is_none() + }; + let pending = family.iter().copied().rfind(editable); + if source_json(&incoming.description).and_then(|source| { + source + .get("snapshot_complete") + .and_then(serde_json::Value::as_bool) + }) == Some(false) + && let Some(current) = pending + { + let observed = alerts + .iter() + .map(|alert| alert.alert_number) + .collect::<BTreeSet<_>>(); + alerts.extend( + observations(¤t.description) + .unwrap_or_default() + .into_iter() + .filter(|alert| !observed.contains(&alert.alert_number)), + ); + alerts = canonical_observations(alerts); + } + let retained = family + .iter() + .copied() + .filter(|task| !editable(task)) + .collect::<Vec<_>>(); + let covered = retained + .iter() + .filter(|task| { + stored_identity(&task.description) + .is_some_and(|(id, stored)| id == task.id && stored == identity) + }) + .flat_map(|task| observations(&task.description).unwrap_or_default()) + .map(|alert| alert.evidence_key()) + .collect::<BTreeSet<_>>(); + let novel = alerts + .into_iter() + .filter(|alert| !covered.contains(&alert.evidence_key())) + .collect::<Vec<_>>(); + + if let Some(current) = pending { + let prior = observations(¤t.description).unwrap_or_default(); + if prior + .iter() + .map(AlertObservation::evidence_key) + .collect::<Vec<_>>() + == novel + .iter() + .map(AlertObservation::evidence_key) + .collect::<Vec<_>>() + { + return RemediationUpdate::Unchanged; + } + let mut refreshed = with_observations(incoming, &novel, ¤t.id); + if let Some((_, history)) = current + .description + .split_once("\n\nChanged source follow-up.") + { + refreshed + .description + .push_str("\n\nChanged source follow-up."); + refreshed.description.push_str(history); + } + let mut updated = current.clone(); + updated.title = refreshed.title; + updated.description = refreshed.description; + updated.review_required = true; + if novel.is_empty() { + updated.title = format!( + "[Dependabot source withdrawn] {}: {}", + identity.repo, identity.package + ); + updated.status = "done".into(); + updated.done_at = incoming.created_at.clone(); + updated.description.push_str( + "\n\nSource-only retirement: no new open findings remain in this observation. This is not evidence of a remediation, delivered PR, or successful run.", + ); + } + return RemediationUpdate::Write(Box::new(updated)); + } + if novel.is_empty() { + return RemediationUpdate::Unchanged; + } + let mut id = if family.is_empty() { + matching_id + } else { + let evidence = novel + .iter() + .map(AlertObservation::evidence_key) + .collect::<Vec<_>>(); + let digest = sha256( + serde_json::json!([identity.work_id(), evidence]) + .to_string() + .as_bytes(), + ); + format!("{revision_prefix}{}", hex::encode(&digest[..6])) + }; + // Never replace retained rows, including source-only retirements or malformed history. + let mut collision = 0_u64; + while existing.iter().any(|task| task.id == id) { + collision += 1; + let digest = sha256(serde_json::json!([id, collision]).to_string().as_bytes()); + id = format!("{revision_prefix}{}", hex::encode(&digest[..6])); + } + let mut followup = with_observations(incoming, &novel, &id); + followup.depends_on = retained + .iter() + .filter(|task| task.status != "done") + .map(|task| task.id.clone()) + .collect(); + if !family.is_empty() { + followup.description.push_str( + "\n\nChanged source follow-up. Prior assignments and completed PR evidence remain in these backlog tasks; inspect their retained runs before making changes:", + ); + for task in &family { + followup.description.push_str(&format!( + "\n- {} (run: {})", + task.id, + task.run.as_deref().unwrap_or("none") + )); + } + } + RemediationUpdate::Write(Box::new(followup)) +} + pub(super) fn description_matches_remediation( description: &str, repo: &str, @@ -156,3 +549,7 @@ pub(super) fn match_remediation_task<'a>( #[cfg(test)] #[path = "remediation_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "remediation_renewal_tests.rs"] +mod renewal_tests; diff --git a/bridge/bff/src/routes/engineering/remediation_renewal_tests.rs b/bridge/bff/src/routes/engineering/remediation_renewal_tests.rs new file mode 100644 index 000000000..f42a03bae --- /dev/null +++ b/bridge/bff/src/routes/engineering/remediation_renewal_tests.rs @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::{ + GithubDependabotAlert, append_bounded_tasks, dependabot_alert_task, merge_discovered_tasks, +}; +use super::tests::{legacy, snapshot}; +use super::*; +use std::collections::BTreeMap; + +fn finding(number: u64, advisory: &str, fixed: &str, updated_at: &str) -> TeamTaskDto { + let alert: GithubDependabotAlert = serde_json::from_value(serde_json::json!({ + "number": number, + "html_url": format!("https://github.com/acme/api/security/dependabot/{number}"), + "dependency": { + "package": {"ecosystem": "npm", "name": "vite"}, + "manifest_path": "Services/package-lock.json", + "scope": "development" + }, + "security_advisory": { + "ghsa_id": advisory, "cve_id": null, "summary": "vulnerable input handling", + "severity": "high" + }, + "security_vulnerability": { + "vulnerable_version_range": "< 8", + "first_patched_version": {"identifier": fixed} + }, + "updated_at": updated_at + })) + .unwrap(); + dependabot_alert_task("acme/api", &alert, "2026-09-14T00:00:00Z") +} + +fn first_finding() -> TeamTaskDto { + finding(17, "GHSA-aaaa-bbbb-cccc", "8.0.0", "2026-09-13T00:00:00Z") +} + +fn next_finding() -> TeamTaskDto { + finding(18, "GHSA-dddd-eeee-ffff", "8.0.1", "2026-09-14T00:00:00Z") +} + +fn finished(mut task: TeamTaskDto) -> TeamTaskDto { + task.status = "done".into(); + task.run = Some(format!("{}-run", task.id)); + task.assignment_nonce = Some("approved-assignment-nonce".into()); + task.done_at = Some("2026-09-14T01:00:00Z".into()); + task.acceptance_criteria = vec!["Approved exact-SHA checks; original input digest".into()]; + task.description + .push_str("\n\nCompleted PR evidence: https://github.com/acme/api/pull/42"); + task +} + +fn sync_poll( + existing: Vec<TeamTaskDto>, + tasks: Vec<TeamTaskDto>, + complete: bool, +) -> (Vec<TeamTaskDto>, usize, usize) { + let mut known = existing + .iter() + .map(|task| (task.id.clone(), task.clone())) + .collect(); + let incoming = + remediation_snapshot(tasks, &known, "acme/api", complete, "2026-09-14T02:00:00Z"); + let mut admitted = Vec::new(); + let mut slots = 0; + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + incoming, + &mut slots, + 20 + )); + let (merged, queued) = merge_discovered_tasks(existing, admitted); + (merged, queued, slots) +} + +fn alert_numbers(task: &TeamTaskDto) -> Vec<u64> { + observations(&task.description) + .unwrap() + .iter() + .map(|alert| alert.alert_number) + .collect() +} + +#[test] +fn current_producer_new_advisory_after_done_gets_followup_not_lost_or_replayed() { + let original = finished(first_finding()); + let before = snapshot(&original); + // The actual current producer deliberately emits the same tuple-scoped v2 ID. + assert_eq!(original.id, next_finding().id); + assert!(original.id.starts_with("dependency-remediation-v2-")); + let (merged, queued, slots) = sync_poll(vec![original], vec![next_finding()], true); + assert_eq!((queued, slots, merged.len()), (1, 1, 2)); + assert_eq!(snapshot(&merged[0]), before); + assert_ne!(merged[1].id, merged[0].id); + assert!(merged[1].id.len() <= 63); + assert_eq!(merged[1].status, "pending"); + assert!(merged[1].review_required); + assert!(merged[1].run.is_none()); + assert!(merged[1].assignment_nonce.is_none()); + assert!(merged[1].description.contains(&merged[0].id)); + assert!( + merged[1] + .description + .contains(merged[0].run.as_deref().unwrap()) + ); + assert_eq!(alert_numbers(&merged[1]), vec![18]); + let (mut merged, queued, slots) = sync_poll(merged, vec![next_finding()], true); + assert_eq!((queued, slots, merged.len()), (0, 0, 2)); + merged[1] = finished(merged[1].clone()); + let prior = merged.iter().map(snapshot).collect::<Vec<_>>(); + for current_open in [vec![next_finding()], vec![first_finding(), next_finding()]] { + let (again, queued, slots) = sync_poll(merged.clone(), current_open, true); + assert_eq!((queued, slots), (0, 0)); + assert_eq!(again.iter().map(snapshot).collect::<Vec<_>>(), prior); + } +} + +#[test] +fn completed_source_ignores_poll_time_titles_and_pr_candidates_but_not_changed_fix_facts() { + let original = finished(first_finding()); + let before = snapshot(&original); + let mut unchanged = finding(17, "GHSA-aaaa-bbbb-cccc", "8.0.0", "2026-09-14T02:00:00Z"); + unchanged.title = "Different untrusted display text".into(); + note_candidate_pulls( + &mut unchanged, + &[&super::super::tests::pull("agent", "fix-vite", 43)], + ); + let (merged, queued, slots) = sync_poll(vec![original], vec![unchanged], true); + assert_eq!((queued, slots, merged.len()), (0, 0, 1)); + assert_eq!(snapshot(&merged[0]), before); + let changed = finding(17, "GHSA-aaaa-bbbb-cccc", "8.0.2", "2026-09-14T02:00:00Z"); + let (merged, queued, slots) = sync_poll(merged, vec![changed], true); + assert_eq!((queued, slots, merged.len()), (1, 1, 2)); + assert_eq!(snapshot(&merged[0]), before); + assert!(merged[1].description.contains("8.0.2")); +} + +#[test] +fn current_producer_pending_source_refreshes_without_spending_capacity_or_changing_identity() { + let original = first_finding(); + let id = original.id.clone(); + let created_at = original.created_at.clone(); + let (merged, queued, slots) = sync_poll(vec![original], vec![next_finding()], true); + assert_eq!((queued, slots, merged.len()), (0, 0, 1)); + assert_eq!(merged[0].id, id); + assert_eq!(merged[0].created_at, created_at); + assert_eq!(alert_numbers(&merged[0]), vec![18]); + assert!(!merged[0].description.contains("GHSA-aaaa-bbbb-cccc")); + assert!(merged[0].description.contains("GHSA-dddd-eeee-ffff")); +} + +#[test] +fn active_approved_and_nonce_bound_pending_inputs_are_frozen_with_dependent_followups() { + for status in ["active", "pending", "review", "stuck"] { + let mut original = finished(first_finding()); + original.status = status.into(); + original.done_at = None; + original.stuck_since = Some("2026-09-14T00:30:00Z".into()); + if status == "pending" { + original.run = None; + } + let before = snapshot(&original); + let (merged, queued, slots) = sync_poll(vec![original], vec![next_finding()], true); + assert_eq!((queued, slots, merged.len()), (1, 1, 2), "{status}"); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(merged[1].depends_on, vec![merged[0].id.clone()]); + assert_eq!(alert_numbers(&merged[1]), vec![18]); + let again_before = merged.iter().map(snapshot).collect::<Vec<_>>(); + let (again, queued, slots) = sync_poll(merged, vec![next_finding()], true); + assert_eq!((queued, slots), (0, 0)); + assert_eq!(again.iter().map(snapshot).collect::<Vec<_>>(), again_before); + } +} + +#[test] +fn current_producer_aggregation_is_order_independent_and_removes_only_closed_pending_findings() { + let (left, queued, slots) = sync_poll( + Vec::new(), + vec![next_finding(), first_finding(), first_finding()], + true, + ); + let (right, right_queued, right_slots) = + sync_poll(Vec::new(), vec![first_finding(), next_finding()], true); + assert_eq!((queued, slots, right_queued, right_slots), (1, 1, 1, 1)); + assert_eq!( + left.iter().map(snapshot).collect::<Vec<_>>(), + right.iter().map(snapshot).collect::<Vec<_>>() + ); + assert_eq!(alert_numbers(&left[0]), vec![17, 18]); + let (partial, queued, slots) = sync_poll(left.clone(), vec![first_finding()], false); + assert_eq!((queued, slots), (0, 0)); + assert_eq!(alert_numbers(&partial[0]), vec![17, 18]); + let (remaining, queued, slots) = sync_poll(left, vec![next_finding()], true); + assert_eq!((queued, slots), (0, 0)); + assert_eq!(alert_numbers(&remaining[0]), vec![18]); + assert!(!remaining[0].description.contains("GHSA-aaaa-bbbb-cccc")); +} + +#[test] +fn full_empty_snapshot_retires_unassigned_source_without_claiming_delivery_or_erasing_runs() { + let pending = first_finding(); + let (partial, queued, slots) = sync_poll(vec![pending.clone()], Vec::new(), false); + assert_eq!((queued, slots), (0, 0)); + assert_eq!(snapshot(&partial[0]), snapshot(&pending)); + let (retired, queued, slots) = sync_poll(vec![pending], Vec::new(), true); + assert_eq!((queued, slots, retired.len()), (0, 0, 1)); + assert_eq!(retired[0].status, "done"); + assert!(retired[0].run.is_none()); + assert!( + retired[0] + .description + .contains("not evidence of a remediation") + ); + assert!(alert_numbers(&retired[0]).is_empty()); + let retired_before = snapshot(&retired[0]); + let (retired, queued, slots) = sync_poll(retired, Vec::new(), true); + assert_eq!((queued, slots, retired.len()), (0, 0, 1)); + assert_eq!(snapshot(&retired[0]), retired_before); + for status in ["active", "done"] { + let mut assigned = finished(first_finding()); + assigned.status = status.into(); + let before = snapshot(&assigned); + let (merged, queued, slots) = sync_poll(vec![assigned], Vec::new(), true); + assert_eq!((queued, slots, merged.len()), (0, 0, 1)); + assert_eq!(snapshot(&merged[0]), before); + } +} + +#[test] +fn refreshed_followup_preserves_original_history_and_withdrawn_findings_are_not_reintroduced() { + let original = finished(first_finding()); + let before = snapshot(&original); + let (merged, _, _) = sync_poll(vec![original], vec![first_finding(), next_finding()], true); + assert_eq!(alert_numbers(&merged[1]), vec![18]); + let updated = finding(18, "GHSA-dddd-eeee-ffff", "8.0.3", "2026-09-14T03:00:00Z"); + let (merged, queued, slots) = sync_poll(merged, vec![updated], true); + assert_eq!((queued, slots, merged.len()), (0, 0, 2)); + assert_eq!(snapshot(&merged[0]), before); + assert!(merged[1].description.contains(&merged[0].id)); + assert!(merged[1].description.contains("8.0.3")); + assert_eq!(alert_numbers(&merged[1]), vec![18]); + let (merged, queued, slots) = sync_poll(merged, vec![first_finding()], true); + assert_eq!((queued, slots), (0, 0)); + assert_eq!(merged[1].status, "done"); + assert!(alert_numbers(&merged[1]).is_empty()); +} + +#[test] +fn proven_legacy_history_remains_immutable_when_current_producer_finds_a_new_alert() { + let original = legacy( + "acme/api", + Some("Services/package-lock.json"), + "vite", + "done", + ); + let before = snapshot(&original); + let (merged, queued, slots) = sync_poll(vec![original], vec![next_finding()], true); + assert_eq!((queued, slots, merged.len()), (1, 1, 2)); + assert_eq!(snapshot(&merged[0]), before); + assert!(merged[1].id.starts_with("dependency-remediation-v2-")); + assert!(merged[1].description.contains(&merged[0].id)); + let mut pending = legacy( + "acme/api", + Some("Services/package-lock.json"), + "vite", + "pending", + ); + pending.run = None; + pending.assignment_nonce = None; + let id = pending.id.clone(); + let (merged, queued, slots) = sync_poll(vec![pending], vec![next_finding()], true); + assert_eq!((queued, slots, merged.len()), (0, 0, 1)); + assert_eq!(merged[0].id, id); + assert_eq!(stored_identity(&merged[0].description).unwrap().0, id); + assert_eq!(alert_numbers(&merged[0]), vec![18]); +} + +#[test] +fn final_merge_rechecks_assignment_and_completion_after_admission() { + for status in ["active", "done"] { + let pending = first_finding(); + let mut known = BTreeMap::from([(pending.id.clone(), pending.clone())]); + let mut admitted = Vec::new(); + let mut slots = 0; + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + vec![next_finding()], + &mut slots, + 1 + )); + assert_eq!((slots, admitted.len()), (0, 1)); + let mut raced = finished(pending); + raced.status = status.into(); + let before = snapshot(&raced); + let (merged, queued) = merge_discovered_tasks(vec![raced], admitted); + assert_eq!((queued, merged.len()), (1, 2)); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(alert_numbers(&merged[1]), vec![18]); + } +} + +#[test] +fn direct_final_merge_aggregates_current_producer_and_preserves_completed_pr_evidence() { + let original = finished(first_finding()); + let before = snapshot(&original); + let (merged, queued) = merge_discovered_tasks( + vec![original], + vec![next_finding(), first_finding(), next_finding()], + ); + assert_eq!((queued, merged.len()), (1, 2)); + assert_eq!(snapshot(&merged[0]), before); + assert_eq!(alert_numbers(&merged[1]), vec![18]); + let again_before = merged.iter().map(snapshot).collect::<Vec<_>>(); + let (again, queued) = merge_discovered_tasks(merged, vec![first_finding(), next_finding()]); + assert_eq!(queued, 0); + assert_eq!(again.iter().map(snapshot).collect::<Vec<_>>(), again_before); +} + +#[test] +fn remediation_queue_cap_retries_new_followups_but_allows_unassigned_refreshes() { + let completed = finished(first_finding()); + let mut known = BTreeMap::from([(completed.id.clone(), completed.clone())]); + let mut admitted = Vec::new(); + let mut slots = 0; + assert!(append_bounded_tasks( + &mut admitted, + &mut known, + vec![next_finding()], + &mut slots, + 0 + )); + assert_eq!((slots, admitted.len(), known.len()), (0, 0, 1)); + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + vec![next_finding()], + &mut slots, + 1 + )); + assert_eq!((slots, admitted.len(), known.len()), (1, 1, 2)); + let pending = first_finding(); + let mut known = BTreeMap::from([(pending.id.clone(), pending)]); + let mut admitted = Vec::new(); + let mut slots = super::super::MAX_ITEMS_PER_SYNC; + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + vec![next_finding()], + &mut slots, + 0 + )); + assert_eq!(admitted.len(), 1); + assert_eq!(slots, super::super::MAX_ITEMS_PER_SYNC); +} + +#[test] +fn missing_v2_original_metadata_cannot_authorize_overwriting_a_completed_row() { + let mut original = finished(first_finding()); + original.description = "Historical completed PR: https://github.com/acme/api/pull/42".into(); + let before = snapshot(&original); + let (merged, queued) = merge_discovered_tasks(vec![original], vec![next_finding()]); + assert_eq!((queued, merged.len()), (1, 2)); + assert_eq!(snapshot(&merged[0]), before); +} + +#[test] +fn legacy_retirement_cannot_rewrite_a_concurrently_assigned_pending_task() { + let mut original = first_finding(); + original.id = "dependabot-alert-legacy".into(); + original.assignment_nonce = Some("in-flight-assignment".into()); + let before = snapshot(&original); + let retirement = super::super::intake::legacy_alert_retirement( + &original.id, + &next_finding().id, + "2026-09-14T01:00:00Z", + ); + let (merged, queued) = merge_discovered_tasks(vec![original], vec![retirement]); + assert_eq!((queued, merged.len()), (0, 1)); + assert_eq!(snapshot(&merged[0]), before); +} + +#[test] +fn duplicated_alert_pages_keep_latest_facts_and_all_pr_candidate_links_in_either_order() { + let mut older = first_finding(); + note_candidate_pulls( + &mut older, + &[&super::super::tests::pull("agent", "fix-vite", 43)], + ); + let mut newer = finding(17, "GHSA-aaaa-bbbb-cccc", "8.0.2", "2026-09-14T03:00:00Z"); + note_candidate_pulls( + &mut newer, + &[&super::super::tests::pull("agent", "fix-vite", 44)], + ); + let (left, _) = merge_discovered_tasks(Vec::new(), vec![older.clone(), newer.clone()]); + let (right, _) = merge_discovered_tasks(Vec::new(), vec![newer, older]); + assert_eq!(snapshot(&left[0]), snapshot(&right[0])); + assert_eq!(alert_numbers(&left[0]), vec![17]); + assert!(left[0].description.contains("8.0.2")); + assert!(left[0].description.contains("/pull/43")); + assert!(left[0].description.contains("/pull/44")); + assert_eq!(left[0].status, "pending"); +} + +#[test] +fn partial_poll_refreshes_seen_alerts_without_discarding_unseen_pending_findings() { + let (existing, _, _) = sync_poll(Vec::new(), vec![first_finding(), next_finding()], true); + let changed = finding(17, "GHSA-aaaa-bbbb-cccc", "8.0.4", "2026-09-14T03:00:00Z"); + let (merged, queued, slots) = sync_poll(existing, vec![changed], false); + assert_eq!((queued, slots, merged.len()), (0, 0, 1)); + assert_eq!(alert_numbers(&merged[0]), vec![17, 18]); + assert!(merged[0].description.contains("8.0.4")); +} + +#[test] +fn source_withdrawal_final_merge_does_not_change_a_newly_assigned_v2_task() { + let pending = first_finding(); + let mut known = BTreeMap::from([(pending.id.clone(), pending.clone())]); + let incoming = + remediation_snapshot(Vec::new(), &known, "acme/api", true, "2026-09-14T04:00:00Z"); + let mut admitted = Vec::new(); + let mut slots = 0; + assert!(!append_bounded_tasks( + &mut admitted, + &mut known, + incoming, + &mut slots, + 0 + )); + let mut assigned = pending; + assigned.assignment_nonce = Some("concurrent-assignment".into()); + let before = snapshot(&assigned); + let (merged, queued) = merge_discovered_tasks(vec![assigned], admitted); + assert_eq!((queued, merged.len()), (0, 1)); + assert_eq!(snapshot(&merged[0]), before); +} diff --git a/bridge/bff/src/routes/engineering/remediation_tests.rs b/bridge/bff/src/routes/engineering/remediation_tests.rs index ea6b67dc4..1c90480be 100644 --- a/bridge/bff/src/routes/engineering/remediation_tests.rs +++ b/bridge/bff/src/routes/engineering/remediation_tests.rs @@ -21,7 +21,7 @@ fn discovered(repo: &str, path: Option<&str>, package: &str, number: u64) -> Tea dependabot_alert_task(repo, &alert, "2026-09-11T00:00:00Z") } -fn legacy(repo: &str, path: Option<&str>, package: &str, status: &str) -> TeamTaskDto { +pub(super) fn legacy(repo: &str, path: Option<&str>, package: &str, status: &str) -> TeamTaskDto { let mut task = discovered(repo, path, package, 17); let id = RemediationIdentity::new(repo, path, package).legacy_work_id(); task.description = task.description.replace(&task.id, &id); @@ -36,7 +36,7 @@ fn legacy(repo: &str, path: Option<&str>, package: &str, status: &str) -> TeamTa task } -fn snapshot(task: &TeamTaskDto) -> serde_json::Value { +pub(super) fn snapshot(task: &TeamTaskDto) -> serde_json::Value { serde_json::to_value(task).unwrap() } @@ -158,7 +158,7 @@ fn proven_legacy_work_resumes_without_changing_state_or_existing_links() { let mut linked = discovered("acme/api", Some("consumer/package-lock.json"), "vite", 19); linked.depends_on = vec![original.id.clone()]; let before = vec![snapshot(&original), snapshot(&linked)]; - let candidate = discovered("acme/api", Some("Services/package-lock.json"), "vite", 99); + let candidate = discovered("acme/api", Some("Services/package-lock.json"), "vite", 17); let (merged, queued) = merge_discovered_tasks(vec![original, linked], vec![candidate]); assert_eq!(queued, 0, "{status}"); assert_eq!(merged.iter().map(snapshot).collect::<Vec<_>>(), before); @@ -171,7 +171,7 @@ fn legacy_explicit_null_manifest_can_resume_but_unknown_is_distinct() { let before = snapshot(&original); let (merged, queued) = merge_discovered_tasks( vec![original], - vec![discovered("ACME/API", None, "vite", 99)], + vec![discovered("ACME/API", None, "vite", 17)], ); assert_eq!(queued, 0); assert_eq!(snapshot(&merged[0]), before); @@ -194,12 +194,16 @@ fn distinct_completed_legacy_manifest_does_not_swallow_new_work() { let original = legacy("acme/api", Some(original_path), "vite", "done"); let before = snapshot(&original); let candidate = discovered("acme/api", Some(new_path), "vite", 18); - let candidate_before = snapshot(&candidate); let (merged, queued) = merge_discovered_tasks(vec![original], vec![candidate.clone()]); assert_eq!(queued, 1); assert_eq!(merged.len(), 2); assert_eq!(snapshot(&merged[0]), before); - assert_eq!(snapshot(&merged[1]), candidate_before); + assert_eq!(merged[1].id, candidate.id); + assert_eq!(merged[1].status, candidate.status); + assert_eq!( + observations(&merged[1].description).unwrap()[0].evidence_key(), + observations(&candidate.description).unwrap()[0].evidence_key() + ); let (repeated, queued) = merge_discovered_tasks(merged, vec![candidate]); assert_eq!(queued, 0); assert_eq!(repeated.len(), 2); @@ -325,10 +329,7 @@ fn admission_reuses_proven_legacy_work_without_spending_queue_capacity() { "active", ); let before = snapshot(&original); - let mut known = BTreeMap::from([( - original.id.clone(), - (original.status.clone(), original.description.clone()), - )]); + let mut known = BTreeMap::from([(original.id.clone(), original.clone())]); let mut admitted = Vec::new(); let mut slots = 0; let distinct = discovered("acme/api", Some("services/package-lock.json"), "vite", 18); @@ -336,7 +337,7 @@ fn admission_reuses_proven_legacy_work_without_spending_queue_capacity() { &mut admitted, &mut known, vec![ - discovered("acme/api", Some("Services/package-lock.json"), "vite", 99), + discovered("acme/api", Some("Services/package-lock.json"), "vite", 17), distinct.clone(), ], &mut slots, diff --git a/bridge/bff/src/routes/engineering/synchronization.rs b/bridge/bff/src/routes/engineering/synchronization.rs index dd54f1b88..58f4cf558 100644 --- a/bridge/bff/src/routes/engineering/synchronization.rs +++ b/bridge/bff/src/routes/engineering/synchronization.rs @@ -30,6 +30,7 @@ use super::intake::{ use super::queue::{append_bounded_tasks, ensure_auto_run_for_backlog, merge_into_backlog}; use super::remediation::{ description_matches_remediation, match_remediation_task, note_candidate_pulls, + remediation_snapshot, }; use super::review::{collect_review_items, dedupe_followup_task}; use super::{ @@ -91,12 +92,7 @@ async fn perform_sync( let existing_backlog = read_task_list(&cluster.read_team_tasks(&config.team_name).await); let mut known_tasks = existing_backlog .iter() - .map(|task| { - ( - task.id.clone(), - (task.status.clone(), task.description.clone()), - ) - }) + .map(|task| (task.id.clone(), task.clone())) .collect::<BTreeMap<_, _>>(); let attempt_count = config .repos @@ -158,20 +154,20 @@ async fn perform_sync( let mut task = dependabot_alert_task(repo, alert, &now); let (matching_id, identity_warning) = match_remediation_task(&mut task, |id| { - known_tasks - .get(id) - .map(|(_, description)| description.as_str()) + known_tasks.get(id).map(|task| task.description.as_str()) }); if let Some(warning) = identity_warning { errors.push(warning); } let legacy_ids = known_tasks .iter() - .filter(|(id, (status, description))| { + .filter(|(id, task)| { id.starts_with("dependabot-alert-") - && status == "pending" + && task.status == "pending" + && task.run.is_none() + && task.assignment_nonce.is_none() && description_matches_remediation( - description, + &task.description, repo, alert.dependency.manifest_path.as_deref(), &alert.dependency.package.name, @@ -182,10 +178,7 @@ async fn perform_sync( for legacy_id in legacy_ids { let retirement = legacy_alert_retirement(&legacy_id, &matching_id, &now); - known_tasks.insert( - legacy_id, - ("done".into(), retirement.description.clone()), - ); + known_tasks.insert(legacy_id, retirement.clone()); tasks.push(retirement); } let covering_pulls = open_pull_coverage @@ -207,6 +200,13 @@ async fn perform_sync( note_candidate_pulls(&mut task, &covering_pulls); signal_tasks.push(task); } + let signal_tasks = remediation_snapshot( + signal_tasks, + &known_tasks, + repo, + !alerts.truncated, + &now, + ); let bounded = append_bounded_tasks( &mut tasks, &mut known_tasks, diff --git a/bridge/bff/src/routes/engineering/tests.rs b/bridge/bff/src/routes/engineering/tests.rs index d1c791ab4..6aecd657f 100644 --- a/bridge/bff/src/routes/engineering/tests.rs +++ b/bridge/bff/src/routes/engineering/tests.rs @@ -358,10 +358,7 @@ fn changed_pending_alert_flows_through_without_using_queue_capacity() { let mut refreshed = task("secret-scanning-alert-abc", "pending"); refreshed.description = "updated_at=new".into(); let mut candidates = Vec::new(); - let mut known = BTreeMap::from([( - existing.id.clone(), - (existing.status.clone(), existing.description.clone()), - )]); + let mut known = BTreeMap::from([(existing.id.clone(), existing.clone())]); let mut queued_slots = 0; assert!(!append_bounded_tasks( &mut candidates, diff --git a/bridge/docs/team-workflows.md b/bridge/docs/team-workflows.md index 078dcd6a0..6d84b461e 100644 --- a/bridge/docs/team-workflows.md +++ b/bridge/docs/team-workflows.md @@ -59,6 +59,20 @@ metadata proves the same repository, manifest and package. If that evidence is missing or ambiguous, the old history remains intact, the new versioned work is tracked separately, and intake reports the ambiguity for review. +Open alerts for one target are aggregated by alert number, independent of poll +order. Comparison uses structured advisory and dependency facts, not titles, +candidate PR links, or poll timestamps. Unassigned pending work is refreshed in +place. Once a run or assignment nonce exists, its approved input is immutable: +new findings become a separate follow-up backlog task, dependent on unfinished +prior work. Completed task IDs, runs and PR evidence stay intact. Findings already +represented in retained work do not endlessly reopen on unchanged polls. + +Only a complete successful open-alert scan can remove absent findings from +unassigned work. Empty pending work is labelled a source-only retirement, not a +delivered fix. Truncated scans retain unresolved pending findings; a later complete +scan determines withdrawals. Follow-ups include only newly observed source facts +and links to prior backlog/run evidence. + A PR title mentioning the package or advisory is only a search hint. It does not prove that the PR fixes this manifest, does not mark remediation delivered, and does not suppress new work. The assigned agent must inspect the actual diff @@ -168,6 +182,13 @@ Examples: Internal evidence files such as `collaboration.jsonl` and `subagent-telemetry.jsonl` remain durable even after child sandboxes are gone. +Role artifact attribution is **recorded** only when explicit producer metadata +matches the role. An explicit different producer wins over a role-looking filename +or path. Files without producer metadata may be grouped using **inferred** +attribution, which is labelled per file; unmatched producers remain visible +without claiming a roster role. Neither recorded nor inferred artifact ownership +proves a structured handback or overrides run nonces and partial-persistence status. + ## 5. Checkpoint and restart Every milestone receives a controller-owned, nonce-scoped checkpoint before diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/artifact-summary.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/artifact-summary.tsx new file mode 100644 index 000000000..cc037a81a --- /dev/null +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/artifact-summary.tsx @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { artifactRoleAttribution } from "@/lib/team-run-evidence"; +import type { MissionArtifact, TeamRole } from "@/lib/types"; + +export function ArtifactSummary({ + artifact, + role, +}: { + artifact: MissionArtifact; + role?: TeamRole; +}) { + const attribution = role + ? artifactRoleAttribution(role, artifact) === "recorded" + ? `from ${role.name.replace(/-/g, " ")} · recorded producer` + : `possibly ${role.name.replace(/-/g, " ")} · inferred attribution` + : artifact.source_agent?.trim() + ? `recorded producer: ${artifact.source_agent} · not attributed to a roster role` + : "producer unknown"; + + return ( + <summary className="cursor-pointer px-4 py-3"> + <span className="font-medium">{artifact.name}</span> + <span className="ml-2 text-xs text-foreground-muted"> + {attribution} + </span> + </summary> + ); +} diff --git a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx index 0d73d3173..3eb3d3177 100644 --- a/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx +++ b/bridge/web/src/app/workspace/teams/[name]/runs/[run]/page.tsx @@ -18,6 +18,7 @@ import { analyzeTeamRun } from "@/lib/team-run-evidence"; import { currentPrincipal } from "@/lib/session"; import type { ReactNode } from "react"; import { HaltTeamRunButton } from "./halt-button"; +import { ArtifactSummary } from "./artifact-summary"; export const dynamic = "force-dynamic"; @@ -491,13 +492,7 @@ export default async function TeamRunPage({ <div className="mt-3 space-y-3"> {evidence.roles.flatMap((r) => r.artifacts.map((a) => ( <details key={`${r.role.name}-${a.name}`} className="rounded-lg border border-border bg-surface-muted/30"> - <summary className="cursor-pointer px-4 py-3"> - <span className="font-medium">{a.name}</span> - <span className="ml-2 text-xs text-foreground-muted"> - from {r.role.name.replace(/-/g, " ")} - {r.artifactAttribution === "inferred" ? " · inferred attribution" : ""} - </span> - </summary> + <ArtifactSummary artifact={a} role={r.role} /> <div className="border-t border-border px-4 py-3"> <ArtifactBody artifact={a} @@ -508,10 +503,7 @@ export default async function TeamRunPage({ )))} {evidence.unattributedArtifacts.map((a) => ( <details key={`unattributed-${a.name}`} className="rounded-lg border border-border bg-surface-muted/30"> - <summary className="cursor-pointer px-4 py-3"> - <span className="font-medium">{a.name}</span> - <span className="ml-2 text-xs text-foreground-muted">principal or unattributed</span> - </summary> + <ArtifactSummary artifact={a} /> <div className="border-t border-border px-4 py-3"> <ArtifactBody artifact={a} diff --git a/bridge/web/src/lib/team-run-evidence.ts b/bridge/web/src/lib/team-run-evidence.ts index 716c1a471..90d1dbeec 100644 --- a/bridge/web/src/lib/team-run-evidence.ts +++ b/bridge/web/src/lib/team-run-evidence.ts @@ -101,13 +101,14 @@ function roleScore(role: TeamRole, artifact: MissionArtifact): number { const normalize = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, ""); const roleId = normalize(role.name); if (!roleId) return 0; + const producer = artifact.source_agent?.trim(); + // An explicit producer is authoritative; paths cannot contradict it. + if (producer) return normalize(producer) === roleId ? 110 : 0; const provenance = [ artifact.name, - artifact.source_agent ?? "", artifact.source_path ?? "", ].map(normalize); - if (normalize(artifact.source_agent ?? "") === roleId) return 110; - if (provenance.some((value) => value.includes(roleId))) return 100; + if (provenance.some((value) => value.includes(roleId))) return 90; const roleText = `${role.name} ${role.system_prompt ?? ""}`.toLowerCase(); const artifactText = `${artifact.name} ${artifact.source_path ?? ""}`.toLowerCase(); if ( @@ -131,6 +132,14 @@ function roleScore(role: TeamRole, artifact: MissionArtifact): number { return 0; } +export function artifactRoleAttribution( + role: TeamRole, + artifact: MissionArtifact, +): RoleEvidence["artifactAttribution"] { + const score = roleScore(role, artifact); + return score === 110 ? "recorded" : score > 0 ? "inferred" : "none"; +} + function rolePlan(task: TaskEvidenceInput): { selected: Set<string>; skipped: Set<string>; @@ -251,7 +260,7 @@ export function analyzeTeamRun(team: TeamEvidenceInput, task: TaskEvidenceInput) if (ranked[0]) { assigned.set(artifact.name, { role: ranked[0].role, - inferred: ranked[0].score < 100, + inferred: ranked[0].score !== 110, }); } } @@ -324,9 +333,9 @@ export function analyzeTeamRun(team: TeamEvidenceInput, task: TaskEvidenceInput) event: "role_artifact_recovered", agent: null, member: role.role.name, - outcome: "delivered", + outcome: null, message_id: null, - preview: `${role.artifacts.length} retained artifact${role.artifacts.length === 1 ? "" : "s"}`, + preview: `${role.artifacts.length} retained artifact${role.artifacts.length === 1 ? "" : "s"} · ${role.artifactAttribution} attribution; not a structured handback`, source: "artifact-derived", }); } diff --git a/bridge/web/tests/team-run-evidence.test.mjs b/bridge/web/tests/team-run-evidence.test.mjs new file mode 100644 index 000000000..9c6e75070 --- /dev/null +++ b/bridge/web/tests/team-run-evidence.test.mjs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const context = { exports: {}, URL }; +runInNewContext(ts.transpileModule(readFileSync( + new URL("../src/lib/team-run-evidence.ts", import.meta.url), "utf8", +), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText, context); + +function analyze(artifacts, overrides = {}, names = ["qa"]) { + return context.exports.analyzeTeamRun({ + paused: false, + roster: names.map(name => ({ name, system_prompt: "" })), + }, { + artifacts, + activity: [], + result: null, + launched: false, + execution_phase: null, + assignment: null, + assignment_events: [], + current_run_nonce: null, + role_plan: { selected_roles: [], skipped_roles: [] }, + collaboration_events: [], + ...overrides, + }); +} + +const artifact = (source_agent, name = "qa-report.md") => ({ + name, + source_agent, + source_path: `/sandbox/${name}`, + content: null, + content_truncated: false, +}); + +test("explicit principal producer cannot become recorded QA through name or path", () => { + const input = artifact("principal"); + const evidence = analyze([input]); + assert.equal(evidence.roles[0].artifactAttribution, "none"); + assert.equal(evidence.roles[0].artifacts.length, 0); + assert.equal(evidence.unattributedArtifacts[0], input); + assert.equal(evidence.roles[0].state, "missing"); + assert.equal(evidence.outcome, "incomplete"); + const withPrincipal = analyze([input], {}, ["qa", "principal"]); + assert.equal(withPrincipal.roles[0].artifacts.length, 0); + assert.equal(withPrincipal.roles[1].artifactAttribution, "recorded"); +}); + +test("matching explicit producer is recorded even if its filename names another role", () => { + const evidence = analyze([artifact("qa", "developer-report.md")], {}, ["developer", "qa"]); + assert.equal(evidence.roles[0].artifacts.length, 0); + assert.equal(evidence.roles[1].artifactAttribution, "recorded"); +}); + +test("filename and path without producer metadata are at most inferred", () => { + for (const producer of [undefined, null, "", " "]) { + const evidence = analyze([artifact(producer)]); + assert.equal(evidence.roles[0].artifactAttribution, "inferred"); + assert.equal(evidence.roles[0].state, "missing"); + assert.equal(evidence.outcome, "incomplete"); + assert.equal(evidence.collaboration[0].outcome, null); + assert.match(evidence.collaboration[0].preview, /inferred/); + } + const unknown = analyze([artifact(null, "notes.md")]); + assert.equal(unknown.roles[0].artifactAttribution, "none"); + assert.equal(unknown.unattributedArtifacts.length, 1); +}); + +test("unknown explicit producer stays unattributed; substrings are not role identities", () => { + for (const producer of ["qa-supervisor", "principal-qa", "unknown"]) { + assert.equal(analyze([artifact(producer)]).unattributedArtifacts.length, 1); + } +}); + +test("punctuation-normalized role names are explicit matches, not filename proof", () => { + const evidence = analyze( + [artifact("Dependency_Security", "qa-report.md")], {}, ["qa", "dependency-security"], + ); + assert.equal(evidence.roles[0].artifacts.length, 0); + assert.equal(evidence.roles[1].artifactAttribution, "recorded"); +}); + +test("mixed role artifacts retain per-file attribution for honest rendering", () => { + const explicit = artifact("qa"); + const inferred = artifact(null, "qa-notes.md"); + const evidence = analyze([explicit, inferred]); + assert.equal(evidence.roles[0].artifactAttribution, "inferred"); + assert.equal(context.exports.artifactRoleAttribution(evidence.roles[0].role, explicit), "recorded"); + assert.equal(context.exports.artifactRoleAttribution(evidence.roles[0].role, inferred), "inferred"); +}); + +test("producer evidence does not supersede assignment nonce or partial persistence", () => { + const input = artifact("qa"); + const stale = analyze([input], { + launched: true, + execution_phase: "Running", + current_run_nonce: "new-run-assignment", + assignment: { task_id: "old-run-assignment", state: "Completed" }, + result: { assignment_nonce: "old-run-assignment", status: "success" }, + assignment_events: [{ + task_id: "old-run-assignment", stage: "child_handback", child_role: "qa", + state: "Completed", outcome: "success", + }], + }); + assert.equal(stale.outcome, "running"); + assert.equal(stale.roles[0].state, "missing"); + const partial = analyze([input], { + current_run_nonce: "slot-specific-run-nonce", + assignment: { task_id: "slot-specific-run-nonce", state: "Completed" }, + assignment_events: [{ + task_id: "slot-specific-run-nonce", stage: "child_handback", child_role: "qa", + state: "Completed", outcome: "success", + }], + result: { + assignment_nonce: "slot-specific-run-nonce", status: "success", + artifact_persistence: "partial", artifact_count: 1, declared_artifact_count: 2, + }, + }); + assert.equal(partial.outcome, "delivered_with_issues"); + assert.equal(partial.roles[0].state, "delivered"); +}); + +test("role artifact renderer labels each file and explicit unassigned producers honestly", () => { + const page = readFileSync(new URL( + "../src/app/workspace/teams/[name]/runs/[run]/page.tsx", import.meta.url, + ), "utf8"); + assert.match(page, /import \{ ArtifactSummary \} from "\.\/artifact-summary";/); + assert.match(page, /<ArtifactSummary artifact=\{a\} role=\{r\.role\} \/>/); + assert.match(page, /<ArtifactSummary artifact=\{a\} \/>/); + + const summary = readFileSync(new URL( + "../src/app/workspace/teams/[name]/runs/[run]/artifact-summary.tsx", import.meta.url, + ), "utf8"); + const renderer = { + exports: {}, + require: (name) => { + assert.equal(name, "@/lib/team-run-evidence"); + return context.exports; + }, + element: (tag, props, ...children) => ({ tag, props, children }), + }; + runInNewContext(ts.transpileModule(summary, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + jsxFactory: "element", + }, + }).outputText, renderer); + const cases = [ + [artifact("qa"), { name: "qa" }, "from qa · recorded producer"], + [artifact(null), { name: "qa" }, "possibly qa · inferred attribution"], + [artifact("Dependency_Security"), { name: "dependency-security" }, "from dependency security · recorded producer"], + [artifact("principal"), undefined, "recorded producer: principal · not attributed to a roster role"], + [artifact(null, "notes.md"), undefined, "producer unknown"], + [artifact(" ", "notes.md"), undefined, "producer unknown"], + ]; + for (const [input, role, label] of cases) { + const tree = renderer.exports.ArtifactSummary({ artifact: input, role }); + assert.deepEqual(JSON.parse(JSON.stringify(tree)), { + tag: "summary", + props: { className: "cursor-pointer px-4 py-3" }, + children: [ + { tag: "span", props: { className: "font-medium" }, children: [input.name] }, + { tag: "span", props: { className: "ml-2 text-xs text-foreground-muted" }, children: [label] }, + ], + }); + } +}); From 1f2c4835ab03c36a7da67fedc7200a80db65fc1e Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Mon, 14 Sep 2026 23:38:13 +0200 Subject: [PATCH 109/111] docs: attest reviewed Bridge integration and verified repair closure Record the exact source, independent integration review and three closure rounds, all twenty-five executed Rust regressions and locked web/gateway qualification under explicit maintainer delegation. Preserve remaining full-stack, IMDS disposition and live H100/GitHub acceptance conditions without claiming a second human review or waiving gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../2026-09-11-bridge-application.md | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index a7fad01aa..51c84fee0 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -3,7 +3,110 @@ Licensed under the MIT License. --> # Kars Bridge application publication record -Status: **draft assembly; no source sign-off or release approval claimed**. +Status: **Bounded source-approved under explicit maintainer delegation.** +Current-head technical/security gates and operational acceptance remain required; +this is not beta, deployment or release approval. + +## Current delegated source attestation (2026-09-14) + +Reviewed source: `aa4056358561a2e02928fe5346f54b733ab8eb1a`. +Integration base: `b5ad6791f9085e908cbf3d16b5de9021eb4b43a7`. + +The maintainer authorized publication sign-offs after focused-agent review +rounds in +[comment 5615522306](https://github.com/Azure/kars/pull/551#issuecomment-5615522306). +Copilot exercises the author attestation under that delegation; this does not +claim personal code review by the maintainer. The original integration review +and three repair-closure rounds were performed in the separate read-only AI +context `bridge-publication-final-review` +(`b45996cb-cc53-443a-9c88-43112e10b551`), not by a second human or the +implementation contexts. The reviewer supplied unsigned source-review evidence; +the attestations below are recorded under the disclosed maintainer delegation. + +### Reviewed boundaries and closure + +The source-and-caller review covered authenticated Home intent, proposals and +launch; owner/admin authorization and credential-store boundaries; standing-Team +backlog and recurring intake; human review/revision; agent/run/activity/artifact/ +PR attribution; receipt signatures, immutable pins and inclusion evidence; +web/BFF and Teams SDK contracts; optional Helm lifecycle and root additivity. +It was not exhaustive line-by-line assurance of every module. + +The review found six concrete issues. Their repairs are committed in +`8f353535`, `366f02c8` and `aa405635`; none remains unresolved in the three +subsequent scoped closure reviews: + +| Finding | Verified repair boundary | +| --- | --- | +| Active HTML/SVG artifacts inherited Bridge origin | Active/unknown content downloads unchanged; sandbox CSP, nosniff and no-store protect artifact responses and survive the same-origin proxy. Passive previews and ownership/missing-file denials remain. | +| Operators bypassed admin-only budget/retention controls | Web gates use verified session roles without an SSO development-cookie fallback. BFF middleware and handlers independently require admin; intended operator reads and other authorized operations remain. | +| Gateway initialization used incompatible positional SDK calls | Calls use the locked Kubernetes SDK request-object contract and SDK-derived types, without compatibility casts. Real HTTP fixtures exercise paths, bodies, errors, retries, bookmarks and restart/binding behavior. | +| Completed remediation suppressed later same-package advisories | Structured source evidence drives follow-up work while preserving completed and in-flight IDs, nonces, inputs and PR history. Pending refresh, withdrawal, partial scans, legacy metadata and final-merge races are covered. | +| Separate-namespace gateway targeted the wrong workspace | Commands/watches and bounded read authority use the configured core namespace; conversation storage and ServiceAccount remain add-on-local. Same/separate namespace and owned removal behavior are covered. | +| Filenames were presented as recorded producer evidence | Explicit matching producer metadata takes precedence. Filename-only evidence is inferred per file and cannot override a different recorded producer, assignment nonce or incomplete persistence. | + +Production coverage transfers from the inspected worktree deltas to the published +commit on the parent's source-equivalence verification. The subsequent test-module +and pure `ArtifactSummary` extractions were notified as mechanical, with preserved +test/helper bodies and seven matching before/after rendering trees; they were not +independently re-reviewed as new functionality. Actual hosted registration, +compilation and execution below qualify their final wiring. + +### Actual current-source execution + +[Bridge CI 34897179126](https://github.com/Azure/kars/actions/runs/34897179126) +passed all eleven jobs at the exact source above: + +- BFF job `104154078482` passed strict Clippy and all **259 library plus 4+2 + integration tests**, with none ignored. All **25 new regressions** actually + executed successfully: five admin, four artifact and sixteen recurring-intake + cases. All 37 explicitly required regression names were registered. +- Web job `104154078446` passed all **50 contracts**, typecheck/lint, the actual + **Next 16.3.3** production build and immutable-root container startup. +- Gateway/add-on job `104154078031` used locked **Vitest 4.1.11**, passed all + **84 normal cases**, then ran and passed all **three actual Kind lifecycle + cases**. Real SDK HTTP and controlled Helm-removal regressions are included. +- Dependency/lockfile audits, secret/configuration scans and the required + component aggregate passed. + +The parent retrieved and checked these public logs, including each of the 25 new +Rust pass lines. The independent reviewer did not independently inspect the +hosted logs; its source conclusions are not being represented as test execution. +Earlier local Next/Vitest cache mismatches and local Kind skips were not counted +as locked/native proof. No local Rust build below the disk floor was used. + +### Remaining conditions and scope limits + +Current full core CI `34897179070`, native qualification `34897179224`, required +scanner results and the eventual combined integration head remain separate gates. +The preceding `5e9a1fe5` native 18/18 result is controlled-no-LLM/no-active-SRE +evidence, not execution of this source's complete standing-Team/GitHub journey. +The earlier API/CEL public-CRD wait failure is retained; bounded diagnostics do +not establish its cause or turn it into a passing result. + +CodeQL analysis `34897179154` completed, but its alert check remains failed on +IMDS alert 827. The fixed link-local HTTP request matches Azure's documented +host-local protocol and uses no proxy or redirects; its disposition remains +open. No absent-user answer was treated as authorization to dismiss it, and no +scanner suppression or unsupported HTTPS substitution was made. + +Core credential/private-observation closure belongs to the separate review and +records attributed to context `7f37ca6e-6256-4dd1-8064-d8fe6ac2fc92`, not this +application review. Live LLM-driven standing-Team work, end-to-end GitHub +installation/review/revision, active-SRE combined operation and H100 acceptance +remain unproven here. Actual merging remains a human GitHub action; no automatic +merge authority or new storage/transport framework is introduced. `/sandbox` +remains ephemeral `emptyDir`. + +This attestation does not waive checks, grant a GitHub review or merge bypass, +approve main/release/image promotion, or authorize customer/H100 deployment. +Historical failures and draft statements below describe their original candidates, +not retroactive approval of those revisions. + +Signed-off-by: pallakatos (author source attestation through explicit maintainer-delegated AI review, not a claim of personal code review) <191481949+pallakatos@users.noreply.github.com> +Signed-off-by: GitHub Copilot (independent-context delegated AI source review, not a second human) <223556219+Copilot@users.noreply.github.com> + +## Historical assembly and earlier qualification ## Scope From b967c1e3e330ff0eea0e62b17550b514f0055e12 Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Tue, 15 Sep 2026 00:09:26 +0200 Subject: [PATCH 110/111] docs: bind Bridge source approval to completed native qualification Record all completed repaired-head core and native outcomes and the tree-identical incorporation of the protected credential integration. Preserve current-head qualification, open IMDS disposition and live H100/GitHub acceptance requirements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../2026-09-11-bridge-application.md | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/security-audits/2026-09-11-bridge-application.md b/docs/security-audits/2026-09-11-bridge-application.md index 51c84fee0..021faa2f6 100644 --- a/docs/security-audits/2026-09-11-bridge-application.md +++ b/docs/security-audits/2026-09-11-bridge-application.md @@ -77,10 +77,24 @@ as locked/native proof. No local Rust build below the disk floor was used. ### Remaining conditions and scope limits -Current full core CI `34897179070`, native qualification `34897179224`, required -scanner results and the eventual combined integration head remain separate gates. -The preceding `5e9a1fe5` native 18/18 result is controlled-no-LLM/no-active-SRE -evidence, not execution of this source's complete standing-Team/GitHub journey. +Exact `aa405635` subsequently passed +[full core CI 34897179070](https://github.com/Azure/kars/actions/runs/34897179070): +all 21 jobs, including **184/184 Kind cases**, actual historical schema migration +and the public API/CEL preflight. Its +[native run 34897179224](https://github.com/Azure/kars/actions/runs/34897179224) +passed all **18 runtime cases**, three cold API installs and the required +aggregate. Artifact `10370845677` binds both revisions to that source, reports +rotation in 85.68 seconds, and sets runtime/network-policy qualification true. +The lane remains controlled-no-LLM/no-active-SRE; active-SRE-combined qualification +is false. These results do not demonstrate the complete live standing-Team/ +GitHub journey. + +The final application assembly now includes actual protected integration +`e3d61351100e1091b0fc8b5220ac36e75a81848d`, which merged qualified Azure/kars#554. +Composition `74d959b7` retained the entire pre-merge application tree unchanged; +only duplicate CI insertion/test ordering and a documentation heading conflicted. +This audit update does not change production source. The final current-base PR +head must still establish its own required checks before application merge. The earlier API/CEL public-CRD wait failure is retained; bounded diagnostics do not establish its cause or turn it into a passing result. From f8f4a7faf31dbd6d506e219d039d7c0163cf385c Mon Sep 17 00:00:00 2001 From: pallakatos <lakatos.toth.pal@gmail.com> Date: Tue, 15 Sep 2026 01:26:55 +0200 Subject: [PATCH 111/111] test(e2e): observe coherent current-generation CR status Reproduce and fix the native KarsMemory assertion combining phase from before status publication with Ready/reason from later GETs. Read one complete JSON snapshot and settle within the existing fixture deadlines; preserve all accepted tuples and evaluator semantics, reject identity/intent changes, and fail explicitly on API errors. Cover the shared InferencePolicy, KarsMemory, KarsEval and EgressApproval observation pattern without controller or policy changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/ci.yml | 2 +- tests/e2e/cr_status.py | 132 +++++++++++++++ tests/e2e/cr_status_test.py | 323 ++++++++++++++++++++++++++++++++++++ tests/e2e/run.sh | 66 ++++---- 4 files changed, 487 insertions(+), 36 deletions(-) create mode 100644 tests/e2e/cr_status.py create mode 100644 tests/e2e/cr_status_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17079e1c3..4a579ac12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -435,7 +435,7 @@ jobs: - name: Require the compiled production schema request helper run: node tests/e2e/sre_authority/task_schema_payload.mjs check - name: Check public-schema diagnostic privacy - run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test sandbox_condition_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test governed_services_test + run: PYTHONPATH=tests/e2e python3 -m unittest sre_authority.registration_schema_test sre_authority.bootstrap_probe_test sre_authority.binding_probe_test sre_authority.legacy_crds_test sre_authority.connection_proxy_test credential_schema_test credential_policy_schema_test sandbox_condition_schema_test eval_pod_admission_test private_consumption_test receipt_log_rotation_test governed_services_test cr_status_test - name: Create the same disposable API server as the real harness run: kind create cluster --name kars-e2e --config tests/e2e/kind-config.yaml --kubeconfig "$KUBECONFIG" - name: Prove native Sandbox condition generation pruning, retention and type validation diff --git a/tests/e2e/cr_status.py b/tests/e2e/cr_status.py new file mode 100644 index 000000000..3fe042f0e --- /dev/null +++ b/tests/e2e/cr_status.py @@ -0,0 +1,132 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Read one complete current-generation CR status within the caller's deadline.""" + +import argparse +import json +import math +import re +import subprocess +import sys +import time + + +KINDS = ("inferencepolicy", "karsmemory", "karseval", "egressapproval") + + +class StatusObservationError(RuntimeError): + pass + + +def project(value, require_hosts=False): + if not isinstance(value, dict) or not isinstance(value.get("metadata"), dict): + raise StatusObservationError("invalid resource metadata") + metadata = value["metadata"] + uid, generation, version = (metadata.get(key) for key in ("uid", "generation", "resourceVersion")) + if not isinstance(uid, str) or not uid or not isinstance(version, str) or not version: + raise StatusObservationError("resource identity or version is missing") + if type(generation) is not int or generation < 1: + raise StatusObservationError("invalid resource generation") + identity = uid, generation + status = value.get("status") + if status is None: + return identity, None + if not isinstance(status, dict): + raise StatusObservationError("invalid status object") + conditions = status.get("conditions") + if conditions is None: + return identity, None + if not isinstance(conditions, list) or any(not isinstance(item, dict) for item in conditions): + raise StatusObservationError("invalid status conditions") + ready = [item for item in conditions if item.get("type") == "Ready"] + if len(ready) > 1: + raise StatusObservationError("duplicate Ready conditions") + if not ready: + return identity, None + for observed in (status.get("observedGeneration"), ready[0].get("observedGeneration")): + if observed is None: + return identity, None + if type(observed) is not int or observed < 0: + raise StatusObservationError("invalid observed generation") + if observed != generation: + return identity, None + fields = (status.get("phase"), ready[0].get("status"), ready[0].get("reason")) + if any(field is None or field == "" for field in fields): + return identity, None + if any(not isinstance(field, str) or len(field) > 1024 + or any(character in field for character in "|\r\n") for field in fields): + raise StatusObservationError("invalid status projection") + if require_hosts: + hosts = status.get("hostCount") + if hosts is None: + return identity, None + if type(hosts) is not int or hosts < 0: + raise StatusObservationError("invalid host count") + fields += (str(hosts),) + return identity, fields + + +def observe(kind, name, namespace, deadline): + if kind not in KINDS or not re.fullmatch(r"[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?", name): + raise StatusObservationError("invalid fixture resource") + if len(name) > 253 or len(namespace) > 63 or not re.fullmatch( + r"[a-z0-9](?:[-a-z0-9]*[a-z0-9])?", namespace + ): + raise StatusObservationError("invalid fixture namespace or name") + remaining = deadline - time.time() + if not math.isfinite(remaining) or remaining > 45: + raise StatusObservationError("invalid status deadline") + end = time.monotonic() + remaining + identity = None + while (remaining := end - time.monotonic()) > 0: + try: + result = subprocess.run( + ["kubectl", "--context", "kind-kars-e2e", f"--request-timeout={remaining:.3f}s", + "get", kind, name, "-n", namespace, "-o", "json"], + capture_output=True, text=True, timeout=remaining, + ) + except subprocess.TimeoutExpired: + raise StatusObservationError("status read exceeded its deadline") from None + except OSError: + raise StatusObservationError("status reader could not start") from None + if result.returncode != 0: + raise StatusObservationError("status read failed") + if len(result.stdout) > 1_048_576: + raise StatusObservationError("status response exceeded its bound") + try: + value = json.loads(result.stdout) + except json.JSONDecodeError: + raise StatusObservationError("invalid status JSON") from None + current, fields = project(value, require_hosts=kind == "egressapproval") + if identity is not None and current != identity: + raise StatusObservationError("resource identity or intent changed") + identity = current + remaining = end - time.monotonic() + if remaining <= 0: + break + # The existing unscheduled evaluator assertion waits specifically for Pending/False. + if fields is not None and (kind != "karseval" or fields[:2] == ("Pending", "False")): + return fields + time.sleep(min(1, remaining)) + raise StatusObservationError("current status was not observed before the deadline") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--kind", choices=KINDS, required=True) + parser.add_argument("--name", required=True) + parser.add_argument("--namespace", required=True) + parser.add_argument("--deadline", type=float, required=True) + args = parser.parse_args() + try: + fields = observe(args.kind, args.name, args.namespace, args.deadline) + except StatusObservationError as error: + print(f"CR-STATUS-FAILURE: {error}", file=sys.stderr) + return 1 + print("|".join(fields)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/cr_status_test.py b/tests/e2e/cr_status_test.py new file mode 100644 index 000000000..5b73e37a3 --- /dev/null +++ b/tests/e2e/cr_status_test.py @@ -0,0 +1,323 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Exercise actual shell assertions with controlled, revision-consistent replies.""" + +import copy +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from cr_status import StatusObservationError, observe, project + + +ROOT = Path(__file__).resolve().parent +FIXTURE = r'''#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +while args and args[0].startswith("--"): + if args[0] == "--context": + assert args[1] == "kind-kars-e2e" + args = args[2:] + elif args[0].startswith("--request-timeout="): + args = args[1:] + else: + raise AssertionError("Unexpected global option") +if args[0] == "apply": + sys.stdin.read() + sys.exit(0) +if args[0] == "delete" or args[1] == "configmap": + sys.exit(0) +kind = args[1] +form = args[args.index("-o") + 1] +if "metadata.finalizers" in form: + print('["kars.azure.com/egress-approval-cleanup"]') + sys.exit(0) +record = Path(os.environ["STATUS_READS"]) +reads = json.loads(record.read_text()) if record.exists() else [] +index = len(reads) +reads.append(form) +record.write_text(json.dumps(reads)) +if os.environ.get("STATUS_MODE") == "read-failure": + print("PRIVATE-TRANSPORT-FIXTURE", file=sys.stderr) + sys.exit(19) +phases = {"karsmemory": "Compiled", "inferencepolicy": "Compiled", + "karseval": "Pending", "egressapproval": "Pending"} +reasons = {"karsmemory": "NoSandboxesReferencing", + "inferencepolicy": "AwaitingRouterEnforcement", + "karseval": "Reconciled", "egressapproval": "BlockedOnSandbox"} +status = {"phase": phases[kind], "observedGeneration": 1, "hostCount": 2, + "conditions": [{"type": "Ready", "status": "False", + "reason": reasons[kind], "observedGeneration": 1}]} +if os.environ.get("STATUS_MODE") == "invalid": + status["phase"] = "Failed" + status["conditions"][0]["reason"] = "CompileFailed" +elif index == 0: + status = {} +value = {"metadata": {"uid": "fixture-cr", "generation": 1, + "resourceVersion": str(index + 10)}, "status": status} +if form == "json": + assert "--context" in sys.argv + print(json.dumps(value)) +elif "observedGeneration" in form: + print(status.get("observedGeneration", ""), end="") +elif "hostCount" in form: + print(status.get("hostCount", ""), end="") +elif ".status.phase" in form: + print(status.get("phase", ""), end="") +elif form.endswith(".status}"): + print(status.get("conditions", [{}])[0].get("status", ""), end="") +elif form.endswith(".reason}"): + print(status.get("conditions", [{}])[0].get("reason", ""), end="") +else: + raise AssertionError("Unexpected status projection") +''' + + +def shell_function(source, name): + start = source.index(f"{name}() {{") + return source[start:source.index("\n}\n", start) + 3] + + +class ShellStatusSnapshotTests(unittest.TestCase): + def run_case(self, name, mode="transition"): + source = (ROOT / "run.sh").read_text() + script = """ +set -euo pipefail +SCRIPT_DIR="$1" +FAIL=0 +pass() { printf '[PASS] %s\\n' "$1"; } +fail() { printf '[FAIL] %s\\n' "$1"; FAIL=$((FAIL + 1)); } +dump_cr_diagnostics() { printf 'Public CR diagnostic requested\\n' >&2; } +""" + script += shell_function(source, "wait_for_resource") + script += shell_function(source, name) + script += f"\n{name}\ntest \"$FAIL\" = 0\n" + with tempfile.TemporaryDirectory(prefix="kars-cr-status-") as directory: + root = Path(directory) + executable = root / "kubectl" + executable.write_text(FIXTURE) + executable.chmod(0o700) + record = root / "reads.json" + result = subprocess.run( + ["bash", "-c", script, "cr-status-test", str(ROOT)], + env={**os.environ, "PATH": directory + os.pathsep + os.environ["PATH"], + "STATUS_READS": str(record), "STATUS_MODE": mode}, + capture_output=True, text=True, timeout=10, + ) + reads = json.loads(record.read_text()) if record.exists() else [] + return result, reads + + def test_memory_never_combines_pre_status_phase_with_published_ready_reason(self): + result, reads = self.run_case("test_crd_kars_memory") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("phase=Compiled ready=False reason=NoSandboxesReferencing", result.stdout) + self.assertEqual(reads, ["json", "json"]) + + def test_related_honest_state_assertions_use_complete_single_response_snapshots(self): + for name in ("test_crd_inference_policy", "test_crd_kars_eval", "test_crd_egress_approval"): + with self.subTest(name=name): + result, reads = self.run_case(name) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(reads, ["json", "json"]) + + def test_invalid_complete_memory_state_is_still_rejected(self): + result, reads = self.run_case("test_crd_kars_memory", "invalid") + self.assertNotEqual(result.returncode, 0) + self.assertIn("unexpected honest-state", result.stdout) + self.assertEqual(reads, ["json"]) + + def test_read_failure_is_not_hidden_as_a_success_or_unbounded_poll(self): + result, reads = self.run_case("test_crd_kars_memory", "read-failure") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("PRIVATE-TRANSPORT-FIXTURE", result.stdout + result.stderr) + self.assertIn("status read failed", result.stderr) + self.assertEqual(reads, ["json"]) + + +def resource(phase="Compiled", ready="False", reason="NoSandboxesReferencing"): + return { + "metadata": {"uid": "fixture-cr", "generation": 1, "resourceVersion": "12"}, + "status": {"phase": phase, "observedGeneration": 1, "conditions": [ + {"type": "Ready", "status": ready, "reason": reason, "observedGeneration": 1}, + ]}, + } + + +class StatusProjectionTests(unittest.TestCase): + def test_supported_memory_states_are_not_rewritten(self): + for fields in ( + ("Compiled", "False", "NoSandboxesReferencing"), + ("Compiled", "False", "AwaitingRouterEnforcement"), + ("Ready", "True", "RouterEnforcing"), + ("Failed", "False", "CompileFailed"), + ): + with self.subTest(fields=fields): + self.assertEqual(project(resource(*fields)), (("fixture-cr", 1), fields)) + + def test_unpublished_status_does_not_fabricate_any_field(self): + for status in (None, {}, {"conditions": []}, {"conditions": None}): + value = resource() + value["status"] = status + self.assertEqual(project(value), (("fixture-cr", 1), None)) + for key in ("phase", "observedGeneration"): + value = resource() + del value["status"][key] + self.assertIsNone(project(value)[1]) + for key in ("status", "reason", "observedGeneration"): + value = resource() + del value["status"]["conditions"][0][key] + self.assertIsNone(project(value)[1]) + + def test_status_and_ready_must_both_observe_the_current_generation(self): + for target in ("status", "ready"): + value = resource() + current = value["status"] if target == "status" else value["status"]["conditions"][0] + current["observedGeneration"] = 0 + self.assertIsNone(project(value)[1]) + + def test_duplicate_ready_and_malformed_metadata_are_rejected(self): + value = resource() + value["status"]["conditions"].append(copy.deepcopy(value["status"]["conditions"][0])) + with self.assertRaises(StatusObservationError): + project(value) + for key, invalid in (("uid", ""), ("resourceVersion", None), ("generation", True), + ("generation", 0), ("generation", "1")): + value = resource() + value["metadata"][key] = invalid + with self.subTest(key=key, value=invalid), self.assertRaises(StatusObservationError): + project(value) + + def test_projection_cannot_inject_delimiters_or_coerce_status_types(self): + for invalid in (True, "False|RouterEnforcing", "False\nReady", ["False"]): + value = resource() + value["status"]["conditions"][0]["status"] = invalid + with self.subTest(value=invalid), self.assertRaises(StatusObservationError): + project(value) + + def test_egress_host_count_comes_from_the_same_snapshot_without_defaulting(self): + value = resource("Pending", "False", "BlockedOnSandbox") + self.assertIsNone(project(value, require_hosts=True)[1]) + for count in (0, 2): + value["status"]["hostCount"] = count + self.assertEqual(project(value, require_hosts=True)[1][-1], str(count)) + for invalid in (-1, True, "2"): + value["status"]["hostCount"] = invalid + with self.assertRaises(StatusObservationError): + project(value, require_hosts=True) + + +class StatusDeadlineTests(unittest.TestCase): + def setUp(self): + self.now = 0.0 + self.start_patch(patch("cr_status.time.time", return_value=1000.0)) + self.start_patch(patch("cr_status.time.monotonic", side_effect=lambda: self.now)) + self.start_patch(patch("cr_status.time.sleep", side_effect=self.sleep)) + self.run = self.start_patch(patch("cr_status.subprocess.run")) + + def start_patch(self, patcher): + self.addCleanup(patcher.stop) + return patcher.start() + + def sleep(self, duration): + self.now += duration + + def replies(self, *values): + self.run.side_effect = [ + subprocess.CompletedProcess([], 0, json.dumps(value), "") for value in values + ] + + def observe(self, deadline=1003.0, kind="karsmemory"): + return observe(kind, "e2e-fixture", "kars-system", deadline) + + def test_pending_then_complete_uses_one_json_read_per_snapshot(self): + pending = resource() + pending["status"] = {} + self.replies(pending, resource()) + self.assertEqual(self.observe(), ("Compiled", "False", "NoSandboxesReferencing")) + self.assertEqual(self.run.call_count, 2) + for index, call in enumerate(self.run.call_args_list): + self.assertEqual(call.args[0][:3], ["kubectl", "--context", "kind-kars-e2e"]) + self.assertEqual(call.args[0][-2:], ["-o", "json"]) + self.assertEqual(call.kwargs["timeout"], 3 - index) + + def test_identity_or_intent_changes_are_not_adopted_during_settlement(self): + for key, value in (("uid", "replacement"), ("generation", 2)): + self.now = 0 + pending = resource() + pending["status"] = {} + changed = resource() + changed["metadata"][key] = value + self.replies(pending, changed) + with self.assertRaisesRegex(StatusObservationError, "identity or intent changed"): + self.observe() + + def test_evaluator_preserves_its_existing_pending_false_settlement(self): + self.replies(resource("Running", "False", "Reconciled"), + resource("Pending", "False", "Reconciled")) + self.assertEqual(self.observe(kind="karseval"), ("Pending", "False", "Reconciled")) + self.assertEqual(self.run.call_count, 2) + + def test_existing_deadline_expires_without_an_extra_read(self): + pending = resource() + pending["status"] = {} + self.run.return_value = subprocess.CompletedProcess([], 0, json.dumps(pending), "") + with self.assertRaisesRegex(StatusObservationError, "before the deadline"): + self.observe() + self.assertEqual(self.run.call_count, 3) + self.assertEqual(self.now, 3) + + def test_expired_or_unbounded_deadline_does_not_start_a_request(self): + for deadline in (999.0, 1000.0, float("inf"), float("nan"), 1046.0): + with self.subTest(deadline=deadline), self.assertRaises(StatusObservationError): + self.observe(deadline) + self.run.assert_not_called() + + def test_late_complete_response_cannot_pass_after_the_deadline(self): + def late(*_args, **_kwargs): + self.now = 3 + return subprocess.CompletedProcess([], 0, json.dumps(resource()), "") + self.run.side_effect = late + with self.assertRaisesRegex(StatusObservationError, "before the deadline"): + self.observe() + self.assertEqual(self.run.call_count, 1) + + def test_api_failures_and_invalid_json_are_explicit_and_not_retried(self): + for response in ( + subprocess.CompletedProcess([], 19, "", "PRIVATE-FIXTURE"), + subprocess.CompletedProcess([], 0, "PRIVATE-FIXTURE", ""), + ): + self.run.reset_mock() + self.run.return_value = response + with self.assertRaises(StatusObservationError) as caught: + self.observe() + self.assertNotIn("PRIVATE-FIXTURE", str(caught.exception)) + self.assertEqual(self.run.call_count, 1) + + def test_subprocess_deadline_is_enforced_without_exposing_private_output(self): + self.run.side_effect = subprocess.TimeoutExpired("kubectl", 3, stderr="PRIVATE-FIXTURE") + with self.assertRaisesRegex(StatusObservationError, "read exceeded its deadline"): + self.observe() + self.assertEqual(self.run.call_count, 1) + + def test_only_fixed_fixture_kinds_and_safe_resource_names_can_be_read(self): + for kind, name, namespace in ( + ("secret", "fixture", "kars-system"), + ("karsmemory", "--raw=/api/v1/secrets", "kars-system"), + ("karsmemory", "fixture", "../other"), + ): + with self.assertRaises(StatusObservationError): + observe(kind, name, namespace, 1003.0) + self.run.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 2df2ca304..b8fd70da2 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -421,7 +421,11 @@ test_runtime_openclaw() { # downstream artefact (ConfigMap or Secret) and updates # `.status.conditions[]`. The tests below assert the *contract*: # -# apply CR → downstream ConfigMap exists → Ready=True condition +# apply CR → downstream ConfigMap exists → current, coherent status +# +# ConfigMap publication can precede status publication. Read status fields from +# one response and retain truthful Compiled/Pending states where no router has +# confirmed enforcement; do not manufacture Ready from the ConfigMap alone. # # We do NOT exercise the runtime data-plane (no Foundry calls, no AGT # relay, no real OAuth) — only that the controller wires CR → cluster @@ -518,6 +522,8 @@ spec: provider: azure-openai deployment: gpt-4.1 EOF + local ip_deadline + ip_deadline=$(($(date +%s) + 45)) if wait_for_resource configmap inferencepolicy-e2e-inferencepolicy-profile kars-system 45; then pass "InferencePolicy → profile ConfigMap created" else @@ -531,12 +537,13 @@ EOF # NoSandboxesReferencing if the e2e-test sandbox's router isn't # reachable). Asserting Ready=True here would be a §3 violation — # the controller *correctly* refuses to lie. The compiled - # ConfigMap check above is the controller's complete output; + # ConfigMap and current status are the controller's output; # router enforcement is exercised in unit + integration tests. - local ip_phase ip_ready ip_reason - ip_phase=$(kubectl get inferencepolicy e2e-inferencepolicy -n kars-system -o jsonpath='{.status.phase}' 2>/dev/null || true) - ip_ready=$(kubectl get inferencepolicy e2e-inferencepolicy -n kars-system -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) - ip_reason=$(kubectl get inferencepolicy e2e-inferencepolicy -n kars-system -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' 2>/dev/null || true) + local ip_phase="" ip_ready="" ip_reason="" ip_snapshot + if ip_snapshot=$(python3 "$SCRIPT_DIR/cr_status.py" --kind inferencepolicy \ + --name e2e-inferencepolicy --namespace kars-system --deadline "$ip_deadline"); then + IFS='|' read -r ip_phase ip_ready ip_reason <<<"$ip_snapshot" + fi case "$ip_phase|$ip_ready|$ip_reason" in Compiled\|False\|AwaitingRouterEnforcement|Compiled\|False\|NoSandboxesReferencing|Ready\|True\|RouterEnforcing|Ready\|True\|*) pass "InferencePolicy: phase=$ip_phase ready=$ip_ready reason=$ip_reason (§3 honest state)" @@ -608,6 +615,8 @@ spec: name: e2e-test scope: "agent_e2e-test" EOF + local mem_deadline + mem_deadline=$(($(date +%s) + 45)) if wait_for_resource configmap karsmemory-e2e-karsmemory-binding kars-system 45; then pass "KarsMemory → binding ConfigMap created" else @@ -620,10 +629,11 @@ EOF # with Ready=False / reason=NoSandboxesReferencing or # AwaitingRouterEnforcement. Asserting the legacy # Pending/AwaitingFoundryProvisioning is incorrect after Slice 3a. - local mem_phase mem_ready mem_reason - mem_phase=$(kubectl get karsmemory e2e-karsmemory -n kars-system -o jsonpath='{.status.phase}' 2>/dev/null || true) - mem_ready=$(kubectl get karsmemory e2e-karsmemory -n kars-system -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) - mem_reason=$(kubectl get karsmemory e2e-karsmemory -n kars-system -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' 2>/dev/null || true) + local mem_phase="" mem_ready="" mem_reason="" mem_snapshot + if mem_snapshot=$(python3 "$SCRIPT_DIR/cr_status.py" --kind karsmemory \ + --name e2e-karsmemory --namespace kars-system --deadline "$mem_deadline"); then + IFS='|' read -r mem_phase mem_ready mem_reason <<<"$mem_snapshot" + fi case "$mem_phase|$mem_ready|$mem_reason" in Compiled\|False\|NoSandboxesReferencing|Compiled\|False\|AwaitingRouterEnforcement|Ready\|True\|RouterEnforcing) pass "KarsMemory: phase=$mem_phase ready=$mem_ready reason=$mem_reason (§3 honest state, Slice 3a)" @@ -661,19 +671,12 @@ EOF fi # Wait for the controller to stamp status (phase=Pending Ready=False # is the honest state until a Job/CronJob run completes). - local phase ready reason - for _ in $(seq 1 15); do - phase=$(kubectl get karseval e2e-karseval -n kars-system \ - -o jsonpath='{.status.phase}' 2>/dev/null || true) - ready=$(kubectl get karseval e2e-karseval -n kars-system \ - -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) - reason=$(kubectl get karseval e2e-karseval -n kars-system \ - -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' 2>/dev/null || true) - if [[ "$phase" == "Pending" && "$ready" == "False" ]]; then - break - fi - sleep 2 - done + local phase="" ready="" reason="" eval_snapshot eval_deadline + eval_deadline=$(($(date +%s) + 30)) + if eval_snapshot=$(python3 "$SCRIPT_DIR/cr_status.py" --kind karseval \ + --name e2e-karseval --namespace kars-system --deadline "$eval_deadline"); then + IFS='|' read -r phase ready reason <<<"$eval_snapshot" + fi if [[ "$phase" == "Pending" && "$ready" == "False" ]]; then pass "KarsEval: phase=Pending ready=False reason=$reason (§3 honest state, slice 6.3)" else @@ -1079,19 +1082,12 @@ EOF # The reconciler must at minimum stamp observedGeneration and # hostCount within ~45s. Don't gate on Ready=True because the # sibling sandbox is unlikely to be Ready in Kind. - local deadline ea_phase ea_ready ea_reason ea_hosts ea_observed + local deadline ea_phase="" ea_ready="" ea_reason="" ea_hosts="" ea_snapshot deadline=$(($(date +%s) + 45)) - while [ "$(date +%s)" -lt "$deadline" ]; do - ea_observed=$(kubectl get egressapproval e2e-egress-approval -n kars-system -o jsonpath='{.status.observedGeneration}' 2>/dev/null || true) - if [ -n "$ea_observed" ] && [ "$ea_observed" != "0" ]; then - break - fi - sleep 2 - done - ea_phase=$(kubectl get egressapproval e2e-egress-approval -n kars-system -o jsonpath='{.status.phase}' 2>/dev/null || true) - ea_ready=$(kubectl get egressapproval e2e-egress-approval -n kars-system -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) - ea_reason=$(kubectl get egressapproval e2e-egress-approval -n kars-system -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}' 2>/dev/null || true) - ea_hosts=$(kubectl get egressapproval e2e-egress-approval -n kars-system -o jsonpath='{.status.hostCount}' 2>/dev/null || true) + if ea_snapshot=$(python3 "$SCRIPT_DIR/cr_status.py" --kind egressapproval \ + --name e2e-egress-approval --namespace kars-system --deadline "$deadline"); then + IFS='|' read -r ea_phase ea_ready ea_reason ea_hosts <<<"$ea_snapshot" + fi if [ "$ea_hosts" = "2" ]; then pass "EgressApproval: status.hostCount=2 (reconciler ran)"